328 lines
14 KiB
Python
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
|