From 7cdcd4fb5e2b1cd8e2cfebdb64f5914388536376 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 16:45:42 +0200 Subject: [PATCH] feat(arch): implement single-source-of-truth modular sector registry and DAG validator for scaling to 100+ products --- PRODUCT_GENERATOR_GUIDE.md | 166 +++++++++++++++++++++++ backend/catalog_loader.py | 79 +++++++++++ backend/engine.py | 182 +++++--------------------- backend/validate_dag.py | 105 +++++++++++++++ docker-compose.yml | 1 + frontend/js/data/sectors/chemicals.js | 22 ++++ 6 files changed, 403 insertions(+), 152 deletions(-) create mode 100644 PRODUCT_GENERATOR_GUIDE.md create mode 100644 backend/catalog_loader.py create mode 100644 backend/validate_dag.py diff --git a/PRODUCT_GENERATOR_GUIDE.md b/PRODUCT_GENERATOR_GUIDE.md new file mode 100644 index 0000000..0611772 --- /dev/null +++ b/PRODUCT_GENERATOR_GUIDE.md @@ -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/.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!* diff --git a/backend/catalog_loader.py b/backend/catalog_loader.py new file mode 100644 index 0000000..665ad9e --- /dev/null +++ b/backend/catalog_loader.py @@ -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") diff --git a/backend/engine.py b/backend/engine.py index 7127b24..0cefe96 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -5,159 +5,15 @@ Authoritative offline tick progression, multi-tier DAG supply chains, and action import math import time +from catalog_loader import load_all_sectors_and_products -# 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 - }, +# Dynamically loaded Product & Sector Registry from modular sector files +SECTOR_CONFIGS, PRODUCT_CONFIGS = load_all_sectors_and_products() - # 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 - } -} +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] @@ -444,7 +300,29 @@ def apply_actions(state: dict, actions: list) -> dict: return state 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): 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 diff --git a/backend/validate_dag.py b/backend/validate_dag.py new file mode 100644 index 0000000..aa0b323 --- /dev/null +++ b/backend/validate_dag.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 35fdc3e..021a1cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - DATA_DIR=/app/data volumes: - ./data:/app/data + - ./frontend/js/data/sectors:/app/sectors:ro networks: - production-net diff --git a/frontend/js/data/sectors/chemicals.js b/frontend/js/data/sectors/chemicals.js index d6879cc..e22832f 100644 --- a/frontend/js/data/sectors/chemicals.js +++ b/frontend/js/data/sectors/chemicals.js @@ -95,5 +95,27 @@ export const CHEMICALS_PRODUCTS = [ managerName: 'Materials Scientist', logisticsName: 'Clean Ingot Carrier', 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.' } ];