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:
parent
d62f13ee31
commit
bcf32f3ca7
28 changed files with 4659 additions and 4299 deletions
|
|
@ -1,9 +1,9 @@
|
||||||
# El Puro Configuration
|
# Production - The Idle Game Configuration
|
||||||
# Port on host to expose the application
|
# Port on host to expose the application
|
||||||
HOST_PORT=8080
|
HOST_PORT=8080
|
||||||
|
|
||||||
# Public domain name (e.g. elpuro.example.com or localhost)
|
# Public domain name (e.g. localhost)
|
||||||
DOMAIN_NAME=localhost
|
DOMAIN_NAME=localhost
|
||||||
|
|
||||||
# Secret key for JWT signing
|
# 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
2
.gitignore
vendored
|
|
@ -2,7 +2,7 @@
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Database & Persistent storage
|
# Database & Persistent storage
|
||||||
data/
|
/data/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
|
||||||
143
README.md
143
README.md
|
|
@ -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:
|
* **Real Supply Chain Dependencies (DAG Pipeline):**
|
||||||
* ⚗️ **Lab:** Manuel høst/syntese, hyring af laboranter og opskriftsblanding.
|
* Higher-tier industrial goods require and consume physical stockpiles of lower-tier commodities.
|
||||||
* 💼 **Streets:** Dealer-netværk og **Designer Cocktails** (kombination af stoffer som f.eks. *Calvin Klein*, *Speedball* og *Candy Flip*).
|
* Real-time bottleneck warnings when upstream inputs run short.
|
||||||
* 🕸️ **Network (Talent-Træ):** Forgrenet spindelvæv med 3 distinkte stier: *Path of the Chemist*, *Path of the Kingpin* og *Path of the Smuggler*.
|
* 6 Interconnected Industry Sectors:
|
||||||
* 🏦 **Offshore:** Hvidvaskning af snavsede kontanter til **Clean Bonds** (Prestige-reset med permanente multiplikatorer).
|
1. **Agriculture & Food:** Wheat, Milled Flour, Sugar Beet, Refined Sugar, Artisan Bread, Packaged Meals.
|
||||||
* **10 Progressive Stof-Tiers:** Cannabis, Magic Mushrooms, Adderall, Meth (Blue Sky), MDMA, Ketamin, Kokain (Pure White), Crack, Heroin og Fentanyl.
|
2. **Forestry & Materials:** Raw Timber, Milled Planks, Industrial Paper, Office Furniture.
|
||||||
* **Police Heat & Raids:** Hurtige quick-time hændelser, hvor du kan bestikke politiet, gemme dit lager eller løbe risikoen for 2x profit.
|
3. **Mining & Metallurgy:** Iron Ore, Coking Coal, Structural Steel, Heavy Frameworks.
|
||||||
* **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.
|
4. **Chemicals & Polymers:** Crude Oil, Silica Quartz Sand, Industrial Polymers, Purified Silicon.
|
||||||
* **Cloud & Offline Sync:** Spillet gemmer lokalt og synkroniserer automatisk til serveren hvert 5. sekund.
|
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 │
|
│ Browser Client UI │
|
||||||
│ (HTML5, CSS, JS) │
|
│ (ES Modules, CSS3) │
|
||||||
└───────────┬────────────┘
|
└───────────┬────────────┘
|
||||||
│ HTTP / :HOST_PORT
|
│ HTTP / :HOST_PORT
|
||||||
▼
|
▼
|
||||||
|
|
@ -40,90 +54,73 @@ Spillet kører som en selvstændig **Docker Compose stack** med en lynhurtig Pyt
|
||||||
│ ▼
|
│ ▼
|
||||||
│ ┌───────────────┐
|
│ ┌───────────────┐
|
||||||
│ │ Python FastAPI│
|
│ │ Python FastAPI│
|
||||||
│ │ (JWT & Auth) │
|
│ │ (JWT & Engine)│
|
||||||
│ └───────┬───────┘
|
│ └───────┬───────┘
|
||||||
│ │
|
│ │
|
||||||
▼ ▼
|
▼ ▼
|
||||||
[frontend/] [SQLite DB]
|
[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/)
|
* [Docker](https://docs.docker.com/get-docker/) & [Docker Compose](https://docs.docker.com/compose/)
|
||||||
|
|
||||||
### 2. Klon repository
|
### 2. Configuration (.env)
|
||||||
```bash
|
Create a `.env` file from the example:
|
||||||
git clone https://git.p20.dev/christian/el-puro.git
|
|
||||||
cd el-puro
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Konfiguration (.env)
|
|
||||||
Kopier eksempelfilen og tilpas dine indstillinger:
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Indhold i `.env`:
|
### 3. Launch the Stack
|
||||||
```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
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Spillet er nu tilgængeligt i din browser på:
|
The game is live in your browser at:
|
||||||
👉 **`http://localhost:8080`** (eller den port du valgte i `.env`).
|
👉 **`http://localhost:8080`** (or the port defined in `.env`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Projektstruktur
|
## 📁 Project Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── .env.example # Skabelon til miljøvariable
|
├── .env.example
|
||||||
├── docker-compose.yml # Docker Compose opsætning
|
├── docker-compose.yml
|
||||||
├── backend/ # FastAPI backend
|
├── backend/
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ ├── main.py # API endpoints & logik
|
│ ├── auth.py # JWT authentication & password hashing
|
||||||
│ ├── auth.py # Bcrypt & JWT autentificering
|
│ ├── database.py # SQLite connection and migration setup
|
||||||
│ ├── database.py # SQLite databaseinitialisering
|
│ ├── engine.py # Authoritative DAG simulation and offline calculations
|
||||||
│ └── models.py # Pydantic schemas (med honeypot trap)
|
│ ├── main.py # FastAPI endpoints
|
||||||
├── frontend/ # Mobil-optimeret frontend
|
│ └── models.py # Pydantic schemas
|
||||||
│ ├── index.html # SilkNet OS layout og modals
|
├── frontend/
|
||||||
|
│ ├── index.html # Responsive executive layout
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
│ │ └── style.css # Dark OLED tema og responsivt layout
|
│ │ └── style.css # Modern corporate design system
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── api.js # API klient & sky-synkronisering
|
│ ├── app.js # Application orchestrator & game loop
|
||||||
│ ├── config.js # 10 stoffer, opskrifter, skills og achievements
|
│ ├── core/
|
||||||
│ └── game.js # Spilmotor, loop, heat og mechanics
|
│ │ ├── 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/
|
└── 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.
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from jose import jwt, JWTError
|
||||||
from fastapi import HTTPException, Security, status
|
from fastapi import HTTPException, Security, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
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"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_DAYS = 365
|
ACCESS_TOKEN_EXPIRE_DAYS = 365
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import sqlite3
|
||||||
import os
|
import os
|
||||||
|
|
||||||
DB_DIR = os.getenv("DATA_DIR", "/app/data")
|
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():
|
def get_db():
|
||||||
os.makedirs(DB_DIR, exist_ok=True)
|
os.makedirs(DB_DIR, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -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 math
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Drug Catalog mirroring config.js
|
# Initial Product Catalog configuration mirroring frontend/js/data/products.js
|
||||||
DRUG_CONFIGS = {
|
PRODUCT_CONFIGS = {
|
||||||
'weed': {'rawCost': 10, 'sellPrice': 16, 'produceRate': 1.0, 'sellRate': 1.0, 'supplyRate': 1.0, 'supplierCost': 75, 'workerCost': 100, 'dealerCost': 150, 'unlockCost': 0},
|
# Sector 1: Agriculture & Food
|
||||||
'shrooms': {'rawCost': 35, 'sellPrice': 65, 'produceRate': 0.8, 'sellRate': 0.8, 'supplyRate': 0.8, 'supplierCost': 300, 'workerCost': 450, 'dealerCost': 650, 'unlockCost': 500},
|
'wheat': {
|
||||||
'adderall': {'rawCost': 120, 'sellPrice': 240, 'produceRate': 0.6, 'sellRate': 0.6, 'supplyRate': 0.6, 'supplierCost': 1500, 'workerCost': 2000, 'dealerCost': 3000, 'unlockCost': 2500},
|
'sector': 'agriculture', 'tier': 1, 'inputs': [],
|
||||||
'meth': {'rawCost': 400, 'sellPrice': 950, 'produceRate': 0.5, 'sellRate': 0.5, 'supplyRate': 0.5, 'supplierCost': 6000, 'workerCost': 8000, 'dealerCost': 12000, 'unlockCost': 10000},
|
'outputAmount': 1, 'baseCycleTime': 1.0, 'unlockCost': 0,
|
||||||
'mdma': {'rawCost': 1200, 'sellPrice': 3200, 'produceRate': 0.4, 'sellRate': 0.4, 'supplyRate': 0.4, 'supplierCost': 20000, 'workerCost': 28000, 'dealerCost': 40000, 'unlockCost': 35000},
|
'baseFacilityCost': 15, 'baseManagerCost': 100, 'baseLogisticsCost': 80,
|
||||||
'ketamine': {'rawCost': 3800, 'sellPrice': 11000, 'produceRate': 0.35, 'sellRate': 0.35, 'supplyRate': 0.35, 'supplierCost': 75000, 'workerCost': 95000, 'dealerCost': 140000, 'unlockCost': 120000},
|
'basePrice': 1.50, 'unlockedDefault': True
|
||||||
'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},
|
'flour': {
|
||||||
'heroin': {'rawCost': 110000, 'sellPrice': 420000, 'produceRate': 0.2, 'sellRate': 0.2, 'supplyRate': 0.2, 'supplierCost': 2800000, 'workerCost': 3500000, 'dealerCost': 5500000, 'unlockCost': 4500000},
|
'sector': 'agriculture', 'tier': 2, 'inputs': [{'id': 'wheat', 'amount': 2}],
|
||||||
'fentanyl': {'rawCost': 450000, 'sellPrice': 2100000, 'produceRate': 0.15, 'sellRate': 0.15, 'supplyRate': 0.15, 'supplierCost': 12000000, 'workerCost': 15000000, 'dealerCost': 24000000, 'unlockCost': 20000000}
|
'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 = {
|
UNIT_MILESTONES = [1, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
|
||||||
'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}
|
|
||||||
}
|
|
||||||
|
|
||||||
SKILL_COSTS = {
|
BOARDROOM_PERK_COSTS = {
|
||||||
'chem_1': 500, 'chem_2': 15000, 'chem_3': 250000,
|
'supply_sync': [1, 3, 8, 20, 50],
|
||||||
'king_1': 1000, 'king_2': 35000, 'king_3': 600000,
|
'robotics_overclock': [2, 5, 12, 30, 75],
|
||||||
'smug_1': 2000, 'smug_2': 50000, 'smug_3': 1000000
|
'logistics_efficiency': [2, 6, 15, 40, 100],
|
||||||
}
|
'government_subsidies': [3, 8, 20, 55, 120],
|
||||||
|
'angel_syndicate': [5, 15, 45, 120, 300]
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def clean_round(val: float) -> float:
|
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:
|
if math.isnan(val) or math.isinf(val) or val < 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return round(float(val), 2)
|
return round(float(val), 2)
|
||||||
|
|
||||||
def get_unit_milestone_multiplier(count: int) -> float:
|
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)
|
reached = sum(1 for m in UNIT_MILESTONES if count >= m)
|
||||||
return float(math.pow(2.0, reached))
|
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:
|
def get_initial_state() -> dict:
|
||||||
state = {
|
state = {
|
||||||
'cash': 25.0,
|
'version': 1,
|
||||||
'cleanBonds': 0,
|
'companyName': 'Apex Production Industries',
|
||||||
'totalCleanBonds': 0,
|
'cash': 30.0,
|
||||||
'heat': 0.0,
|
'lifetimeRevenue': 0.0,
|
||||||
'totalEarned': 0.0,
|
'valuation': 30.0,
|
||||||
|
'ventureShares': 0,
|
||||||
|
'totalVentureShares': 0,
|
||||||
|
'ipoCount': 0,
|
||||||
|
'reputation': 0,
|
||||||
|
'buyMode': '1',
|
||||||
|
'activeTab': 'production',
|
||||||
|
'selectedSector': 'all',
|
||||||
|
'selectedProductId': 'wheat',
|
||||||
'stats': {
|
'stats': {
|
||||||
'totalProduced': 0.0,
|
'totalProduced': 0.0,
|
||||||
'totalSold': 0.0,
|
'contractsCompleted': 0,
|
||||||
'totalSupplied': 0.0,
|
'managersHired': 0
|
||||||
'purityProcs': 0,
|
|
||||||
'raidsSurvived': 0,
|
|
||||||
'prestigeCount': 0
|
|
||||||
},
|
},
|
||||||
'achievements': {}, # { ach_id: claimed_tier_int }
|
'products': {},
|
||||||
'offshorePerks': {}, # { perk_id: level_int }
|
'boardroomPerks': {p: 0 for p in BOARDROOM_PERK_COSTS},
|
||||||
'helpers': {h: False for h in HELPER_COSTS}, # { helper_id: bool }
|
'achievements': {},
|
||||||
'skills': {s: False for s in SKILL_COSTS},
|
'activeContracts': [],
|
||||||
'synthesis': {r: 0 for r in SYNTHESIS_RECIPES},
|
|
||||||
'drugs': {},
|
|
||||||
'lastTick': time.time()
|
'lastTick': time.time()
|
||||||
}
|
}
|
||||||
for drug_id, cfg in DRUG_CONFIGS.items():
|
|
||||||
state['drugs'][drug_id] = {
|
for pid, p in PRODUCT_CONFIGS.items():
|
||||||
'unlocked': (drug_id == 'weed'),
|
is_unlocked = p.get('unlockedDefault', False)
|
||||||
'raw': 0.0,
|
state['products'][pid] = {
|
||||||
'product': 0.0,
|
'unlocked': is_unlocked,
|
||||||
'suppliers': 0,
|
'stock': 0.0,
|
||||||
'workers': 0,
|
'lines': 1 if is_unlocked else 0,
|
||||||
'dealers': 0
|
'manager': False,
|
||||||
|
'logistics': 1 if is_unlocked else 0,
|
||||||
|
'cycleProgress': 0.0,
|
||||||
|
'isBottlenecked': False,
|
||||||
|
'totalProduced': 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def get_multipliers(state: dict):
|
def calculate_shares_earned(lifetime_revenue: float) -> int:
|
||||||
# Total lifetime bonds provide permanent base boost
|
if lifetime_revenue < 100000.0:
|
||||||
total_bonds = max(state.get('totalCleanBonds', 0), state.get('cleanBonds', 0))
|
return 0
|
||||||
skills = state.get('skills', {})
|
return int(math.floor(10.0 * math.sqrt(lifetime_revenue / 100000.0)))
|
||||||
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 process_offline_and_ticks(state: dict) -> dict:
|
def process_offline_and_ticks(state: dict) -> dict:
|
||||||
|
"""Computes authoritative tick progress up to the current timestamp (capped at 8 hours)."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
last = state.get('lastTick', now)
|
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
|
state['lastTick'] = now
|
||||||
|
|
||||||
if delta <= 0:
|
if elapsed <= 0.1:
|
||||||
return sanitize_state(state)
|
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
|
# Simulate in reasonable sub-steps for chained DAG dependency
|
||||||
all_tier_sup = get_all_tier_milestone_multiplier(state, 'suppliers')
|
sub_steps = max(1, min(int(elapsed), 120))
|
||||||
all_tier_work = get_all_tier_milestone_multiplier(state, 'workers')
|
dt = elapsed / sub_steps
|
||||||
all_tier_deal = get_all_tier_milestone_multiplier(state, 'dealers')
|
|
||||||
|
|
||||||
stats = state.setdefault('stats', {})
|
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
|
||||||
|
|
||||||
for drug_id, data in state.get('drugs', {}).items():
|
lines = p_state.get('lines', 0)
|
||||||
cfg = DRUG_CONFIGS.get(drug_id)
|
if lines <= 0:
|
||||||
if not cfg or not data.get('unlocked'):
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
# Suppliers automatic raw cultivation / procurement
|
milestone_mult = get_unit_milestone_multiplier(lines)
|
||||||
suppliers = data.get('suppliers', 0)
|
cycle_rate = (1.0 / p_cfg['baseCycleTime']) * milestone_mult * robotics_speed_mult
|
||||||
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
|
|
||||||
|
|
||||||
# Workers production (Refining raw -> product)
|
inputs = p_cfg.get('inputs', [])
|
||||||
workers = data.get('workers', 0)
|
if not inputs:
|
||||||
raw = data.get('raw', 0.0)
|
# Raw extraction
|
||||||
if workers > 0 and raw > 0:
|
p_state['isBottlenecked'] = False
|
||||||
unit_milestone_m = get_unit_milestone_multiplier(workers)
|
p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
|
||||||
potential_prod = workers * cfg['produceRate'] * prod_m * worker_m * unit_milestone_m * all_tier_work * delta
|
if p_state['cycleProgress'] >= 1.0:
|
||||||
actual_prod = min(raw, potential_prod)
|
cycles = int(p_state['cycleProgress'])
|
||||||
data['raw'] = max(0.0, raw - actual_prod)
|
p_state['cycleProgress'] -= cycles
|
||||||
data['product'] = data.get('product', 0.0) + actual_prod
|
produced = cycles * p_cfg['outputAmount'] * lines * supply_yield_mult
|
||||||
stats['totalProduced'] = stats.get('totalProduced', 0.0) + actual_prod
|
p_state['stock'] = p_state.get('stock', 0.0) + produced
|
||||||
|
p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced
|
||||||
|
else:
|
||||||
|
# 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
|
||||||
|
|
||||||
# Dealers selling (Product -> Cash)
|
if max_cycles <= 0:
|
||||||
dealers = data.get('dealers', 0)
|
p_state['isBottlenecked'] = True
|
||||||
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
|
|
||||||
else:
|
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_worker' and drug_id in DRUG_CONFIGS:
|
# Passive Logistics Sales
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
logistics = p_state.get('logistics', 0)
|
||||||
hire_qty = max(1, int(act.get('count', 1)))
|
if logistics > 0 and p_state.get('stock', 0.0) > 0:
|
||||||
base_cost = cfg['workerCost']
|
max_sell = logistics * 0.75 * dt
|
||||||
for _ in range(hire_qty):
|
sold = min(p_state['stock'], max_sell)
|
||||||
count = state['drugs'][drug_id].get('workers', 0)
|
p_state['stock'] -= sold
|
||||||
cost = math.floor(base_cost * math.pow(scaling_r, count))
|
|
||||||
if state['cash'] >= cost:
|
|
||||||
state['cash'] -= cost
|
|
||||||
state['drugs'][drug_id]['workers'] = count + 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS:
|
earned = sold * p_cfg['basePrice'] * logistics_price_mult
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
state['cash'] = state.get('cash', 0.0) + earned
|
||||||
hire_qty = max(1, int(act.get('count', 1)))
|
state['lifetimeRevenue'] = state.get('lifetimeRevenue', 0.0) + earned
|
||||||
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
|
|
||||||
|
|
||||||
elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS:
|
# Update valuation
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
facility_assets = sum(
|
||||||
cost = cfg['unlockCost']
|
p_state.get('lines', 0) * PRODUCT_CONFIGS[pid]['baseFacilityCost']
|
||||||
if state['cash'] >= cost and not state['drugs'][drug_id].get('unlocked'):
|
for pid, p_state in state.get('products', {}).items()
|
||||||
state['cash'] -= cost
|
if pid in PRODUCT_CONFIGS
|
||||||
state['drugs'][drug_id]['unlocked'] = True
|
)
|
||||||
|
state['valuation'] = state.get('cash', 0.0) + facility_assets + (state.get('lifetimeRevenue', 0.0) * 0.15)
|
||||||
# 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))
|
|
||||||
|
|
||||||
return state
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
from auth import hash_password, verify_password, create_access_token, get_current_user_id
|
||||||
import engine
|
import engine
|
||||||
|
|
||||||
app = FastAPI(title="El Puro API", version="1.1.0")
|
app = FastAPI(title="Production API", version="2.0.0")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -23,7 +23,7 @@ def startup_event():
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health_check():
|
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)
|
@app.post("/api/auth/register", response_model=AuthResponse)
|
||||||
def register(req: RegisterRequest):
|
def register(req: RegisterRequest):
|
||||||
|
|
|
||||||
76
backend/test_engine.py
Normal file
76
backend/test_engine.py
Normal 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!")
|
||||||
|
|
@ -5,12 +5,12 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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
|
- DATA_DIR=/app/data
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- elpuro-net
|
- production-net
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
|
|
@ -25,8 +25,8 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- elpuro-net
|
- production-net
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
elpuro-net:
|
production-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,265 +3,216 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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">
|
<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>
|
<title>Production - The Idle Game</title>
|
||||||
<!-- Fonts -->
|
|
||||||
|
<!-- Modern Corporate & Financial Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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 (Game Icons & Lucide) -->
|
|
||||||
|
<!-- Iconify Web Component CDN (Lucide Icons) -->
|
||||||
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script>
|
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="app-container">
|
<div id="app-wrapper">
|
||||||
|
|
||||||
<!-- Sticky Header (Status bar + Financial HUD + Heat Bar) -->
|
<!-- Executive Header (Sticky across Desktop & Mobile) -->
|
||||||
<header id="sticky-header">
|
<header id="executive-header">
|
||||||
<!-- Top Status Bar -->
|
<!-- Top Status Bar -->
|
||||||
<div id="system-bar">
|
<div id="corporate-status-bar">
|
||||||
<div class="status-left">
|
<div class="status-left">
|
||||||
<span class="brand-tag">
|
<div class="corporate-brand">
|
||||||
<iconify-icon icon="game-icons:flasks"></iconify-icon> EL PURO
|
<div class="brand-logo-icon">
|
||||||
</span>
|
<iconify-icon icon="lucide:factory"></iconify-icon>
|
||||||
<span class="secure-indicator">
|
</div>
|
||||||
<span class="secure-dot"></span> SECURE
|
<div class="brand-text">
|
||||||
</span>
|
<span class="brand-title">PRODUCTION</span>
|
||||||
</div>
|
<span class="brand-subtitle">The Idle Game</span>
|
||||||
<div class="status-right" style="display: flex; align-items: center; gap: 8px;">
|
</div>
|
||||||
<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>
|
|
||||||
</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>
|
||||||
<div class="hud-rate-val" style="color: var(--neon-cyan);">+<span id="hud-multiplier">0</span>% Boost</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Police Heat Bar -->
|
<!-- Financial HUD (Liquid Capital, Net Flow, Valuation, Venture Equity) -->
|
||||||
<div id="heat-container">
|
<div id="financial-hud">
|
||||||
<div class="heat-label-row">
|
<div class="hud-stat-box primary-hud-box">
|
||||||
<span>POLICE HEAT</span>
|
<div class="hud-label">Liquid Capital</div>
|
||||||
<span id="heat-pct">0%</span>
|
<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>
|
</div>
|
||||||
<div class="heat-bar-track">
|
|
||||||
<div id="heat-bar-fill"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Inventory Overview Strip (Always visible) -->
|
<div class="hud-stat-box">
|
||||||
<div id="quick-stash-strip">
|
<div class="hud-label">Enterprise Valuation</div>
|
||||||
<div class="stash-strip-title">
|
<div class="hud-main-val font-mono" id="hud-valuation">$0.00</div>
|
||||||
<span>STASH OVERVIEW:</span>
|
<div class="hud-sub-val font-mono text-muted">Asset Value & Equity</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="quick-stash-items" class="stash-strip-items">
|
|
||||||
<!-- Dynamically populated chips of all owned products -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buy Multiplier Selector Bar (1x, 10x, 100x, Next Milestone, Max) -->
|
<div class="hud-stat-box">
|
||||||
<div id="buy-multiplier-bar">
|
<div class="hud-label">Venture Capital ($V-Shares)</div>
|
||||||
<span class="buy-multi-label">BUY QTY:</span>
|
<div class="hud-main-val font-mono text-equity">
|
||||||
<div class="buy-multi-group">
|
<iconify-icon icon="lucide:gem"></iconify-icon>
|
||||||
<button class="btn-multi-pill active" data-mode="1">1x</button>
|
<span id="hud-shares">0</span>
|
||||||
<button class="btn-multi-pill" data-mode="10">10x</button>
|
</div>
|
||||||
<button class="btn-multi-pill" data-mode="100">100x</button>
|
<div class="hud-sub-val font-mono text-equity">IPO Expansion Equity</div>
|
||||||
<button class="btn-multi-pill" data-mode="next">Next</button>
|
</div>
|
||||||
<button class="btn-multi-pill" data-mode="max">Max</button>
|
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Toast Notifications Container -->
|
<!-- Main Workspace Layout (3-Column Desktop Grid / 1-Column Mobile) -->
|
||||||
<div id="toast-container"></div>
|
<div id="workspace-layout">
|
||||||
|
|
||||||
<!-- Main Viewport with Dynamic Views -->
|
<!-- Left Column: Corporate Navigation (Desktop Sidebar) -->
|
||||||
<main id="main-viewport">
|
<aside id="desktop-nav-sidebar">
|
||||||
<!-- Views are rendered dynamically by game.js -->
|
<div class="sidebar-section-title">Departments</div>
|
||||||
</main>
|
<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-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-btn" data-tab="boardroom">
|
||||||
|
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||||||
|
<span>Boardroom & IPO</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-tab="milestones">
|
||||||
|
<iconify-icon icon="lucide:trophy"></iconify-icon>
|
||||||
|
<span>Milestones</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Bottom Navigation Bar (Fixed for quick thumb access) -->
|
<div class="sidebar-divider"></div>
|
||||||
<nav id="bottom-nav">
|
|
||||||
<button class="nav-tab active" data-tab="lab">
|
<div class="sidebar-section-title">Industry Sectors</div>
|
||||||
<iconify-icon icon="game-icons:flasks"></iconify-icon>
|
<div class="sidebar-sectors-list">
|
||||||
<span>Lab</span>
|
<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>
|
||||||
<button class="nav-tab" data-tab="streets">
|
<button class="nav-tab" data-tab="contracts">
|
||||||
<iconify-icon icon="game-icons:cash"></iconify-icon>
|
<iconify-icon icon="lucide:scroll-text"></iconify-icon>
|
||||||
<span>Streets</span>
|
<span>Contracts</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-tab" data-tab="trophies" style="position: relative;">
|
<button class="nav-tab" data-tab="boardroom">
|
||||||
<iconify-icon icon="game-icons:trophy-cup"></iconify-icon>
|
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||||||
<span>Trophies</span>
|
<span>Boardroom</span>
|
||||||
<span id="trophy-badge" class="nav-badge" style="display: none;">0</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-tab" data-tab="helpers">
|
<button class="nav-tab" data-tab="milestones">
|
||||||
<iconify-icon icon="game-icons:artificial-intelligence"></iconify-icon>
|
<iconify-icon icon="lucide:trophy"></iconify-icon>
|
||||||
<span>Helpers</span>
|
<span>Milestones</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>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
</div>
|
<!-- Toast Notifications Container -->
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
<!-- Authentication Modal -->
|
<!-- Authentication & Cloud Sync Modal -->
|
||||||
<div id="auth-modal" class="modal-overlay">
|
<div id="auth-modal" class="modal-overlay">
|
||||||
<div class="modal-content">
|
<div class="modal-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<iconify-icon icon="lucide:shield-alert" style="font-size: 1.6rem; color: var(--brand-gold);"></iconify-icon>
|
<iconify-icon icon="lucide:shield-check" class="modal-icon"></iconify-icon>
|
||||||
<div class="modal-title">Encrypted Terminal Login</div>
|
<div>
|
||||||
</div>
|
<h3 class="modal-title">Executive Cloud Terminal</h3>
|
||||||
<p style="font-size: 0.78rem; color: var(--text-secondary); line-height: 1.4;">
|
<p class="modal-subtitle">Save your production empire and sync across devices.</p>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- HONEYPOT TRAP (Bots fill this invisible input) -->
|
|
||||||
<div class="hp-trap" 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>
|
|
||||||
<button id="btn-close-auth" class="btn-tactical" style="margin-top: 4px;">Play as Guest</button>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- 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>
|
</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);">
|
<div class="input-field-group">
|
||||||
<span>Precision: </span><span style="color: var(--neon-cyan);">2 Decimal Places</span> (Fixed)<br>
|
<label>Executive Username</label>
|
||||||
<span>Preview: </span><span id="settings-preview-val" style="color: #4ade80;">$1,234,567.89 ➔ $1.23M</span>
|
<input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="btn-close-settings" class="btn-tactical btn-primary-action" style="padding: 10px;">Save & Return</button>
|
<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 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-text-close">Continue as Guest</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/config.js"></script>
|
<!-- Application Entry Point (Native ES Module) -->
|
||||||
<script src="js/api.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
<script src="js/game.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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
432
frontend/js/app.js
Normal 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;
|
||||||
|
});
|
||||||
|
|
@ -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
226
frontend/js/core/engine.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
72
frontend/js/core/formatters.js
Normal file
72
frontend/js/core/formatters.js
Normal 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
162
frontend/js/core/state.js
Normal 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
117
frontend/js/core/sync.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/js/data/achievements.js
Normal file
44
frontend/js/data/achievements.js
Normal 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]
|
||||||
|
}
|
||||||
|
];
|
||||||
63
frontend/js/data/boardroom.js
Normal file
63
frontend/js/data/boardroom.js
Normal 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));
|
||||||
|
}
|
||||||
128
frontend/js/data/contracts.js
Normal file
128
frontend/js/data/contracts.js
Normal 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];
|
||||||
|
}
|
||||||
591
frontend/js/data/products.js
Normal file
591
frontend/js/data/products.js
Normal 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;
|
||||||
|
}
|
||||||
2038
frontend/js/game.js
2038
frontend/js/game.js
File diff suppressed because it is too large
Load diff
65
frontend/js/ui/achievementsView.js
Normal file
65
frontend/js/ui/achievementsView.js
Normal 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;
|
||||||
|
}
|
||||||
117
frontend/js/ui/boardroomView.js
Normal file
117
frontend/js/ui/boardroomView.js
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
112
frontend/js/ui/contractsView.js
Normal file
112
frontend/js/ui/contractsView.js
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
141
frontend/js/ui/inspectorView.js
Normal file
141
frontend/js/ui/inspectorView.js
Normal 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 • 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
243
frontend/js/ui/productionView.js
Normal file
243
frontend/js/ui/productionView.js
Normal 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'} • 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} • Tier ${p.tier}
|
||||||
|
</span>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
<h3 class="facility-name">${p.name}</h3>
|
||||||
|
<span class="facility-type-sub">${p.facilityName} • <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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue