feat: transform game into Production - The Idle Game with multi-tier supply chains, B2B contracts, IPO prestige, and responsive executive UI

This commit is contained in:
Christian 2026-10-15 21:29:51 +02:00
parent d62f13ee31
commit bcf32f3ca7
28 changed files with 4659 additions and 4299 deletions

View file

@ -1,9 +1,9 @@
# El Puro Configuration
# Production - The Idle Game Configuration
# Port on host to expose the application
HOST_PORT=8080
# Public domain name (e.g. elpuro.example.com or localhost)
# Public domain name (e.g. localhost)
DOMAIN_NAME=localhost
# Secret key for JWT signing
SECRET_KEY=el_puro_secret_key_change_in_production_jwt_99_1_pure
SECRET_KEY=production_game_enterprise_jwt_secret_key_change_in_production

2
.gitignore vendored
View file

@ -2,7 +2,7 @@
.env
# Database & Persistent storage
data/
/data/
*.db
*.sqlite
*.sqlite3

143
README.md
View file

@ -1,33 +1,47 @@
# EL PURO
# PRODUCTION - The Idle Game
> **"99.1% Pure. Zero Compromise."**
> **"Build the Ultimate Global Supply Chain Empire."**
**El Puro** er et mobil-optimeret incremental/idle-spil stylet som en hemmelig, krypteret burner phone (*SilkNet OS v3.2*). Spillet er skabt til hurtige mikro-sessioner (30 sekunder til 3 minutter), hvor du opbygger et undergrundsimperium fra en lille cannabis-plante til et globalt syntetisk kartel.
**Production - The Idle Game** is an expansive, strategic incremental/idle web game inspired by *Capitalism*, *Factorio-lite*, and modern idle tycoon simulators. Take command of a burgeoning enterprise, starting from primary extraction (wheat, timber, iron ore) and scaling into complex, multi-tiered industrial supply chains producing microchips, commercial trucks, consumer smartphones, and corporate office furniture.
Spillet kører som en selvstændig **Docker Compose stack** med en lynhurtig Python FastAPI backend, SQLite database og en Nginx reverse-proxy.
The game is packaged as a lightweight, turnkey **Docker Compose stack** powered by a fast Python FastAPI backend with SQLite persistence and an Nginx reverse-proxy frontend.
---
## 📱 Nøglefunktioner
## 🌟 Key Features
* **Burner Phone Interface:** Mørkt OLED-design med monospaced taktiske datafonte (`JetBrains Mono`), statuslinje med Police Heat meter og 4 touch-venlige bund-tabs:
* ⚗️ **Lab:** Manuel høst/syntese, hyring af laboranter og opskriftsblanding.
* 💼 **Streets:** Dealer-netværk og **Designer Cocktails** (kombination af stoffer som f.eks. *Calvin Klein*, *Speedball* og *Candy Flip*).
* 🕸️ **Network (Talent-Træ):** Forgrenet spindelvæv med 3 distinkte stier: *Path of the Chemist*, *Path of the Kingpin* og *Path of the Smuggler*.
* 🏦 **Offshore:** Hvidvaskning af snavsede kontanter til **Clean Bonds** (Prestige-reset med permanente multiplikatorer).
* **10 Progressive Stof-Tiers:** Cannabis, Magic Mushrooms, Adderall, Meth (Blue Sky), MDMA, Ketamin, Kokain (Pure White), Crack, Heroin og Fentanyl.
* **Police Heat & Raids:** Hurtige quick-time hændelser, hvor du kan bestikke politiet, gemme dit lager eller løbe risikoen for 2x profit.
* **Simpelt Honeypot Brugersystem:** Kun brugernavn og password. Ingen e-mail, ingen 2FA. Inkluderer en usynlig honeypot-fælde (`website_hp`), der automatisk afviser bot-registreringer.
* **Cloud & Offline Sync:** Spillet gemmer lokalt og synkroniserer automatisk til serveren hvert 5. sekund.
* **Real Supply Chain Dependencies (DAG Pipeline):**
* Higher-tier industrial goods require and consume physical stockpiles of lower-tier commodities.
* Real-time bottleneck warnings when upstream inputs run short.
* 6 Interconnected Industry Sectors:
1. **Agriculture & Food:** Wheat, Milled Flour, Sugar Beet, Refined Sugar, Artisan Bread, Packaged Meals.
2. **Forestry & Materials:** Raw Timber, Milled Planks, Industrial Paper, Office Furniture.
3. **Mining & Metallurgy:** Iron Ore, Coking Coal, Structural Steel, Heavy Frameworks.
4. **Chemicals & Polymers:** Crude Oil, Silica Quartz Sand, Industrial Polymers, Purified Silicon.
5. **High-Tech & Electronics:** Silicon Microchips, PCB Motherboards, Consumer Smartphones.
6. **Heavy Industry & Vehicles:** Industrial Engines, Commercial Logistics Trucks.
* **Dual Revenue Channels:**
* **Continuous Logistics Distribution:** Deploy dedicated logistics fleets to automatically wholesale products to regional distributors for continuous passive cashflow.
* **B2B Procurement Contracts Board:** Bid on time-sensitive procurement tenders from MegaCorps and industrial clients for massive capital injections and reputation.
* **Corporate IPO & Boardroom Prestige:**
* File for an **Initial Public Offering (IPO)** on Wall Street when your enterprise valuation reaches critical mass.
* Cash in for **Venture Shares ($V-Shares)** to unlock permanent perks on the Boardroom R&D Tech Tree (Automated ERP Routing, Industrial Robotics, Logistics Subsidies, Venture Capital Syndicate).
* **Responsive Executive UI:**
* **Desktop Command Station:** 3-column workstation layout (Departments & Sectors sidebar, main Production Floor, and live Supply Chain Inspector with instant spot-market liquidation).
* **Mobile Optimization:** Ergonomic single-column interface with bottom navigation tabs and thumb-friendly controls.
* **Cloud & Offline Sync:**
* Plays instantly as a Guest with local persistence.
* Optional lightweight account system (username + password with anti-bot honeypot protection).
* Authoritative offline tick engine: when you return, your automated lines calculate production and logistics sales up to 8 hours.
---
## 🛠️ Arkitektur & Teknologistak
## 🛠️ Architecture
```
┌────────────────────────┐
│ Browser / Mobil UI │
│ (HTML5, CSS, JS) │
│ Browser Client UI │
│ (ES Modules, CSS3) │
└───────────┬────────────┘
│ HTTP / :HOST_PORT
▼
@ -40,90 +54,73 @@ Spillet kører som en selvstændig **Docker Compose stack** med en lynhurtig Pyt
│ ▼
│ ┌───────────────┐
│ │ Python FastAPI│
│ │ (JWT & Auth) │
│ │ (JWT & Engine)│
│ └───────┬───────┘
│ │
▼ ▼
[frontend/] [SQLite DB]
(/app/data/elpuro.db)
(/app/data/production.db)
```
---
## 🚀 Hurtig Start med Docker Compose
## 🚀 Quick Start with Docker Compose
### 1. Forudsætninger
### 1. Prerequisites
* [Docker](https://docs.docker.com/get-docker/) & [Docker Compose](https://docs.docker.com/compose/)
### 2. Klon repository
```bash
git clone https://git.p20.dev/christian/el-puro.git
cd el-puro
```
### 3. Konfiguration (.env)
Kopier eksempelfilen og tilpas dine indstillinger:
### 2. Configuration (.env)
Create a `.env` file from the example:
```bash
cp .env.example .env
```
Indhold i `.env`:
```env
# Port på værtsmaskinen hvor spillet tilgås
HOST_PORT=8080
# Offentligt domænenavn (valgfrit til vhost-routing)
DOMAIN_NAME=localhost
# Hemmelig nøgle til JWT signering
SECRET_KEY=el_puro_secret_key_change_in_production_jwt_99_1_pure
```
### 4. Start stakken
### 3. Launch the Stack
```bash
docker compose up -d --build
```
Spillet er nu tilgængeligt i din browser på:
👉 **`http://localhost:8080`** (eller den port du valgte i `.env`).
The game is live in your browser at:
👉 **`http://localhost:8080`** (or the port defined in `.env`).
---
## 📁 Projektstruktur
## 📁 Project Directory Structure
```
.
├── .env.example # Skabelon til miljøvariable
├── docker-compose.yml # Docker Compose opsætning
├── backend/ # FastAPI backend
├── .env.example
├── docker-compose.yml
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── main.py # API endpoints & logik
│ ├── auth.py # Bcrypt & JWT autentificering
│ ├── database.py # SQLite databaseinitialisering
│ └── models.py # Pydantic schemas (med honeypot trap)
├── frontend/ # Mobil-optimeret frontend
│ ├── index.html # SilkNet OS layout og modals
│ ├── auth.py # JWT authentication & password hashing
│ ├── database.py # SQLite connection and migration setup
│ ├── engine.py # Authoritative DAG simulation and offline calculations
│ ├── main.py # FastAPI endpoints
│ └── models.py # Pydantic schemas
├── frontend/
│ ├── index.html # Responsive executive layout
│ ├── css/
│ │ └── style.css # Dark OLED tema og responsivt layout
│ │ └── style.css # Modern corporate design system
│ └── js/
│ ├── api.js # API klient & sky-synkronisering
│ ├── config.js # 10 stoffer, opskrifter, skills og achievements
│ └── game.js # Spilmotor, loop, heat og mechanics
│ ├── app.js # Application orchestrator & game loop
│ ├── core/
│ │ ├── engine.js # Client-side DAG tick simulation
│ │ ├── formatters.js# Number notations (Standard K/M/B/T, Scientific)
│ │ ├── state.js # State manager, schema handling & IPO reset
│ │ └── sync.js # Backend synchronization & auth handling
│ ├── data/
│ │ ├── products.js # 24+ commodities across 6 sectors
│ │ ├── contracts.js # B2B procurement tenders
│ │ ├── boardroom.js # IPO perks and Venture Share logic
│ │ └── achievements.js
│ └── ui/
│ ├── productionView.js
│ ├── contractsView.js
│ ├── boardroomView.js
│ ├── inspectorView.js
│ └── achievementsView.js
└── nginx/
└── default.conf.template # Nginx reverse proxy konfiguration
└── default.conf.template
```
---
## 🔒 Honeypot Sikkerhed
Registrerings-endpointet (`POST /api/auth/register`) inkluderer en skjult `website_hp` parameter.
* Menneskelige brugere ser ikke feltet og lader det stå tomt.
* Automatiserede bots udfylder formularen blindt, hvorefter serveren afviser anmodningen med `400 Bad Request`. Dette holder databasen fri for falske konti uden behov for Captcha.
---
## 📜 Licens
Privat projekt tilhørende Christian. Alle rettigheder forbeholdes.

View file

@ -5,7 +5,7 @@ from jose import jwt, JWTError
from fastapi import HTTPException, Security, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
SECRET_KEY = os.getenv("SECRET_KEY", "el_puro_secret_key_change_in_production_jwt_99_1_pure")
SECRET_KEY = os.getenv("SECRET_KEY", "production_game_secret_key_change_in_production_jwt")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 365

View file

@ -2,7 +2,7 @@ import sqlite3
import os
DB_DIR = os.getenv("DATA_DIR", "/app/data")
DB_PATH = os.path.join(DB_DIR, "elpuro.db")
DB_PATH = os.path.join(DB_DIR, "production.db")
def get_db():
os.makedirs(DB_DIR, exist_ok=True)

View file

@ -1,506 +1,392 @@
"""
Production - The Idle Game: Core Simulation Backend Engine
Authoritative offline tick progression, multi-tier DAG supply chains, and action validation.
"""
import math
import time
# Drug Catalog mirroring config.js
DRUG_CONFIGS = {
'weed': {'rawCost': 10, 'sellPrice': 16, 'produceRate': 1.0, 'sellRate': 1.0, 'supplyRate': 1.0, 'supplierCost': 75, 'workerCost': 100, 'dealerCost': 150, 'unlockCost': 0},
'shrooms': {'rawCost': 35, 'sellPrice': 65, 'produceRate': 0.8, 'sellRate': 0.8, 'supplyRate': 0.8, 'supplierCost': 300, 'workerCost': 450, 'dealerCost': 650, 'unlockCost': 500},
'adderall': {'rawCost': 120, 'sellPrice': 240, 'produceRate': 0.6, 'sellRate': 0.6, 'supplyRate': 0.6, 'supplierCost': 1500, 'workerCost': 2000, 'dealerCost': 3000, 'unlockCost': 2500},
'meth': {'rawCost': 400, 'sellPrice': 950, 'produceRate': 0.5, 'sellRate': 0.5, 'supplyRate': 0.5, 'supplierCost': 6000, 'workerCost': 8000, 'dealerCost': 12000, 'unlockCost': 10000},
'mdma': {'rawCost': 1200, 'sellPrice': 3200, 'produceRate': 0.4, 'sellRate': 0.4, 'supplyRate': 0.4, 'supplierCost': 20000, 'workerCost': 28000, 'dealerCost': 40000, 'unlockCost': 35000},
'ketamine': {'rawCost': 3800, 'sellPrice': 11000, 'produceRate': 0.35, 'sellRate': 0.35, 'supplyRate': 0.35, 'supplierCost': 75000, 'workerCost': 95000, 'dealerCost': 140000, 'unlockCost': 120000},
'cocaine': {'rawCost': 12000, 'sellPrice': 38000, 'produceRate': 0.3, 'sellRate': 0.3, 'supplyRate': 0.3, 'supplierCost': 250000, 'workerCost': 320000, 'dealerCost': 480000, 'unlockCost': 400000},
'crack': {'rawCost': 35000, 'sellPrice': 115000, 'produceRate': 0.25, 'sellRate': 0.25, 'supplyRate': 0.25, 'supplierCost': 700000, 'workerCost': 900000, 'dealerCost': 1400000, 'unlockCost': 1200000},
'heroin': {'rawCost': 110000, 'sellPrice': 420000, 'produceRate': 0.2, 'sellRate': 0.2, 'supplyRate': 0.2, 'supplierCost': 2800000, 'workerCost': 3500000, 'dealerCost': 5500000, 'unlockCost': 4500000},
'fentanyl': {'rawCost': 450000, 'sellPrice': 2100000, 'produceRate': 0.15, 'sellRate': 0.15, 'supplyRate': 0.15, 'supplierCost': 12000000, 'workerCost': 15000000, 'dealerCost': 24000000, 'unlockCost': 20000000}
# 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
}
}
SYNTHESIS_RECIPES = {
'calvin_klein': {'ingredients': {'cocaine': 5, 'ketamine': 5}, 'payout': 95000},
'speedball': {'ingredients': {'heroin': 3, 'cocaine': 3}, 'payout': 850000},
'candy_flip': {'ingredients': {'shrooms': 15, 'mdma': 10}, 'payout': 75000}
}
UNIT_MILESTONES = [1, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
SKILL_COSTS = {
'chem_1': 500, 'chem_2': 15000, 'chem_3': 250000,
'king_1': 1000, 'king_2': 35000, 'king_3': 600000,
'smug_1': 2000, 'smug_2': 50000, 'smug_3': 1000000
}
UNIT_MILESTONES = [1, 5, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
OFFSHORE_PERK_COSTS = {
'swiss_vault': [1, 3, 8, 20, 50],
'auto_contracts': [2, 5, 12, 30],
'corrupt_judiciary': [2, 6, 15, 40],
'bulk_discount': [5, 15, 35, 80],
'compound_interest': [4, 10, 25, 60, 150]
}
HELPER_COSTS = {
'bulk_buyer': 2500, # Level 1: Multi-buy bulk execution
'roi_advisor': 15000, # Level 2: Real-time ROI analysis across raw, prod, deal
'smart_allocator': 75000 # Level 3: Percentage portfolio investment (10%, 25%, 50%, 100%)
}
ACHIEVEMENT_TIER_REQS = {
'dirty_empire': [10000, 250000, 5000000, 100000000, 1000000000],
'master_synthesizer': [100, 1000, 15000, 200000, 2500000],
'street_monopoly': [100, 1500, 25000, 300000, 3000000],
'supply_baron': [250, 3000, 50000, 500000],
'vault_hoarder': [50000, 500000, 10000000, 250000000],
'syndicate_army': [25, 100, 500, 2500],
'purity_legend': [5, 25, 100, 500],
'shadow_cartel': [3, 10, 30],
'swiss_kingpin': [1, 3, 10, 25],
'formula_expansion': [3, 6, 8, 10]
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:
"""Rounds float gracefully to 2 decimal places without floating point anomalies."""
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:
"""Calculates 2x multiplier for each milestone threshold achieved."""
reached = sum(1 for m in UNIT_MILESTONES if count >= m)
return float(math.pow(2.0, reached))
def get_all_tier_milestone_multiplier(state: dict, unit_key: str) -> float:
"""Calculates 2x global set multiplier when all unlocked drugs reach a milestone."""
unlocked = [d for d in state.get('drugs', {}).values() if d.get('unlocked')]
if not unlocked:
return 1.0
min_count = min(d.get(unit_key, 0) for d in unlocked)
reached = sum(1 for m in UNIT_MILESTONES if min_count >= m)
return float(math.pow(2.0, reached))
def get_initial_state() -> dict:
state = {
'cash': 25.0,
'cleanBonds': 0,
'totalCleanBonds': 0,
'heat': 0.0,
'totalEarned': 0.0,
'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,
'totalSold': 0.0,
'totalSupplied': 0.0,
'purityProcs': 0,
'raidsSurvived': 0,
'prestigeCount': 0
'contractsCompleted': 0,
'managersHired': 0
},
'achievements': {}, # { ach_id: claimed_tier_int }
'offshorePerks': {}, # { perk_id: level_int }
'helpers': {h: False for h in HELPER_COSTS}, # { helper_id: bool }
'skills': {s: False for s in SKILL_COSTS},
'synthesis': {r: 0 for r in SYNTHESIS_RECIPES},
'drugs': {},
'products': {},
'boardroomPerks': {p: 0 for p in BOARDROOM_PERK_COSTS},
'achievements': {},
'activeContracts': [],
'lastTick': time.time()
}
for drug_id, cfg in DRUG_CONFIGS.items():
state['drugs'][drug_id] = {
'unlocked': (drug_id == 'weed'),
'raw': 0.0,
'product': 0.0,
'suppliers': 0,
'workers': 0,
'dealers': 0
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 get_multipliers(state: dict):
# Total lifetime bonds provide permanent base boost
total_bonds = max(state.get('totalCleanBonds', 0), state.get('cleanBonds', 0))
skills = state.get('skills', {})
ach = state.get('achievements', {})
perks = state.get('offshorePerks', {})
# Compound interest perk raises bond sale value
compound_lvl = perks.get('compound_interest', 0)
bond_sell_rate = 0.04 + (compound_lvl * 0.02)
bond_prod_rate = 0.03
prod_m = 1.0 + (total_bonds * bond_prod_rate)
if skills.get('chem_1'): prod_m += 0.15
# Achievement bonuses for production
synth_tier = ach.get('master_synthesizer', 0)
prod_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, synth_tier)]
formula_tier = ach.get('formula_expansion', 0)
prod_m += [0.0, 0.05, 0.10, 0.15, 0.30][min(4, formula_tier)]
# Sales multiplier
sell_m = 1.0 + (total_bonds * bond_sell_rate)
if skills.get('king_2'): sell_m += 0.25
empire_tier = ach.get('dirty_empire', 0)
sell_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, empire_tier)]
# Raw material cost multiplier
raw_m = 0.85 if skills.get('chem_2') else 1.0
vault_tier = ach.get('vault_hoarder', 0)
raw_discount = [0.0, 0.05, 0.10, 0.15, 0.25][min(4, vault_tier)]
raw_m = max(0.1, raw_m - raw_discount)
# Dealer speed multiplier
dealer_m = 1.0
if skills.get('king_1'): dealer_m += 0.20
if skills.get('king_3'): dealer_m += 0.50
street_tier = ach.get('street_monopoly', 0)
dealer_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, street_tier)]
# Supplier speed multiplier
supply_tier = ach.get('supply_baron', 0)
supplier_m = 1.0 + [0.0, 0.05, 0.10, 0.15, 0.25][min(4, supply_tier)]
# Syndicate Army all speed multiplier
army_tier = ach.get('syndicate_army', 0)
all_unit_m = 1.0 + [0.0, 0.05, 0.10, 0.15, 0.25][min(4, army_tier)]
dealer_m *= all_unit_m
supplier_m *= all_unit_m
worker_m = all_unit_m
return prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m
def get_current_stat_value(state: dict, stat_key: str) -> float:
"""Helper to query current progress for an achievement category."""
if stat_key == 'totalEarned':
return float(state.get('totalEarned', 0.0))
elif stat_key == 'cash':
return float(state.get('cash', 0.0))
elif stat_key == 'totalPersonnel':
total = 0
for d in state.get('drugs', {}).values():
total += d.get('suppliers', 0) + d.get('workers', 0) + d.get('dealers', 0)
return float(total)
elif stat_key == 'unlockedCount':
return float(sum(1 for d in state.get('drugs', {}).values() if d.get('unlocked')))
else:
return float(state.get('stats', {}).get(stat_key, 0.0))
def validate_achievement_claim(state: dict, ach_id: str, tier: int) -> bool:
reqs = ACHIEVEMENT_TIER_REQS.get(ach_id)
if not reqs or tier < 1 or tier > len(reqs):
return False
current_claimed = state.get('achievements', {}).get(ach_id, 0)
if tier != current_claimed + 1:
return False
req_val = reqs[tier - 1]
stat_key_map = {
'dirty_empire': 'totalEarned',
'master_synthesizer': 'totalProduced',
'street_monopoly': 'totalSold',
'supply_baron': 'totalSupplied',
'vault_hoarder': 'cash',
'syndicate_army': 'totalPersonnel',
'purity_legend': 'purityProcs',
'shadow_cartel': 'raidsSurvived',
'swiss_kingpin': 'prestigeCount',
'formula_expansion': 'unlockedCount'
}
stat_key = stat_key_map.get(ach_id, 'totalEarned')
current_val = get_current_stat_value(state, stat_key)
return current_val >= req_val
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)
delta = min(max(0.0, now - last), 86400.0) # Cap at 24h offline
elapsed = max(0.0, min(now - last, 8.0 * 3600.0))
state['lastTick'] = now
if delta <= 0:
return sanitize_state(state)
if elapsed <= 0.1:
return state
prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m = get_multipliers(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)
# All-tier milestone multipliers
all_tier_sup = get_all_tier_milestone_multiplier(state, 'suppliers')
all_tier_work = get_all_tier_milestone_multiplier(state, 'workers')
all_tier_deal = get_all_tier_milestone_multiplier(state, 'dealers')
# Simulate in reasonable sub-steps for chained DAG dependency
sub_steps = max(1, min(int(elapsed), 120))
dt = elapsed / sub_steps
stats = state.setdefault('stats', {})
for drug_id, data in state.get('drugs', {}).items():
cfg = DRUG_CONFIGS.get(drug_id)
if not cfg or not data.get('unlocked'):
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
# Suppliers automatic raw cultivation / procurement
suppliers = data.get('suppliers', 0)
if suppliers > 0:
unit_milestone_m = get_unit_milestone_multiplier(suppliers)
supply_speed = suppliers * cfg.get('supplyRate', 1.0) * prod_m * supplier_m * unit_milestone_m * all_tier_sup
added_raw = supply_speed * delta
data['raw'] = data.get('raw', 0.0) + added_raw
stats['totalSupplied'] = stats.get('totalSupplied', 0.0) + added_raw
lines = p_state.get('lines', 0)
if lines <= 0:
continue
# Workers production (Refining raw -> product)
workers = data.get('workers', 0)
raw = data.get('raw', 0.0)
if workers > 0 and raw > 0:
unit_milestone_m = get_unit_milestone_multiplier(workers)
potential_prod = workers * cfg['produceRate'] * prod_m * worker_m * unit_milestone_m * all_tier_work * delta
actual_prod = min(raw, potential_prod)
data['raw'] = max(0.0, raw - actual_prod)
data['product'] = data.get('product', 0.0) + actual_prod
stats['totalProduced'] = stats.get('totalProduced', 0.0) + actual_prod
milestone_mult = get_unit_milestone_multiplier(lines)
cycle_rate = (1.0 / p_cfg['baseCycleTime']) * milestone_mult * robotics_speed_mult
# Dealers selling (Product -> Cash)
dealers = data.get('dealers', 0)
product = data.get('product', 0.0)
if dealers > 0 and product > 0:
unit_milestone_m = get_unit_milestone_multiplier(dealers)
potential_sell = dealers * cfg['sellRate'] * dealer_m * unit_milestone_m * all_tier_deal * delta
actual_sell = min(product, potential_sell)
data['product'] = max(0.0, product - actual_sell)
rev = actual_sell * cfg['sellPrice'] * sell_m
state['cash'] = state.get('cash', 0.0) + rev
state['totalEarned'] = state.get('totalEarned', 0.0) + rev
stats['totalSold'] = stats.get('totalSold', 0.0) + actual_sell
# Heat decay
state['heat'] = max(0.0, state.get('heat', 0.0) - (0.5 * delta))
return sanitize_state(state)
def apply_actions(state: dict, actions: list) -> dict:
"""Applies and authorizes player actions sequentially against server authority."""
prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m = get_multipliers(state)
# Unit price scaling factor (1.15 down to 1.11 via bulk_discount perk)
discount_lvl = state.get('offshorePerks', {}).get('bulk_discount', 0)
scaling_r = max(1.10, 1.15 - (discount_lvl * 0.01))
for act in actions:
action_type = act.get('type')
drug_id = act.get('drug_id')
if action_type == 'buy_raw' and drug_id in DRUG_CONFIGS:
cfg = DRUG_CONFIGS[drug_id]
cost = cfg['rawCost'] * raw_m
amount = max(1, int(act.get('amount', 1)))
total_cost = cost * amount
if state['cash'] >= total_cost:
state['cash'] -= total_cost
state['drugs'][drug_id]['raw'] = state['drugs'][drug_id].get('raw', 0.0) + amount
state.setdefault('stats', {})['totalSupplied'] = state['stats'].get('totalSupplied', 0.0) + amount
elif action_type == 'manufacture' and drug_id in DRUG_CONFIGS:
data = state['drugs'][drug_id]
amount = max(1, int(act.get('amount', 1)))
actual = min(amount, int(data.get('raw', 0)))
if actual > 0:
data['raw'] -= actual
yield_count = actual
if state.get('skills', {}).get('chem_3'):
# 10% chance on client for 3x yield
yield_count *= 1.3
state.setdefault('stats', {})['purityProcs'] = state['stats'].get('purityProcs', 0) + 1
data['product'] = data.get('product', 0.0) + yield_count
state.setdefault('stats', {})['totalProduced'] = state['stats'].get('totalProduced', 0.0) + yield_count
# Swiss vault secrecy perk reduces heat generation
vault_lvl = state.get('offshorePerks', {}).get('swiss_vault', 0)
heat_rise = max(0.2, (0.5 * actual) * (1.0 - vault_lvl * 0.12))
state['heat'] = min(100.0, state.get('heat', 0.0) + heat_rise)
elif action_type == 'sell_manual' and drug_id in DRUG_CONFIGS:
data = state['drugs'][drug_id]
cfg = DRUG_CONFIGS[drug_id]
amount = max(1, int(act.get('amount', 1)))
actual = min(amount, int(data.get('product', 0)))
if actual > 0:
data['product'] -= actual
profit = actual * cfg['sellPrice'] * sell_m
state['cash'] += profit
state['totalEarned'] += profit
state.setdefault('stats', {})['totalSold'] = state['stats'].get('totalSold', 0.0) + actual
vault_lvl = state.get('offshorePerks', {}).get('swiss_vault', 0)
heat_rise = max(0.3, (0.8 * actual) * (1.0 - vault_lvl * 0.12))
state['heat'] = min(100.0, state.get('heat', 0.0) + heat_rise)
elif action_type == 'hire_supplier' and drug_id in DRUG_CONFIGS:
cfg = DRUG_CONFIGS[drug_id]
hire_qty = max(1, int(act.get('count', 1)))
base_cost = cfg.get('supplierCost', 75)
for _ in range(hire_qty):
count = state['drugs'][drug_id].get('suppliers', 0)
cost = math.floor(base_cost * math.pow(scaling_r, count))
if state['cash'] >= cost:
state['cash'] -= cost
state['drugs'][drug_id]['suppliers'] = count + 1
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:
break
# Manufacturing requiring inputs
max_cycles = int(min(
state['products'].get(inp['id'], {}).get('stock', 0.0) // (inp['amount'] * lines)
for inp in inputs
)) if inputs else 0
elif action_type == 'hire_worker' and drug_id in DRUG_CONFIGS:
cfg = DRUG_CONFIGS[drug_id]
hire_qty = max(1, int(act.get('count', 1)))
base_cost = cfg['workerCost']
for _ in range(hire_qty):
count = state['drugs'][drug_id].get('workers', 0)
cost = math.floor(base_cost * math.pow(scaling_r, count))
if state['cash'] >= cost:
state['cash'] -= cost
state['drugs'][drug_id]['workers'] = count + 1
if max_cycles <= 0:
p_state['isBottlenecked'] = True
else:
break
p_state['isBottlenecked'] = False
p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
if p_state['cycleProgress'] >= 1.0:
cycles_to_run = min(int(p_state['cycleProgress']), max_cycles)
if cycles_to_run > 0:
p_state['cycleProgress'] -= cycles_to_run
for inp in inputs:
if inp['id'] in state['products']:
state['products'][inp['id']]['stock'] = max(
0.0,
state['products'][inp['id']]['stock'] - (inp['amount'] * lines * cycles_to_run)
)
produced = cycles_to_run * p_cfg['outputAmount'] * lines
p_state['stock'] = p_state.get('stock', 0.0) + produced
p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced
elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS:
cfg = DRUG_CONFIGS[drug_id]
hire_qty = max(1, int(act.get('count', 1)))
base_cost = cfg['dealerCost']
for _ in range(hire_qty):
count = state['drugs'][drug_id].get('dealers', 0)
cost = math.floor(base_cost * math.pow(scaling_r, count))
if state['cash'] >= cost:
state['cash'] -= cost
state['drugs'][drug_id]['dealers'] = count + 1
else:
break
# 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
elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS:
cfg = DRUG_CONFIGS[drug_id]
cost = cfg['unlockCost']
if state['cash'] >= cost and not state['drugs'][drug_id].get('unlocked'):
state['cash'] -= cost
state['drugs'][drug_id]['unlocked'] = True
earned = sold * p_cfg['basePrice'] * logistics_price_mult
state['cash'] = state.get('cash', 0.0) + earned
state['lifetimeRevenue'] = state.get('lifetimeRevenue', 0.0) + earned
# Procurement pipeline perk gives free starter units!
starter_units = state.get('offshorePerks', {}).get('auto_contracts', 0)
if starter_units > 0:
state['drugs'][drug_id]['suppliers'] = state['drugs'][drug_id].get('suppliers', 0) + starter_units
state['drugs'][drug_id]['workers'] = state['drugs'][drug_id].get('workers', 0) + starter_units
elif action_type == 'blend_recipe':
recipe_id = act.get('recipe_id')
if recipe_id in SYNTHESIS_RECIPES:
r = SYNTHESIS_RECIPES[recipe_id]
can_blend = True
for ingr_id, needed in r['ingredients'].items():
if state['drugs'].get(ingr_id, {}).get('product', 0) < needed:
can_blend = False
break
if can_blend:
for ingr_id, needed in r['ingredients'].items():
state['drugs'][ingr_id]['product'] -= needed
state['cash'] += r['payout']
state['totalEarned'] += r['payout']
state['heat'] = min(100.0, state.get('heat', 0.0) + 5.0)
elif action_type == 'buy_skill':
skill_id = act.get('skill_id')
if skill_id in SKILL_COSTS and not state.get('skills', {}).get(skill_id):
cost = SKILL_COSTS[skill_id]
if state['cash'] >= cost:
state['cash'] -= cost
state.setdefault('skills', {})[skill_id] = True
elif action_type == 'claim_achievement':
ach_id = act.get('ach_id')
tier = int(act.get('tier', 1))
if validate_achievement_claim(state, ach_id, tier):
state.setdefault('achievements', {})[ach_id] = tier
elif action_type == 'buy_offshore_perk':
perk_id = act.get('perk_id')
if perk_id in OFFSHORE_PERK_COSTS:
costs = OFFSHORE_PERK_COSTS[perk_id]
current_lvl = state.setdefault('offshorePerks', {}).get(perk_id, 0)
if current_lvl < len(costs):
cost = costs[current_lvl]
if state.get('cleanBonds', 0) >= cost:
state['cleanBonds'] -= cost
state['offshorePerks'][perk_id] = current_lvl + 1
elif action_type == 'buy_helper':
helper_id = act.get('helper_id')
if helper_id in HELPER_COSTS and not state.setdefault('helpers', {}).get(helper_id):
cost = HELPER_COSTS[helper_id]
if state['cash'] >= cost:
state['cash'] -= cost
state['helpers'][helper_id] = True
elif action_type == 'bribe_police':
base_bribe = max(100.0, state['cash'] * 0.15)
judiciary_lvl = state.get('offshorePerks', {}).get('corrupt_judiciary', 0)
bribe_cost = max(25.0, base_bribe * (1.0 - judiciary_lvl * 0.20))
if state['cash'] >= bribe_cost:
state['cash'] -= bribe_cost
state['heat'] = 0.0
state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1
elif action_type == 'flush_stash':
state['heat'] = 20.0
state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1
elif action_type == 'breakout_raid':
success = bool(act.get('success', False))
if success:
state['heat'] = 0.0
state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1
elif action_type == 'launder_prestige':
bonds_to_earn = math.floor(math.sqrt(max(0.0, state.get('totalEarned', 0.0)) / 25000.0))
gain = max(0, bonds_to_earn - state.get('totalCleanBonds', 0))
if gain >= 1:
state['totalCleanBonds'] = state.get('totalCleanBonds', 0) + gain
state['cleanBonds'] = state.get('cleanBonds', 0) + gain
stats = state.setdefault('stats', {})
stats['prestigeCount'] = stats.get('prestigeCount', 0) + 1
# Calculate start cash with bond and swiss kingpin bonuses
start_cash = 50.0 + (state['totalCleanBonds'] * 25.0)
swiss_tier = state.get('achievements', {}).get('swiss_kingpin', 0)
if swiss_tier >= 1: start_cash += 250.0
if swiss_tier >= 2: start_cash += 2500.0
if swiss_tier >= 3: start_cash += 25000.0
if swiss_tier >= 4: start_cash += 250000.0
state['cash'] = start_cash
state['heat'] = 0.0
# Starter units from auto_contracts perk
free_starter = state.get('offshorePerks', {}).get('auto_contracts', 0)
for d_id, d_data in state['drugs'].items():
d_data['unlocked'] = (d_id == 'weed')
d_data['raw'] = 0.0
d_data['product'] = 0.0
d_data['suppliers'] = free_starter if d_data['unlocked'] else 0
d_data['workers'] = free_starter if d_data['unlocked'] else 0
d_data['dealers'] = 0
return sanitize_state(state)
def sanitize_state(state: dict) -> dict:
"""Enforces max 1 decimal and prevents negative or runaway numbers."""
state['cash'] = clean_round(state.get('cash', 0.0))
state['heat'] = clean_round(min(100.0, max(0.0, state.get('heat', 0.0))))
state['totalEarned'] = clean_round(state.get('totalEarned', 0.0))
state['cleanBonds'] = max(0, int(state.get('cleanBonds', 0)))
state['totalCleanBonds'] = max(state['cleanBonds'], int(state.get('totalCleanBonds', 0)))
stats = state.setdefault('stats', {})
for k in ['totalProduced', 'totalSold', 'totalSupplied']:
stats[k] = clean_round(stats.get(k, 0.0))
for k in ['purityProcs', 'raidsSurvived', 'prestigeCount']:
stats[k] = int(stats.get(k, 0))
state.setdefault('achievements', {})
state.setdefault('offshorePerks', {})
state.setdefault('helpers', {h: False for h in HELPER_COSTS})
for d_id, data in state.get('drugs', {}).items():
data['raw'] = clean_round(data.get('raw', 0.0))
data['product'] = clean_round(data.get('product', 0.0))
data['suppliers'] = int(data.get('suppliers', 0))
data['workers'] = int(data.get('workers', 0))
data['dealers'] = int(data.get('dealers', 0))
# 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

View file

@ -7,7 +7,7 @@ from models import RegisterRequest, LoginRequest, AuthResponse, SaveGameRequest,
from auth import hash_password, verify_password, create_access_token, get_current_user_id
import engine
app = FastAPI(title="El Puro API", version="1.1.0")
app = FastAPI(title="Production API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
@ -23,7 +23,7 @@ def startup_event():
@app.get("/api/health")
def health_check():
return {"status": "ok", "app": "El Puro", "version": "1.1.0"}
return {"status": "ok", "app": "Production - The Idle Game", "version": "2.0.0"}
@app.post("/api/auth/register", response_model=AuthResponse)
def register(req: RegisterRequest):

76
backend/test_engine.py Normal file
View file

@ -0,0 +1,76 @@
"""
Verification test suite for Production Backend Engine
"""
import sys
import time
import os
# Add backend directory to sys.path
sys.path.insert(0, os.path.dirname(__file__))
import engine
def test_initial_state():
state = engine.get_initial_state()
assert state['cash'] == 30.0
assert 'wheat' in state['products']
assert state['products']['wheat']['unlocked'] is True
assert state['products']['wheat']['lines'] == 1
assert state['products']['flour']['unlocked'] is False
print("PASS: test_initial_state")
def test_raw_production_and_logistics():
state = engine.get_initial_state()
# Fast forward 10 seconds
state['lastTick'] = time.time() - 10.0
updated = engine.process_offline_and_ticks(state)
# Wheat has cycle time 1.0s, output 1, 1 line, plus logistics selling
assert updated['products']['wheat']['totalProduced'] > 0
assert updated['cash'] > 30.0
print(f"PASS: test_raw_production_and_logistics (Produced: {updated['products']['wheat']['totalProduced']}, Cash: {updated['cash']})")
def test_chained_dependency_and_bottleneck():
state = engine.get_initial_state()
# Turn off wheat lines to simulate 0 upstream production
state['products']['wheat']['lines'] = 0
state['products']['wheat']['stock'] = 0.0
# Unlock flour and give it 1 line
state['products']['flour']['unlocked'] = True
state['products']['flour']['lines'] = 1
# 1. Process with no wheat: flour should bottleneck
state['lastTick'] = time.time() - 5.0
state = engine.process_offline_and_ticks(state)
assert state['products']['flour']['stock'] == 0.0
assert state['products']['flour']['isBottlenecked'] is True
# 2. Give 10 wheat: flour should consume wheat and produce flour
state['products']['wheat']['stock'] = 10.0
state['lastTick'] = time.time() - 4.0
state = engine.process_offline_and_ticks(state)
assert state['products']['flour']['totalProduced'] > 0
assert state['products']['wheat']['stock'] < 10.0 # Consumed!
print("PASS: test_chained_dependency_and_bottleneck")
def test_ipo_and_shares():
state = engine.get_initial_state()
state['lifetimeRevenue'] = 400000.0 # $400k
earned = engine.calculate_shares_earned(state['lifetimeRevenue'])
assert earned == 20 # 10 * sqrt(400k / 100k) = 10 * 2 = 20
# Apply IPO action
state = engine.apply_actions(state, [{'type': 'TRIGGER_IPO'}])
assert state['ventureShares'] == 20
assert state['ipoCount'] == 1
assert state['products']['wheat']['lines'] == 1
assert state['cash'] == 30.0
print("PASS: test_ipo_and_shares")
if __name__ == '__main__':
test_initial_state()
test_raw_production_and_logistics()
test_chained_dependency_and_bottleneck()
test_ipo_and_shares()
print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!")

View file

@ -5,12 +5,12 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
environment:
- SECRET_KEY=${SECRET_KEY:-el_puro_secret_key_change_in_production_jwt_99_1_pure}
- SECRET_KEY=${SECRET_KEY:-production_game_secret_key_change_in_production_jwt}
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
networks:
- elpuro-net
- production-net
frontend:
image: nginx:alpine
@ -25,8 +25,8 @@ services:
depends_on:
- backend
networks:
- elpuro-net
- production-net
networks:
elpuro-net:
production-net:
driver: bridge

File diff suppressed because it is too large Load diff

View file

@ -3,265 +3,216 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>EL PURO</title>
<!-- Fonts -->
<title>Production - The Idle Game</title>
<!-- Modern Corporate & Financial Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;800&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<!-- Iconify Web Component CDN (Game Icons & Lucide) -->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Iconify Web Component CDN (Lucide Icons) -->
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app-container">
<div id="app-wrapper">
<!-- Sticky Header (Status bar + Financial HUD + Heat Bar) -->
<header id="sticky-header">
<!-- Executive Header (Sticky across Desktop & Mobile) -->
<header id="executive-header">
<!-- Top Status Bar -->
<div id="system-bar">
<div id="corporate-status-bar">
<div class="status-left">
<span class="brand-tag">
<iconify-icon icon="game-icons:flasks"></iconify-icon> EL PURO
</span>
<span class="secure-indicator">
<span class="secure-dot"></span> SECURE
</span>
<div class="corporate-brand">
<div class="brand-logo-icon">
<iconify-icon icon="lucide:factory"></iconify-icon>
</div>
<div class="status-right" style="display: flex; align-items: center; gap: 8px;">
<button id="btn-open-settings" style="background: none; border: 1px solid #2a3b4c; border-radius: 4px; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 2px 6px; font-size: 0.9rem;" title="Game Settings & Number Notation">
<iconify-icon icon="lucide:settings"></iconify-icon>
<div class="brand-text">
<span class="brand-title">PRODUCTION</span>
<span class="brand-subtitle">The Idle Game</span>
</div>
</div>
</div>
<div class="status-right">
<button id="btn-open-settings" class="btn-icon-soft" title="Toggle Number Notation (Standard / Scientific)">
<iconify-icon icon="lucide:binary"></iconify-icon>
<span class="btn-text-sm">Notation</span>
</button>
<button id="user-badge" class="btn-user-badge" title="Account / Cloud Sync">
[GUEST]
</button>
<span id="user-badge" style="cursor: pointer; text-decoration: underline;">[ANON]</span>
<span id="system-clock">15:00</span>
</div>
</div>
<!-- HUD Summary (Cash, Income, Clean Bonds) -->
<div id="hud-summary">
<div>
<div class="hud-title">Dirty Cash</div>
<div class="hud-cash-val">$<span id="hud-cash">0.00</span></div>
<div class="hud-rate-val">+$<span id="hud-rate">0.00</span>/s</div>
</div>
<div class="hud-bonds">
<div class="hud-title">Clean Bonds</div>
<div class="bonds-val">
<iconify-icon icon="game-icons:tied-scroll"></iconify-icon>
<span id="hud-bonds">0</span>
</div>
<div class="hud-rate-val" style="color: var(--neon-cyan);">+<span id="hud-multiplier">0</span>% Boost</div>
</div>
<!-- Financial HUD (Liquid Capital, Net Flow, Valuation, Venture Equity) -->
<div id="financial-hud">
<div class="hud-stat-box primary-hud-box">
<div class="hud-label">Liquid Capital</div>
<div class="hud-main-val font-mono">$<span id="hud-cash">0.00</span></div>
<div class="hud-sub-val font-mono rate-positive">+$<span id="hud-rate">0.00</span>/s</div>
</div>
<!-- Police Heat Bar -->
<div id="heat-container">
<div class="heat-label-row">
<span>POLICE HEAT</span>
<span id="heat-pct">0%</span>
</div>
<div class="heat-bar-track">
<div id="heat-bar-fill"></div>
</div>
<div class="hud-stat-box">
<div class="hud-label">Enterprise Valuation</div>
<div class="hud-main-val font-mono" id="hud-valuation">$0.00</div>
<div class="hud-sub-val font-mono text-muted">Asset Value & Equity</div>
</div>
<!-- Quick Inventory Overview Strip (Always visible) -->
<div id="quick-stash-strip">
<div class="stash-strip-title">
<span>STASH OVERVIEW:</span>
</div>
<div id="quick-stash-items" class="stash-strip-items">
<!-- Dynamically populated chips of all owned products -->
<div class="hud-stat-box">
<div class="hud-label">Venture Capital ($V-Shares)</div>
<div class="hud-main-val font-mono text-equity">
<iconify-icon icon="lucide:gem"></iconify-icon>
<span id="hud-shares">0</span>
</div>
<div class="hud-sub-val font-mono text-equity">IPO Expansion Equity</div>
</div>
<!-- Buy Multiplier Selector Bar (1x, 10x, 100x, Next Milestone, Max) -->
<div id="buy-multiplier-bar">
<span class="buy-multi-label">BUY QTY:</span>
<div class="buy-multi-group">
<!-- Buy Multiplier Selector -->
<div class="hud-multiplier-box">
<span class="multi-label">Buy Batch:</span>
<div class="multi-button-group">
<button class="btn-multi-pill active" data-mode="1">1x</button>
<button class="btn-multi-pill" data-mode="10">10x</button>
<button class="btn-multi-pill" data-mode="100">100x</button>
<button class="btn-multi-pill" data-mode="next">Next</button>
<button class="btn-multi-pill" data-mode="max">Max</button>
</div>
</div>
</div>
</header>
<!-- Toast Notifications Container -->
<div id="toast-container"></div>
<!-- Main Workspace Layout (3-Column Desktop Grid / 1-Column Mobile) -->
<div id="workspace-layout">
<!-- Main Viewport with Dynamic Views -->
<main id="main-viewport">
<!-- Views are rendered dynamically by game.js -->
</main>
<!-- Bottom Navigation Bar (Fixed for quick thumb access) -->
<nav id="bottom-nav">
<button class="nav-tab active" data-tab="lab">
<iconify-icon icon="game-icons:flasks"></iconify-icon>
<span>Lab</span>
<!-- Left Column: Corporate Navigation (Desktop Sidebar) -->
<aside id="desktop-nav-sidebar">
<div class="sidebar-section-title">Departments</div>
<nav class="sidebar-nav">
<button class="nav-btn active" data-tab="production">
<iconify-icon icon="lucide:layout-grid"></iconify-icon>
<span>Production Floor</span>
</button>
<button class="nav-tab" data-tab="streets">
<iconify-icon icon="game-icons:cash"></iconify-icon>
<span>Streets</span>
<button class="nav-btn" data-tab="contracts">
<iconify-icon icon="lucide:scroll-text"></iconify-icon>
<span>B2B Contracts</span>
<span id="contracts-badge" class="nav-pill-badge">0</span>
</button>
<button class="nav-tab" data-tab="trophies" style="position: relative;">
<iconify-icon icon="game-icons:trophy-cup"></iconify-icon>
<span>Trophies</span>
<span id="trophy-badge" class="nav-badge" style="display: none;">0</span>
<button class="nav-btn" data-tab="boardroom">
<iconify-icon icon="lucide:trending-up"></iconify-icon>
<span>Boardroom & IPO</span>
</button>
<button class="nav-tab" data-tab="helpers">
<iconify-icon icon="game-icons:artificial-intelligence"></iconify-icon>
<span>Helpers</span>
</button>
<button class="nav-tab" data-tab="tree">
<iconify-icon icon="game-icons:spider-web"></iconify-icon>
<span>Network</span>
</button>
<button class="nav-tab" data-tab="offshore">
<iconify-icon icon="game-icons:briefcase"></iconify-icon>
<span>Offshore</span>
<button class="nav-btn" data-tab="milestones">
<iconify-icon icon="lucide:trophy"></iconify-icon>
<span>Milestones</span>
</button>
</nav>
</div>
<div class="sidebar-divider"></div>
<!-- Authentication Modal -->
<div id="auth-modal" class="modal-overlay">
<div class="modal-content">
<div class="sidebar-section-title">Industry Sectors</div>
<div class="sidebar-sectors-list">
<div class="sidebar-sector-item">
<iconify-icon icon="lucide:wheat" style="color: #16a34a;"></iconify-icon>
<span>Agriculture & Food</span>
</div>
<div class="sidebar-sector-item">
<iconify-icon icon="lucide:trees" style="color: #ca8a04;"></iconify-icon>
<span>Forestry & Materials</span>
</div>
<div class="sidebar-sector-item">
<iconify-icon icon="lucide:pickaxe" style="color: #475569;"></iconify-icon>
<span>Mining & Metallurgy</span>
</div>
<div class="sidebar-sector-item">
<iconify-icon icon="lucide:flask-conical" style="color: #0284c7;"></iconify-icon>
<span>Chemicals & Polymers</span>
</div>
<div class="sidebar-sector-item">
<iconify-icon icon="lucide:cpu" style="color: #7c3aed;"></iconify-icon>
<span>High-Tech Electronics</span>
</div>
<div class="sidebar-sector-item">
<iconify-icon icon="lucide:truck" style="color: #ea580c;"></iconify-icon>
<span>Heavy Industry</span>
</div>
</div>
</aside>
<!-- Center Column: Main Interactive Viewport -->
<main id="main-viewport">
<!-- Rendered dynamically by app.js (Production, Contracts, Boardroom, Milestones) -->
</main>
<!-- Right Column: Strategic Inspector & Supply Chain Panel (Desktop only) -->
<aside id="desktop-inspector-panel">
<!-- Rendered dynamically by inspectorView.js -->
</aside>
</div>
<!-- Mobile Bottom Navigation Bar (Visible on mobile viewports) -->
<nav id="mobile-bottom-nav">
<button class="nav-tab active" data-tab="production">
<iconify-icon icon="lucide:layout-grid"></iconify-icon>
<span>Production</span>
</button>
<button class="nav-tab" data-tab="contracts">
<iconify-icon icon="lucide:scroll-text"></iconify-icon>
<span>Contracts</span>
</button>
<button class="nav-tab" data-tab="boardroom">
<iconify-icon icon="lucide:trending-up"></iconify-icon>
<span>Boardroom</span>
</button>
<button class="nav-tab" data-tab="milestones">
<iconify-icon icon="lucide:trophy"></iconify-icon>
<span>Milestones</span>
</button>
</nav>
<!-- Toast Notifications Container -->
<div id="toast-container"></div>
<!-- Authentication & Cloud Sync Modal -->
<div id="auth-modal" class="modal-overlay">
<div class="modal-card">
<div class="modal-header">
<iconify-icon icon="lucide:shield-alert" style="font-size: 1.6rem; color: var(--brand-gold);"></iconify-icon>
<div class="modal-title">Encrypted Terminal Login</div>
<iconify-icon icon="lucide:shield-check" class="modal-icon"></iconify-icon>
<div>
<h3 class="modal-title">Executive Cloud Terminal</h3>
<p class="modal-subtitle">Save your production empire and sync across devices.</p>
</div>
<p style="font-size: 0.78rem; color: var(--text-secondary); line-height: 1.4;">
No email. No phone. Only a username and password to secure your syndicate records.
</p>
<div class="input-group">
<label class="input-label">Operator Alias</label>
<input type="text" id="auth-username" class="input-field" placeholder="e.g. ElHeisenberg" autocomplete="off">
</div>
<div class="input-group">
<label class="input-label">Cipher Code (Password)</label>
<input type="password" id="auth-password" class="input-field" placeholder="••••••••">
<div class="input-field-group">
<label>Executive Username</label>
<input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username">
</div>
<!-- HONEYPOT TRAP (Bots fill this invisible input) -->
<div class="hp-trap" aria-hidden="true">
<div class="input-field-group">
<label>Security Password</label>
<input type="password" id="auth-password" class="modal-input" placeholder="••••••••" autocomplete="current-password">
</div>
<!-- Honeypot field for bot protection -->
<div style="display: none;" aria-hidden="true">
<input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off">
</div>
<div style="display: flex; gap: 8px; margin-top: 8px;">
<button id="btn-login-submit" class="btn-tactical btn-primary-action" style="flex: 1; padding: 10px;">Login</button>
<button id="btn-register-submit" class="btn-tactical" style="flex: 1; padding: 10px;">Register</button>
<div class="modal-actions-row">
<button id="btn-login-submit" class="btn-primary-corp">Login</button>
<button id="btn-register-submit" class="btn-corp-secondary">Register New</button>
</div>
<button id="btn-close-auth" class="btn-tactical" style="margin-top: 4px;">Play as Guest</button>
<button id="btn-close-auth" class="btn-text-close">Continue as Guest</button>
</div>
</div>
</div>
<!-- Police Raid Quick-Time Modal -->
<div id="raid-modal" class="modal-overlay">
<div class="modal-content" style="border-color: var(--neon-red);">
<div class="modal-header">
<iconify-icon icon="game-icons:police-badge" style="font-size: 2rem; color: var(--neon-red);"></iconify-icon>
<div>
<div class="modal-title" style="color: var(--neon-red);">FEDERAL INTERCEPT!</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);">DEA raid in progress!</div>
</div>
</div>
<p style="font-size: 0.8rem; line-height: 1.4;">
Police sirens outside your warehouse! Decide quickly before your stash is seized!
</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button id="btn-raid-bribe" class="btn-tactical btn-sell-action" style="padding: 10px;">
Pay Bribe ($<span id="raid-bribe-cost">500</span>)
<span class="btn-subtext">Clears all heat immediately</span>
</button>
<button id="btn-raid-stash" class="btn-tactical" style="padding: 10px;">
Flush & Hide Stash
<span class="btn-subtext">Save 100% inventory, heat drops to 20%</span>
</button>
<button id="btn-raid-shootout" class="btn-tactical btn-primary-action" style="padding: 10px;">
Run the Blockade
<span class="btn-subtext">50% chance 2x payout, 50% chance loss</span>
</button>
</div>
</div>
</div>
<!-- Application Entry Point (Native ES Module) -->
<script type="module" src="js/app.js"></script>
<!-- Custom Tactical Confirmation / Alert Modal (Replaces browser alert and confirm) -->
<div id="confirm-modal" class="modal-overlay">
<div class="modal-content" style="border-color: var(--brand-gold);">
<div class="modal-header">
<iconify-icon id="confirm-modal-icon" icon="lucide:alert-circle" style="font-size: 1.8rem; color: var(--brand-gold);"></iconify-icon>
<div>
<div id="confirm-modal-title" class="modal-title">CONFIRMATION</div>
<div id="confirm-modal-subtitle" style="font-size: 0.72rem; color: var(--text-secondary);">Syndicate Protocol</div>
</div>
</div>
<p id="confirm-modal-message" style="font-size: 0.82rem; line-height: 1.5; color: var(--text-primary); margin: 4px 0 10px;">
Are you sure you wish to proceed?
</p>
<div id="confirm-modal-actions" style="display: flex; gap: 8px;">
<button id="btn-confirm-cancel" class="btn-tactical" style="flex: 1; padding: 10px;">Cancel</button>
<button id="btn-confirm-ok" class="btn-tactical btn-sell-action" style="flex: 1; padding: 10px;">Confirm</button>
</div>
</div>
</div>
<!-- System Settings & Notation Preferences Modal -->
<div id="settings-modal" class="modal-overlay">
<div class="modal-content" style="border-color: var(--neon-cyan);">
<div class="modal-header">
<iconify-icon icon="lucide:sliders" style="font-size: 1.8rem; color: var(--neon-cyan);"></iconify-icon>
<div>
<div class="modal-title">TERMINAL SETTINGS</div>
<div style="font-size: 0.72rem; color: var(--text-secondary);">Number Display & Preferences</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; margin: 6px 0;">
<div>
<div style="font-family: var(--font-mono); font-size: 0.78rem; font-weight: bold; color: var(--text-primary); margin-bottom: 6px;">
NUMBER NOTATION STYLE:
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="display: flex; align-items: center; gap: 10px; background: #0c1218; padding: 10px; border-radius: 8px; border: 1px solid #1e2c3a; cursor: pointer;">
<input type="radio" name="notation-choice" value="standard" style="accent-color: var(--neon-cyan);">
<div>
<div style="font-weight: bold; font-size: 0.82rem; color: #fff;">Idle Game Progression (Recommended)</div>
<div style="font-size: 0.7rem; color: var(--text-secondary); font-family: var(--font-mono);">
e.g. 1.25M, 45.50B, 120.00T, 10.00Qa, 5.00Qi...
</div>
</div>
</label>
<label style="display: flex; align-items: center; gap: 10px; background: #0c1218; padding: 10px; border-radius: 8px; border: 1px solid #1e2c3a; cursor: pointer;">
<input type="radio" name="notation-choice" value="scientific" style="accent-color: var(--neon-cyan);">
<div>
<div style="font-weight: bold; font-size: 0.82rem; color: #fff;">Scientific Notation</div>
<div style="font-size: 0.7rem; color: var(--text-secondary); font-family: var(--font-mono);">
e.g. 1.25e6, 4.55e10, 1.20e14, 5.00e18...
</div>
</div>
</label>
</div>
</div>
<div style="background: #090e13; border: 1px solid #182533; border-radius: 8px; padding: 10px; font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary);">
<span>Precision: </span><span style="color: var(--neon-cyan);">2 Decimal Places</span> (Fixed)<br>
<span>Preview: </span><span id="settings-preview-val" style="color: #4ade80;">$1,234,567.89 ➔ $1.23M</span>
</div>
</div>
<button id="btn-close-settings" class="btn-tactical btn-primary-action" style="padding: 10px;">Save & Return</button>
</div>
</div>
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/game.js"></script>
</body>
</html>

View file

@ -1,120 +0,0 @@
// API Communication and State Sync
const API = {
tokenKey: 'el_puro_jwt_token',
userKey: 'el_puro_username',
getToken() {
return localStorage.getItem(this.tokenKey);
},
getUsername() {
return localStorage.getItem(this.userKey);
},
setAuth(token, username) {
localStorage.setItem(this.tokenKey, token);
localStorage.setItem(this.userKey, username);
},
logout() {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.userKey);
},
async register(username, password, websiteHp = '') {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
website_hp: websiteHp
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Registration failed' }));
throw new Error(err.detail || 'Registration failed');
}
const data = await res.json();
this.setAuth(data.token, data.username);
return data;
},
async login(username, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Invalid credentials' }));
throw new Error(err.detail || 'Login failed');
}
const data = await res.json();
this.setAuth(data.token, data.username);
return data;
},
async loadRemoteSave() {
const token = this.getToken();
if (!token) return null;
try {
const res = await fetch('/api/game/state', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) this.logout();
return null;
}
const data = await res.json();
return data.has_save ? data.save_data : null;
} catch (e) {
console.warn('Network issue loading cloud save, fallback to local', e);
return null;
}
},
async syncTick(actions = [], seq = null) {
const token = this.getToken();
if (!token) return null;
try {
const res = await fetch('/api/game/tick', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ actions, seq })
});
if (!res.ok) {
if (res.status === 401) this.logout();
return null;
}
const data = await res.json();
return data;
} catch (e) {
console.warn('Network sync failed, queuing actions', e);
return null;
}
},
async saveRemote(saveData) {
const token = this.getToken();
if (!token) return;
try {
await fetch('/api/game/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ save_data: saveData })
});
} catch (e) {
console.warn('Failed cloud save sync', e);
}
}
};

432
frontend/js/app.js Normal file
View file

@ -0,0 +1,432 @@
// Production - The Idle Game: Main Application Orchestrator
import { GState } from './core/state.js';
import { SimulationEngine } from './core/engine.js';
import { SyncManager } from './core/sync.js';
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
import { PRODUCTS, getProduct } from './data/products.js';
import { renderProductionFloor, calculateMultiCost } from './ui/productionView.js';
import { renderContractsBoard } from './ui/contractsView.js';
import { renderBoardroom } from './ui/boardroomView.js';
import { renderInspector } from './ui/inspectorView.js';
import { renderAchievements } from './ui/achievementsView.js';
class ProductionApp {
constructor() {
this.sm = GState;
this.engine = new SimulationEngine(this.sm);
this.sync = new SyncManager(this.sm);
this.lastFrameTime = performance.now();
this.lastSaveTime = Date.now();
}
async init() {
// Check for offline progress
const now = Date.now();
const elapsedSeconds = (now - this.sm.state.lastTick) / 1000;
if (elapsedSeconds > 5) {
const result = this.engine.processOffline(elapsedSeconds);
if (result.earned > 0) {
this.showToast(`Executive Briefing: Your automated lines generated ${fmtCurrency(result.earned)} during ${Math.round(result.seconds / 60)}m away!`, 'success');
}
}
this.setupDomEvents();
this.renderAll();
// Check if logged in and sync cloud state
if (this.sync.isLoggedIn()) {
this.sync.fetchCloudState().then(cloud => {
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
// Cloud save is newer, merge and save
this.sm.state = { ...this.sm.state, ...cloud };
this.sm.save();
this.renderAll();
}
});
}
// Main game loop (tick at 30-60fps)
const loop = (currentTime) => {
const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5); // cap at 0.5s per frame
this.lastFrameTime = currentTime;
this.engine.tick(dt);
this.updateHud();
// Auto-save every 5 seconds
if (Date.now() - this.lastSaveTime > 5000) {
this.sm.save();
this.lastSaveTime = Date.now();
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
// Periodic view refresh for timers and progress bars (every 250ms)
setInterval(() => {
this.renderActiveView();
this.renderInspectorPane();
}, 250);
}
setupDomEvents() {
// Navigation Tabs (Desktop sidebar + Mobile bottom nav)
document.querySelectorAll('.nav-btn, .nav-tab').forEach(btn => {
btn.onclick = () => {
const tab = btn.dataset.tab;
if (!tab) return;
this.sm.state.activeTab = tab;
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
this.renderActiveView();
};
});
// Buy Multiplier Bar
document.querySelectorAll('.btn-multi-pill').forEach(btn => {
btn.onclick = () => {
const mode = btn.dataset.mode;
this.sm.state.buyMode = mode;
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.renderActiveView();
};
});
// User Profile & Settings
const userBadge = document.getElementById('user-badge');
if (userBadge) {
userBadge.onclick = () => this.openAuthModal();
}
const settingsBtn = document.getElementById('btn-open-settings');
if (settingsBtn) {
settingsBtn.onclick = () => this.toggleSettings();
}
// Auth Modal Close & Submit
const closeAuth = document.getElementById('btn-close-auth');
if (closeAuth) closeAuth.onclick = () => this.closeAuthModal();
const loginBtn = document.getElementById('btn-login-submit');
if (loginBtn) loginBtn.onclick = () => this.handleLogin();
const registerBtn = document.getElementById('btn-register-submit');
if (registerBtn) registerBtn.onclick = () => this.handleRegister();
}
handleAction(action) {
const state = this.sm.state;
switch (action.type) {
case 'SELECT_SECTOR':
state.selectedSector = action.sector;
this.renderActiveView();
break;
case 'SELECT_PRODUCT':
state.selectedProductId = action.productId;
this.renderActiveView();
this.renderInspectorPane();
break;
case 'UNLOCK_PRODUCT': {
const p = getProduct(action.productId);
if (p && state.cash >= p.unlockCost && !state.products[p.id]?.unlocked) {
state.cash -= p.unlockCost;
if (!state.products[p.id]) state.products[p.id] = {};
state.products[p.id].unlocked = true;
state.products[p.id].lines = 1;
state.selectedProductId = p.id;
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
this.sync.queueAction(action);
this.renderAll();
}
break;
}
case 'BUY_LINES': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, state.buyMode);
if (state.cash >= calc.cost) {
state.cash -= calc.cost;
pState.lines += calc.count;
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
this.sync.queueAction(action);
this.renderAll();
}
}
break;
}
case 'HIRE_MANAGER': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState && !pState.manager && state.cash >= p.baseManagerCost) {
state.cash -= p.baseManagerCost;
pState.manager = true;
state.stats.managersHired = (state.stats.managersHired || 0) + 1;
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
this.sync.queueAction(action);
this.renderAll();
}
break;
}
case 'BUY_LOGISTICS': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const cost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
if (state.cash >= cost) {
state.cash -= cost;
pState.logistics = (pState.logistics || 0) + 1;
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
this.sync.queueAction(action);
this.renderAll();
}
}
break;
}
case 'MANUAL_CYCLE': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
// Check inputs
const isRaw = !p.inputs || p.inputs.length === 0;
let canRun = true;
if (!isRaw) {
canRun = p.inputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount);
}
if (canRun) {
if (!isRaw) {
p.inputs.forEach(inp => {
state.products[inp.id].stock -= inp.amount;
});
}
const yieldAmt = p.outputAmount * Math.max(1, pState.lines);
pState.stock += yieldAmt;
pState.totalProduced = (pState.totalProduced || 0) + yieldAmt;
state.stats.totalProduced = (state.stats.totalProduced || 0) + yieldAmt;
this.showToast(`Batch Produced: +${yieldAmt} ${p.name}!`, 'info');
} else {
this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
}
this.renderAll();
}
break;
}
case 'FULFILL_CONTRACT': {
const idx = (state.activeContracts || []).findIndex(c => c.uid === action.uid);
if (idx !== -1) {
const c = state.activeContracts[idx];
const pState = state.products[c.productId];
if (pState && pState.stock >= c.targetAmount) {
pState.stock -= c.targetAmount;
// Add subsidy bonus if researched
const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
const totalPayout = c.rewardCash * subsidyBonus;
state.cash += totalPayout;
state.lifetimeRevenue += totalPayout;
state.reputation = (state.reputation || 0) + c.rewardRep;
state.stats.contractsCompleted = (state.stats.contractsCompleted || 0) + 1;
state.activeContracts.splice(idx, 1);
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
this.sync.queueAction(action);
this.renderAll();
}
}
break;
}
case 'TRIGGER_IPO': {
const confirmed = confirm('Confirm Corporate Initial Public Offering (IPO)?\n\nYour factories and cash will reset in exchange for permanent Venture Shares ($V-Shares) and corporate equity!');
if (confirmed) {
const success = this.sm.performIPO();
if (success) {
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
this.renderAll();
}
}
break;
}
case 'BUY_PERK': {
const perk = (state.boardroomPerks || {})[action.perkId] || 0;
// Find perk definition
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
if (def && perk < def.maxLevel) {
const cost = def.costShares[perk];
if (state.ventureShares >= cost) {
state.ventureShares -= cost;
state.boardroomPerks[action.perkId] = perk + 1;
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
this.renderAll();
}
}
});
break;
}
case 'LIQUIDATE_STOCK': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState && pState.stock > 0) {
const ratio = action.ratio || 1.0;
const sold = Math.floor(pState.stock * ratio);
if (sold > 0) {
pState.stock -= sold;
const earned = sold * p.basePrice;
state.cash += earned;
state.lifetimeRevenue += earned;
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
this.renderAll();
}
}
break;
}
}
}
updateHud() {
const state = this.sm.state;
const elCash = document.getElementById('hud-cash');
if (elCash) elCash.textContent = fmtNum(state.cash);
const elRate = document.getElementById('hud-rate');
if (elRate) elRate.textContent = fmtRate(this.engine.cashRate).replace('+$', '');
const elValuation = document.getElementById('hud-valuation');
if (elValuation) elValuation.textContent = fmtCurrency(state.valuation);
const elShares = document.getElementById('hud-shares');
if (elShares) elShares.textContent = fmtNum(state.ventureShares, 0);
const elContractsBadge = document.getElementById('contracts-badge');
if (elContractsBadge) {
const count = (state.activeContracts || []).length;
elContractsBadge.textContent = count;
elContractsBadge.style.display = count > 0 ? 'inline-flex' : 'none';
}
const userBadge = document.getElementById('user-badge');
if (userBadge) {
userBadge.textContent = this.sync.isLoggedIn() ? `[${this.sync.getUsername()}]` : `[GUEST]`;
}
}
renderActiveView() {
const container = document.getElementById('main-viewport');
if (!container) return;
const tab = this.sm.state.activeTab || 'production';
if (tab === 'production') {
renderProductionFloor(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'contracts') {
renderContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'boardroom') {
renderBoardroom(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'milestones') {
renderAchievements(container, this.sm.state, (a) => this.handleAction(a));
}
}
renderInspectorPane() {
const inspectorContainer = document.getElementById('desktop-inspector-panel');
if (inspectorContainer) {
renderInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a));
}
}
renderAll() {
this.updateHud();
this.renderActiveView();
this.renderInspectorPane();
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast-msg toast-${type}`;
toast.innerHTML = `
<iconify-icon icon="${type === 'success' ? 'lucide:check-circle' : type === 'warning' ? 'lucide:alert-circle' : 'lucide:info'}"></iconify-icon>
<span>${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 400);
}, 3200);
}
openAuthModal() {
const modal = document.getElementById('auth-modal');
if (modal) modal.classList.add('open');
}
closeAuthModal() {
const modal = document.getElementById('auth-modal');
if (modal) modal.classList.remove('open');
}
async handleLogin() {
const u = document.getElementById('auth-username')?.value.trim();
const p = document.getElementById('auth-password')?.value;
if (!u || !p) return alert('Please enter username and password');
try {
await this.sync.login(u, p);
this.showToast(`Logged in as ${u}`, 'success');
this.closeAuthModal();
this.updateHud();
} catch (err) {
alert(err.message || 'Login failed');
}
}
async handleRegister() {
const u = document.getElementById('auth-username')?.value.trim();
const p = document.getElementById('auth-password')?.value;
const hp = document.getElementById('auth-website-hp')?.value;
if (!u || !p) return alert('Please enter username and password');
try {
await this.sync.register(u, p, hp);
this.showToast(`Account created! Welcome ${u}`, 'success');
this.closeAuthModal();
this.updateHud();
} catch (err) {
alert(err.message || 'Registration failed');
}
}
toggleSettings() {
const current = getNotation();
const next = current === 'standard' ? 'scientific' : 'standard';
setNotation(next);
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
this.renderAll();
}
}
// Bootstrap
window.addEventListener('DOMContentLoaded', () => {
const app = new ProductionApp();
app.init();
window.App = app;
});

View file

@ -1,584 +0,0 @@
// El Puro Drug, Recipe, Skill Tree & Achievement Configuration
const DRUG_CATALOG = [
{
id: 'weed',
name: 'Cannabis (Weed)',
category: 'botanical',
icon: 'game-icons:cannabis',
color: '#39ff14',
rawName: 'Seeds & Soil',
productName: 'Cured Bud',
rawCost: 10,
sellPrice: 16,
unlockCost: 0,
supplierName: 'Seed Courier',
supplierCost: 75,
supplyRate: 1.0,
workerName: 'Grower',
dealerName: 'Corner Pusher',
workerCost: 100,
dealerCost: 150,
produceRate: 1.0,
sellRate: 1.0,
unlockedDefault: true
},
{
id: 'shrooms',
name: 'Magic Mushrooms',
category: 'botanical',
icon: 'game-icons:mushroom-gills',
color: '#a3e635',
rawName: 'Spores & Grain',
productName: 'Dried Caps',
rawCost: 35,
sellPrice: 65,
unlockCost: 500,
supplierName: 'Spore Forager',
supplierCost: 300,
supplyRate: 0.8,
workerName: 'Mycologist',
dealerName: 'Festival Plug',
workerCost: 450,
dealerCost: 650,
produceRate: 0.8,
sellRate: 0.8,
unlockedDefault: false
},
{
id: 'adderall',
name: 'Pharma Speed (Addy)',
category: 'pharma',
icon: 'game-icons:pills',
color: '#f59e0b',
rawName: 'Diverted Blisters',
productName: 'Pressed Salts',
rawCost: 120,
sellPrice: 240,
unlockCost: 2500,
supplierName: 'Pharmacy Diverter',
supplierCost: 1500,
supplyRate: 0.6,
workerName: 'Pharma Tech',
dealerName: 'Campus Runner',
workerCost: 2000,
dealerCost: 3000,
produceRate: 0.6,
sellRate: 0.6,
unlockedDefault: false
},
{
id: 'meth',
name: 'Meth (Blue Sky)',
category: 'synthetic',
icon: 'game-icons:flasks',
color: '#00e5ff',
rawName: 'Methylamine & Sudafed',
productName: 'Blue Shards',
rawCost: 400,
sellPrice: 950,
unlockCost: 10000,
supplierName: 'Chemical Smuggler',
supplierCost: 6000,
supplyRate: 0.5,
workerName: 'Lab Cook',
dealerName: 'Trailer Boss',
workerCost: 8000,
dealerCost: 12000,
produceRate: 0.5,
sellRate: 0.5,
unlockedDefault: false
},
{
id: 'mdma',
name: 'MDMA (Ecstasy)',
category: 'party',
icon: 'game-icons:pill',
color: '#ec4899',
rawName: 'Safrole Oil & Binder',
productName: 'Tesla Presses',
rawCost: 1200,
sellPrice: 3200,
unlockCost: 35000,
supplierName: 'Safrole Importer',
supplierCost: 20000,
supplyRate: 0.4,
workerName: 'Rave Chemist',
dealerName: 'Club Promoter',
workerCost: 28000,
dealerCost: 40000,
produceRate: 0.4,
sellRate: 0.4,
unlockedDefault: false
},
{
id: 'ketamine',
name: 'Ketamine (Special K)',
category: 'synthetic',
icon: 'game-icons:test-tubes',
color: '#8b5cf6',
rawName: 'Veterinary Liquid',
productName: 'Cooked Powder',
rawCost: 3800,
sellPrice: 11000,
unlockCost: 120000,
supplierName: 'Vet Clinic Insider',
supplierCost: 75000,
supplyRate: 0.35,
workerName: 'Vet Contact',
dealerName: 'Afterparty Plug',
workerCost: 95000,
dealerCost: 140000,
produceRate: 0.35,
sellRate: 0.35,
unlockedDefault: false
},
{
id: 'cocaine',
name: 'Cocaine (Pure White)',
category: 'cartel',
icon: 'game-icons:powder',
color: '#ffffff',
rawName: 'Andean Coca Paste',
productName: 'Pure Flake',
rawCost: 12000,
sellPrice: 38000,
unlockCost: 400000,
supplierName: 'Jungle Smuggler',
supplierCost: 250000,
supplyRate: 0.3,
workerName: 'Cartel Chemist',
dealerName: 'VIP Broker',
workerCost: 320000,
dealerCost: 480000,
produceRate: 0.3,
sellRate: 0.3,
unlockedDefault: false
},
{
id: 'crack',
name: 'Crack Cocaine',
category: 'street',
icon: 'game-icons:rock',
color: '#fbbf24',
rawName: 'Flake & Ammonia Base',
productName: 'Cooked Rocks',
rawCost: 35000,
sellPrice: 115000,
unlockCost: 1200000,
supplierName: 'Base Sourcing Crew',
supplierCost: 700000,
supplyRate: 0.25,
workerName: 'Trap Master',
dealerName: 'Block Captain',
workerCost: 900000,
dealerCost: 1400000,
produceRate: 0.25,
sellRate: 0.25,
unlockedDefault: false
},
{
id: 'heroin',
name: 'Heroin (Black Tar)',
category: 'opioid',
icon: 'game-icons:syringe',
color: '#78350f',
rawName: 'Raw Opium Latex',
productName: 'Refined Tar',
rawCost: 110000,
sellPrice: 420000,
unlockCost: 4500000,
supplierName: 'Poppy Harvester',
supplierCost: 2800000,
supplyRate: 0.2,
workerName: 'Poppy Refiner',
dealerName: 'Network Distributor',
workerCost: 3500000,
dealerCost: 5500000,
produceRate: 0.2,
sellRate: 0.2,
unlockedDefault: false
},
{
id: 'fentanyl',
name: 'Fentanyl (The Apex)',
category: 'synthetic',
icon: 'game-icons:biohazard',
color: '#ef4444',
rawName: 'Hazardous Precursors',
productName: 'Microgram Purity',
rawCost: 450000,
sellPrice: 2100000,
unlockCost: 20000000,
supplierName: 'Cartel Procurement Agent',
supplierCost: 12000000,
supplyRate: 0.15,
workerName: 'Hazmat Synthesizer',
dealerName: 'Ghost Syndicate',
workerCost: 15000000,
dealerCost: 24000000,
produceRate: 0.15,
sellRate: 0.15,
unlockedDefault: false
}
];
// Synthesis & Recipe Combinations
const SYNTHESIS_RECIPES = [
{
id: 'calvin_klein',
name: 'Calvin Klein (CK)',
ingredients: { cocaine: 5, ketamine: 5 },
batchOutput: 8,
sellPrice: 95000,
description: 'Elite blend of pure flake and horse tranquilizer. Elite nightclub hype.',
icon: 'game-icons:two-shadows'
},
{
id: 'speedball',
name: 'Speedball',
ingredients: { heroin: 3, cocaine: 3 },
batchOutput: 5,
sellPrice: 850000,
description: 'Lethal upper-downer cocktail. Highly sought after by desperate spenders.',
icon: 'game-icons:lightning-trio'
},
{
id: 'candy_flip',
name: 'Candy Flip',
ingredients: { shrooms: 15, mdma: 10 },
batchOutput: 15,
sellPrice: 75000,
description: 'Psychedelic empathogen fusion. Raves pay top dollar instantly.',
icon: 'game-icons:sparkles'
}
];
// Branching Skill Tree (The Spiderweb)
const SKILL_TREE = [
// Chemist Branch
{
id: 'chem_1',
branch: 'chemist',
name: 'Lab Grade Glassware',
desc: '+15% Production speed across all labs.',
cost: 500,
prereqs: [],
applied: false
},
{
id: 'chem_2',
branch: 'chemist',
name: 'Precursor Synthesis',
desc: '-15% Raw material costs globally.',
cost: 15000,
prereqs: ['chem_1'],
applied: false
},
{
id: 'chem_3',
branch: 'chemist',
name: '99.1% Crystallization',
desc: '10% chance on manual or worker tick to produce 3x product yield.',
cost: 250000,
prereqs: ['chem_2'],
applied: false
},
// Kingpin Branch
{
id: 'king_1',
branch: 'kingpin',
name: 'Corner Lookouts',
desc: '+20% Dealer selling throughput.',
cost: 1000,
prereqs: [],
applied: false
},
{
id: 'king_2',
branch: 'kingpin',
name: 'Bulk Wholesaling',
desc: '+25% Selling price on all products.',
cost: 35000,
prereqs: ['king_1'],
applied: false
},
{
id: 'king_3',
branch: 'kingpin',
name: 'Monopoly Territory',
desc: 'Auto-dealers consume product and generate cash 50% faster.',
cost: 600000,
prereqs: ['king_2'],
applied: false
},
// Smuggler Branch
{
id: 'smug_1',
branch: 'smuggler',
name: 'Burner Relay',
desc: 'Police Heat rises 35% slower.',
cost: 2000,
prereqs: [],
applied: false
},
{
id: 'smug_2',
branch: 'smuggler',
name: 'Bribe Network',
desc: 'Police raid bribe costs reduced by 50%.',
cost: 50000,
prereqs: ['smug_1'],
applied: false
},
{
id: 'smug_3',
branch: 'smuggler',
name: 'Swiss Shell Corp',
desc: 'Money Laundering converts dirty cash with +50% more Clean Bonds.',
cost: 1000000,
prereqs: ['smug_2'],
applied: false
}
];
// --- UNIT MILESTONES (1, 5, 10, 25, 50, 100...) ---
const UNIT_MILESTONES = [1, 5, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000];
// --- CLEVER HELPERS (AI Syndicate Advisors & Automated Assistants) ---
const CLEVER_HELPERS = [
{
id: 'bulk_buyer',
name: 'Logistics Quartermaster',
title: 'Bulk Procurement & Multi-Buying',
icon: 'game-icons:cargo-crate',
cost: 2500,
desc: 'Unlocks advanced bulk purchasing pipelines (10x, 100x, Next Milestone, and Max Affordable) across all raw suppliers, lab technicians, and street dealers.',
feature: 'Multi-Unit Bulk Purchases'
},
{
id: 'roi_advisor',
name: 'Cartel Quant / ROI Advisor',
title: 'Real-Time Capital Efficiency Engine',
icon: 'game-icons:progression',
cost: 15000,
desc: 'Calculates marginal cost per revenue gain ($ / $/s) across raw procurement, production facilities, and street distribution to highlight the optimal investment right now.',
feature: 'Live ROI Analytics & Best Target Recommendation'
},
{
id: 'smart_allocator',
name: 'Chief Syndicate Strategist',
title: 'Portfolio Percentage Allocation',
icon: 'game-icons:chess-king',
cost: 75000,
desc: 'Empowers one-click instant investments of 10%, 25%, 50%, or 100% of available cash into the highest-ROI units automatically.',
feature: '10% / 25% / 50% / 100% Cash Portfolio Injection'
}
];
// --- SWISS OFFSHORE PERKS (Permanent upgrades bought with Clean Bonds) ---
const OFFSHORE_PERKS = [
{
id: 'swiss_vault',
name: 'Swiss Vault Secrecy',
icon: 'game-icons:safe',
desc: 'Heavy suppression on police surveillance. Heat rises slower during all operations.',
costs: [1, 3, 8, 20, 50],
bonusPerLevel: 0.12,
formatBonus: (lvl) => `-${lvl * 12}% Heat Rise`
},
{
id: 'auto_contracts',
name: 'Procurement Pipeline',
icon: 'game-icons:conveyor-belt',
desc: 'Unlocking drug formulas grants free starter suppliers & workers immediately.',
costs: [2, 5, 12, 30],
bonusPerLevel: 1,
formatBonus: (lvl) => `+${lvl} Free Starter Suppliers & Workers`
},
{
id: 'corrupt_judiciary',
name: 'Corrupt Judiciary',
icon: 'game-icons:judge-wig',
desc: 'Discounts on police raid bribes and lighter consequences during blockades.',
costs: [2, 6, 15, 40],
bonusPerLevel: 0.20,
formatBonus: (lvl) => `-${lvl * 20}% Raid Bribe Cost`
},
{
id: 'bulk_discount',
name: 'Wholesale Black Market',
icon: 'game-icons:price-tag',
desc: 'Reduces unit price scaling factor from 1.15 down towards 1.11.',
costs: [5, 15, 35, 80],
bonusPerLevel: 0.01,
formatBonus: (lvl) => `Scaling: ${(1.15 - lvl * 0.01).toFixed(2)}x (was 1.15x)`
},
{
id: 'compound_interest',
name: 'Compound Bond Yield',
icon: 'game-icons:gold-bar',
desc: 'Each Clean Bond yields an extra +2% global sales multiplier.',
costs: [4, 10, 25, 60, 150],
bonusPerLevel: 0.02,
formatBonus: (lvl) => `+${(4 + lvl * 2)}% Global Sales / Bond`
}
];
// --- MULTI-TIERED TROPHIES & ACHIEVEMENTS ---
const ACHIEVEMENT_TIERS = [
{
id: 'dirty_empire',
name: 'Dirty Empire',
category: 'finance',
icon: 'game-icons:money-stack',
desc: 'Accumulate career revenue.',
statKey: 'totalEarned',
formatValue: (v) => `$${fmtNum(v)}`,
tiers: [
{ req: 10000, name: 'Street Hustler', rewardDesc: '+5% Global Sales Value', bonusType: 'sell_multi', bonusVal: 0.05 },
{ req: 250000, name: 'Trap Lord', rewardDesc: '+10% Global Sales Value', bonusType: 'sell_multi', bonusVal: 0.10 },
{ req: 5000000, name: 'Syndicate Boss', rewardDesc: '+15% Global Sales Value', bonusType: 'sell_multi', bonusVal: 0.15 },
{ req: 100000000, name: 'Kingpin of Kings', rewardDesc: '+25% Global Sales Value', bonusType: 'sell_multi', bonusVal: 0.25 },
{ req: 1000000000, name: 'El Puro Supremo', rewardDesc: '+50% Global Sales Value', bonusType: 'sell_multi', bonusVal: 0.50 }
]
},
{
id: 'master_synthesizer',
name: 'Master Synthesizer',
category: 'production',
icon: 'game-icons:flask',
desc: 'Synthesize product in active labs.',
statKey: 'totalProduced',
formatValue: (v) => `${fmtNum(v)}g`,
tiers: [
{ req: 100, name: 'Test Tube Cook', rewardDesc: '+5% Production Speed', bonusType: 'prod_multi', bonusVal: 0.05 },
{ req: 1000, name: 'Lab Technician', rewardDesc: '+10% Production Speed', bonusType: 'prod_multi', bonusVal: 0.10 },
{ req: 15000, name: 'Chief Chemist', rewardDesc: '+15% Production Speed', bonusType: 'prod_multi', bonusVal: 0.15 },
{ req: 200000, name: 'Industrial Alchemist', rewardDesc: '+25% Production Speed', bonusType: 'prod_multi', bonusVal: 0.25 },
{ req: 2500000, name: 'Synthesis God', rewardDesc: '+50% Production Speed', bonusType: 'prod_multi', bonusVal: 0.50 }
]
},
{
id: 'street_monopoly',
name: 'Corner Network',
category: 'distribution',
icon: 'game-icons:hand-truck',
desc: 'Distribute grams on the streets.',
statKey: 'totalSold',
formatValue: (v) => `${fmtNum(v)}g`,
tiers: [
{ req: 100, name: 'Corner Pitcher', rewardDesc: '+5% Dealer Throughput', bonusType: 'dealer_speed', bonusVal: 0.05 },
{ req: 1500, name: 'Neighborhood Hub', rewardDesc: '+10% Dealer Throughput', bonusType: 'dealer_speed', bonusVal: 0.10 },
{ req: 25000, name: 'Metropolitan Monopoly', rewardDesc: '+15% Dealer Throughput', bonusType: 'dealer_speed', bonusVal: 0.15 },
{ req: 300000, name: 'Continental Route', rewardDesc: '+25% Dealer Throughput', bonusType: 'dealer_speed', bonusVal: 0.25 },
{ req: 3000000, name: 'Global Cartel Distribution', rewardDesc: '+50% Dealer Throughput', bonusType: 'dealer_speed', bonusVal: 0.50 }
]
},
{
id: 'supply_baron',
name: 'Supply Chain Baron',
category: 'logistics',
icon: 'game-icons:sprout',
desc: 'Harvest & cultivate raw materials via suppliers.',
statKey: 'totalSupplied',
formatValue: (v) => `${fmtNum(v)} raw`,
tiers: [
{ req: 250, name: 'Seedling Sprout', rewardDesc: '+5% Supplier Speed', bonusType: 'supplier_speed', bonusVal: 0.05 },
{ req: 3000, name: 'Greenhouse Fleet', rewardDesc: '+10% Supplier Speed', bonusType: 'supplier_speed', bonusVal: 0.10 },
{ req: 50000, name: 'Agricultural Consortium', rewardDesc: '+15% Supplier Speed', bonusType: 'supplier_speed', bonusVal: 0.15 },
{ req: 500000, name: 'Precursor Monopoly', rewardDesc: '+25% Supplier Speed', bonusType: 'supplier_speed', bonusVal: 0.25 }
]
},
{
id: 'vault_hoarder',
name: 'Liquid Vault',
category: 'finance',
icon: 'game-icons:vault',
desc: 'Hold liquid cash simultaneously on hand.',
statKey: 'cash',
formatValue: (v) => `$${fmtNum(v)}`,
tiers: [
{ req: 50000, name: 'Shoebox Stash', rewardDesc: '-5% Raw Material Costs', bonusType: 'raw_discount', bonusVal: 0.05 },
{ req: 500000, name: 'Duffel Bag Stack', rewardDesc: '-10% Raw Material Costs', bonusType: 'raw_discount', bonusVal: 0.10 },
{ req: 10000000, name: 'Private Bank Vault', rewardDesc: '-15% Raw Material Costs', bonusType: 'raw_discount', bonusVal: 0.15 },
{ req: 250000000, name: 'Central Bank Infiltrator', rewardDesc: '-25% Raw Material Costs', bonusType: 'raw_discount', bonusVal: 0.25 }
]
},
{
id: 'syndicate_army',
name: 'Syndicate Army',
category: 'workforce',
icon: 'game-icons:crowd-dive',
desc: 'Hire total workforce (Suppliers + Workers + Dealers).',
statKey: 'totalPersonnel',
formatValue: (v) => `${fmtInt(v)} staff`,
tiers: [
{ req: 25, name: 'Crew of 25', rewardDesc: '+5% All Unit Speeds', bonusType: 'all_speed', bonusVal: 0.05 },
{ req: 100, name: 'Centurion 100', rewardDesc: '+10% All Unit Speeds', bonusType: 'all_speed', bonusVal: 0.10 },
{ req: 500, name: 'Battalion 500', rewardDesc: '+15% All Unit Speeds', bonusType: 'all_speed', bonusVal: 0.15 },
{ req: 2500, name: 'Legion 2,500', rewardDesc: '+25% All Unit Speeds', bonusType: 'all_speed', bonusVal: 0.25 }
]
},
{
id: 'purity_legend',
name: '99.1% Crystallization',
category: 'chemistry',
icon: 'game-icons:crystal-growth',
desc: 'Trigger rare 3x Purity Syntheses.',
statKey: 'purityProcs',
formatValue: (v) => `${fmtInt(v)} procs`,
tiers: [
{ req: 5, name: 'Blue Crystal Spark', rewardDesc: '+2% Extra 3x Proc Chance', bonusType: 'proc_chance', bonusVal: 0.02 },
{ req: 25, name: 'Glass Master', rewardDesc: '+4% Extra 3x Proc Chance', bonusType: 'proc_chance', bonusVal: 0.04 },
{ req: 100, name: 'Cook of Legend', rewardDesc: '+8% Extra 3x Proc Chance', bonusType: 'proc_chance', bonusVal: 0.08 },
{ req: 500, name: 'Absolute 100% Purity', rewardDesc: '+15% Extra 3x Proc Chance', bonusType: 'proc_chance', bonusVal: 0.15 }
]
},
{
id: 'shadow_cartel',
name: 'Heat Evasion',
category: 'stealth',
icon: 'game-icons:police-officer-head',
desc: 'Survive raids by bribe or breakout.',
statKey: 'raidsSurvived',
formatValue: (v) => `${fmtInt(v)} survived`,
tiers: [
{ req: 3, name: 'Friendly Sheriff', rewardDesc: '-10% Police Heat Generation', bonusType: 'heat_reduction', bonusVal: 0.10 },
{ req: 10, name: 'Bought Precinct', rewardDesc: '-20% Police Heat Generation', bonusType: 'heat_reduction', bonusVal: 0.20 },
{ req: 30, name: 'Immunity Pass', rewardDesc: '-35% Police Heat Generation', bonusType: 'heat_reduction', bonusVal: 0.35 }
]
},
{
id: 'swiss_kingpin',
name: 'Swiss Laundromat',
category: 'prestige',
icon: 'game-icons:tied-scroll',
desc: 'Perform Offshore Prestige Laundering.',
statKey: 'prestigeCount',
formatValue: (v) => `${fmtInt(v)} resets`,
tiers: [
{ req: 1, name: 'First Swiss Wire', rewardDesc: '+$250 Starting Cash on Reset', bonusType: 'prestige_start_cash', bonusVal: 250 },
{ req: 3, name: 'Geneva Account', rewardDesc: '+$2,500 Starting Cash on Reset', bonusType: 'prestige_start_cash', bonusVal: 2500 },
{ req: 10, name: 'Zurich Private Vault', rewardDesc: '+$25,000 Starting Cash on Reset', bonusType: 'prestige_start_cash', bonusVal: 25000 },
{ req: 25, name: 'Shadow World Bank', rewardDesc: '+$250,000 Starting Cash on Reset', bonusType: 'prestige_start_cash', bonusVal: 250000 }
]
},
{
id: 'formula_expansion',
name: 'Cartel Formulary',
category: 'discovery',
icon: 'game-icons:dna1',
desc: 'Unlock classified substance formulas.',
statKey: 'unlockedCount',
formatValue: (v) => `${fmtInt(v)} / 10 formulas`,
tiers: [
{ req: 3, name: '3 Formulas Mastered', rewardDesc: '+5% All Production', bonusType: 'prod_multi', bonusVal: 0.05 },
{ req: 6, name: '6 Formulas Mastered', rewardDesc: '+10% All Production', bonusType: 'prod_multi', bonusVal: 0.10 },
{ req: 8, name: '8 Formulas Mastered', rewardDesc: '+15% All Production', bonusType: 'prod_multi', bonusVal: 0.15 },
{ req: 10, name: 'Apex Spectrum (All 10)', rewardDesc: '+30% All Production', bonusType: 'prod_multi', bonusVal: 0.30 }
]
}
];
// Backwards-compatibility alias
const ACHIEVEMENTS = ACHIEVEMENT_TIERS;

226
frontend/js/core/engine.js Normal file
View file

@ -0,0 +1,226 @@
// Production - The Idle Game: Core Simulation Engine
import { PRODUCTS, getUnitMilestoneMultiplier } from '../data/products.js';
import { CONTRACT_TEMPLATES } from '../data/contracts.js';
import { ACHIEVEMENTS } from '../data/achievements.js';
export class SimulationEngine {
constructor(stateManager) {
this.sm = stateManager;
this.cashRate = 0.0;
this._rateAccumulator = 0.0;
this._rateTimer = 0.0;
}
/**
* Executes a simulation tick for delta-time dt (in seconds)
*/
tick(dt) {
if (dt <= 0) return;
const state = this.sm.state;
const perks = state.boardroomPerks || {};
// Boardroom Multipliers
const supplyYieldMult = 1.0 + 0.15 * (perks['supply_sync'] || 0);
const roboticsSpeedMult = 1.0 + 0.20 * (perks['robotics_overclock'] || 0);
const logisticsPriceMult = 1.0 + 0.25 * (perks['logistics_efficiency'] || 0);
let tickRevenue = 0.0;
for (const p of PRODUCTS) {
const pState = state.products[p.id];
if (!pState || !pState.unlocked) continue;
const lines = pState.lines || 0;
if (lines <= 0) continue;
// Compute facility speed
const milestoneMult = getUnitMilestoneMultiplier(lines);
const cycleRate = (1.0 / p.baseCycleTime) * milestoneMult * roboticsSpeedMult;
// Handle Raw Extraction vs Manufacturing Inputs
const isRaw = !p.inputs || p.inputs.length === 0;
if (isRaw) {
pState.isBottlenecked = false;
pState.cycleProgress += cycleRate * dt;
if (pState.cycleProgress >= 1.0) {
const cycles = Math.floor(pState.cycleProgress);
pState.cycleProgress -= cycles;
const produced = cycles * p.outputAmount * lines * supplyYieldMult;
pState.stock += produced;
pState.totalProduced = (pState.totalProduced || 0) + produced;
state.stats.totalProduced = (state.stats.totalProduced || 0) + produced;
}
} else {
// Check if we have required input stock
const maxCyclesPossible = Math.min(
...p.inputs.map(inp => {
const inputStock = state.products[inp.id]?.stock || 0;
const reqPerCycle = inp.amount * lines;
return reqPerCycle > 0 ? Math.floor(inputStock / reqPerCycle) : 0;
})
);
if (maxCyclesPossible <= 0) {
pState.isBottlenecked = true;
// Retain existing cycle progress until inputs arrive
} else {
pState.isBottlenecked = false;
pState.cycleProgress += cycleRate * dt;
if (pState.cycleProgress >= 1.0) {
const cyclesToRun = Math.min(Math.floor(pState.cycleProgress), maxCyclesPossible);
if (cyclesToRun > 0) {
pState.cycleProgress -= cyclesToRun;
// Deduct inputs
for (const inp of p.inputs) {
if (state.products[inp.id]) {
state.products[inp.id].stock = Math.max(
0,
state.products[inp.id].stock - (inp.amount * lines * cyclesToRun)
);
}
}
// Credit finished goods
const produced = cyclesToRun * p.outputAmount * lines;
pState.stock += produced;
pState.totalProduced = (pState.totalProduced || 0) + produced;
state.stats.totalProduced = (state.stats.totalProduced || 0) + produced;
}
}
}
}
// Continuous Logistics Distribution (Wholesale Sales)
const logisticsUnits = pState.logistics || 0;
if (logisticsUnits > 0 && pState.stock > 0) {
// Logistics fleet capacity: 0.75 items/sec per unit
const maxSellCapacity = logisticsUnits * 0.75 * dt;
const sold = Math.min(pState.stock, maxSellCapacity);
pState.stock -= sold;
const salePrice = p.basePrice * logisticsPriceMult;
const earned = sold * salePrice;
state.cash += earned;
state.lifetimeRevenue += earned;
tickRevenue += earned;
}
}
// Track net revenue rate ($/s)
this._rateAccumulator += tickRevenue;
this._rateTimer += dt;
if (this._rateTimer >= 0.5) {
this.cashRate = this._rateAccumulator / this._rateTimer;
this._rateAccumulator = 0.0;
this._rateTimer = 0.0;
}
// Update company valuation: Liquid cash + Capital investment in lines + 20% of lifetime revenue
let facilityAssetValuation = 0;
for (const p of PRODUCTS) {
const pState = state.products[p.id];
if (pState && pState.lines > 0) {
facilityAssetValuation += pState.lines * p.baseFacilityCost;
}
}
state.valuation = state.cash + facilityAssetValuation + (state.lifetimeRevenue * 0.15);
// Update B2B contracts timers
this.updateContracts(dt);
// Check milestones/achievements
this.checkAchievements();
state.lastTick = Date.now();
}
/**
* Advances and generates active procurement tenders
*/
updateContracts(dt) {
const state = this.sm.state;
if (!state.activeContracts) state.activeContracts = [];
for (let i = state.activeContracts.length - 1; i >= 0; i--) {
const c = state.activeContracts[i];
c.remainingSeconds -= dt;
if (c.remainingSeconds <= 0) {
// Expired contract, remove
state.activeContracts.splice(i, 1);
}
}
// Replenish up to 3 open contracts
if (state.activeContracts.length < 3) {
const unlockedProducts = PRODUCTS.filter(p => state.products[p.id]?.unlocked);
if (unlockedProducts.length > 0) {
const availableTemplates = CONTRACT_TEMPLATES.filter(tmpl =>
unlockedProducts.some(p => p.id === tmpl.productId)
);
const tmpl = availableTemplates.length > 0
? availableTemplates[Math.floor(Math.random() * availableTemplates.length)]
: CONTRACT_TEMPLATES[0];
const uid = 'c_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
state.activeContracts.push({
uid,
templateId: tmpl.id,
productId: tmpl.productId,
title: tmpl.title,
clientId: tmpl.clientId,
targetAmount: tmpl.requiredAmount,
remainingSeconds: tmpl.timeLimit,
rewardCash: tmpl.rewardCash,
rewardRep: tmpl.rewardRep
});
}
}
}
/**
* Checks and flags unlocked corporate achievements
*/
checkAchievements() {
const state = this.sm.state;
for (const ach of ACHIEVEMENTS) {
let currentVal = 0;
if (ach.reqType === 'total_produced') currentVal = state.stats.totalProduced || 0;
else if (ach.reqType === 'total_revenue') currentVal = state.lifetimeRevenue || 0;
else if (ach.reqType === 'contracts_completed') currentVal = state.stats.contractsCompleted || 0;
else if (ach.reqType === 'ipo_count') currentVal = state.ipoCount || 0;
else if (ach.reqType === 'managers_hired') currentVal = state.stats.managersHired || 0;
const currentTier = state.achievements[ach.id] || 0;
if (currentTier < ach.thresholds.length) {
if (currentVal >= ach.thresholds[currentTier]) {
state.achievements[ach.id] = currentTier + 1;
}
}
}
}
/**
* Simulates offline progression up to offlineSeconds (capped at 8 hours)
*/
processOffline(offlineSeconds) {
const cappedSeconds = Math.min(Math.max(0, offlineSeconds), 8 * 3600);
if (cappedSeconds <= 1) return { seconds: 0, earned: 0 };
const startCash = this.sm.state.cash;
// Step forward in discrete 1-second chunks for accurate multi-tier consumption
const steps = Math.min(Math.floor(cappedSeconds), 3600);
const stepDt = cappedSeconds / steps;
for (let i = 0; i < steps; i++) {
this.tick(stepDt);
}
const earned = Math.max(0, this.sm.state.cash - startCash);
return { seconds: cappedSeconds, earned };
}
}

