feat(arch): implement single-source-of-truth modular sector registry and DAG validator for scaling to 100+ products
This commit is contained in:
parent
98fb1147b3
commit
7cdcd4fb5e
6 changed files with 403 additions and 152 deletions
166
PRODUCT_GENERATOR_GUIDE.md
Normal file
166
PRODUCT_GENERATOR_GUIDE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Plug-and-Play Production Unit & Sector Generator Guide
|
||||||
|
|
||||||
|
This guide describes how to add **5, 10, 50, or 100+** new production units, commodities, and industrial sectors to **Production: The Idle Game**.
|
||||||
|
|
||||||
|
The game uses a **Single-Source-of-Truth** architecture:
|
||||||
|
* Adding or modifying a sector file in `frontend/js/data/sectors/<sectorName>.js` **automatically** updates both the frontend UI and the Python authoritative simulation backend.
|
||||||
|
* You never need to touch engine code, database schemas, or calculation formulas!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quick-Start: Adding a New Product to an Existing Sector
|
||||||
|
|
||||||
|
To add a new product to an existing sector (e.g. `frontend/js/data/sectors/chemicals.js`):
|
||||||
|
|
||||||
|
Simply copy and append this block to the sector's `PRODUCTS` array:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: 'bio_fertilizer', // Unique snake_case string ID
|
||||||
|
name: 'Bio-Organic Fertilizer', // User-facing commodity name
|
||||||
|
sector: 'chemicals', // Parent sector ID ('agriculture', 'chemicals', etc.)
|
||||||
|
tier: 2, // Industrial Tier (1, 2, 3, 4+)
|
||||||
|
icon: 'lucide:sprout', // Lucide or Iconify icon name
|
||||||
|
color: '#10b981', // Hex accent color
|
||||||
|
unit: 'barrels', // Measurement unit (e.g. 'bushels', 'ingots', 'crates')
|
||||||
|
|
||||||
|
// DAG Supply Chain Inputs (Leave empty [] for raw extraction)
|
||||||
|
inputs: [
|
||||||
|
{ id: 'sugar_beet', amount: 2 },
|
||||||
|
{ id: 'polymers', amount: 1 }
|
||||||
|
],
|
||||||
|
outputAmount: 1, // Yield per batch
|
||||||
|
baseCycleTime: 3.2, // Raw cycle duration in seconds
|
||||||
|
|
||||||
|
// Capital Investment & Economics
|
||||||
|
unlockCost: 65000, // Cost to acquire the industrial permit
|
||||||
|
unlockedDefault: false, // True only for starter tier 1 units
|
||||||
|
baseFacilityCost: 45000, // Base line cost (scales exponentially by 1.15^lines)
|
||||||
|
baseManagerCost: 110000, // Cost to hire automating Operations Lead
|
||||||
|
baseLogisticsCost: 80000, // Cost to commission 1st wholesale logistics fleet
|
||||||
|
basePrice: 95.00, // Base wholesale market spot price
|
||||||
|
|
||||||
|
// Immersive Corporate Metadata
|
||||||
|
facilityName: 'Bio-Synthesis Complex', // Name of the physical production building
|
||||||
|
managerName: 'Bio-Process Engineer', // Title of the facility operations lead
|
||||||
|
logisticsName: 'Sealed Tanker Fleet', // Name of the dedicated logistics fleet
|
||||||
|
description: 'Eco-synthesized agricultural nutrients formulated from organic biomass.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Quick-Start: Adding a Completely New Sector
|
||||||
|
|
||||||
|
To add a brand new industrial sector (e.g. `Biotechnology & Pharmaceuticals`):
|
||||||
|
|
||||||
|
### Step A: Create `frontend/js/data/sectors/biotech.js`
|
||||||
|
```javascript
|
||||||
|
// Sector: Biotechnology & Pharmaceuticals
|
||||||
|
export const BIOTECH_SECTOR = {
|
||||||
|
id: 'biotech',
|
||||||
|
name: 'Biotech & Pharma',
|
||||||
|
icon: 'lucide:dna',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
description: 'Advanced genomics, clinical pharmaceuticals, and synthetic biochemistry.'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BIOTECH_PRODUCTS = [
|
||||||
|
{
|
||||||
|
id: 'cell_culture',
|
||||||
|
name: 'Cellular Cultures',
|
||||||
|
sector: 'biotech',
|
||||||
|
tier: 1,
|
||||||
|
icon: 'lucide:microscope',
|
||||||
|
color: '#a78bfa',
|
||||||
|
unit: 'vials',
|
||||||
|
inputs: [], // Raw bio-harvesting
|
||||||
|
outputAmount: 1,
|
||||||
|
baseCycleTime: 1.5,
|
||||||
|
unlockCost: 50000,
|
||||||
|
unlockedDefault: false,
|
||||||
|
baseFacilityCost: 35000,
|
||||||
|
baseManagerCost: 90000,
|
||||||
|
baseLogisticsCost: 60000,
|
||||||
|
basePrice: 65.00,
|
||||||
|
facilityName: 'Bio-Incubator Lab',
|
||||||
|
managerName: 'Chief Microbiologist',
|
||||||
|
logisticsName: 'Cryo-Courier Unit',
|
||||||
|
description: 'Living cell cultures cultivated in sterile bioreactor vessels.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'synthetic_insulin',
|
||||||
|
name: 'Synthetic Pharmaceuticals',
|
||||||
|
sector: 'biotech',
|
||||||
|
tier: 2,
|
||||||
|
icon: 'lucide:pill',
|
||||||
|
color: '#c084fc',
|
||||||
|
unit: 'doses',
|
||||||
|
inputs: [
|
||||||
|
{ id: 'cell_culture', amount: 2 },
|
||||||
|
{ id: 'refined_sugar', amount: 1 }
|
||||||
|
],
|
||||||
|
outputAmount: 1,
|
||||||
|
baseCycleTime: 3.5,
|
||||||
|
unlockCost: 280000,
|
||||||
|
unlockedDefault: false,
|
||||||
|
baseFacilityCost: 220000,
|
||||||
|
baseManagerCost: 450000,
|
||||||
|
baseLogisticsCost: 320000,
|
||||||
|
basePrice: 380.00,
|
||||||
|
facilityName: 'Pharmaceutical Synthesis Line',
|
||||||
|
managerName: 'Clinical Director',
|
||||||
|
logisticsName: 'Cold-Chain Express',
|
||||||
|
description: 'Purified medicinal formulations synthesized for global hospital networks.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step B: Register the Sector in `frontend/js/data/products.js`
|
||||||
|
Open `frontend/js/data/products.js` and add 2 lines:
|
||||||
|
```javascript
|
||||||
|
import { BIOTECH_SECTOR, BIOTECH_PRODUCTS } from './sectors/biotech.js';
|
||||||
|
|
||||||
|
export const SECTORS = [
|
||||||
|
...,
|
||||||
|
BIOTECH_SECTOR
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRODUCTS = [
|
||||||
|
...,
|
||||||
|
...BIOTECH_PRODUCTS
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Automated Verification Tool: `validate_dag.py`
|
||||||
|
|
||||||
|
Whenever you add new products or sectors, run the built-in integrity validator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python backend/validate_dag.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This validator checks:
|
||||||
|
1. **Acyclic Invariant:** Confirms there are zero circular dependency loops (e.g. A requires B, B requires A).
|
||||||
|
2. **Missing Reference Guard:** Confirms every `inputs[].id` exists in the catalog.
|
||||||
|
3. **Economic Balance Check:** Confirms that a product's output wholesale revenue exceeds the cost of its raw input materials.
|
||||||
|
4. **Tier Consistency:** Checks that products don't accidentally consume items from higher tiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Visual Asset Generation Prompt Recipe
|
||||||
|
|
||||||
|
For every product added, generate matching visuals and place them in the assets folders:
|
||||||
|
* **Product Specimen Photo:** `frontend/assets/images/products/{id}_product.jpg`
|
||||||
|
* **Facility Blueprint Photo:** `frontend/assets/images/facilities/{id}_facility.jpg`
|
||||||
|
|
||||||
|
### AI Image Generator Prompt Template:
|
||||||
|
> **Product Prompt:**
|
||||||
|
> `Commercial studio macro photograph of {Product Name} ({Unit}). Isolated on a dark matte slate workbench, soft directional studio rim lighting, razor-sharp focus on material texture, hyper-detailed, Hasselblad H6D-100c 120mm macro lens, 8k.`
|
||||||
|
>
|
||||||
|
> **Facility Prompt:**
|
||||||
|
> `Cinematic wide-angle architectural exterior photo of a high-tech {Facility Name}. Modern industrial engineering, stainless steel pipes, clean structural geometry, volumetric twilight lighting, subtle atmospheric steam, National Geographic documentary style, 35mm tilt-shift lens.`
|
||||||
|
|
||||||
|
*Note: If image files are not yet created, the game gracefully displays crisp SVG icons and styled placeholders without breaking!*
|
||||||
79
backend/catalog_loader.py
Normal file
79
backend/catalog_loader.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""
|
||||||
|
Production - The Idle Game: Sector & Product Registry Loader
|
||||||
|
Parses modular JS sector files into authoritative Python dictionaries.
|
||||||
|
Ensures single-source-of-truth between frontend definitions and backend simulation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
def js_to_dict(js_code: str):
|
||||||
|
"""Converts a standard JavaScript object/array literal to a Python dict/list."""
|
||||||
|
# Remove single-line comments
|
||||||
|
clean = re.sub(r'//.*', '', js_code)
|
||||||
|
# Quote unquoted property keys: e.g. id: 'wheat' -> "id": "wheat"
|
||||||
|
clean = re.sub(r'([{,]\s*)([a-zA-Z0-9_]+)\s*:', r'\1"\2":', clean)
|
||||||
|
# Replace single quotes with double quotes
|
||||||
|
clean = clean.replace("'", '"')
|
||||||
|
# Remove trailing commas before } or ]
|
||||||
|
clean = re.sub(r',\s*([}\]])', r'\1', clean)
|
||||||
|
return json.loads(clean)
|
||||||
|
|
||||||
|
def get_sectors_directory() -> str:
|
||||||
|
"""Finds the directory containing sector .js files whether in local dev or Docker."""
|
||||||
|
candidates = [
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "frontend", "js", "data", "sectors"), # Local repo dev
|
||||||
|
os.path.join(os.path.dirname(__file__), "sectors"), # Docker mount /app/sectors
|
||||||
|
"/app/sectors", # Standard container mount
|
||||||
|
os.path.abspath("frontend/js/data/sectors")
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if os.path.isdir(c) and glob.glob(os.path.join(c, "*.js")):
|
||||||
|
return os.path.abspath(c)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def load_all_sectors_and_products():
|
||||||
|
"""
|
||||||
|
Scans the sectors directory, parsing all modular JS sector files.
|
||||||
|
Returns:
|
||||||
|
(sectors_dict, products_dict)
|
||||||
|
"""
|
||||||
|
sec_dir = get_sectors_directory()
|
||||||
|
sectors = {}
|
||||||
|
products = {}
|
||||||
|
|
||||||
|
if not sec_dir:
|
||||||
|
print("[WARNING] No sector files directory found! Falling back to empty catalog.")
|
||||||
|
return sectors, products
|
||||||
|
|
||||||
|
for file_path in glob.glob(os.path.join(sec_dir, "*.js")):
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Parse Sector Object
|
||||||
|
sec_match = re.search(r'export\s+const\s+[A-Z_]+_SECTOR\s*=\s*({[\s\S]*?});', content)
|
||||||
|
if sec_match:
|
||||||
|
sec_obj = js_to_dict(sec_match.group(1))
|
||||||
|
sectors[sec_obj["id"]] = sec_obj
|
||||||
|
|
||||||
|
# Parse Products Array
|
||||||
|
prod_match = re.search(r'export\s+const\s+[A-Z_]+_PRODUCTS\s*=\s*(\[[\s\S]*?\]);', content)
|
||||||
|
if prod_match:
|
||||||
|
prod_list = js_to_dict(prod_match.group(1))
|
||||||
|
for p in prod_list:
|
||||||
|
products[p["id"]] = p
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to parse sector file {file_path}: {e}")
|
||||||
|
|
||||||
|
return sectors, products
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
s, p = load_all_sectors_and_products()
|
||||||
|
print(f"Loaded {len(s)} sectors and {len(p)} products.")
|
||||||
|
for sid, sobj in s.items():
|
||||||
|
count = sum(1 for prod in p.values() if prod.get('sector') == sid)
|
||||||
|
print(f" - {sobj['name']} ({sid}): {count} commodities")
|
||||||
|
|
@ -5,159 +5,15 @@ Authoritative offline tick progression, multi-tier DAG supply chains, and action
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
from catalog_loader import load_all_sectors_and_products
|
||||||
|
|
||||||
# Initial Product Catalog configuration mirroring frontend/js/data/products.js
|
# Dynamically loaded Product & Sector Registry from modular sector files
|
||||||
PRODUCT_CONFIGS = {
|
SECTOR_CONFIGS, PRODUCT_CONFIGS = load_all_sectors_and_products()
|
||||||
# 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
|
def refresh_catalog():
|
||||||
'timber': {
|
"""Hot-reloads the product catalog if new sector files were added dynamically."""
|
||||||
'sector': 'materials', 'tier': 1, 'inputs': [],
|
global SECTOR_CONFIGS, PRODUCT_CONFIGS
|
||||||
'outputAmount': 1, 'baseCycleTime': 1.2, 'unlockCost': 120,
|
SECTOR_CONFIGS, PRODUCT_CONFIGS = load_all_sectors_and_products()
|
||||||
'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]
|
UNIT_MILESTONES = [1, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
|
||||||
|
|
||||||
|
|
@ -444,7 +300,29 @@ def apply_actions(state: dict, actions: list) -> dict:
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def sanitize_state(raw: dict) -> dict:
|
def sanitize_state(raw: dict) -> dict:
|
||||||
"""Ensures incoming state doesn't have NaN or negative numbers."""
|
"""Ensures incoming state doesn't have NaN or negative numbers and dynamically populates new products."""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return get_initial_state()
|
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
|
return raw
|
||||||
|
|
|
||||||
105
backend/validate_dag.py
Normal file
105
backend/validate_dag.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""
|
||||||
|
Production - The Idle Game: Automated DAG & Supply Chain Integrity Validator
|
||||||
|
Scans all sector manifests and verifies:
|
||||||
|
1. All input IDs reference existing, valid product IDs.
|
||||||
|
2. No circular / cyclic dependency loops exist (Directed Acyclic Graph invariant).
|
||||||
|
3. Economic margin scaling (Wholesale value > Raw input costs).
|
||||||
|
4. Tier ordering integrity (Downstream Tier >= Upstream Tier).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add backend directory to path
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from catalog_loader import load_all_sectors_and_products
|
||||||
|
|
||||||
|
def validate_dag():
|
||||||
|
sectors, products = load_all_sectors_and_products()
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
print(f"=== Validating Supply Chain DAG Integrity ===")
|
||||||
|
print(f"Discovered: {len(sectors)} Sectors, {len(products)} Commodities / Facilities.\n")
|
||||||
|
|
||||||
|
if not products:
|
||||||
|
errors.append("FATAL: No products discovered in catalog!")
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
# 1. Existence and Key Verification
|
||||||
|
for pid, p in products.items():
|
||||||
|
# Check required fields
|
||||||
|
for field in ['id', 'name', 'sector', 'tier', 'baseCycleTime', 'basePrice', 'outputAmount', 'baseFacilityCost']:
|
||||||
|
if field not in p:
|
||||||
|
errors.append(f"Product '{pid}' is missing required field '{field}'.")
|
||||||
|
|
||||||
|
# Check sector exists
|
||||||
|
sec_id = p.get('sector')
|
||||||
|
if sec_id not in sectors:
|
||||||
|
errors.append(f"Product '{pid}' references unknown sector '{sec_id}'.")
|
||||||
|
|
||||||
|
# Check inputs exist
|
||||||
|
inputs = p.get('inputs', [])
|
||||||
|
input_cost = 0.0
|
||||||
|
for inp in inputs:
|
||||||
|
inp_id = inp.get('id')
|
||||||
|
amt = inp.get('amount', 1)
|
||||||
|
if inp_id not in products:
|
||||||
|
errors.append(f"Product '{pid}' requires nonexistent input '{inp_id}'.")
|
||||||
|
else:
|
||||||
|
parent = products[inp_id]
|
||||||
|
input_cost += parent.get('basePrice', 0.0) * amt
|
||||||
|
# Tier sanity check
|
||||||
|
if parent.get('tier', 1) > p.get('tier', 1):
|
||||||
|
warnings.append(f"Tier Inversion: '{pid}' (Tier {p.get('tier')}) consumes higher-tier product '{inp_id}' (Tier {parent.get('tier')}).")
|
||||||
|
|
||||||
|
# Economic margin check (Wholesale price vs input cost)
|
||||||
|
output_val = p.get('basePrice', 0.0) * p.get('outputAmount', 1)
|
||||||
|
if inputs and output_val <= input_cost:
|
||||||
|
warnings.append(f"Negative Profit Margin: '{pid}' batch produces ${output_val:.2f} but consumes ${input_cost:.2f} in inputs.")
|
||||||
|
|
||||||
|
# 2. Circular Dependency (Cycle Detection via DFS)
|
||||||
|
visited = {} # 0 = unvisited, 1 = visiting (in recursion stack), 2 = visited
|
||||||
|
|
||||||
|
def has_cycle(curr_id, path):
|
||||||
|
visited[curr_id] = 1 # Mark as currently visiting
|
||||||
|
curr_p = products.get(curr_id, {})
|
||||||
|
for inp in curr_p.get('inputs', []):
|
||||||
|
nxt_id = inp.get('id')
|
||||||
|
if nxt_id not in products:
|
||||||
|
continue
|
||||||
|
if visited.get(nxt_id, 0) == 1:
|
||||||
|
# Cycle found!
|
||||||
|
cycle_str = " -> ".join(path + [nxt_id])
|
||||||
|
errors.append(f"CIRCULAR DEPENDENCY DETECTED: {cycle_str}")
|
||||||
|
return True
|
||||||
|
if visited.get(nxt_id, 0) == 0:
|
||||||
|
if has_cycle(nxt_id, path + [nxt_id]):
|
||||||
|
return True
|
||||||
|
visited[curr_id] = 2 # Done with this branch
|
||||||
|
return False
|
||||||
|
|
||||||
|
for pid in products:
|
||||||
|
if visited.get(pid, 0) == 0:
|
||||||
|
has_cycle(pid, [pid])
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
errs, warns = validate_dag()
|
||||||
|
if warns:
|
||||||
|
print("[WARNINGS]:")
|
||||||
|
for w in warns:
|
||||||
|
print(f" [WARN] {w}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if errs:
|
||||||
|
print("[ERRORS FOUND]:")
|
||||||
|
for e in errs:
|
||||||
|
print(f" [FAIL] {e}")
|
||||||
|
print(f"\nDAG Validation FAILED with {len(errs)} error(s).")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("SUCCESS: DAG VALIDATION PASSED. Supply Chain is acyclic, consistent, and healthy!")
|
||||||
|
sys.exit(0)
|
||||||
|
|
@ -9,6 +9,7 @@ services:
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./frontend/js/data/sectors:/app/sectors:ro
|
||||||
networks:
|
networks:
|
||||||
- production-net
|
- production-net
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,5 +95,27 @@ export const CHEMICALS_PRODUCTS = [
|
||||||
managerName: 'Materials Scientist',
|
managerName: 'Materials Scientist',
|
||||||
logisticsName: 'Clean Ingot Carrier',
|
logisticsName: 'Clean Ingot Carrier',
|
||||||
description: '99.999% pure electronic grade monocrystalline silicon.'
|
description: '99.999% pure electronic grade monocrystalline silicon.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'industrial_fertilizer',
|
||||||
|
name: 'Industrial Fertilizer',
|
||||||
|
sector: 'chemicals',
|
||||||
|
tier: 2,
|
||||||
|
icon: 'lucide:flask-round',
|
||||||
|
color: '#10b981',
|
||||||
|
unit: 'barrels',
|
||||||
|
inputs: [{ id: 'crude_oil', amount: 1 }],
|
||||||
|
outputAmount: 2,
|
||||||
|
baseCycleTime: 2.8,
|
||||||
|
unlockCost: 75000,
|
||||||
|
unlockedDefault: false,
|
||||||
|
baseFacilityCost: 55000,
|
||||||
|
baseManagerCost: 120000,
|
||||||
|
baseLogisticsCost: 85000,
|
||||||
|
basePrice: 110.00,
|
||||||
|
facilityName: 'Fertilizer Synthesis Plant',
|
||||||
|
managerName: 'Synthesis Chemist',
|
||||||
|
logisticsName: 'Agri-Chemical Carrier',
|
||||||
|
description: 'High-nitrogen chemical nutrients accelerating large-scale agrarian yields.'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue