ProductionGame/backend/engine.py

328 lines
14 KiB
Python

"""
Production - The Idle Game: Core Simulation Backend Engine
Authoritative offline tick progression, multi-tier DAG supply chains, and action validation.
"""
import math
import time
from catalog_loader import load_all_sectors_and_products
# Dynamically loaded Product & Sector Registry from modular sector files
SECTOR_CONFIGS, PRODUCT_CONFIGS = load_all_sectors_and_products()
def refresh_catalog():
"""Hot-reloads the product catalog if new sector files were added dynamically."""
global SECTOR_CONFIGS, PRODUCT_CONFIGS
SECTOR_CONFIGS, PRODUCT_CONFIGS = load_all_sectors_and_products()
UNIT_MILESTONES = [1, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
BOARDROOM_PERK_COSTS = {
'supply_sync': [1, 3, 8, 20, 50],
'robotics_overclock': [2, 5, 12, 30, 75],
'logistics_efficiency': [2, 6, 15, 40, 100],
'government_subsidies': [3, 8, 20, 55, 120],
'angel_syndicate': [5, 15, 45, 120, 300]
}
def clean_round(val: float) -> float:
if math.isnan(val) or math.isinf(val) or val < 0:
return 0.0
return round(float(val), 2)
def get_unit_milestone_multiplier(count: int) -> float:
reached = sum(1 for m in UNIT_MILESTONES if count >= m)
return float(math.pow(2.0, reached))
def get_initial_state() -> dict:
state = {
'version': 1,
'companyName': 'Apex Production Industries',
'cash': 30.0,
'lifetimeRevenue': 0.0,
'valuation': 30.0,
'ventureShares': 0,
'totalVentureShares': 0,
'ipoCount': 0,
'reputation': 0,
'buyMode': '1',
'activeTab': 'production',
'selectedSector': 'all',
'selectedProductId': 'wheat',
'stats': {
'totalProduced': 0.0,
'contractsCompleted': 0,
'managersHired': 0
},
'products': {},
'boardroomPerks': {p: 0 for p in BOARDROOM_PERK_COSTS},
'achievements': {},
'activeContracts': [],
'lastTick': time.time()
}
for pid, p in PRODUCT_CONFIGS.items():
is_unlocked = p.get('unlockedDefault', False)
state['products'][pid] = {
'unlocked': is_unlocked,
'stock': 0.0,
'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
}
return state
def calculate_shares_earned(lifetime_revenue: float) -> int:
if lifetime_revenue < 100000.0:
return 0
return int(math.floor(10.0 * math.sqrt(lifetime_revenue / 100000.0)))
def process_offline_and_ticks(state: dict) -> dict:
"""Computes authoritative tick progress up to the current timestamp (capped at 8 hours)."""
now = time.time()
last = state.get('lastTick', now)
elapsed = max(0.0, min(now - last, 8.0 * 3600.0))
state['lastTick'] = now
if elapsed <= 0.1:
return state
perks = state.get('boardroomPerks', {})
supply_yield_mult = 1.0 + 0.15 * perks.get('supply_sync', 0)
robotics_speed_mult = 1.0 + 0.20 * perks.get('robotics_overclock', 0)
logistics_price_mult = 1.0 + 0.25 * perks.get('logistics_efficiency', 0)
# Simulate in reasonable sub-steps for chained DAG dependency
sub_steps = max(1, min(int(elapsed), 120))
dt = elapsed / sub_steps
for _ in range(sub_steps):
for pid, p_cfg in PRODUCT_CONFIGS.items():
p_state = state.get('products', {}).get(pid)
if not p_state or not p_state.get('unlocked'):
continue
lines = p_state.get('lines', 0)
if lines <= 0:
continue
milestone_mult = get_unit_milestone_multiplier(lines)
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:
# Raw extraction
p_state['isBottlenecked'] = False
p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
if p_state['cycleProgress'] >= 1.0:
cycles = int(p_state['cycleProgress'])
p_state['cycleProgress'] -= cycles
produced = cycles * p_cfg['outputAmount'] * lines * supply_yield_mult
p_state['stock'] = p_state.get('stock', 0.0) + produced
p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced
else:
# 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:
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)
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.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
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
else:
p_state['isBottlenecked'] = True
# Passive Logistics Sales with stock reservation for downstream factories
logistics = p_state.get('logistics', 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):
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:
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(
p_state.get('lines', 0) * PRODUCT_CONFIGS[pid]['baseFacilityCost']
for pid, p_state in state.get('products', {}).items()
if pid in PRODUCT_CONFIGS
)
state['valuation'] = state.get('cash', 0.0) + facility_assets + (state.get('lifetimeRevenue', 0.0) * 0.15)
return state
def apply_actions(state: dict, actions: list) -> dict:
"""Validates and executes queued actions sent from the frontend."""
for act in actions:
atype = act.get('type')
if atype == 'UNLOCK_PRODUCT':
pid = act.get('productId')
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
p_cfg = PRODUCT_CONFIGS[pid]
cost = p_cfg['unlockCost']
if state.get('cash', 0.0) >= cost and not state['products'][pid].get('unlocked'):
state['cash'] -= cost
state['products'][pid]['unlocked'] = True
state['products'][pid]['lines'] = 1
elif atype == 'BUY_LINES':
pid = act.get('productId')
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
p_cfg = PRODUCT_CONFIGS[pid]
current_lines = state['products'][pid].get('lines', 0)
cost = p_cfg['baseFacilityCost'] * math.pow(1.15, current_lines)
if state.get('cash', 0.0) >= cost:
state['cash'] -= cost
state['products'][pid]['lines'] = current_lines + 1
elif atype == 'HIRE_MANAGER':
pid = act.get('productId')
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
cost = PRODUCT_CONFIGS[pid]['baseManagerCost']
if state.get('cash', 0.0) >= cost and not state['products'][pid].get('manager'):
state['cash'] -= cost
state['products'][pid]['manager'] = True
state['stats']['managersHired'] = state['stats'].get('managersHired', 0) + 1
elif atype == 'BUY_LOGISTICS':
pid = act.get('productId')
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
current_logistics = state['products'][pid].get('logistics', 0)
cost = PRODUCT_CONFIGS[pid]['baseLogisticsCost'] * math.pow(1.15, current_logistics)
if state.get('cash', 0.0) >= cost:
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 == '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:
preserved_shares = state.get('ventureShares', 0) + earned
preserved_total_shares = state.get('totalVentureShares', 0) + earned
preserved_perks = state.get('boardroomPerks', {})
preserved_ipos = state.get('ipoCount', 0) + 1
# Reset to clean initial state
state = get_initial_state()
state['ventureShares'] = preserved_shares
state['totalVentureShares'] = preserved_total_shares
state['boardroomPerks'] = preserved_perks
state['ipoCount'] = preserved_ipos
elif atype == 'BUY_PERK':
perk_id = act.get('perkId')
if perk_id in BOARDROOM_PERK_COSTS:
current_lvl = state.get('boardroomPerks', {}).get(perk_id, 0)
costs = BOARDROOM_PERK_COSTS[perk_id]
if current_lvl < len(costs):
req_shares = costs[current_lvl]
if state.get('ventureShares', 0) >= req_shares:
state['ventureShares'] -= req_shares
state['boardroomPerks'][perk_id] = current_lvl + 1
return state
def sanitize_state(raw: dict) -> dict:
"""Ensures incoming state doesn't have NaN or negative numbers and dynamically populates new products."""
if not isinstance(raw, dict):
return get_initial_state()
# Dynamic re-hydration: Ensure any new products added to the catalog exist in the player's saved state
if 'products' not in raw or not isinstance(raw['products'], dict):
raw['products'] = {}
for pid, p in PRODUCT_CONFIGS.items():
if pid not in raw['products']:
is_unlocked = p.get('unlockedDefault', False)
raw['products'][pid] = {
'unlocked': is_unlocked,
'stock': 0.0,
'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
}
return raw