View file

@ -0,0 +1,72 @@
// Production - The Idle Game: Number & String Formatters
const NUMBER_SUFFIXES = [
'', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No',
'Dc', 'Ud', 'Dd', 'Td', 'Qad', 'Qid', 'Sxd', 'Spd', 'Ocd', 'Nod',
'Vg', 'Uvg', 'Dvg', 'Tvg', 'Qavg', 'Qivg', 'Sxvg', 'Spvg', 'Ocvg', 'Novg',
'Tg'
];
let userNotation = localStorage.getItem('production_notation') || 'standard';
export function getNotation() {
return userNotation;
}
export function setNotation(notation) {
userNotation = notation;
localStorage.setItem('production_notation', notation);
}
/**
* Format any numerical currency or quantity cleanly
*/
export function fmtNum(val, decimals = 2) {
if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0.00';
const num = Number(val);
if (num === 0) return '0.00';
const abs = Math.abs(num);
const sign = num < 0 ? '-' : '';
if (abs < 1e3) {
return sign + abs.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
if (abs < 1e6 && userNotation !== 'scientific') {
return sign + abs.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
if (userNotation === 'scientific') {
const exponent = Math.floor(Math.log10(abs));
const mantissa = abs / Math.pow(10, exponent);
return sign + mantissa.toFixed(decimals) + 'e' + exponent;
}
const tier = Math.floor(Math.log10(abs) / 3);
if (tier < NUMBER_SUFFIXES.length) {
const scaled = abs / Math.pow(10, tier * 3);
return sign + scaled.toFixed(decimals) + NUMBER_SUFFIXES[tier];
} else {
const exponent = Math.floor(Math.log10(abs));
const mantissa = abs / Math.pow(10, exponent);
return sign + mantissa.toFixed(decimals) + 'e' + exponent;
}
}
export function fmtCurrency(val, decimals = 2) {
return '$' + fmtNum(val, decimals);
}
export function fmtRate(val) {
return '+$' + fmtNum(val, 2) + '/s';
}
export function fmtTime(seconds) {
const s = Math.max(0, Math.floor(seconds));
const mins = Math.floor(s / 60);
const secs = s % 60;
if (mins > 0) {
return `${mins}m ${secs.toString().padStart(2, '0')}s`;
}
return `${secs}s`;
}

162
frontend/js/core/state.js Normal file
View file

@ -0,0 +1,162 @@
// Production - The Idle Game: State Container & Persistence
import { PRODUCTS } from '../data/products.js';
import { BOARDROOM_PERKS, calculateSharesEarned } from '../data/boardroom.js';
import { CONTRACT_TEMPLATES } from '../data/contracts.js';
import { ACHIEVEMENTS } from '../data/achievements.js';
const STORAGE_KEY = 'production_the_idle_game_save_v1';
export function getInitialState() {
const 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,
contractsCompleted: 0,
managersHired: 0
},
products: {},
boardroomPerks: {},
achievements: {},
activeContracts: [],
lastTick: Date.now()
};
// Initialize all products
PRODUCTS.forEach(p => {
state.products[p.id] = {
unlocked: p.unlockedDefault,
stock: 0.0,
lines: p.unlockedDefault ? 1 : 0,
manager: false,
logistics: p.unlockedDefault ? 1 : 0,
cycleProgress: 0.0,
isBottlenecked: false,
totalProduced: 0.0
};
});
// Initialize boardroom perks
BOARDROOM_PERKS.forEach(perk => {
state.boardroomPerks[perk.id] = 0;
});
// Initialize achievements
ACHIEVEMENTS.forEach(ach => {
state.achievements[ach.id] = 0;
});
// Generate initial starting contracts
state.activeContracts = [
{
uid: 'c_start_1',
templateId: CONTRACT_TEMPLATES[0].id,
productId: CONTRACT_TEMPLATES[0].productId,
title: CONTRACT_TEMPLATES[0].title,
clientId: CONTRACT_TEMPLATES[0].clientId,
targetAmount: CONTRACT_TEMPLATES[0].requiredAmount,
remainingSeconds: CONTRACT_TEMPLATES[0].timeLimit,
rewardCash: CONTRACT_TEMPLATES[0].rewardCash,
rewardRep: CONTRACT_TEMPLATES[0].rewardRep
}
];
return state;
}
export class StateManager {
constructor() {
this.state = this.load();
this.listeners = new Set();
}
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
notify(changedKeys = []) {
for (const fn of this.listeners) {
fn(this.state, changedKeys);
}
}
load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return getInitialState();
const parsed = JSON.parse(raw);
const fresh = getInitialState();
// Merge safely to preserve schema additions
const merged = { ...fresh, ...parsed };
merged.products = { ...fresh.products, ...(parsed.products || {}) };
merged.boardroomPerks = { ...fresh.boardroomPerks, ...(parsed.boardroomPerks || {}) };
merged.achievements = { ...fresh.achievements, ...(parsed.achievements || {}) };
merged.stats = { ...fresh.stats, ...(parsed.stats || {}) };
if (!Array.isArray(merged.activeContracts) || merged.activeContracts.length === 0) {
merged.activeContracts = fresh.activeContracts;
}
return merged;
} catch (err) {
console.error('Failed to load local state, fallback to fresh:', err);
return getInitialState();
}
}
save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch (err) {
console.error('Failed to save state to localStorage:', err);
}
}
reset() {
this.state = getInitialState();
this.save();
this.notify();
}
performIPO() {
const earned = calculateSharesEarned(this.state.lifetimeRevenue);
if (earned <= 0) return false;
const startingBonusPerk = this.state.boardroomPerks['angel_syndicate'] || 0;
const startingCashBonuses = [30.0, 500.0, 5000.0, 50000.0, 500000.0, 5000000.0];
const newStartingCash = startingCashBonuses[Math.min(startingBonusPerk, startingCashBonuses.length - 1)];
const preservedShares = this.state.ventureShares + earned;
const preservedTotalShares = this.state.totalVentureShares + earned;
const preservedPerks = { ...this.state.boardroomPerks };
const preservedIpoCount = this.state.ipoCount + 1;
const preservedAchievements = { ...this.state.achievements };
// Fresh state
this.state = getInitialState();
this.state.cash = newStartingCash;
this.state.ventureShares = preservedShares;
this.state.totalVentureShares = preservedTotalShares;
this.state.ipoCount = preservedIpoCount;
this.state.boardroomPerks = preservedPerks;
this.state.achievements = preservedAchievements;
this.save();
this.notify();
return true;
}
}
export const GState = new StateManager();

117
frontend/js/core/sync.js Normal file
View file

@ -0,0 +1,117 @@
// Production - The Idle Game: Backend Synchronization & Authentication
const TOKEN_KEY = 'production_auth_token';
const USERNAME_KEY = 'production_username';
export class SyncManager {
constructor(stateManager) {
this.sm = stateManager;
this.token = localStorage.getItem(TOKEN_KEY) || null;
this.username = localStorage.getItem(USERNAME_KEY) || null;
this.isSyncing = false;
this.pendingActions = [];
this.debounceTimer = null;
}
isLoggedIn() {
return !!this.token;
}
getUsername() {
return this.username || 'Executive Guest';
}
setAuth(token, username) {
this.token = token;
this.username = username;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USERNAME_KEY, username);
}
logout() {
this.token = null;
this.username = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USERNAME_KEY);
}
queueAction(action) {
this.pendingActions.push(action);
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.flush(), 1000);
}
async flush() {
if (!this.token || this.isSyncing || this.pendingActions.length === 0) return;
this.isSyncing = true;
const actionsToSend = [...this.pendingActions];
this.pendingActions = [];
try {
const res = await fetch('/api/game/tick', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({
seq: Date.now(),
actions: actionsToSend,
client_tick: Date.now() / 1000
})
});
if (!res.ok) {
// If unauthorized, clear token
if (res.status === 401) this.logout();
// Return unsent actions to queue
this.pendingActions.unshift(...actionsToSend);
}
} catch (err) {
console.warn('Sync failed (will retry):', err);
this.pendingActions.unshift(...actionsToSend);
} finally {
this.isSyncing = false;
}
}
async fetchCloudState() {
if (!this.token) return null;
try {
const res = await fetch('/api/game/state', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (res.ok) {
const data = await res.json();
return data.save_data || null;
}
} catch (err) {
console.error('Error fetching cloud state:', err);
}
return null;
}
async login(username, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Login failed');
this.setAuth(data.token, data.username);
return data;
}
async register(username, password, website_hp = '') {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, website_hp })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Registration failed');
this.setAuth(data.token, data.username);
return data;
}
}

View file

@ -0,0 +1,44 @@
// Production - The Idle Game: Corporate Milestones & Achievements
export const ACHIEVEMENTS = [
{
id: 'first_harvest',
title: 'Industrial Genesis',
icon: 'lucide:sprout',
description: 'Harvest your first raw commodity.',
reqType: 'total_produced',
thresholds: [10, 100, 1000, 25000, 500000]
},
{
id: 'revenue_mogul',
title: 'Corporate Treasury',
icon: 'lucide:badge-dollar-sign',
description: 'Reach milestones in total corporate lifetime revenue.',
reqType: 'total_revenue',
thresholds: [1000, 50000, 1000000, 50000000, 1000000000]
},
{
id: 'contract_tycoon',
title: 'Master Contractor',
icon: 'lucide:scroll-text',
description: 'Successfully fulfill corporate B2B procurement tenders.',
reqType: 'contracts_completed',
thresholds: [1, 5, 20, 50, 100]
},
{
id: 'wall_street',
title: 'Wall Street Debut',
icon: 'lucide:trending-up',
description: 'Complete corporate Initial Public Offerings (IPOs).',
reqType: 'ipo_count',
thresholds: [1, 3, 10, 25, 50]
},
{
id: 'automation_empire',
title: 'Hands-Off Conglomerate',
icon: 'lucide:users',
description: 'Hire automated Operations Managers across your facilities.',
reqType: 'managers_hired',
thresholds: [1, 5, 12, 18, 24]
}
];

View file

@ -0,0 +1,63 @@
// Production - The Idle Game: Corporate IPO & Boardroom Perks
// Permanent prestige mechanics fueled by Venture Shares ($V-Shares)
export const BOARDROOM_PERKS = [
{
id: 'supply_sync',
name: 'Automated Supply Chain Routing',
icon: 'lucide:network',
color: '#2563eb',
description: 'Advanced ERP routing balances raw input inventories across downstream factories.',
costShares: [1, 3, 8, 20, 50],
maxLevel: 5,
effectDesc: '+15% raw material extraction yield per level.'
},
{
id: 'robotics_overclock',
name: 'Industrial Robotics & AI',
icon: 'lucide:bot',
color: '#7c3aed',
description: 'Automated robotic arms accelerate manufacturing cycle completion times.',
costShares: [2, 5, 12, 30, 75],
maxLevel: 5,
effectDesc: '+20% faster cycle speeds across all production lines.'
},
{
id: 'logistics_efficiency',
name: 'Fleet Logistics Optimization',
icon: 'lucide:truck',
color: '#059669',
description: 'Streamlined commercial distribution channels boost wholesale profit margins.',
costShares: [2, 6, 15, 40, 100],
maxLevel: 5,
effectDesc: '+25% higher sale price on continuous logistics distribution.'
},
{
id: 'government_subsidies',
name: 'State Subsidies & Procurement',
icon: 'lucide:landmark',
color: '#d97706',
description: 'Secures high-tier government grants and preferential B2B procurement payouts.',
costShares: [3, 8, 20, 55, 120],
maxLevel: 5,
effectDesc: '+35% bonus cash payout on completed B2B contracts.'
},
{
id: 'angel_syndicate',
name: 'Venture Capital Syndicate',
icon: 'lucide:badge-dollar-sign',
color: '#dc2626',
description: 'Top-tier institutional investors seed every new corporate expansion with starting capital.',
costShares: [5, 15, 45, 120, 300],
maxLevel: 5,
effectDesc: 'Increases starting capital by $500, $5k, $50k, $500k, $5M after IPO.'
}
];
export const IPO_MIN_VALUATION = 100000; // $100k valuation to initiate first IPO
export function calculateSharesEarned(lifetimeRevenue) {
if (lifetimeRevenue < IPO_MIN_VALUATION) return 0;
// Formula: floor(10 * sqrt(lifetimeRevenue / 100,000))
return Math.floor(10 * Math.sqrt(lifetimeRevenue / IPO_MIN_VALUATION));
}

View file

@ -0,0 +1,128 @@
// Production - The Idle Game: B2B Procurement Contracts Board
// Dynamic and recurring corporate tenders offering massive cash bonuses and prestige points
export const CONTRACT_CLIENTS = [
{
id: 'apex_retail',
name: 'Apex Superstores Corp.',
icon: 'lucide:shopping-bag',
badge: 'Retail Giant',
description: 'Nationwide department chain needing consumer goods and foodstuffs.'
},
{
id: 'titan_construction',
name: 'Titan Infrastructure Ltd.',
icon: 'lucide:hard-hat',
badge: 'Civil Engineering',
description: 'Infrastructure contractor requiring steel, lumber, and heavy frames.'
},
{
id: 'omni_tech',
name: 'Omni Technologies Global',
icon: 'lucide:cpu',
badge: 'High-Tech Conglomerate',
description: 'Electronics powerhouse sourcing chips, polymers, and smart devices.'
},
{
id: 'vanguard_logistics',
name: 'Vanguard Transport Group',
icon: 'lucide:container',
badge: 'Logistics Network',
description: 'Freight operator expanding vehicle fleets and packaging rolls.'
}
];
export const CONTRACT_TEMPLATES = [
{
id: 'c_bakery_bulk',
clientId: 'apex_retail',
title: 'Supermarket Bakery Stocking',
productId: 'fresh_bread',
requiredAmount: 50,
rewardCash: 4500,
rewardRep: 10,
timeLimit: 120, // 2 minutes
tierReq: 1
},
{
id: 'c_timber_scaffold',
clientId: 'titan_construction',
title: 'Metro Scaffolding Project',
productId: 'planks',
requiredAmount: 100,
rewardCash: 18000,
rewardRep: 25,
timeLimit: 180,
tierReq: 1
},
{
id: 'c_steel_bridge',
clientId: 'titan_construction',
title: 'Highway Overpass Framework',
productId: 'steel_ingots',
requiredAmount: 120,
rewardCash: 125000,
rewardRep: 60,
timeLimit: 240,
tierReq: 2
},
{
id: 'c_paper_packaging',
clientId: 'vanguard_logistics',
title: 'National Distribution Packaging',
productId: 'paper',
requiredAmount: 250,
rewardCash: 65000,
rewardRep: 40,
timeLimit: 200,
tierReq: 2
},
{
id: 'c_poly_molding',
clientId: 'omni_tech',
title: 'Device Casing Polymer Supply',
productId: 'polymers',
requiredAmount: 150,
rewardCash: 480000,
rewardRep: 120,
timeLimit: 300,
tierReq: 3
},
{
id: 'c_silicon_fab',
clientId: 'omni_tech',
title: 'Cleanroom Wafer Ingot Batch',
productId: 'silicon',
requiredAmount: 80,
rewardCash: 620000,
rewardRep: 180,
timeLimit: 300,
tierReq: 3
},
{
id: 'c_board_supply',
clientId: 'omni_tech',
title: 'Server Farm Motherboard Tender',
productId: 'circuit_boards',
requiredAmount: 60,
rewardCash: 2400000,
rewardRep: 350,
timeLimit: 360,
tierReq: 4
},
{
id: 'c_fleet_expansion',
clientId: 'vanguard_logistics',
title: 'Interstate Transport Fleet Order',
productId: 'commercial_trucks',
requiredAmount: 25,
rewardCash: 12500000,
rewardRep: 800,
timeLimit: 480,
tierReq: 5
}
];
export function getClient(id) {
return CONTRACT_CLIENTS.find(c => c.id === id) || CONTRACT_CLIENTS[0];
}

View file

