ProductionGame/backend/engine.py

395 lines
18 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
# Initial Product Catalog configuration mirroring frontend/js/data/products.js
PRODUCT_CONFIGS = {
# Sector 1: Agriculture & Food
'wheat': {
'sector': 'agriculture', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 1.0, 'unlockCost': 0,
'baseFacilityCost': 15, 'baseManagerCost': 100, 'baseLogisticsCost': 80,
'basePrice': 1.50, 'unlockedDefault': True
},
'flour': {
'sector': 'agriculture', 'tier': 2, 'inputs': [{'id': 'wheat', 'amount': 2}],
'outputAmount': 1, 'baseCycleTime': 1.8, 'unlockCost': 250,
'baseFacilityCost': 200, 'baseManagerCost': 850, 'baseLogisticsCost': 600,
'basePrice': 6.20, 'unlockedDefault': False
},
'sugar_beet': {
'sector': 'agriculture', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 1.5, 'unlockCost': 650,
'baseFacilityCost': 550, 'baseManagerCost': 1800, 'baseLogisticsCost': 1200,
'basePrice': 4.80, 'unlockedDefault': False
},
'refined_sugar': {
'sector': 'agriculture', 'tier': 2, 'inputs': [{'id': 'sugar_beet', 'amount': 3}],
'outputAmount': 1, 'baseCycleTime': 2.2, 'unlockCost': 2200,
'baseFacilityCost': 1800, 'baseManagerCost': 4500, 'baseLogisticsCost': 3200,
'basePrice': 22.00, 'unlockedDefault': False
},
'fresh_bread': {
'sector': 'agriculture', 'tier': 3, 'inputs': [{'id': 'flour', 'amount': 2}, {'id': 'refined_sugar', 'amount': 1}],
'outputAmount': 2, 'baseCycleTime': 3.0, 'unlockCost': 9500,
'baseFacilityCost': 7500, 'baseManagerCost': 18000, 'baseLogisticsCost': 12500,
'basePrice': 58.00, 'unlockedDefault': False
},
'packaged_meals': {
'sector': 'agriculture', 'tier': 4, 'inputs': [{'id': 'fresh_bread', 'amount': 3}],
'outputAmount': 1, 'baseCycleTime': 4.5, 'unlockCost': 45000,
'baseFacilityCost': 36000, 'baseManagerCost': 75000, 'baseLogisticsCost': 55000,
'basePrice': 260.00, 'unlockedDefault': False
},
# Sector 2: Forestry & Materials
'timber': {
'sector': 'materials', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 1.2, 'unlockCost': 120,
'baseFacilityCost': 95, 'baseManagerCost': 350, 'baseLogisticsCost': 240,
'basePrice': 2.80, 'unlockedDefault': False
},
'planks': {
'sector': 'materials', 'tier': 2, 'inputs': [{'id': 'timber', 'amount': 2}],
'outputAmount': 1, 'baseCycleTime': 2.0, 'unlockCost': 1200,
'baseFacilityCost': 950, 'baseManagerCost': 2800, 'baseLogisticsCost': 2000,
'basePrice': 11.50, 'unlockedDefault': False
},
'paper': {
'sector': 'materials', 'tier': 2, 'inputs': [{'id': 'timber', 'amount': 3}],
'outputAmount': 2, 'baseCycleTime': 2.5, 'unlockCost': 3500,
'baseFacilityCost': 2800, 'baseManagerCost': 6500, 'baseLogisticsCost': 4800,
'basePrice': 16.00, 'unlockedDefault': False
},
'furniture': {
'sector': 'materials', 'tier': 3, 'inputs': [{'id': 'planks', 'amount': 3}],
'outputAmount': 1, 'baseCycleTime': 3.8, 'unlockCost': 18000,
'baseFacilityCost': 14000, 'baseManagerCost': 35000, 'baseLogisticsCost': 26000,
'basePrice': 95.00, 'unlockedDefault': False
},
# Sector 3: Mining & Metallurgy
'iron_ore': {
'sector': 'mining', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 1.6, 'unlockCost': 1500,
'baseFacilityCost': 1200, 'baseManagerCost': 3500, 'baseLogisticsCost': 2400,
'basePrice': 12.00, 'unlockedDefault': False
},
'coal': {
'sector': 'mining', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 1.8, 'unlockCost': 2800,
'baseFacilityCost': 2200, 'baseManagerCost': 5500, 'baseLogisticsCost': 3800,
'basePrice': 15.00, 'unlockedDefault': False
},
'steel_ingots': {
'sector': 'mining', 'tier': 2, 'inputs': [{'id': 'iron_ore', 'amount': 2}, {'id': 'coal', 'amount': 1}],
'outputAmount': 1, 'baseCycleTime': 3.2, 'unlockCost': 12500,
'baseFacilityCost': 9800, 'baseManagerCost': 24000, 'baseLogisticsCost': 17000,
'basePrice': 72.00, 'unlockedDefault': False
},
'metal_frames': {
'sector': 'mining', 'tier': 3, 'inputs': [{'id': 'steel_ingots', 'amount': 3}],
'outputAmount': 1, 'baseCycleTime': 4.6, 'unlockCost': 65000,
'baseFacilityCost': 52000, 'baseManagerCost': 110000, 'baseLogisticsCost': 80000,
'basePrice': 320.00, 'unlockedDefault': False
},
# Sector 4: Chemicals & Polymers
'crude_oil': {
'sector': 'chemicals', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 2.0, 'unlockCost': 25000,
'baseFacilityCost': 20000, 'baseManagerCost': 48000, 'baseLogisticsCost': 35000,
'basePrice': 55.00, 'unlockedDefault': False
},
'silica_sand': {
'sector': 'chemicals', 'tier': 1, 'inputs': [],
'outputAmount': 1, 'baseCycleTime': 2.2, 'unlockCost': 45000,
'baseFacilityCost': 36000, 'baseManagerCost': 75000, 'baseLogisticsCost': 52000,
'basePrice': 85.00, 'unlockedDefault': False
},
'polymers': {
'sector': 'chemicals', 'tier': 2, 'inputs': [{'id': 'crude_oil', 'amount': 2}],
'outputAmount': 1, 'baseCycleTime': 3.5, 'unlockCost': 110000,
'baseFacilityCost': 88000, 'baseManagerCost': 175000, 'baseLogisticsCost': 120000,
'basePrice': 210.00, 'unlockedDefault': False
},
'silicon': {
'sector': 'chemicals', 'tier': 2, 'inputs': [{'id': 'silica_sand', 'amount': 2}],
'outputAmount': 1, 'baseCycleTime': 4.0, 'unlockCost': 220000,
'baseFacilityCost': 175000, 'baseManagerCost': 35000, 'baseLogisticsCost': 240000,
'basePrice': 420.00, 'unlockedDefault': False
},
# Sector 5: High-Tech & Electronics
'microchips': {
'sector': 'electronics', 'tier': 3, 'inputs': [{'id': 'silicon', 'amount': 1}, {'id': 'polymers', 'amount': 1}],
'outputAmount': 2, 'baseCycleTime': 4.8, 'unlockCost': 550000,
'baseFacilityCost': 440000, 'baseManagerCost': 850000, 'baseLogisticsCost': 600000,
'basePrice': 650.00, 'unlockedDefault': False
},
'circuit_boards': {
'sector': 'electronics', 'tier': 3, 'inputs': [{'id': 'microchips', 'amount': 2}, {'id': 'polymers', 'amount': 2}],
'outputAmount': 1, 'baseCycleTime': 5.5, 'unlockCost': 1500000,
'baseFacilityCost': 1200000, 'baseManagerCost': 2400000, 'baseLogisticsCost': 1600000,
'basePrice': 2200.00, 'unlockedDefault': False
},
'smartphones': {
'sector': 'electronics', 'tier': 4, 'inputs': [{'id': 'circuit_boards', 'amount': 1}, {'id': 'polymers', 'amount': 2}],
'outputAmount': 1, 'baseCycleTime': 6.5, 'unlockCost': 5000000,
'baseFacilityCost': 4000000, 'baseManagerCost': 8000000, 'baseLogisticsCost': 5500000,
'basePrice': 6800.00, 'unlockedDefault': False
},
# Sector 6: Heavy Industry & Vehicles
'engines': {
'sector': 'heavy_industry', 'tier': 3, 'inputs': [{'id': 'steel_ingots', 'amount': 3}, {'id': 'metal_frames', 'amount': 1}],
'outputAmount': 1, 'baseCycleTime': 6.0, 'unlockCost': 3500000,
'baseFacilityCost': 2800000, 'baseManagerCost': 5500000, 'baseLogisticsCost': 3800000,
'basePrice': 4200.00, 'unlockedDefault': False
},
'commercial_trucks': {
'sector': 'heavy_industry', 'tier': 4, 'inputs': [{'id': 'engines', 'amount': 1}, {'id': 'metal_frames', 'amount': 2}, {'id': 'circuit_boards', 'amount': 1}],
'outputAmount': 1, 'baseCycleTime': 8.5, 'unlockCost': 15000000,
'baseFacilityCost': 12000000, 'baseManagerCost': 22000000, 'baseLogisticsCost': 15000000,
'basePrice': 26500.00, 'unlockedDefault': False
}
}
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,
'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)
cycle_rate = (1.0 / p_cfg['baseCycleTime']) * milestone_mult * robotics_speed_mult
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 input batch reservation
needed = [(inp['id'], inp['amount'] * lines) for inp in inputs]
can_start = all(state['products'].get(item_id, {}).get('stock', 0.0) >= req for item_id, req in needed)
if p_state.get('cycleProgress', 0.0) <= 0.0:
if not can_start:
p_state['isBottlenecked'] = True
else:
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'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
else:
p_state['isBottlenecked'] = False
p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
if p_state['cycleProgress'] >= 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_start:
for item_id, req in needed:
state['products'][item_id]['stock'] = max(0.0, state['products'][item_id].get('stock', 0.0) - req)
else:
p_state['isBottlenecked'] = True
# Passive Logistics Sales
logistics = p_state.get('logistics', 0)
if logistics > 0 and p_state.get('stock', 0.0) > 0:
max_sell = logistics * 0.75 * dt
sold = min(p_state['stock'], 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 == '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."""
if not isinstance(raw, dict):
return get_initial_state()
return raw