450 lines
21 KiB
Python
450 lines
21 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,
|
|
'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."""
|
|
if not isinstance(raw, dict):
|
|
return get_initial_state()
|
|
return raw
|