@ -0,0 +1,591 @@
// Production - The Idle Game: Product Catalog & Supply Chain Definitions
// Scalable data-driven structure supporting multi-tier inputs, sectors, and facilities
export const SECTORS = [
{
id: 'agriculture',
name: 'Agriculture & Food',
icon: 'lucide:wheat',
color: '#16a34a', // Emerald Green
description: 'Primary crops, livestock, milling, and commercial foodstuffs.'
},
{
id: 'materials',
name: 'Forestry & Materials',
icon: 'lucide:trees',
color: '#ca8a04', // Amber/Wood
description: 'Timber harvesting, lumber mills, paper, and commercial furniture.'
},
{
id: 'mining',
name: 'Mining & Metallurgy',
icon: 'lucide:pickaxe',
color: '#475569', // Slate/Steel
description: 'Ore extraction, blast furnaces, alloy smelting, and heavy frames.'
},
{
id: 'chemicals',
name: 'Chemicals & Polymers',
icon: 'lucide:flask-conical',
color: '#0284c7', // Sky Blue
description: 'Crude oil refinement, plastics, polymers, and synthetic compounds.'
},
{
id: 'electronics',
name: 'High-Tech & Electronics',
icon: 'lucide:cpu',
color: '#7c3aed', // Purple
description: 'Purified silicon, microchips, circuit assemblies, and smart devices.'
},
{
id: 'heavy_industry',
name: 'Heavy Industry & Vehicles',
icon: 'lucide:truck',
color: '#ea580c', // Industrial Orange
description: 'Combustion engines, logistics fleets, industrial robotics, and transport.'
}
];
export const PRODUCTS = [
// --- SECTOR 1: AGRICULTURE & FOOD ---
{
id: 'wheat',
name: 'Grain (Wheat)',
sector: 'agriculture',
tier: 1,
icon: 'lucide:wheat',
color: '#65a30d',
unit: 'bushels',
inputs: [], // Raw extraction
outputAmount: 1,
baseCycleTime: 1.0,
unlockCost: 0,
unlockedDefault: true,
baseFacilityCost: 15,
baseManagerCost: 100,
baseLogisticsCost: 80,
basePrice: 1.50,
facilityName: 'Wheat Field',
managerName: 'Farm Overseer',
logisticsName: 'Grain Sifter',
description: 'Golden wheat harvested from fertile corporate acreage.'
},
{
id: 'flour',
name: 'Milled Flour',
sector: 'agriculture',
tier: 2,
icon: 'lucide:cookie',
color: '#84cc16',
unit: 'sacks',
inputs: [{ id: 'wheat', amount: 2 }],
outputAmount: 1,
baseCycleTime: 1.8,
unlockCost: 250,
unlockedDefault: false,
baseFacilityCost: 200,
baseManagerCost: 850,
baseLogisticsCost: 600,
basePrice: 6.20,
facilityName: 'Grain Mill',
managerName: 'Master Miller',
logisticsName: 'Flour Courier',
description: 'Fine milled wheat flour ready for commercial bakeries.'
},
{
id: 'sugar_beet',
name: 'Sugar Beet',
sector: 'agriculture',
tier: 1,
icon: 'lucide:sprout',
color: '#a3e635',
unit: 'crates',
inputs: [],
outputAmount: 1,
baseCycleTime: 1.5,
unlockCost: 650,
unlockedDefault: false,
baseFacilityCost: 550,
baseManagerCost: 1800,
baseLogisticsCost: 1200,
basePrice: 4.80,
facilityName: 'Sugar Plantation',
managerName: 'Field Director',
logisticsName: 'Beet Hauler',
description: 'Sweet root vegetables cultivated for industrial sweetener extraction.'
},
{
id: 'refined_sugar',
name: 'Refined Sugar',
sector: 'agriculture',
tier: 2,
icon: 'lucide:candy',
color: '#e2e8f0',
unit: 'bags',
inputs: [{ id: 'sugar_beet', amount: 3 }],
outputAmount: 1,
baseCycleTime: 2.2,
unlockCost: 2200,
unlockedDefault: false,
baseFacilityCost: 1800,
baseManagerCost: 4500,
baseLogisticsCost: 3200,
basePrice: 22.00,
facilityName: 'Refinery Plant',
managerName: 'Sugar Chemist',
logisticsName: 'Bulk Distributor',
description: 'Pure granulated sucrose ready for food production.'
},
{
id: 'fresh_bread',
name: 'Artisan Bread',
sector: 'agriculture',
tier: 3,
icon: 'lucide:croissant',
color: '#f59e0b',
unit: 'loaves',
inputs: [{ id: 'flour', amount: 2 }, { id: 'refined_sugar', amount: 1 }],
outputAmount: 2,
baseCycleTime: 3.0,
unlockCost: 9500,
unlockedDefault: false,
baseFacilityCost: 7500,
baseManagerCost: 18000,
baseLogisticsCost: 12500,
basePrice: 58.00,
facilityName: 'Industrial Bakery',
managerName: 'Head Baker',
logisticsName: 'Delivery Fleet',
description: 'Freshly baked wholesale bread dispatched to grocery chains.'
},
{
id: 'packaged_meals',
name: 'Packaged Meal Kits',
sector: 'agriculture',
tier: 4,
icon: 'lucide:utensils',
color: '#10b981',
unit: 'cases',
inputs: [{ id: 'fresh_bread', amount: 3 }],
outputAmount: 1,
baseCycleTime: 4.5,
unlockCost: 45000,
unlockedDefault: false,
baseFacilityCost: 36000,
baseManagerCost: 75000,
baseLogisticsCost: 55000,
basePrice: 260.00,
facilityName: 'Food Processing Line',
managerName: 'Operations Lead',
logisticsName: 'Refrigerated Transport',
description: 'Ready-to-eat branded meal kits with high retail margin.'
},
// --- SECTOR 2: FORESTRY & MATERIALS ---
{
id: 'timber',
name: 'Raw Timber',
sector: 'materials',
tier: 1,
icon: 'lucide:trees',
color: '#b45309',
unit: 'logs',
inputs: [],
outputAmount: 1,
baseCycleTime: 1.2,
unlockCost: 120,
unlockedDefault: false,
baseFacilityCost: 95,
baseManagerCost: 350,
baseLogisticsCost: 240,
basePrice: 2.80,
facilityName: 'Lumber Camp',
managerName: 'Forester Chief',
logisticsName: 'Log Carrier',
description: 'Mature softwood felled in managed timber estates.'
},
{
id: 'planks',
name: 'Milled Planks',
sector: 'materials',
tier: 2,
icon: 'lucide:boxes',
color: '#d97706',
unit: 'bundles',
inputs: [{ id: 'timber', amount: 2 }],
outputAmount: 1,
baseCycleTime: 2.0,
unlockCost: 1200,
unlockedDefault: false,
baseFacilityCost: 950,
baseManagerCost: 2800,
baseLogisticsCost: 2000,
basePrice: 11.50,
facilityName: 'Sawmill',
managerName: 'Sawyer Foreman',
logisticsName: 'Lumber Flatbed',
description: 'Standardized structural lumber for construction and joinery.'
},
{
id: 'paper',
name: 'Industrial Paper',
sector: 'materials',
tier: 2,
icon: 'lucide:file-text',
color: '#cbd5e1',
unit: 'rolls',
inputs: [{ id: 'timber', amount: 3 }],
outputAmount: 2,
baseCycleTime: 2.5,
unlockCost: 3500,
unlockedDefault: false,
baseFacilityCost: 2800,
baseManagerCost: 6500,
baseLogisticsCost: 4800,
basePrice: 16.00,
facilityName: 'Pulp Mill',
managerName: 'Chemical Pulpist',
logisticsName: 'Paper Dispatcher',
description: 'Heavy paper stock used for cardboard packaging and publishing.'
},
{
id: 'furniture',
name: 'Office Furniture',
sector: 'materials',
tier: 3,
icon: 'lucide:armchair',
color: '#92400e',
unit: 'sets',
inputs: [{ id: 'planks', amount: 3 }],
outputAmount: 1,
baseCycleTime: 3.8,
unlockCost: 18000,
unlockedDefault: false,
baseFacilityCost: 14000,
baseManagerCost: 35000,
baseLogisticsCost: 26000,
basePrice: 95.00,
facilityName: 'Joinery Workshop',
managerName: 'Master Carpenter',
logisticsName: 'Showroom Distributor',
description: 'Ergonomic executive desks, cabinetry, and boardroom chairs.'
},
// --- SECTOR 3: MINING & METALLURGY ---
{
id: 'iron_ore',
name: 'Iron Ore',
sector: 'mining',
tier: 1,
icon: 'lucide:mountain',
color: '#64748b',
unit: 'tons',
inputs: [],
outputAmount: 1,
baseCycleTime: 1.6,
unlockCost: 1500,
unlockedDefault: false,
baseFacilityCost: 1200,
baseManagerCost: 3500,
baseLogisticsCost: 2400,
basePrice: 12.00,
facilityName: 'Open-Pit Iron Mine',
managerName: 'Mining Superintendant',
logisticsName: 'Ore Hopper Train',
description: 'Dense hematite extracted from open-pit mineral deposits.'
},
{
id: 'coal',
name: 'Coking Coal',
sector: 'mining',
tier: 1,
icon: 'lucide:flame',
color: '#334155',
unit: 'tons',
inputs: [],
outputAmount: 1,
baseCycleTime: 1.8,
unlockCost: 2800,
unlockedDefault: false,
baseFacilityCost: 2200,
baseManagerCost: 5500,
baseLogisticsCost: 3800,
basePrice: 15.00,
facilityName: 'Coal Pit',
managerName: 'Pit Supervisor',
logisticsName: 'Coal Conveyor',
description: 'High-energy carbon fuel essential for blast furnace reduction.'
},
{
id: 'steel_ingots',
name: 'Structural Steel',
sector: 'mining',
tier: 2,
icon: 'lucide:anvil',
color: '#94a3b8',
unit: 'billets',
inputs: [{ id: 'iron_ore', amount: 2 }, { id: 'coal', amount: 1 }],
outputAmount: 1,
baseCycleTime: 3.2,
unlockCost: 12500,
unlockedDefault: false,
baseFacilityCost: 9800,
baseManagerCost: 24000,
baseLogisticsCost: 17000,
basePrice: 72.00,
facilityName: 'Blast Smelter',
managerName: 'Foundry Master',
logisticsName: 'Heavy Rail Freight',
description: 'High-tensile carbon steel alloys for industrial manufacturing.'
},
{
id: 'metal_frames',
name: 'Heavy Frameworks',
sector: 'mining',
tier: 3,
icon: 'lucide:construction',
color: '#475569',
unit: 'girders',
inputs: [{ id: 'steel_ingots', amount: 3 }],
outputAmount: 1,
baseCycleTime: 4.6,
unlockCost: 65000,
unlockedDefault: false,
baseFacilityCost: 52000,
baseManagerCost: 110000,
baseLogisticsCost: 80000,
basePrice: 320.00,
facilityName: 'Fabrication Plant',
managerName: 'Chief Fabricator',
logisticsName: 'Heavy Hauler',
description: 'Rigid reinforced metal chassis and skyscraper beams.'
},
// --- SECTOR 4: CHEMICALS & POLYMERS ---
{
id: 'crude_oil',
name: 'Crude Oil',
sector: 'chemicals',
tier: 1,
icon: 'lucide:droplet',
color: '#1e293b',
unit: 'barrels',
inputs: [],
outputAmount: 1,
baseCycleTime: 2.0,
unlockCost: 25000,
unlockedDefault: false,
baseFacilityCost: 20000,
baseManagerCost: 48000,
baseLogisticsCost: 35000,
basePrice: 55.00,
facilityName: 'Drilling Derrick',
managerName: 'Derrick Engineer',
logisticsName: 'Pipeline Station',
description: 'Black liquid gold pumped from deep continental wells.'
},
{
id: 'silica_sand',
name: 'Silica Quartz Sand',
sector: 'chemicals',
tier: 1,
icon: 'lucide:sparkles',
color: '#38bdf8',
unit: 'drums',
inputs: [],
outputAmount: 1,
baseCycleTime: 2.2,
unlockCost: 45000,
unlockedDefault: false,
baseFacilityCost: 36000,
baseManagerCost: 75000,
baseLogisticsCost: 52000,
basePrice: 85.00,
facilityName: 'Quartz Quarry',
managerName: 'Geological Analyst',
logisticsName: 'Quartz Transport',
description: 'High-purity quartz crystals mined for semiconductor refining.'
},
{
id: 'polymers',
name: 'Industrial Polymers',
sector: 'chemicals',
tier: 2,
icon: 'lucide:test-tube',
color: '#0ea5e9',
unit: 'pellets',
inputs: [{ id: 'crude_oil', amount: 2 }],
outputAmount: 1,
baseCycleTime: 3.5,
unlockCost: 110000,
unlockedDefault: false,
baseFacilityCost: 88000,
baseManagerCost: 175000,
baseLogisticsCost: 120000,
basePrice: 210.00,
facilityName: 'Cracking Facility',
managerName: 'Petrochemical Lead',
logisticsName: 'Chemical Tanker',
description: 'Versatile synthetic thermoplastic pellets for manufacturing.'
},
{
id: 'silicon',
name: 'Purified Silicon',
sector: 'chemicals',
tier: 2,
icon: 'lucide:gem',
color: '#06b6d4',
unit: 'ingots',
inputs: [{ id: 'silica_sand', amount: 2 }],
outputAmount: 1,
baseCycleTime: 4.0,
unlockCost: 220000,
unlockedDefault: false,
baseFacilityCost: 175000,
baseManagerCost: 350000,
baseLogisticsCost: 240000,
basePrice: 420.00,
facilityName: 'Silicon Smelter',
managerName: 'Materials Scientist',
logisticsName: 'Clean Ingot Carrier',
description: '99.999% pure electronic grade monocrystalline silicon.'
},
// --- SECTOR 5: HIGH-TECH & ELECTRONICS ---
{
id: 'microchips',
name: 'Silicon Microchips',
sector: 'electronics',
tier: 3,
icon: 'lucide:cpu',
color: '#8b5cf6',
unit: 'chips',
inputs: [{ id: 'silicon', amount: 1 }, { id: 'polymers', amount: 1 }],
outputAmount: 2,
baseCycleTime: 4.8,
unlockCost: 550000,
unlockedDefault: false,
baseFacilityCost: 440000,
baseManagerCost: 850000,
baseLogisticsCost: 600000,
basePrice: 650.00,
facilityName: 'Cleanroom Fab',
managerName: 'Lithography Director',
logisticsName: 'Precision Courier',
description: 'Nanometer-scale integrated circuits etched via photolithography.'
},
{
id: 'circuit_boards',
name: 'PCB Motherboards',
sector: 'electronics',
tier: 3,
icon: 'lucide:circuit-board',
color: '#a855f7',
unit: 'boards',
inputs: [{ id: 'microchips', amount: 2 }, { id: 'polymers', amount: 2 }],
outputAmount: 1,
baseCycleTime: 5.5,
unlockCost: 1500000,
unlockedDefault: false,
baseFacilityCost: 1200000,
baseManagerCost: 2400000,
baseLogisticsCost: 1600000,
basePrice: 2200.00,
facilityName: 'PCB Assembly Line',
managerName: 'SMT Chief',
logisticsName: 'Static-Safe Transport',
description: 'Multi-layer printed circuit boards populated with components.'
},
{
id: 'smartphones',
name: 'Consumer Smartphones',
sector: 'electronics',
tier: 4,
icon: 'lucide:smartphone',
color: '#ec4899',
unit: 'devices',
inputs: [{ id: 'circuit_boards', amount: 1 }, { id: 'polymers', amount: 2 }],
outputAmount: 1,
baseCycleTime: 6.5,
unlockCost: 5000000,
unlockedDefault: false,
baseFacilityCost: 4000000,
baseManagerCost: 8000000,
baseLogisticsCost: 5500000,
basePrice: 6800.00,
facilityName: 'Tech Assembly Plant',
managerName: 'VP of Electronics',
logisticsName: 'Global Freight Air',
description: 'Flagship handheld smartphones shipped to worldwide retail.'
},
// --- SECTOR 6: HEAVY INDUSTRY & VEHICLES ---
{
id: 'engines',
name: 'Industrial Engines',
sector: 'heavy_industry',
tier: 3,
icon: 'lucide:cog',
color: '#f97316',
unit: 'motors',
inputs: [{ id: 'steel_ingots', amount: 3 }, { id: 'metal_frames', amount: 1 }],
outputAmount: 1,
baseCycleTime: 6.0,
unlockCost: 3500000,
unlockedDefault: false,
baseFacilityCost: 2800000,
baseManagerCost: 5500000,
baseLogisticsCost: 3800000,
basePrice: 4200.00,
facilityName: 'Motor Foundry',
managerName: 'Engine Master',
logisticsName: 'Crated Transport',
description: 'High-torque turbodiesel and hybrid electric powerplants.'
},
{
id: 'commercial_trucks',
name: 'Commercial Trucks',
sector: 'heavy_industry',
tier: 4,
icon: 'lucide:truck',
color: '#dc2626',
unit: 'trucks',
inputs: [{ id: 'engines', amount: 1 }, { id: 'metal_frames', amount: 2 }, { id: 'circuit_boards', amount: 1 }],
outputAmount: 1,
baseCycleTime: 8.5,
unlockCost: 15000000,
unlockedDefault: false,
baseFacilityCost: 12000000,
baseManagerCost: 22000000,
baseLogisticsCost: 15000000,
basePrice: 26500.00,
facilityName: 'Vehicle Mega-Factory',
managerName: 'Plant General Manager',
logisticsName: 'Carrier Fleet',
description: 'Heavy duty articulated transport trucks for national logistics.'
}
];
// Helper to look up a product by ID
export function getProduct(id) {
return PRODUCTS.find(p => p.id === id);
}
// Milestone thresholds that grant a 2x throughput multiplier
export const UNIT_MILESTONES = [1, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000];
export function getUnitMilestoneMultiplier(count) {
let reached = 0;
for (const m of UNIT_MILESTONES) {
if (count >= m) reached++;
else break;
}
return Math.pow(2.0, reached);
}
export function getNextMilestone(count) {
for (const m of UNIT_MILESTONES) {
if (count < m) return m;
}
return count + 1000;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
// Production - The Idle Game: Corporate Milestones View
import { ACHIEVEMENTS } from '../data/achievements.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function renderAchievements(container, state, onAction) {
const achState = state.achievements || {};
let html = `
<div class="achievements-header-pane">
<h2 class="view-title">
<iconify-icon icon="lucide:trophy" class="title-icon"></iconify-icon>
Corporate Milestones & Accolades
</h2>
<p class="view-subtitle">
Track your corporate conglomerate's historical accomplishments across production, revenue, tenders, and stock market triumphs.
</p>
</div>
<div class="achievements-grid">
`;
for (const ach of ACHIEVEMENTS) {
const currentTier = achState[ach.id] || 0;
let currentVal = 0;
if (ach.reqType === 'total_produced') currentVal = state.stats.totalProduced || 0;
else if (ach.reqType === 'total_revenue') currentVal = state.lifetimeRevenue || 0;
else if (ach.reqType === 'contracts_completed') currentVal = state.stats.contractsCompleted || 0;
else if (ach.reqType === 'ipo_count') currentVal = state.ipoCount || 0;
else if (ach.reqType === 'managers_hired') currentVal = state.stats.managersHired || 0;
const maxTier = ach.thresholds.length;
const isMaxed = currentTier >= maxTier;
const nextThreshold = !isMaxed ? ach.thresholds[currentTier] : ach.thresholds[maxTier - 1];
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
html += `
<div class="achievement-card ${currentTier > 0 ? 'achieved' : ''}">
<div class="ach-icon-col">
<div class="ach-icon-circle ${currentTier > 0 ? 'ach-unlocked' : ''}">
<iconify-icon icon="${ach.icon}"></iconify-icon>
</div>
</div>
<div class="ach-info-col">
<div class="ach-title-row">
<h4 class="ach-title">${ach.title}</h4>
<span class="ach-tier-badge">Tier ${currentTier} / ${maxTier}</span>
</div>
<p class="ach-desc">${ach.description}</p>
<div class="ach-progress-bar-track">
<div class="ach-progress-bar-fill" style="width: ${progressPct}%;"></div>
</div>
<div class="ach-progress-labels font-mono">
<span>${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}</span>
<span>${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}</span>
</div>
</div>
</div>
`;
}
html += `</div>`;
container.innerHTML = html;
}

View file

@ -0,0 +1,117 @@
// Production - The Idle Game: Corporate IPO & Boardroom Perks View
import { BOARDROOM_PERKS, calculateSharesEarned, IPO_MIN_VALUATION } from '../data/boardroom.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function renderBoardroom(container, state, onAction) {
const earnedShares = calculateSharesEarned(state.lifetimeRevenue);
const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
const perks = state.boardroomPerks || {};
let html = `
<div class="boardroom-header-pane">
<div class="boardroom-hero">
<div class="hero-left">
<span class="executive-tag">
<iconify-icon icon="lucide:building-2"></iconify-icon> Executive Boardroom
</span>
<h2 class="boardroom-title">Corporate IPO & Venture Capital</h2>
<p class="boardroom-desc">
Take your manufacturing conglomerate public on the global securities exchange.
Liquidate current factories to secure permanent <strong>Venture Shares ($V-Shares)</strong>, funding cutting-edge R&D and boardroom privileges.
</p>
</div>
<div class="hero-valuation-card">
<div class="val-label">Corporate Enterprise Valuation</div>
<div class="val-number font-mono">${fmtCurrency(state.valuation)}</div>
<div class="val-lifetime">Lifetime Gross Revenue: <strong>${fmtCurrency(state.lifetimeRevenue)}</strong></div>
</div>
</div>
<div class="ipo-action-banner ${canIPO ? 'ready-ipo' : 'locked-ipo'}">
<div class="ipo-banner-info">
<div class="ipo-shares-badge font-mono">
<iconify-icon icon="lucide:gem"></iconify-icon>
<span>+${fmtNum(earnedShares, 0)} $V-Shares</span>
</div>
<div>
<h4 class="ipo-banner-title">Initial Public Offering (IPO) Ready</h4>
<p class="ipo-banner-sub">
${canIPO
? `Execute corporate IPO now to claim ${fmtNum(earnedShares, 0)} Venture Shares and restart with permanent bonuses.`
: `Requires minimum ${fmtCurrency(IPO_MIN_VALUATION)} in lifetime revenue to file for IPO.`}
</p>
</div>
</div>
<button class="btn-primary-corp btn-ipo-trigger" ${canIPO ? '' : 'disabled'}>
<iconify-icon icon="lucide:trending-up"></iconify-icon>
<span>Launch IPO</span>
</button>
</div>
</div>
<div class="boardroom-shares-summary">
<div class="shares-pill">
<span class="shares-label">Available $V-Shares:</span>
<strong class="shares-val font-mono"><iconify-icon icon="lucide:gem"></iconify-icon> ${fmtNum(state.ventureShares, 0)}</strong>
</div>
<div class="shares-pill">
<span class="shares-label">IPOs Completed:</span>
<strong class="shares-val font-mono">${state.ipoCount || 0}</strong>
</div>
</div>
<div class="perks-section-title">
<h3><iconify-icon icon="lucide:shield-check"></iconify-icon> Boardroom R&D Tech Tree</h3>
<span>Permanent corporate privileges that persist across all future IPO expansions</span>
</div>
<div class="perks-grid">
`;
for (const perk of BOARDROOM_PERKS) {
const currentLvl = perks[perk.id] || 0;
const isMaxed = currentLvl >= perk.maxLevel;
const nextCost = !isMaxed ? perk.costShares[currentLvl] : 0;
const canAfford = state.ventureShares >= nextCost && !isMaxed;
html += `
<div class="perk-card ${isMaxed ? 'perk-maxed' : ''}">
<div class="perk-header">
<div class="perk-icon-wrap" style="background: ${perk.color}15; color: ${perk.color};">
<iconify-icon icon="${perk.icon}" class="icon-lg"></iconify-icon>
</div>
<div>
<div class="perk-tier-badge">Level ${currentLvl} / ${perk.maxLevel}</div>
<h4 class="perk-name">${perk.name}</h4>
</div>
</div>
<p class="perk-desc">${perk.description}</p>
<div class="perk-effect-box">
<iconify-icon icon="lucide:zap" style="color: ${perk.color};"></iconify-icon>
<span>${perk.effectDesc}</span>
</div>
<div class="perk-footer">
<button class="btn-corp-perk btn-buy-perk" data-perk="${perk.id}" ${canAfford ? '' : 'disabled'}>
<iconify-icon icon="lucide:gem"></iconify-icon>
<span>${isMaxed ? 'Max Tier Acquired' : `Upgrade (${fmtNum(nextCost, 0)} $V-Shares)`}</span>
</button>
</div>
</div>
`;
}
html += `</div>`;
container.innerHTML = html;
const ipoBtn = container.querySelector('.btn-ipo-trigger');
if (ipoBtn) {
ipoBtn.onclick = () => onAction({ type: 'TRIGGER_IPO' });
}
container.querySelectorAll('.btn-buy-perk').forEach(btn => {
btn.onclick = () => onAction({ type: 'BUY_PERK', perkId: btn.dataset.perk });
});
}

View file

@ -0,0 +1,112 @@
// Production - The Idle Game: B2B Procurement Contracts Board
import { getProduct } from '../data/products.js';
import { getClient } from '../data/contracts.js';
import { fmtCurrency, fmtNum, fmtTime } from '../core/formatters.js';
export function renderContractsBoard(container, state, onAction) {
const contracts = state.activeContracts || [];
let html = `
<div class="contracts-header-pane">
<div class="contracts-header-info">
<h2 class="view-title">
<iconify-icon icon="lucide:scroll-text" class="title-icon"></iconify-icon>
B2B Corporate Procurement Tenders
</h2>
<p class="view-subtitle">
Supply high-priority commodity shipments to corporate clients for massive cash injections and corporate prestige.
</p>
</div>
<div class="contracts-meta-badge">
<span>Completed Tenders:</span>
<strong>${state.stats.contractsCompleted || 0}</strong>
</div>
</div>
<div class="contracts-grid">
`;
if (contracts.length === 0) {
html += `
<div class="empty-contracts-card">
<iconify-icon icon="lucide:clock" class="empty-icon"></iconify-icon>
<h3>Awaiting New Tenders</h3>
<p>Corporate procurement agents are preparing new commodity RFPs...</p>
</div>
`;
}
for (const c of contracts) {
const client = getClient(c.clientId);
const product = getProduct(c.productId);
const currentStock = state.products[c.productId]?.stock || 0;
const canFulfill = currentStock >= c.targetAmount;
const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
html += `
<div class="contract-card ${canFulfill ? 'ready-to-fulfill' : ''}">
<div class="contract-client-row">
<div class="client-brand">
<div class="client-avatar">
<iconify-icon icon="${client.icon}"></iconify-icon>
</div>
<div>
<h4 class="client-name">${client.name}</h4>
<span class="client-badge">${client.badge}</span>
</div>
</div>
<div class="contract-timer font-mono ${c.remainingSeconds < 60 ? 'timer-urgent' : ''}">
<iconify-icon icon="lucide:timer"></iconify-icon>
<span>${fmtTime(c.remainingSeconds)}</span>
</div>
</div>
<div class="contract-body">
<h3 class="contract-title">${c.title}</h3>
<div class="contract-req-row">
<div class="contract-req-item">
<span class="req-label">Required Commodity:</span>
<div class="req-val-wrap">
<iconify-icon icon="${product?.icon || 'lucide:box'}" style="color: ${product?.color || '#2563eb'};"></iconify-icon>
<strong>${product?.name}</strong>
</div>
</div>
<div class="contract-req-progress font-mono">
${fmtNum(currentStock, 0)} / <strong>${fmtNum(c.targetAmount, 0)}</strong> ${product?.unit || 'units'}
</div>
</div>
<div class="contract-progress-track">
<div class="contract-progress-fill" style="width: ${progressPct}%;"></div>
</div>
<div class="contract-payout-box">
<div class="payout-col">
<span class="payout-label">Contract Payout:</span>
<span class="payout-val font-mono">${fmtCurrency(c.rewardCash)}</span>
</div>
<div class="payout-col">
<span class="payout-label">Corporate Rep:</span>
<span class="payout-val font-mono">+${c.rewardRep} Rep</span>
</div>
</div>
</div>
<div class="contract-footer">
<button class="btn-primary-corp btn-fulfill-contract" data-uid="${c.uid}" ${canFulfill ? '' : 'disabled'}>
<iconify-icon icon="lucide:check-check"></iconify-icon>
<span>${canFulfill ? 'Deliver Shipment & Collect Payout' : `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`}</span>
</button>
</div>
</div>
`;
}
html += `</div>`;
container.innerHTML = html;
container.querySelectorAll('.btn-fulfill-contract').forEach(btn => {
btn.onclick = () => onAction({ type: 'FULFILL_CONTRACT', uid: btn.dataset.uid });
});
}

View file

@ -0,0 +1,141 @@
// Production - The Idle Game: Desktop Inspector & Supply Chain Analytics Panel
import { PRODUCTS, SECTORS, getProduct } from '../data/products.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function renderInspector(container, state, onAction) {
const selectedId = state.selectedProductId || 'wheat';
const product = getProduct(selectedId) || PRODUCTS[0];
const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0, unlocked: false };
const sector = SECTORS.find(s => s.id === product.sector);
// Find downstream products that consume this product
const downstream = PRODUCTS.filter(p => p.inputs && p.inputs.some(inp => inp.id === product.id));
// Calculate liquidation value
const totalLiquidationValue = (pState.stock || 0) * product.basePrice;
const halfLiquidationValue = totalLiquidationValue * 0.5;
let html = `
<div class="inspector-card">
<div class="inspector-header">
<div class="inspector-badge-row">
<span class="sector-tag" style="background: ${product.color}15; color: ${product.color};">
<iconify-icon icon="${product.icon}"></iconify-icon> ${sector?.name}
</span>
<span class="tier-tag">Tier ${product.tier}</span>
</div>
<h3 class="inspector-title">${product.name}</h3>
<p class="inspector-desc">${product.description}</p>
</div>
<!-- Real-time metrics grid -->
<div class="inspector-metrics-grid">
<div class="metric-box">
<span class="metric-label">Warehouse Stock:</span>
<strong class="metric-val font-mono">${fmtNum(pState.stock, 0)} ${product.unit}</strong>
</div>
<div class="metric-box">
<span class="metric-label">Wholesale Price:</span>
<strong class="metric-val font-mono">${fmtCurrency(product.basePrice)}</strong>
</div>
<div class="metric-box">
<span class="metric-label">Production Lines:</span>
<strong class="metric-val font-mono">${pState.lines} active</strong>
</div>
<div class="metric-box">
<span class="metric-label">Logistics Fleets:</span>
<strong class="metric-val font-mono">${pState.logistics || 0} active</strong>
</div>
</div>
<!-- Instant Wholesale Market Liquidation -->
<div class="inspector-section">
<h4 class="section-heading">
<iconify-icon icon="lucide:dollar-sign"></iconify-icon> Instant Wholesale Liquidation
</h4>
<p class="section-subtext">Liquidate stockpiles immediately onto the wholesale spot market.</p>
<div class="liquidation-buttons">
<button class="btn-corp-secondary btn-liquidate-half" data-product="${product.id}" ${pState.stock >= 2 ? '' : 'disabled'}>
Sell 50% (${fmtCurrency(halfLiquidationValue)})
</button>
<button class="btn-primary-corp btn-liquidate-all" data-product="${product.id}" ${pState.stock >= 1 ? '' : 'disabled'}>
Sell 100% (${fmtCurrency(totalLiquidationValue)})
</button>
</div>
</div>
<!-- Upstream & Downstream Supply Chain Flow -->
<div class="inspector-section">
<h4 class="section-heading">
<iconify-icon icon="lucide:git-fork"></iconify-icon> Supply Chain Dependencies
</h4>
<!-- Inputs (Upstream) -->
<div class="chain-box">
<span class="chain-title">Direct Inputs:</span>
${(!product.inputs || product.inputs.length === 0) ? `
<div class="chain-empty">Primary commodity &bull; Direct extraction from natural deposits.</div>
` : `
<div class="chain-items">
${product.inputs.map(inp => {
const parent = getProduct(inp.id);
const parentStock = state.products[inp.id]?.stock || 0;
return `
<div class="chain-item-row" data-product="${parent.id}">
<div class="chain-item-left">
<iconify-icon icon="${parent.icon}" style="color: ${parent.color};"></iconify-icon>
<span>${parent.name}</span>
</div>
<span class="chain-item-ratio font-mono">${inp.amount} per unit (${fmtNum(parentStock, 0)} on hand)</span>
</div>
`;
}).join('')}
</div>
`}
</div>
<!-- Downstream Consumers -->
<div class="chain-box">
<span class="chain-title">Downstream Consumers:</span>
${downstream.length === 0 ? `
<div class="chain-empty">End-tier consumer or industrial finished product.</div>
` : `
<div class="chain-items">
${downstream.map(child => {
const isUnlocked = state.products[child.id]?.unlocked;
return `
<div class="chain-item-row" data-product="${child.id}">
<div class="chain-item-left">
<iconify-icon icon="${child.icon}" style="color: ${child.color};"></iconify-icon>
<span>${child.name}</span>
</div>
<span class="chain-badge ${isUnlocked ? 'badge-open' : 'badge-locked'}">
${isUnlocked ? 'Active' : 'Locked'}
</span>
</div>
`;
}).join('')}
</div>
`}
</div>
</div>
</div>
`;
container.innerHTML = html;
const halfBtn = container.querySelector('.btn-liquidate-half');
if (halfBtn) {
halfBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 0.5 });
}
const allBtn = container.querySelector('.btn-liquidate-all');
if (allBtn) {
allBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 1.0 });
}
container.querySelectorAll('.chain-item-row').forEach(row => {
row.onclick = () => onAction({ type: 'SELECT_PRODUCT', productId: row.dataset.product });
});
}

View file

@ -0,0 +1,243 @@
// Production - The Idle Game: Production Floor View
import { PRODUCTS, SECTORS, getProduct, getNextMilestone, getUnitMilestoneMultiplier } from '../data/products.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function calculateMultiCost(baseCost, currentCount, multiplier) {
// Standard incremental scaling: cost = baseCost * 1.15^count
// For n purchases: sum(baseCost * 1.15^(count + i))
const growth = 1.15;
let countToBuy = 1;
if (multiplier === '1') countToBuy = 1;
else if (multiplier === '10') countToBuy = 10;
else if (multiplier === '100') countToBuy = 100;
else if (multiplier === 'next') {
const nextTarget = getNextMilestone(currentCount);
countToBuy = Math.max(1, nextTarget - currentCount);
} else if (multiplier === 'max') {
countToBuy = 1; // Will be handled dynamically in loop
}
let totalCost = 0;
for (let i = 0; i < countToBuy; i++) {
totalCost += baseCost * Math.pow(growth, currentCount + i);
}
return { count: countToBuy, cost: totalCost };
}
export function renderProductionFloor(container, state, onAction) {
const selectedSector = state.selectedSector || 'all';
const buyMode = state.buyMode || '1';
const filteredProducts = PRODUCTS.filter(p => {
if (selectedSector === 'all') return true;
return p.sector === selectedSector;
});
let html = `
<div class="production-toolbar">
<div class="sector-tabs-scroll">
<button class="sector-pill ${selectedSector === 'all' ? 'active' : ''}" data-sector="all">
<iconify-icon icon="lucide:layout-grid"></iconify-icon>
<span>All Sectors</span>
</button>
${SECTORS.map(s => `
<button class="sector-pill ${selectedSector === s.id ? 'active' : ''}" data-sector="${s.id}">
<iconify-icon icon="${s.icon}" style="color: ${s.color};"></iconify-icon>
<span>${s.name}</span>
</button>
`).join('')}
</div>
</div>
<div class="production-grid">
`;
for (const p of filteredProducts) {
const pState = state.products[p.id] || { unlocked: false, lines: 0, stock: 0, cycleProgress: 0, isBottlenecked: false };
const sector = SECTORS.find(s => s.id === p.sector);
const isSelected = state.selectedProductId === p.id;
if (!pState.unlocked) {
// Locked Card
const canAffordUnlock = state.cash >= p.unlockCost;
html += `
<div class="facility-card locked-card ${isSelected ? 'selected' : ''}" data-product="${p.id}">
<div class="facility-header">
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
</div>
<div class="facility-title-wrap">
<span class="sector-badge" style="color: ${sector?.color || '#64748b'};">
${sector?.name || 'Industry'} &bull; Tier ${p.tier}
</span>
<h3 class="facility-name">${p.name}</h3>
</div>
</div>
<p class="facility-desc">${p.description}</p>
<div class="locked-footer">
<button class="btn-primary-corp btn-unlock" data-product="${p.id}" ${canAffordUnlock ? '' : 'disabled'}>
<iconify-icon icon="lucide:lock-open"></iconify-icon>
<span>Acquire Permit (${fmtCurrency(p.unlockCost)})</span>
</button>
</div>
</div>
`;
continue;
}
// Unlocked Active Card
const nextM = getNextMilestone(pState.lines);
const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
const canAffordBuy = state.cash >= calc.cost;
const managerCost = p.baseManagerCost;
const canAffordManager = state.cash >= managerCost;
const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
const canAffordLogistics = state.cash >= logisticsCost;
// Input Requirements status
const isRaw = !p.inputs || p.inputs.length === 0;
let inputsHtml = '';
if (!isRaw) {
inputsHtml = `
<div class="inputs-strip">
<span class="inputs-label">Inputs per line:</span>
<div class="inputs-chips">
${p.inputs.map(inp => {
const inpProduct = getProduct(inp.id);
const currentStock = state.products[inp.id]?.stock || 0;
const neededPerLine = inp.amount * pState.lines;
const isLow = currentStock < neededPerLine;
return `
<span class="input-chip ${isLow ? 'low-stock' : ''}" title="${inpProduct?.name}">
<iconify-icon icon="${inpProduct?.icon || 'lucide:box'}"></iconify-icon>
${inpProduct?.name}: <strong>${fmtNum(currentStock, 0)}</strong> / ${fmtNum(neededPerLine, 0)}
</span>
`;
}).join('')}
</div>
</div>
`;
}
let statusBadge = '';
if (pState.isBottlenecked) {
statusBadge = `<span class="status-badge badge-warning"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`;
} else if (pState.lines > 0) {
statusBadge = `<span class="status-badge badge-success"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`;
} else {
statusBadge = `<span class="status-badge badge-idle">Idle (0 Lines)</span>`;
}
const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));
html += `
<div class="facility-card active-card ${isSelected ? 'selected' : ''}" data-product="${p.id}">
<div class="facility-header">
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
</div>
<div class="facility-title-wrap">
<div class="facility-meta-row">
<span class="sector-badge" style="color: ${sector?.color || '#64748b'};">
${sector?.name} &bull; Tier ${p.tier}
</span>
${statusBadge}
</div>
<h3 class="facility-name">${p.name}</h3>
<span class="facility-type-sub">${p.facilityName} &bull; <strong>${pState.lines}</strong> lines (${nextM - pState.lines} to 2x)</span>
</div>
</div>
<!-- Cycle Progress Bar -->
<div class="cycle-bar-track">
<div class="cycle-bar-fill" style="width: ${progressPct}%; background-color: ${p.color};"></div>
</div>
<!-- Stock and Inventory Summary -->
<div class="facility-stats-grid">
<div class="stat-cell">
<span class="stat-label">Stockpile:</span>
<span class="stat-value font-mono"><strong>${fmtNum(pState.stock, 0)}</strong> ${p.unit}</span>
</div>
<div class="stat-cell">
<span class="stat-label">Wholesale Price:</span>
<span class="stat-value font-mono">${fmtCurrency(p.basePrice)}</span>
</div>
<div class="stat-cell">
<span class="stat-label">Logistics Fleet:</span>
<span class="stat-value font-mono">${pState.logistics || 0} fleets</span>
</div>
</div>
${inputsHtml}
<!-- Facility Actions Bar -->
<div class="facility-actions-row">
<button class="btn-corp-action btn-cycle" data-product="${p.id}" title="Trigger manual batch">
<iconify-icon icon="lucide:play"></iconify-icon>
<span>Run Batch</span>
</button>
<button class="btn-primary-corp btn-expand" data-product="${p.id}" ${canAffordBuy ? '' : 'disabled'}>
<iconify-icon icon="lucide:plus-circle"></iconify-icon>
<span>+${calc.count} Lines (${fmtCurrency(calc.cost)})</span>
</button>
${!pState.manager ? `
<button class="btn-corp-secondary btn-hire-mgr" data-product="${p.id}" ${canAffordManager ? '' : 'disabled'} title="Automates production continuously">
<iconify-icon icon="lucide:user-check"></iconify-icon>
<span>Hire Lead (${fmtCurrency(managerCost)})</span>
</button>
` : `
<span class="mgr-hired-badge" title="Automated Lead Active">
<iconify-icon icon="lucide:bot"></iconify-icon> Auto
</span>
`}
<button class="btn-corp-secondary btn-expand-logistics" data-product="${p.id}" ${canAffordLogistics ? '' : 'disabled'} title="Increase passive sales capacity">
<iconify-icon icon="lucide:truck"></iconify-icon>
<span>+Logistics (${fmtCurrency(logisticsCost)})</span>
</button>
</div>
</div>
`;
}
html += `</div>`;
container.innerHTML = html;
// Attach delegated events
container.querySelectorAll('.sector-pill').forEach(btn => {
btn.onclick = () => onAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
});
container.querySelectorAll('.facility-card').forEach(card => {
card.onclick = (e) => {
if (e.target.closest('button')) return; // Ignore clicks on action buttons
onAction({ type: 'SELECT_PRODUCT', productId: card.dataset.product });
};
});
container.querySelectorAll('.btn-unlock').forEach(btn => {
btn.onclick = () => onAction({ type: 'UNLOCK_PRODUCT', productId: btn.dataset.product });
});
container.querySelectorAll('.btn-cycle').forEach(btn => {
btn.onclick = () => onAction({ type: 'MANUAL_CYCLE', productId: btn.dataset.product });
});
container.querySelectorAll('.btn-expand').forEach(btn => {
btn.onclick = () => onAction({ type: 'BUY_LINES', productId: btn.dataset.product });
});
container.querySelectorAll('.btn-hire-mgr').forEach(btn => {
btn.onclick = () => onAction({ type: 'HIRE_MANAGER', productId: btn.dataset.product });
});
container.querySelectorAll('.btn-expand-logistics').forEach(btn => {
btn.onclick = () => onAction({ type: 'BUY_LOGISTICS', productId: btn.dataset.product });
});
}