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
|
||||
HOST_PORT=8080
|
||||
|
||||
# Public domain name (e.g. elpuro.example.com or localhost)
|
||||
# Public domain name (e.g. localhost)
|
||||
DOMAIN_NAME=localhost
|
||||
|
||||
# Secret key for JWT signing
|
||||
SECRET_KEY=el_puro_secret_key_change_in_production_jwt_99_1_pure
|
||||
SECRET_KEY=production_game_enterprise_jwt_secret_key_change_in_production
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,7 +2,7 @@
|
|||
.env
|
||||
|
||||
# Database & Persistent storage
|
||||
data/
|
||||
/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.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:
|
||||
* ⚗️ **Lab:** Manuel høst/syntese, hyring af laboranter og opskriftsblanding.
|
||||
* 💼 **Streets:** Dealer-netværk og **Designer Cocktails** (kombination af stoffer som f.eks. *Calvin Klein*, *Speedball* og *Candy Flip*).
|
||||
* 🕸️ **Network (Talent-Træ):** Forgrenet spindelvæv med 3 distinkte stier: *Path of the Chemist*, *Path of the Kingpin* og *Path of the Smuggler*.
|
||||
* 🏦 **Offshore:** Hvidvaskning af snavsede kontanter til **Clean Bonds** (Prestige-reset med permanente multiplikatorer).
|
||||
* **10 Progressive Stof-Tiers:** Cannabis, Magic Mushrooms, Adderall, Meth (Blue Sky), MDMA, Ketamin, Kokain (Pure White), Crack, Heroin og Fentanyl.
|
||||
* **Police Heat & Raids:** Hurtige quick-time hændelser, hvor du kan bestikke politiet, gemme dit lager eller løbe risikoen for 2x profit.
|
||||
* **Simpelt Honeypot Brugersystem:** Kun brugernavn og password. Ingen e-mail, ingen 2FA. Inkluderer en usynlig honeypot-fælde (`website_hp`), der automatisk afviser bot-registreringer.
|
||||
* **Cloud & Offline Sync:** Spillet gemmer lokalt og synkroniserer automatisk til serveren hvert 5. sekund.
|
||||
* **Real Supply Chain Dependencies (DAG Pipeline):**
|
||||
* Higher-tier industrial goods require and consume physical stockpiles of lower-tier commodities.
|
||||
* Real-time bottleneck warnings when upstream inputs run short.
|
||||
* 6 Interconnected Industry Sectors:
|
||||
1. **Agriculture & Food:** Wheat, Milled Flour, Sugar Beet, Refined Sugar, Artisan Bread, Packaged Meals.
|
||||
2. **Forestry & Materials:** Raw Timber, Milled Planks, Industrial Paper, Office Furniture.
|
||||
3. **Mining & Metallurgy:** Iron Ore, Coking Coal, Structural Steel, Heavy Frameworks.
|
||||
4. **Chemicals & Polymers:** Crude Oil, Silica Quartz Sand, Industrial Polymers, Purified Silicon.
|
||||
5. **High-Tech & Electronics:** Silicon Microchips, PCB Motherboards, Consumer Smartphones.
|
||||
6. **Heavy Industry & Vehicles:** Industrial Engines, Commercial Logistics Trucks.
|
||||
* **Dual Revenue Channels:**
|
||||
* **Continuous Logistics Distribution:** Deploy dedicated logistics fleets to automatically wholesale products to regional distributors for continuous passive cashflow.
|
||||
* **B2B Procurement Contracts Board:** Bid on time-sensitive procurement tenders from MegaCorps and industrial clients for massive capital injections and reputation.
|
||||
* **Corporate IPO & Boardroom Prestige:**
|
||||
* File for an **Initial Public Offering (IPO)** on Wall Street when your enterprise valuation reaches critical mass.
|
||||
* Cash in for **Venture Shares ($V-Shares)** to unlock permanent perks on the Boardroom R&D Tech Tree (Automated ERP Routing, Industrial Robotics, Logistics Subsidies, Venture Capital Syndicate).
|
||||
* **Responsive Executive UI:**
|
||||
* **Desktop Command Station:** 3-column workstation layout (Departments & Sectors sidebar, main Production Floor, and live Supply Chain Inspector with instant spot-market liquidation).
|
||||
* **Mobile Optimization:** Ergonomic single-column interface with bottom navigation tabs and thumb-friendly controls.
|
||||
* **Cloud & Offline Sync:**
|
||||
* Plays instantly as a Guest with local persistence.
|
||||
* Optional lightweight account system (username + password with anti-bot honeypot protection).
|
||||
* Authoritative offline tick engine: when you return, your automated lines calculate production and logistics sales up to 8 hours.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Arkitektur & Teknologistak
|
||||
## 🛠️ Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ Browser / Mobil UI │
|
||||
│ (HTML5, CSS, JS) │
|
||||
│ Browser Client UI │
|
||||
│ (ES Modules, CSS3) │
|
||||
└───────────┬────────────┘
|
||||
│ HTTP / :HOST_PORT
|
||||
▼
|
||||
|
|
@ -40,90 +54,73 @@ Spillet kører som en selvstændig **Docker Compose stack** med en lynhurtig Pyt
|
|||
│ ▼
|
||||
│ ┌───────────────┐
|
||||
│ │ Python FastAPI│
|
||||
│ │ (JWT & Auth) │
|
||||
│ │ (JWT & Engine)│
|
||||
│ └───────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
[frontend/] [SQLite DB]
|
||||
(/app/data/elpuro.db)
|
||||
(/app/data/production.db)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Hurtig Start med Docker Compose
|
||||
## 🚀 Quick Start with Docker Compose
|
||||
|
||||
### 1. Forudsætninger
|
||||
### 1. Prerequisites
|
||||
* [Docker](https://docs.docker.com/get-docker/) & [Docker Compose](https://docs.docker.com/compose/)
|
||||
|
||||
### 2. Klon repository
|
||||
```bash
|
||||
git clone https://git.p20.dev/christian/el-puro.git
|
||||
cd el-puro
|
||||
```
|
||||
|
||||
### 3. Konfiguration (.env)
|
||||
Kopier eksempelfilen og tilpas dine indstillinger:
|
||||
### 2. Configuration (.env)
|
||||
Create a `.env` file from the example:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Indhold i `.env`:
|
||||
```env
|
||||
# Port på værtsmaskinen hvor spillet tilgås
|
||||
HOST_PORT=8080
|
||||
|
||||
# Offentligt domænenavn (valgfrit til vhost-routing)
|
||||
DOMAIN_NAME=localhost
|
||||
|
||||
# Hemmelig nøgle til JWT signering
|
||||
SECRET_KEY=el_puro_secret_key_change_in_production_jwt_99_1_pure
|
||||
```
|
||||
|
||||
### 4. Start stakken
|
||||
### 3. Launch the Stack
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Spillet er nu tilgængeligt i din browser på:
|
||||
👉 **`http://localhost:8080`** (eller den port du valgte i `.env`).
|
||||
The game is live in your browser at:
|
||||
👉 **`http://localhost:8080`** (or the port defined in `.env`).
|
||||
|
||||
---
|
||||
|
||||
## 📁 Projektstruktur
|
||||
## 📁 Project Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── .env.example # Skabelon til miljøvariable
|
||||
├── docker-compose.yml # Docker Compose opsætning
|
||||
├── backend/ # FastAPI backend
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── main.py # API endpoints & logik
|
||||
│ ├── auth.py # Bcrypt & JWT autentificering
|
||||
│ ├── database.py # SQLite databaseinitialisering
|
||||
│ └── models.py # Pydantic schemas (med honeypot trap)
|
||||
├── frontend/ # Mobil-optimeret frontend
|
||||
│ ├── index.html # SilkNet OS layout og modals
|
||||
│ ├── auth.py # JWT authentication & password hashing
|
||||
│ ├── database.py # SQLite connection and migration setup
|
||||
│ ├── engine.py # Authoritative DAG simulation and offline calculations
|
||||
│ ├── main.py # FastAPI endpoints
|
||||
│ └── models.py # Pydantic schemas
|
||||
├── frontend/
|
||||
│ ├── index.html # Responsive executive layout
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Dark OLED tema og responsivt layout
|
||||
│ │ └── style.css # Modern corporate design system
|
||||
│ └── js/
|
||||
│ ├── api.js # API klient & sky-synkronisering
|
||||
│ ├── config.js # 10 stoffer, opskrifter, skills og achievements
|
||||
│ └── game.js # Spilmotor, loop, heat og mechanics
|
||||
│ ├── app.js # Application orchestrator & game loop
|
||||
│ ├── core/
|
||||
│ │ ├── engine.js # Client-side DAG tick simulation
|
||||
│ │ ├── formatters.js# Number notations (Standard K/M/B/T, Scientific)
|
||||
│ │ ├── state.js # State manager, schema handling & IPO reset
|
||||
│ │ └── sync.js # Backend synchronization & auth handling
|
||||
│ ├── data/
|
||||
│ │ ├── products.js # 24+ commodities across 6 sectors
|
||||
│ │ ├── contracts.js # B2B procurement tenders
|
||||
│ │ ├── boardroom.js # IPO perks and Venture Share logic
|
||||
│ │ └── achievements.js
|
||||
│ └── ui/
|
||||
│ ├── productionView.js
|
||||
│ ├── contractsView.js
|
||||
│ ├── boardroomView.js
|
||||
│ ├── inspectorView.js
|
||||
│ └── achievementsView.js
|
||||
└── nginx/
|
||||
└── default.conf.template # Nginx reverse proxy konfiguration
|
||||
└── default.conf.template
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Honeypot Sikkerhed
|
||||
|
||||
Registrerings-endpointet (`POST /api/auth/register`) inkluderer en skjult `website_hp` parameter.
|
||||
* Menneskelige brugere ser ikke feltet og lader det stå tomt.
|
||||
* Automatiserede bots udfylder formularen blindt, hvorefter serveren afviser anmodningen med `400 Bad Request`. Dette holder databasen fri for falske konti uden behov for Captcha.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Licens
|
||||
|
||||
Privat projekt tilhørende Christian. Alle rettigheder forbeholdes.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from jose import jwt, JWTError
|
|||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "el_puro_secret_key_change_in_production_jwt_99_1_pure")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "production_game_secret_key_change_in_production_jwt")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 365
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import sqlite3
|
|||
import os
|
||||
|
||||
DB_DIR = os.getenv("DATA_DIR", "/app/data")
|
||||
DB_PATH = os.path.join(DB_DIR, "elpuro.db")
|
||||
DB_PATH = os.path.join(DB_DIR, "production.db")
|
||||
|
||||
def get_db():
|
||||
os.makedirs(DB_DIR, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -1,506 +1,392 @@
|
|||
"""
|
||||
Production - The Idle Game: Core Simulation Backend Engine
|
||||
Authoritative offline tick progression, multi-tier DAG supply chains, and action validation.
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
# Drug Catalog mirroring config.js
|
||||
DRUG_CONFIGS = {
|
||||
'weed': {'rawCost': 10, 'sellPrice': 16, 'produceRate': 1.0, 'sellRate': 1.0, 'supplyRate': 1.0, 'supplierCost': 75, 'workerCost': 100, 'dealerCost': 150, 'unlockCost': 0},
|
||||
'shrooms': {'rawCost': 35, 'sellPrice': 65, 'produceRate': 0.8, 'sellRate': 0.8, 'supplyRate': 0.8, 'supplierCost': 300, 'workerCost': 450, 'dealerCost': 650, 'unlockCost': 500},
|
||||
'adderall': {'rawCost': 120, 'sellPrice': 240, 'produceRate': 0.6, 'sellRate': 0.6, 'supplyRate': 0.6, 'supplierCost': 1500, 'workerCost': 2000, 'dealerCost': 3000, 'unlockCost': 2500},
|
||||
'meth': {'rawCost': 400, 'sellPrice': 950, 'produceRate': 0.5, 'sellRate': 0.5, 'supplyRate': 0.5, 'supplierCost': 6000, 'workerCost': 8000, 'dealerCost': 12000, 'unlockCost': 10000},
|
||||
'mdma': {'rawCost': 1200, 'sellPrice': 3200, 'produceRate': 0.4, 'sellRate': 0.4, 'supplyRate': 0.4, 'supplierCost': 20000, 'workerCost': 28000, 'dealerCost': 40000, 'unlockCost': 35000},
|
||||
'ketamine': {'rawCost': 3800, 'sellPrice': 11000, 'produceRate': 0.35, 'sellRate': 0.35, 'supplyRate': 0.35, 'supplierCost': 75000, 'workerCost': 95000, 'dealerCost': 140000, 'unlockCost': 120000},
|
||||
'cocaine': {'rawCost': 12000, 'sellPrice': 38000, 'produceRate': 0.3, 'sellRate': 0.3, 'supplyRate': 0.3, 'supplierCost': 250000, 'workerCost': 320000, 'dealerCost': 480000, 'unlockCost': 400000},
|
||||
'crack': {'rawCost': 35000, 'sellPrice': 115000, 'produceRate': 0.25, 'sellRate': 0.25, 'supplyRate': 0.25, 'supplierCost': 700000, 'workerCost': 900000, 'dealerCost': 1400000, 'unlockCost': 1200000},
|
||||
'heroin': {'rawCost': 110000, 'sellPrice': 420000, 'produceRate': 0.2, 'sellRate': 0.2, 'supplyRate': 0.2, 'supplierCost': 2800000, 'workerCost': 3500000, 'dealerCost': 5500000, 'unlockCost': 4500000},
|
||||
'fentanyl': {'rawCost': 450000, 'sellPrice': 2100000, 'produceRate': 0.15, 'sellRate': 0.15, 'supplyRate': 0.15, 'supplierCost': 12000000, 'workerCost': 15000000, 'dealerCost': 24000000, 'unlockCost': 20000000}
|
||||
# Initial Product Catalog configuration mirroring frontend/js/data/products.js
|
||||
PRODUCT_CONFIGS = {
|
||||
# Sector 1: Agriculture & Food
|
||||
'wheat': {
|
||||
'sector': 'agriculture', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 1.0, 'unlockCost': 0,
|
||||
'baseFacilityCost': 15, 'baseManagerCost': 100, 'baseLogisticsCost': 80,
|
||||
'basePrice': 1.50, 'unlockedDefault': True
|
||||
},
|
||||
'flour': {
|
||||
'sector': 'agriculture', 'tier': 2, 'inputs': [{'id': 'wheat', 'amount': 2}],
|
||||
'outputAmount': 1, 'baseCycleTime': 1.8, 'unlockCost': 250,
|
||||
'baseFacilityCost': 200, 'baseManagerCost': 850, 'baseLogisticsCost': 600,
|
||||
'basePrice': 6.20, 'unlockedDefault': False
|
||||
},
|
||||
'sugar_beet': {
|
||||
'sector': 'agriculture', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 1.5, 'unlockCost': 650,
|
||||
'baseFacilityCost': 550, 'baseManagerCost': 1800, 'baseLogisticsCost': 1200,
|
||||
'basePrice': 4.80, 'unlockedDefault': False
|
||||
},
|
||||
'refined_sugar': {
|
||||
'sector': 'agriculture', 'tier': 2, 'inputs': [{'id': 'sugar_beet', 'amount': 3}],
|
||||
'outputAmount': 1, 'baseCycleTime': 2.2, 'unlockCost': 2200,
|
||||
'baseFacilityCost': 1800, 'baseManagerCost': 4500, 'baseLogisticsCost': 3200,
|
||||
'basePrice': 22.00, 'unlockedDefault': False
|
||||
},
|
||||
'fresh_bread': {
|
||||
'sector': 'agriculture', 'tier': 3, 'inputs': [{'id': 'flour', 'amount': 2}, {'id': 'refined_sugar', 'amount': 1}],
|
||||
'outputAmount': 2, 'baseCycleTime': 3.0, 'unlockCost': 9500,
|
||||
'baseFacilityCost': 7500, 'baseManagerCost': 18000, 'baseLogisticsCost': 12500,
|
||||
'basePrice': 58.00, 'unlockedDefault': False
|
||||
},
|
||||
'packaged_meals': {
|
||||
'sector': 'agriculture', 'tier': 4, 'inputs': [{'id': 'fresh_bread', 'amount': 3}],
|
||||
'outputAmount': 1, 'baseCycleTime': 4.5, 'unlockCost': 45000,
|
||||
'baseFacilityCost': 36000, 'baseManagerCost': 75000, 'baseLogisticsCost': 55000,
|
||||
'basePrice': 260.00, 'unlockedDefault': False
|
||||
},
|
||||
|
||||
# Sector 2: Forestry & Materials
|
||||
'timber': {
|
||||
'sector': 'materials', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 1.2, 'unlockCost': 120,
|
||||
'baseFacilityCost': 95, 'baseManagerCost': 350, 'baseLogisticsCost': 240,
|
||||
'basePrice': 2.80, 'unlockedDefault': False
|
||||
},
|
||||
'planks': {
|
||||
'sector': 'materials', 'tier': 2, 'inputs': [{'id': 'timber', 'amount': 2}],
|
||||
'outputAmount': 1, 'baseCycleTime': 2.0, 'unlockCost': 1200,
|
||||
'baseFacilityCost': 950, 'baseManagerCost': 2800, 'baseLogisticsCost': 2000,
|
||||
'basePrice': 11.50, 'unlockedDefault': False
|
||||
},
|
||||
'paper': {
|
||||
'sector': 'materials', 'tier': 2, 'inputs': [{'id': 'timber', 'amount': 3}],
|
||||
'outputAmount': 2, 'baseCycleTime': 2.5, 'unlockCost': 3500,
|
||||
'baseFacilityCost': 2800, 'baseManagerCost': 6500, 'baseLogisticsCost': 4800,
|
||||
'basePrice': 16.00, 'unlockedDefault': False
|
||||
},
|
||||
'furniture': {
|
||||
'sector': 'materials', 'tier': 3, 'inputs': [{'id': 'planks', 'amount': 3}],
|
||||
'outputAmount': 1, 'baseCycleTime': 3.8, 'unlockCost': 18000,
|
||||
'baseFacilityCost': 14000, 'baseManagerCost': 35000, 'baseLogisticsCost': 26000,
|
||||
'basePrice': 95.00, 'unlockedDefault': False
|
||||
},
|
||||
|
||||
# Sector 3: Mining & Metallurgy
|
||||
'iron_ore': {
|
||||
'sector': 'mining', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 1.6, 'unlockCost': 1500,
|
||||
'baseFacilityCost': 1200, 'baseManagerCost': 3500, 'baseLogisticsCost': 2400,
|
||||
'basePrice': 12.00, 'unlockedDefault': False
|
||||
},
|
||||
'coal': {
|
||||
'sector': 'mining', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 1.8, 'unlockCost': 2800,
|
||||
'baseFacilityCost': 2200, 'baseManagerCost': 5500, 'baseLogisticsCost': 3800,
|
||||
'basePrice': 15.00, 'unlockedDefault': False
|
||||
},
|
||||
'steel_ingots': {
|
||||
'sector': 'mining', 'tier': 2, 'inputs': [{'id': 'iron_ore', 'amount': 2}, {'id': 'coal', 'amount': 1}],
|
||||
'outputAmount': 1, 'baseCycleTime': 3.2, 'unlockCost': 12500,
|
||||
'baseFacilityCost': 9800, 'baseManagerCost': 24000, 'baseLogisticsCost': 17000,
|
||||
'basePrice': 72.00, 'unlockedDefault': False
|
||||
},
|
||||
'metal_frames': {
|
||||
'sector': 'mining', 'tier': 3, 'inputs': [{'id': 'steel_ingots', 'amount': 3}],
|
||||
'outputAmount': 1, 'baseCycleTime': 4.6, 'unlockCost': 65000,
|
||||
'baseFacilityCost': 52000, 'baseManagerCost': 110000, 'baseLogisticsCost': 80000,
|
||||
'basePrice': 320.00, 'unlockedDefault': False
|
||||
},
|
||||
|
||||
# Sector 4: Chemicals & Polymers
|
||||
'crude_oil': {
|
||||
'sector': 'chemicals', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 2.0, 'unlockCost': 25000,
|
||||
'baseFacilityCost': 20000, 'baseManagerCost': 48000, 'baseLogisticsCost': 35000,
|
||||
'basePrice': 55.00, 'unlockedDefault': False
|
||||
},
|
||||
'silica_sand': {
|
||||
'sector': 'chemicals', 'tier': 1, 'inputs': [],
|
||||
'outputAmount': 1, 'baseCycleTime': 2.2, 'unlockCost': 45000,
|
||||
'baseFacilityCost': 36000, 'baseManagerCost': 75000, 'baseLogisticsCost': 52000,
|
||||
'basePrice': 85.00, 'unlockedDefault': False
|
||||
},
|
||||
'polymers': {
|
||||
'sector': 'chemicals', 'tier': 2, 'inputs': [{'id': 'crude_oil', 'amount': 2}],
|
||||
'outputAmount': 1, 'baseCycleTime': 3.5, 'unlockCost': 110000,
|
||||
'baseFacilityCost': 88000, 'baseManagerCost': 175000, 'baseLogisticsCost': 120000,
|
||||
'basePrice': 210.00, 'unlockedDefault': False
|
||||
},
|
||||
'silicon': {
|
||||
'sector': 'chemicals', 'tier': 2, 'inputs': [{'id': 'silica_sand', 'amount': 2}],
|
||||
'outputAmount': 1, 'baseCycleTime': 4.0, 'unlockCost': 220000,
|
||||
'baseFacilityCost': 175000, 'baseManagerCost': 35000, 'baseLogisticsCost': 240000,
|
||||
'basePrice': 420.00, 'unlockedDefault': False
|
||||
},
|
||||
|
||||
# Sector 5: High-Tech & Electronics
|
||||
'microchips': {
|
||||
'sector': 'electronics', 'tier': 3, 'inputs': [{'id': 'silicon', 'amount': 1}, {'id': 'polymers', 'amount': 1}],
|
||||
'outputAmount': 2, 'baseCycleTime': 4.8, 'unlockCost': 550000,
|
||||
'baseFacilityCost': 440000, 'baseManagerCost': 850000, 'baseLogisticsCost': 600000,
|
||||
'basePrice': 650.00, 'unlockedDefault': False
|
||||
},
|
||||
'circuit_boards': {
|
||||
'sector': 'electronics', 'tier': 3, 'inputs': [{'id': 'microchips', 'amount': 2}, {'id': 'polymers', 'amount': 2}],
|
||||
'outputAmount': 1, 'baseCycleTime': 5.5, 'unlockCost': 1500000,
|
||||
'baseFacilityCost': 1200000, 'baseManagerCost': 2400000, 'baseLogisticsCost': 1600000,
|
||||
'basePrice': 2200.00, 'unlockedDefault': False
|
||||
},
|
||||
'smartphones': {
|
||||
'sector': 'electronics', 'tier': 4, 'inputs': [{'id': 'circuit_boards', 'amount': 1}, {'id': 'polymers', 'amount': 2}],
|
||||
'outputAmount': 1, 'baseCycleTime': 6.5, 'unlockCost': 5000000,
|
||||
'baseFacilityCost': 4000000, 'baseManagerCost': 8000000, 'baseLogisticsCost': 5500000,
|
||||
'basePrice': 6800.00, 'unlockedDefault': False
|
||||
},
|
||||
|
||||
# Sector 6: Heavy Industry & Vehicles
|
||||
'engines': {
|
||||
'sector': 'heavy_industry', 'tier': 3, 'inputs': [{'id': 'steel_ingots', 'amount': 3}, {'id': 'metal_frames', 'amount': 1}],
|
||||
'outputAmount': 1, 'baseCycleTime': 6.0, 'unlockCost': 3500000,
|
||||
'baseFacilityCost': 2800000, 'baseManagerCost': 5500000, 'baseLogisticsCost': 3800000,
|
||||
'basePrice': 4200.00, 'unlockedDefault': False
|
||||
},
|
||||
'commercial_trucks': {
|
||||
'sector': 'heavy_industry', 'tier': 4, 'inputs': [{'id': 'engines', 'amount': 1}, {'id': 'metal_frames', 'amount': 2}, {'id': 'circuit_boards', 'amount': 1}],
|
||||
'outputAmount': 1, 'baseCycleTime': 8.5, 'unlockCost': 15000000,
|
||||
'baseFacilityCost': 12000000, 'baseManagerCost': 22000000, 'baseLogisticsCost': 15000000,
|
||||
'basePrice': 26500.00, 'unlockedDefault': False
|
||||
}
|
||||
}
|
||||
|
||||
SYNTHESIS_RECIPES = {
|
||||
'calvin_klein': {'ingredients': {'cocaine': 5, 'ketamine': 5}, 'payout': 95000},
|
||||
'speedball': {'ingredients': {'heroin': 3, 'cocaine': 3}, 'payout': 850000},
|
||||
'candy_flip': {'ingredients': {'shrooms': 15, 'mdma': 10}, 'payout': 75000}
|
||||
}
|
||||
UNIT_MILESTONES = [1, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
|
||||
|
||||
SKILL_COSTS = {
|
||||
'chem_1': 500, 'chem_2': 15000, 'chem_3': 250000,
|
||||
'king_1': 1000, 'king_2': 35000, 'king_3': 600000,
|
||||
'smug_1': 2000, 'smug_2': 50000, 'smug_3': 1000000
|
||||
}
|
||||
|
||||
UNIT_MILESTONES = [1, 5, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000]
|
||||
|
||||
OFFSHORE_PERK_COSTS = {
|
||||
'swiss_vault': [1, 3, 8, 20, 50],
|
||||
'auto_contracts': [2, 5, 12, 30],
|
||||
'corrupt_judiciary': [2, 6, 15, 40],
|
||||
'bulk_discount': [5, 15, 35, 80],
|
||||
'compound_interest': [4, 10, 25, 60, 150]
|
||||
}
|
||||
|
||||
HELPER_COSTS = {
|
||||
'bulk_buyer': 2500, # Level 1: Multi-buy bulk execution
|
||||
'roi_advisor': 15000, # Level 2: Real-time ROI analysis across raw, prod, deal
|
||||
'smart_allocator': 75000 # Level 3: Percentage portfolio investment (10%, 25%, 50%, 100%)
|
||||
}
|
||||
|
||||
ACHIEVEMENT_TIER_REQS = {
|
||||
'dirty_empire': [10000, 250000, 5000000, 100000000, 1000000000],
|
||||
'master_synthesizer': [100, 1000, 15000, 200000, 2500000],
|
||||
'street_monopoly': [100, 1500, 25000, 300000, 3000000],
|
||||
'supply_baron': [250, 3000, 50000, 500000],
|
||||
'vault_hoarder': [50000, 500000, 10000000, 250000000],
|
||||
'syndicate_army': [25, 100, 500, 2500],
|
||||
'purity_legend': [5, 25, 100, 500],
|
||||
'shadow_cartel': [3, 10, 30],
|
||||
'swiss_kingpin': [1, 3, 10, 25],
|
||||
'formula_expansion': [3, 6, 8, 10]
|
||||
BOARDROOM_PERK_COSTS = {
|
||||
'supply_sync': [1, 3, 8, 20, 50],
|
||||
'robotics_overclock': [2, 5, 12, 30, 75],
|
||||
'logistics_efficiency': [2, 6, 15, 40, 100],
|
||||
'government_subsidies': [3, 8, 20, 55, 120],
|
||||
'angel_syndicate': [5, 15, 45, 120, 300]
|
||||
}
|
||||
|
||||
def clean_round(val: float) -> float:
|
||||
"""Rounds float gracefully to 2 decimal places without floating point anomalies."""
|
||||
if math.isnan(val) or math.isinf(val) or val < 0:
|
||||
return 0.0
|
||||
return round(float(val), 2)
|
||||
|
||||
def get_unit_milestone_multiplier(count: int) -> float:
|
||||
"""Calculates 2x multiplier for each milestone threshold achieved."""
|
||||
reached = sum(1 for m in UNIT_MILESTONES if count >= m)
|
||||
return float(math.pow(2.0, reached))
|
||||
|
||||
def get_all_tier_milestone_multiplier(state: dict, unit_key: str) -> float:
|
||||
"""Calculates 2x global set multiplier when all unlocked drugs reach a milestone."""
|
||||
unlocked = [d for d in state.get('drugs', {}).values() if d.get('unlocked')]
|
||||
if not unlocked:
|
||||
return 1.0
|
||||
min_count = min(d.get(unit_key, 0) for d in unlocked)
|
||||
reached = sum(1 for m in UNIT_MILESTONES if min_count >= m)
|
||||
return float(math.pow(2.0, reached))
|
||||
|
||||
def get_initial_state() -> dict:
|
||||
state = {
|
||||
'cash': 25.0,
|
||||
'cleanBonds': 0,
|
||||
'totalCleanBonds': 0,
|
||||
'heat': 0.0,
|
||||
'totalEarned': 0.0,
|
||||
'version': 1,
|
||||
'companyName': 'Apex Production Industries',
|
||||
'cash': 30.0,
|
||||
'lifetimeRevenue': 0.0,
|
||||
'valuation': 30.0,
|
||||
'ventureShares': 0,
|
||||
'totalVentureShares': 0,
|
||||
'ipoCount': 0,
|
||||
'reputation': 0,
|
||||
'buyMode': '1',
|
||||
'activeTab': 'production',
|
||||
'selectedSector': 'all',
|
||||
'selectedProductId': 'wheat',
|
||||
'stats': {
|
||||
'totalProduced': 0.0,
|
||||
'totalSold': 0.0,
|
||||
'totalSupplied': 0.0,
|
||||
'purityProcs': 0,
|
||||
'raidsSurvived': 0,
|
||||
'prestigeCount': 0
|
||||
'contractsCompleted': 0,
|
||||
'managersHired': 0
|
||||
},
|
||||
'achievements': {}, # { ach_id: claimed_tier_int }
|
||||
'offshorePerks': {}, # { perk_id: level_int }
|
||||
'helpers': {h: False for h in HELPER_COSTS}, # { helper_id: bool }
|
||||
'skills': {s: False for s in SKILL_COSTS},
|
||||
'synthesis': {r: 0 for r in SYNTHESIS_RECIPES},
|
||||
'drugs': {},
|
||||
'products': {},
|
||||
'boardroomPerks': {p: 0 for p in BOARDROOM_PERK_COSTS},
|
||||
'achievements': {},
|
||||
'activeContracts': [],
|
||||
'lastTick': time.time()
|
||||
}
|
||||
for drug_id, cfg in DRUG_CONFIGS.items():
|
||||
state['drugs'][drug_id] = {
|
||||
'unlocked': (drug_id == 'weed'),
|
||||
'raw': 0.0,
|
||||
'product': 0.0,
|
||||
'suppliers': 0,
|
||||
'workers': 0,
|
||||
'dealers': 0
|
||||
|
||||
for pid, p in PRODUCT_CONFIGS.items():
|
||||
is_unlocked = p.get('unlockedDefault', False)
|
||||
state['products'][pid] = {
|
||||
'unlocked': is_unlocked,
|
||||
'stock': 0.0,
|
||||
'lines': 1 if is_unlocked else 0,
|
||||
'manager': False,
|
||||
'logistics': 1 if is_unlocked else 0,
|
||||
'cycleProgress': 0.0,
|
||||
'isBottlenecked': False,
|
||||
'totalProduced': 0.0
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
def get_multipliers(state: dict):
|
||||
# Total lifetime bonds provide permanent base boost
|
||||
total_bonds = max(state.get('totalCleanBonds', 0), state.get('cleanBonds', 0))
|
||||
skills = state.get('skills', {})
|
||||
ach = state.get('achievements', {})
|
||||
perks = state.get('offshorePerks', {})
|
||||
|
||||
# Compound interest perk raises bond sale value
|
||||
compound_lvl = perks.get('compound_interest', 0)
|
||||
bond_sell_rate = 0.04 + (compound_lvl * 0.02)
|
||||
bond_prod_rate = 0.03
|
||||
|
||||
prod_m = 1.0 + (total_bonds * bond_prod_rate)
|
||||
if skills.get('chem_1'): prod_m += 0.15
|
||||
|
||||
# Achievement bonuses for production
|
||||
synth_tier = ach.get('master_synthesizer', 0)
|
||||
prod_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, synth_tier)]
|
||||
formula_tier = ach.get('formula_expansion', 0)
|
||||
prod_m += [0.0, 0.05, 0.10, 0.15, 0.30][min(4, formula_tier)]
|
||||
|
||||
# Sales multiplier
|
||||
sell_m = 1.0 + (total_bonds * bond_sell_rate)
|
||||
if skills.get('king_2'): sell_m += 0.25
|
||||
empire_tier = ach.get('dirty_empire', 0)
|
||||
sell_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, empire_tier)]
|
||||
|
||||
# Raw material cost multiplier
|
||||
raw_m = 0.85 if skills.get('chem_2') else 1.0
|
||||
vault_tier = ach.get('vault_hoarder', 0)
|
||||
raw_discount = [0.0, 0.05, 0.10, 0.15, 0.25][min(4, vault_tier)]
|
||||
raw_m = max(0.1, raw_m - raw_discount)
|
||||
|
||||
# Dealer speed multiplier
|
||||
dealer_m = 1.0
|
||||
if skills.get('king_1'): dealer_m += 0.20
|
||||
if skills.get('king_3'): dealer_m += 0.50
|
||||
street_tier = ach.get('street_monopoly', 0)
|
||||
dealer_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, street_tier)]
|
||||
|
||||
# Supplier speed multiplier
|
||||
supply_tier = ach.get('supply_baron', 0)
|
||||
supplier_m = 1.0 + [0.0, 0.05, 0.10, 0.15, 0.25][min(4, supply_tier)]
|
||||
|
||||
# Syndicate Army all speed multiplier
|
||||
army_tier = ach.get('syndicate_army', 0)
|
||||
all_unit_m = 1.0 + [0.0, 0.05, 0.10, 0.15, 0.25][min(4, army_tier)]
|
||||
|
||||
dealer_m *= all_unit_m
|
||||
supplier_m *= all_unit_m
|
||||
worker_m = all_unit_m
|
||||
|
||||
return prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m
|
||||
|
||||
def get_current_stat_value(state: dict, stat_key: str) -> float:
|
||||
"""Helper to query current progress for an achievement category."""
|
||||
if stat_key == 'totalEarned':
|
||||
return float(state.get('totalEarned', 0.0))
|
||||
elif stat_key == 'cash':
|
||||
return float(state.get('cash', 0.0))
|
||||
elif stat_key == 'totalPersonnel':
|
||||
total = 0
|
||||
for d in state.get('drugs', {}).values():
|
||||
total += d.get('suppliers', 0) + d.get('workers', 0) + d.get('dealers', 0)
|
||||
return float(total)
|
||||
elif stat_key == 'unlockedCount':
|
||||
return float(sum(1 for d in state.get('drugs', {}).values() if d.get('unlocked')))
|
||||
else:
|
||||
return float(state.get('stats', {}).get(stat_key, 0.0))
|
||||
|
||||
def validate_achievement_claim(state: dict, ach_id: str, tier: int) -> bool:
|
||||
reqs = ACHIEVEMENT_TIER_REQS.get(ach_id)
|
||||
if not reqs or tier < 1 or tier > len(reqs):
|
||||
return False
|
||||
|
||||
current_claimed = state.get('achievements', {}).get(ach_id, 0)
|
||||
if tier != current_claimed + 1:
|
||||
return False
|
||||
|
||||
req_val = reqs[tier - 1]
|
||||
stat_key_map = {
|
||||
'dirty_empire': 'totalEarned',
|
||||
'master_synthesizer': 'totalProduced',
|
||||
'street_monopoly': 'totalSold',
|
||||
'supply_baron': 'totalSupplied',
|
||||
'vault_hoarder': 'cash',
|
||||
'syndicate_army': 'totalPersonnel',
|
||||
'purity_legend': 'purityProcs',
|
||||
'shadow_cartel': 'raidsSurvived',
|
||||
'swiss_kingpin': 'prestigeCount',
|
||||
'formula_expansion': 'unlockedCount'
|
||||
}
|
||||
stat_key = stat_key_map.get(ach_id, 'totalEarned')
|
||||
current_val = get_current_stat_value(state, stat_key)
|
||||
return current_val >= req_val
|
||||
def calculate_shares_earned(lifetime_revenue: float) -> int:
|
||||
if lifetime_revenue < 100000.0:
|
||||
return 0
|
||||
return int(math.floor(10.0 * math.sqrt(lifetime_revenue / 100000.0)))
|
||||
|
||||
def process_offline_and_ticks(state: dict) -> dict:
|
||||
"""Computes authoritative tick progress up to the current timestamp (capped at 8 hours)."""
|
||||
now = time.time()
|
||||
last = state.get('lastTick', now)
|
||||
delta = min(max(0.0, now - last), 86400.0) # Cap at 24h offline
|
||||
elapsed = max(0.0, min(now - last, 8.0 * 3600.0))
|
||||
state['lastTick'] = now
|
||||
|
||||
if delta <= 0:
|
||||
return sanitize_state(state)
|
||||
if elapsed <= 0.1:
|
||||
return state
|
||||
|
||||
prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m = get_multipliers(state)
|
||||
perks = state.get('boardroomPerks', {})
|
||||
supply_yield_mult = 1.0 + 0.15 * perks.get('supply_sync', 0)
|
||||
robotics_speed_mult = 1.0 + 0.20 * perks.get('robotics_overclock', 0)
|
||||
logistics_price_mult = 1.0 + 0.25 * perks.get('logistics_efficiency', 0)
|
||||
|
||||
# All-tier milestone multipliers
|
||||
all_tier_sup = get_all_tier_milestone_multiplier(state, 'suppliers')
|
||||
all_tier_work = get_all_tier_milestone_multiplier(state, 'workers')
|
||||
all_tier_deal = get_all_tier_milestone_multiplier(state, 'dealers')
|
||||
# Simulate in reasonable sub-steps for chained DAG dependency
|
||||
sub_steps = max(1, min(int(elapsed), 120))
|
||||
dt = elapsed / sub_steps
|
||||
|
||||
stats = state.setdefault('stats', {})
|
||||
|
||||
for drug_id, data in state.get('drugs', {}).items():
|
||||
cfg = DRUG_CONFIGS.get(drug_id)
|
||||
if not cfg or not data.get('unlocked'):
|
||||
for _ in range(sub_steps):
|
||||
for pid, p_cfg in PRODUCT_CONFIGS.items():
|
||||
p_state = state.get('products', {}).get(pid)
|
||||
if not p_state or not p_state.get('unlocked'):
|
||||
continue
|
||||
|
||||
# Suppliers automatic raw cultivation / procurement
|
||||
suppliers = data.get('suppliers', 0)
|
||||
if suppliers > 0:
|
||||
unit_milestone_m = get_unit_milestone_multiplier(suppliers)
|
||||
supply_speed = suppliers * cfg.get('supplyRate', 1.0) * prod_m * supplier_m * unit_milestone_m * all_tier_sup
|
||||
added_raw = supply_speed * delta
|
||||
data['raw'] = data.get('raw', 0.0) + added_raw
|
||||
stats['totalSupplied'] = stats.get('totalSupplied', 0.0) + added_raw
|
||||
lines = p_state.get('lines', 0)
|
||||
if lines <= 0:
|
||||
continue
|
||||
|
||||
# Workers production (Refining raw -> product)
|
||||
workers = data.get('workers', 0)
|
||||
raw = data.get('raw', 0.0)
|
||||
if workers > 0 and raw > 0:
|
||||
unit_milestone_m = get_unit_milestone_multiplier(workers)
|
||||
potential_prod = workers * cfg['produceRate'] * prod_m * worker_m * unit_milestone_m * all_tier_work * delta
|
||||
actual_prod = min(raw, potential_prod)
|
||||
data['raw'] = max(0.0, raw - actual_prod)
|
||||
data['product'] = data.get('product', 0.0) + actual_prod
|
||||
stats['totalProduced'] = stats.get('totalProduced', 0.0) + actual_prod
|
||||
milestone_mult = get_unit_milestone_multiplier(lines)
|
||||
cycle_rate = (1.0 / p_cfg['baseCycleTime']) * milestone_mult * robotics_speed_mult
|
||||
|
||||
# Dealers selling (Product -> Cash)
|
||||
dealers = data.get('dealers', 0)
|
||||
product = data.get('product', 0.0)
|
||||
if dealers > 0 and product > 0:
|
||||
unit_milestone_m = get_unit_milestone_multiplier(dealers)
|
||||
potential_sell = dealers * cfg['sellRate'] * dealer_m * unit_milestone_m * all_tier_deal * delta
|
||||
actual_sell = min(product, potential_sell)
|
||||
data['product'] = max(0.0, product - actual_sell)
|
||||
rev = actual_sell * cfg['sellPrice'] * sell_m
|
||||
state['cash'] = state.get('cash', 0.0) + rev
|
||||
state['totalEarned'] = state.get('totalEarned', 0.0) + rev
|
||||
stats['totalSold'] = stats.get('totalSold', 0.0) + actual_sell
|
||||
|
||||
# Heat decay
|
||||
state['heat'] = max(0.0, state.get('heat', 0.0) - (0.5 * delta))
|
||||
|
||||
return sanitize_state(state)
|
||||
|
||||
def apply_actions(state: dict, actions: list) -> dict:
|
||||
"""Applies and authorizes player actions sequentially against server authority."""
|
||||
prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m = get_multipliers(state)
|
||||
|
||||
# Unit price scaling factor (1.15 down to 1.11 via bulk_discount perk)
|
||||
discount_lvl = state.get('offshorePerks', {}).get('bulk_discount', 0)
|
||||
scaling_r = max(1.10, 1.15 - (discount_lvl * 0.01))
|
||||
|
||||
for act in actions:
|
||||
action_type = act.get('type')
|
||||
drug_id = act.get('drug_id')
|
||||
|
||||
if action_type == 'buy_raw' and drug_id in DRUG_CONFIGS:
|
||||
cfg = DRUG_CONFIGS[drug_id]
|
||||
cost = cfg['rawCost'] * raw_m
|
||||
amount = max(1, int(act.get('amount', 1)))
|
||||
total_cost = cost * amount
|
||||
if state['cash'] >= total_cost:
|
||||
state['cash'] -= total_cost
|
||||
state['drugs'][drug_id]['raw'] = state['drugs'][drug_id].get('raw', 0.0) + amount
|
||||
state.setdefault('stats', {})['totalSupplied'] = state['stats'].get('totalSupplied', 0.0) + amount
|
||||
|
||||
elif action_type == 'manufacture' and drug_id in DRUG_CONFIGS:
|
||||
data = state['drugs'][drug_id]
|
||||
amount = max(1, int(act.get('amount', 1)))
|
||||
actual = min(amount, int(data.get('raw', 0)))
|
||||
if actual > 0:
|
||||
data['raw'] -= actual
|
||||
yield_count = actual
|
||||
if state.get('skills', {}).get('chem_3'):
|
||||
# 10% chance on client for 3x yield
|
||||
yield_count *= 1.3
|
||||
state.setdefault('stats', {})['purityProcs'] = state['stats'].get('purityProcs', 0) + 1
|
||||
data['product'] = data.get('product', 0.0) + yield_count
|
||||
state.setdefault('stats', {})['totalProduced'] = state['stats'].get('totalProduced', 0.0) + yield_count
|
||||
|
||||
# Swiss vault secrecy perk reduces heat generation
|
||||
vault_lvl = state.get('offshorePerks', {}).get('swiss_vault', 0)
|
||||
heat_rise = max(0.2, (0.5 * actual) * (1.0 - vault_lvl * 0.12))
|
||||
state['heat'] = min(100.0, state.get('heat', 0.0) + heat_rise)
|
||||
|
||||
elif action_type == 'sell_manual' and drug_id in DRUG_CONFIGS:
|
||||
data = state['drugs'][drug_id]
|
||||
cfg = DRUG_CONFIGS[drug_id]
|
||||
amount = max(1, int(act.get('amount', 1)))
|
||||
actual = min(amount, int(data.get('product', 0)))
|
||||
if actual > 0:
|
||||
data['product'] -= actual
|
||||
profit = actual * cfg['sellPrice'] * sell_m
|
||||
state['cash'] += profit
|
||||
state['totalEarned'] += profit
|
||||
state.setdefault('stats', {})['totalSold'] = state['stats'].get('totalSold', 0.0) + actual
|
||||
|
||||
vault_lvl = state.get('offshorePerks', {}).get('swiss_vault', 0)
|
||||
heat_rise = max(0.3, (0.8 * actual) * (1.0 - vault_lvl * 0.12))
|
||||
state['heat'] = min(100.0, state.get('heat', 0.0) + heat_rise)
|
||||
|
||||
elif action_type == 'hire_supplier' and drug_id in DRUG_CONFIGS:
|
||||
cfg = DRUG_CONFIGS[drug_id]
|
||||
hire_qty = max(1, int(act.get('count', 1)))
|
||||
base_cost = cfg.get('supplierCost', 75)
|
||||
for _ in range(hire_qty):
|
||||
count = state['drugs'][drug_id].get('suppliers', 0)
|
||||
cost = math.floor(base_cost * math.pow(scaling_r, count))
|
||||
if state['cash'] >= cost:
|
||||
state['cash'] -= cost
|
||||
state['drugs'][drug_id]['suppliers'] = count + 1
|
||||
inputs = p_cfg.get('inputs', [])
|
||||
if not inputs:
|
||||
# Raw extraction
|
||||
p_state['isBottlenecked'] = False
|
||||
p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
|
||||
if p_state['cycleProgress'] >= 1.0:
|
||||
cycles = int(p_state['cycleProgress'])
|
||||
p_state['cycleProgress'] -= cycles
|
||||
produced = cycles * p_cfg['outputAmount'] * lines * supply_yield_mult
|
||||
p_state['stock'] = p_state.get('stock', 0.0) + produced
|
||||
p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced
|
||||
else:
|
||||
break
|
||||
# Manufacturing requiring inputs
|
||||
max_cycles = int(min(
|
||||
state['products'].get(inp['id'], {}).get('stock', 0.0) // (inp['amount'] * lines)
|
||||
for inp in inputs
|
||||
)) if inputs else 0
|
||||
|
||||
elif action_type == 'hire_worker' and drug_id in DRUG_CONFIGS:
|
||||
cfg = DRUG_CONFIGS[drug_id]
|
||||
hire_qty = max(1, int(act.get('count', 1)))
|
||||
base_cost = cfg['workerCost']
|
||||
for _ in range(hire_qty):
|
||||
count = state['drugs'][drug_id].get('workers', 0)
|
||||
cost = math.floor(base_cost * math.pow(scaling_r, count))
|
||||
if state['cash'] >= cost:
|
||||
state['cash'] -= cost
|
||||
state['drugs'][drug_id]['workers'] = count + 1
|
||||
if max_cycles <= 0:
|
||||
p_state['isBottlenecked'] = True
|
||||
else:
|
||||
break
|
||||
p_state['isBottlenecked'] = False
|
||||
p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt)
|
||||
if p_state['cycleProgress'] >= 1.0:
|
||||
cycles_to_run = min(int(p_state['cycleProgress']), max_cycles)
|
||||
if cycles_to_run > 0:
|
||||
p_state['cycleProgress'] -= cycles_to_run
|
||||
for inp in inputs:
|
||||
if inp['id'] in state['products']:
|
||||
state['products'][inp['id']]['stock'] = max(
|
||||
0.0,
|
||||
state['products'][inp['id']]['stock'] - (inp['amount'] * lines * cycles_to_run)
|
||||
)
|
||||
produced = cycles_to_run * p_cfg['outputAmount'] * lines
|
||||
p_state['stock'] = p_state.get('stock', 0.0) + produced
|
||||
p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced
|
||||
|
||||
elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS:
|
||||
cfg = DRUG_CONFIGS[drug_id]
|
||||
hire_qty = max(1, int(act.get('count', 1)))
|
||||
base_cost = cfg['dealerCost']
|
||||
for _ in range(hire_qty):
|
||||
count = state['drugs'][drug_id].get('dealers', 0)
|
||||
cost = math.floor(base_cost * math.pow(scaling_r, count))
|
||||
if state['cash'] >= cost:
|
||||
state['cash'] -= cost
|
||||
state['drugs'][drug_id]['dealers'] = count + 1
|
||||
else:
|
||||
break
|
||||
# Passive Logistics Sales
|
||||
logistics = p_state.get('logistics', 0)
|
||||
if logistics > 0 and p_state.get('stock', 0.0) > 0:
|
||||
max_sell = logistics * 0.75 * dt
|
||||
sold = min(p_state['stock'], max_sell)
|
||||
p_state['stock'] -= sold
|
||||
|
||||
elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS:
|
||||
cfg = DRUG_CONFIGS[drug_id]
|
||||
cost = cfg['unlockCost']
|
||||
if state['cash'] >= cost and not state['drugs'][drug_id].get('unlocked'):
|
||||
state['cash'] -= cost
|
||||
state['drugs'][drug_id]['unlocked'] = True
|
||||
earned = sold * p_cfg['basePrice'] * logistics_price_mult
|
||||
state['cash'] = state.get('cash', 0.0) + earned
|
||||
state['lifetimeRevenue'] = state.get('lifetimeRevenue', 0.0) + earned
|
||||
|
||||
# Procurement pipeline perk gives free starter units!
|
||||
starter_units = state.get('offshorePerks', {}).get('auto_contracts', 0)
|
||||
if starter_units > 0:
|
||||
state['drugs'][drug_id]['suppliers'] = state['drugs'][drug_id].get('suppliers', 0) + starter_units
|
||||
state['drugs'][drug_id]['workers'] = state['drugs'][drug_id].get('workers', 0) + starter_units
|
||||
|
||||
elif action_type == 'blend_recipe':
|
||||
recipe_id = act.get('recipe_id')
|
||||
if recipe_id in SYNTHESIS_RECIPES:
|
||||
r = SYNTHESIS_RECIPES[recipe_id]
|
||||
can_blend = True
|
||||
for ingr_id, needed in r['ingredients'].items():
|
||||
if state['drugs'].get(ingr_id, {}).get('product', 0) < needed:
|
||||
can_blend = False
|
||||
break
|
||||
if can_blend:
|
||||
for ingr_id, needed in r['ingredients'].items():
|
||||
state['drugs'][ingr_id]['product'] -= needed
|
||||
state['cash'] += r['payout']
|
||||
state['totalEarned'] += r['payout']
|
||||
state['heat'] = min(100.0, state.get('heat', 0.0) + 5.0)
|
||||
|
||||
elif action_type == 'buy_skill':
|
||||
skill_id = act.get('skill_id')
|
||||
if skill_id in SKILL_COSTS and not state.get('skills', {}).get(skill_id):
|
||||
cost = SKILL_COSTS[skill_id]
|
||||
if state['cash'] >= cost:
|
||||
state['cash'] -= cost
|
||||
state.setdefault('skills', {})[skill_id] = True
|
||||
|
||||
elif action_type == 'claim_achievement':
|
||||
ach_id = act.get('ach_id')
|
||||
tier = int(act.get('tier', 1))
|
||||
if validate_achievement_claim(state, ach_id, tier):
|
||||
state.setdefault('achievements', {})[ach_id] = tier
|
||||
|
||||
elif action_type == 'buy_offshore_perk':
|
||||
perk_id = act.get('perk_id')
|
||||
if perk_id in OFFSHORE_PERK_COSTS:
|
||||
costs = OFFSHORE_PERK_COSTS[perk_id]
|
||||
current_lvl = state.setdefault('offshorePerks', {}).get(perk_id, 0)
|
||||
if current_lvl < len(costs):
|
||||
cost = costs[current_lvl]
|
||||
if state.get('cleanBonds', 0) >= cost:
|
||||
state['cleanBonds'] -= cost
|
||||
state['offshorePerks'][perk_id] = current_lvl + 1
|
||||
|
||||
elif action_type == 'buy_helper':
|
||||
helper_id = act.get('helper_id')
|
||||
if helper_id in HELPER_COSTS and not state.setdefault('helpers', {}).get(helper_id):
|
||||
cost = HELPER_COSTS[helper_id]
|
||||
if state['cash'] >= cost:
|
||||
state['cash'] -= cost
|
||||
state['helpers'][helper_id] = True
|
||||
|
||||
elif action_type == 'bribe_police':
|
||||
base_bribe = max(100.0, state['cash'] * 0.15)
|
||||
judiciary_lvl = state.get('offshorePerks', {}).get('corrupt_judiciary', 0)
|
||||
bribe_cost = max(25.0, base_bribe * (1.0 - judiciary_lvl * 0.20))
|
||||
if state['cash'] >= bribe_cost:
|
||||
state['cash'] -= bribe_cost
|
||||
state['heat'] = 0.0
|
||||
state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1
|
||||
|
||||
elif action_type == 'flush_stash':
|
||||
state['heat'] = 20.0
|
||||
state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1
|
||||
|
||||
elif action_type == 'breakout_raid':
|
||||
success = bool(act.get('success', False))
|
||||
if success:
|
||||
state['heat'] = 0.0
|
||||
state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1
|
||||
|
||||
elif action_type == 'launder_prestige':
|
||||
bonds_to_earn = math.floor(math.sqrt(max(0.0, state.get('totalEarned', 0.0)) / 25000.0))
|
||||
gain = max(0, bonds_to_earn - state.get('totalCleanBonds', 0))
|
||||
if gain >= 1:
|
||||
state['totalCleanBonds'] = state.get('totalCleanBonds', 0) + gain
|
||||
state['cleanBonds'] = state.get('cleanBonds', 0) + gain
|
||||
|
||||
stats = state.setdefault('stats', {})
|
||||
stats['prestigeCount'] = stats.get('prestigeCount', 0) + 1
|
||||
|
||||
# Calculate start cash with bond and swiss kingpin bonuses
|
||||
start_cash = 50.0 + (state['totalCleanBonds'] * 25.0)
|
||||
swiss_tier = state.get('achievements', {}).get('swiss_kingpin', 0)
|
||||
if swiss_tier >= 1: start_cash += 250.0
|
||||
if swiss_tier >= 2: start_cash += 2500.0
|
||||
if swiss_tier >= 3: start_cash += 25000.0
|
||||
if swiss_tier >= 4: start_cash += 250000.0
|
||||
|
||||
state['cash'] = start_cash
|
||||
state['heat'] = 0.0
|
||||
|
||||
# Starter units from auto_contracts perk
|
||||
free_starter = state.get('offshorePerks', {}).get('auto_contracts', 0)
|
||||
|
||||
for d_id, d_data in state['drugs'].items():
|
||||
d_data['unlocked'] = (d_id == 'weed')
|
||||
d_data['raw'] = 0.0
|
||||
d_data['product'] = 0.0
|
||||
d_data['suppliers'] = free_starter if d_data['unlocked'] else 0
|
||||
d_data['workers'] = free_starter if d_data['unlocked'] else 0
|
||||
d_data['dealers'] = 0
|
||||
|
||||
return sanitize_state(state)
|
||||
|
||||
def sanitize_state(state: dict) -> dict:
|
||||
"""Enforces max 1 decimal and prevents negative or runaway numbers."""
|
||||
state['cash'] = clean_round(state.get('cash', 0.0))
|
||||
state['heat'] = clean_round(min(100.0, max(0.0, state.get('heat', 0.0))))
|
||||
state['totalEarned'] = clean_round(state.get('totalEarned', 0.0))
|
||||
state['cleanBonds'] = max(0, int(state.get('cleanBonds', 0)))
|
||||
state['totalCleanBonds'] = max(state['cleanBonds'], int(state.get('totalCleanBonds', 0)))
|
||||
|
||||
stats = state.setdefault('stats', {})
|
||||
for k in ['totalProduced', 'totalSold', 'totalSupplied']:
|
||||
stats[k] = clean_round(stats.get(k, 0.0))
|
||||
for k in ['purityProcs', 'raidsSurvived', 'prestigeCount']:
|
||||
stats[k] = int(stats.get(k, 0))
|
||||
|
||||
state.setdefault('achievements', {})
|
||||
state.setdefault('offshorePerks', {})
|
||||
state.setdefault('helpers', {h: False for h in HELPER_COSTS})
|
||||
|
||||
for d_id, data in state.get('drugs', {}).items():
|
||||
data['raw'] = clean_round(data.get('raw', 0.0))
|
||||
data['product'] = clean_round(data.get('product', 0.0))
|
||||
data['suppliers'] = int(data.get('suppliers', 0))
|
||||
data['workers'] = int(data.get('workers', 0))
|
||||
data['dealers'] = int(data.get('dealers', 0))
|
||||
# Update valuation
|
||||
facility_assets = sum(
|
||||
p_state.get('lines', 0) * PRODUCT_CONFIGS[pid]['baseFacilityCost']
|
||||
for pid, p_state in state.get('products', {}).items()
|
||||
if pid in PRODUCT_CONFIGS
|
||||
)
|
||||
state['valuation'] = state.get('cash', 0.0) + facility_assets + (state.get('lifetimeRevenue', 0.0) * 0.15)
|
||||
|
||||
return state
|
||||
|
||||
def apply_actions(state: dict, actions: list) -> dict:
|
||||
"""Validates and executes queued actions sent from the frontend."""
|
||||
for act in actions:
|
||||
atype = act.get('type')
|
||||
if atype == 'UNLOCK_PRODUCT':
|
||||
pid = act.get('productId')
|
||||
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
|
||||
p_cfg = PRODUCT_CONFIGS[pid]
|
||||
cost = p_cfg['unlockCost']
|
||||
if state.get('cash', 0.0) >= cost and not state['products'][pid].get('unlocked'):
|
||||
state['cash'] -= cost
|
||||
state['products'][pid]['unlocked'] = True
|
||||
state['products'][pid]['lines'] = 1
|
||||
|
||||
elif atype == 'BUY_LINES':
|
||||
pid = act.get('productId')
|
||||
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
|
||||
p_cfg = PRODUCT_CONFIGS[pid]
|
||||
current_lines = state['products'][pid].get('lines', 0)
|
||||
cost = p_cfg['baseFacilityCost'] * math.pow(1.15, current_lines)
|
||||
if state.get('cash', 0.0) >= cost:
|
||||
state['cash'] -= cost
|
||||
state['products'][pid]['lines'] = current_lines + 1
|
||||
|
||||
elif atype == 'HIRE_MANAGER':
|
||||
pid = act.get('productId')
|
||||
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
|
||||
cost = PRODUCT_CONFIGS[pid]['baseManagerCost']
|
||||
if state.get('cash', 0.0) >= cost and not state['products'][pid].get('manager'):
|
||||
state['cash'] -= cost
|
||||
state['products'][pid]['manager'] = True
|
||||
state['stats']['managersHired'] = state['stats'].get('managersHired', 0) + 1
|
||||
|
||||
elif atype == 'BUY_LOGISTICS':
|
||||
pid = act.get('productId')
|
||||
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
|
||||
current_logistics = state['products'][pid].get('logistics', 0)
|
||||
cost = PRODUCT_CONFIGS[pid]['baseLogisticsCost'] * math.pow(1.15, current_logistics)
|
||||
if state.get('cash', 0.0) >= cost:
|
||||
state['cash'] -= cost
|
||||
state['products'][pid]['logistics'] = current_logistics + 1
|
||||
|
||||
elif atype == 'TRIGGER_IPO':
|
||||
earned = calculate_shares_earned(state.get('lifetimeRevenue', 0.0))
|
||||
if earned > 0:
|
||||
preserved_shares = state.get('ventureShares', 0) + earned
|
||||
preserved_total_shares = state.get('totalVentureShares', 0) + earned
|
||||
preserved_perks = state.get('boardroomPerks', {})
|
||||
preserved_ipos = state.get('ipoCount', 0) + 1
|
||||
|
||||
# Reset to clean initial state
|
||||
state = get_initial_state()
|
||||
state['ventureShares'] = preserved_shares
|
||||
state['totalVentureShares'] = preserved_total_shares
|
||||
state['boardroomPerks'] = preserved_perks
|
||||
state['ipoCount'] = preserved_ipos
|
||||
|
||||
elif atype == 'BUY_PERK':
|
||||
perk_id = act.get('perkId')
|
||||
if perk_id in BOARDROOM_PERK_COSTS:
|
||||
current_lvl = state.get('boardroomPerks', {}).get(perk_id, 0)
|
||||
costs = BOARDROOM_PERK_COSTS[perk_id]
|
||||
if current_lvl < len(costs):
|
||||
req_shares = costs[current_lvl]
|
||||
if state.get('ventureShares', 0) >= req_shares:
|
||||
state['ventureShares'] -= req_shares
|
||||
state['boardroomPerks'][perk_id] = current_lvl + 1
|
||||
|
||||
return state
|
||||
|
||||
def sanitize_state(raw: dict) -> dict:
|
||||
"""Ensures incoming state doesn't have NaN or negative numbers."""
|
||||
if not isinstance(raw, dict):
|
||||
return get_initial_state()
|
||||
return raw
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from models import RegisterRequest, LoginRequest, AuthResponse, SaveGameRequest,
|
|||
from auth import hash_password, verify_password, create_access_token, get_current_user_id
|
||||
import engine
|
||||
|
||||
app = FastAPI(title="El Puro API", version="1.1.0")
|
||||
app = FastAPI(title="Production API", version="2.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
@ -23,7 +23,7 @@ def startup_event():
|
|||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "app": "El Puro", "version": "1.1.0"}
|
||||
return {"status": "ok", "app": "Production - The Idle Game", "version": "2.0.0"}
|
||||
|
||||
@app.post("/api/auth/register", response_model=AuthResponse)
|
||||
def register(req: RegisterRequest):
|
||||
|
|
|
|||
76
backend/test_engine.py
Normal file
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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-el_puro_secret_key_change_in_production_jwt_99_1_pure}
|
||||
- SECRET_KEY=${SECRET_KEY:-production_game_secret_key_change_in_production_jwt}
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- elpuro-net
|
||||
- production-net
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
|
|
@ -25,8 +25,8 @@ services:
|
|||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- elpuro-net
|
||||
- production-net
|
||||
|
||||
networks:
|
||||
elpuro-net:
|
||||
production-net:
|
||||
driver: bridge
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,265 +3,216 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>EL PURO</title>
|
||||
<!-- Fonts -->
|
||||
<title>Production - The Idle Game</title>
|
||||
|
||||
<!-- Modern Corporate & Financial Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;800&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
<!-- Iconify Web Component CDN (Game Icons & Lucide) -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Iconify Web Component CDN (Lucide Icons) -->
|
||||
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app-container">
|
||||
<div id="app-wrapper">
|
||||
|
||||
<!-- Sticky Header (Status bar + Financial HUD + Heat Bar) -->
|
||||
<header id="sticky-header">
|
||||
<!-- Executive Header (Sticky across Desktop & Mobile) -->
|
||||
<header id="executive-header">
|
||||
<!-- Top Status Bar -->
|
||||
<div id="system-bar">
|
||||
<div id="corporate-status-bar">
|
||||
<div class="status-left">
|
||||
<span class="brand-tag">
|
||||
<iconify-icon icon="game-icons:flasks"></iconify-icon> EL PURO
|
||||
</span>
|
||||
<span class="secure-indicator">
|
||||
<span class="secure-dot"></span> SECURE
|
||||
</span>
|
||||
<div class="corporate-brand">
|
||||
<div class="brand-logo-icon">
|
||||
<iconify-icon icon="lucide:factory"></iconify-icon>
|
||||
</div>
|
||||
<div class="status-right" style="display: flex; align-items: center; gap: 8px;">
|
||||
<button id="btn-open-settings" style="background: none; border: 1px solid #2a3b4c; border-radius: 4px; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 2px 6px; font-size: 0.9rem;" title="Game Settings & Number Notation">
|
||||
<iconify-icon icon="lucide:settings"></iconify-icon>
|
||||
<div class="brand-text">
|
||||
<span class="brand-title">PRODUCTION</span>
|
||||
<span class="brand-subtitle">The Idle Game</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-right">
|
||||
<button id="btn-open-settings" class="btn-icon-soft" title="Toggle Number Notation (Standard / Scientific)">
|
||||
<iconify-icon icon="lucide:binary"></iconify-icon>
|
||||
<span class="btn-text-sm">Notation</span>
|
||||
</button>
|
||||
<button id="user-badge" class="btn-user-badge" title="Account / Cloud Sync">
|
||||
[GUEST]
|
||||
</button>
|
||||
<span id="user-badge" style="cursor: pointer; text-decoration: underline;">[ANON]</span>
|
||||
<span id="system-clock">15:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HUD Summary (Cash, Income, Clean Bonds) -->
|
||||
<div id="hud-summary">
|
||||
<div>
|
||||
<div class="hud-title">Dirty Cash</div>
|
||||
<div class="hud-cash-val">$<span id="hud-cash">0.00</span></div>
|
||||
<div class="hud-rate-val">+$<span id="hud-rate">0.00</span>/s</div>
|
||||
</div>
|
||||
<div class="hud-bonds">
|
||||
<div class="hud-title">Clean Bonds</div>
|
||||
<div class="bonds-val">
|
||||
<iconify-icon icon="game-icons:tied-scroll"></iconify-icon>
|
||||
<span id="hud-bonds">0</span>
|
||||
</div>
|
||||
<div class="hud-rate-val" style="color: var(--neon-cyan);">+<span id="hud-multiplier">0</span>% Boost</div>
|
||||
</div>
|
||||
<!-- Financial HUD (Liquid Capital, Net Flow, Valuation, Venture Equity) -->
|
||||
<div id="financial-hud">
|
||||
<div class="hud-stat-box primary-hud-box">
|
||||
<div class="hud-label">Liquid Capital</div>
|
||||
<div class="hud-main-val font-mono">$<span id="hud-cash">0.00</span></div>
|
||||
<div class="hud-sub-val font-mono rate-positive">+$<span id="hud-rate">0.00</span>/s</div>
|
||||
</div>
|
||||
|
||||
<!-- Police Heat Bar -->
|
||||
<div id="heat-container">
|
||||
<div class="heat-label-row">
|
||||
<span>POLICE HEAT</span>
|
||||
<span id="heat-pct">0%</span>
|
||||
</div>
|
||||
<div class="heat-bar-track">
|
||||
<div id="heat-bar-fill"></div>
|
||||
</div>
|
||||
<div class="hud-stat-box">
|
||||
<div class="hud-label">Enterprise Valuation</div>
|
||||
<div class="hud-main-val font-mono" id="hud-valuation">$0.00</div>
|
||||
<div class="hud-sub-val font-mono text-muted">Asset Value & Equity</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Inventory Overview Strip (Always visible) -->
|
||||
<div id="quick-stash-strip">
|
||||
<div class="stash-strip-title">
|
||||
<span>STASH OVERVIEW:</span>
|
||||
</div>
|
||||
<div id="quick-stash-items" class="stash-strip-items">
|
||||
<!-- Dynamically populated chips of all owned products -->
|
||||
<div class="hud-stat-box">
|
||||
<div class="hud-label">Venture Capital ($V-Shares)</div>
|
||||
<div class="hud-main-val font-mono text-equity">
|
||||
<iconify-icon icon="lucide:gem"></iconify-icon>
|
||||
<span id="hud-shares">0</span>
|
||||
</div>
|
||||
<div class="hud-sub-val font-mono text-equity">IPO Expansion Equity</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Multiplier Selector Bar (1x, 10x, 100x, Next Milestone, Max) -->
|
||||
<div id="buy-multiplier-bar">
|
||||
<span class="buy-multi-label">BUY QTY:</span>
|
||||
<div class="buy-multi-group">
|
||||
<!-- Buy Multiplier Selector -->
|
||||
<div class="hud-multiplier-box">
|
||||
<span class="multi-label">Buy Batch:</span>
|
||||
<div class="multi-button-group">
|
||||
<button class="btn-multi-pill active" data-mode="1">1x</button>
|
||||
<button class="btn-multi-pill" data-mode="10">10x</button>
|
||||
<button class="btn-multi-pill" data-mode="100">100x</button>
|
||||
<button class="btn-multi-pill" data-mode="next">Next</button>
|
||||
<button class="btn-multi-pill" data-mode="max">Max</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Toast Notifications Container -->
|
||||
<div id="toast-container"></div>
|
||||
<!-- Main Workspace Layout (3-Column Desktop Grid / 1-Column Mobile) -->
|
||||
<div id="workspace-layout">
|
||||
|
||||
<!-- Main Viewport with Dynamic Views -->
|
||||
<main id="main-viewport">
|
||||
<!-- Views are rendered dynamically by game.js -->
|
||||
</main>
|
||||
|
||||
<!-- Bottom Navigation Bar (Fixed for quick thumb access) -->
|
||||
<nav id="bottom-nav">
|
||||
<button class="nav-tab active" data-tab="lab">
|
||||
<iconify-icon icon="game-icons:flasks"></iconify-icon>
|
||||
<span>Lab</span>
|
||||
<!-- Left Column: Corporate Navigation (Desktop Sidebar) -->
|
||||
<aside id="desktop-nav-sidebar">
|
||||
<div class="sidebar-section-title">Departments</div>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-btn active" data-tab="production">
|
||||
<iconify-icon icon="lucide:layout-grid"></iconify-icon>
|
||||
<span>Production Floor</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="streets">
|
||||
<iconify-icon icon="game-icons:cash"></iconify-icon>
|
||||
<span>Streets</span>
|
||||
<button class="nav-btn" data-tab="contracts">
|
||||
<iconify-icon icon="lucide:scroll-text"></iconify-icon>
|
||||
<span>B2B Contracts</span>
|
||||
<span id="contracts-badge" class="nav-pill-badge">0</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="trophies" style="position: relative;">
|
||||
<iconify-icon icon="game-icons:trophy-cup"></iconify-icon>
|
||||
<span>Trophies</span>
|
||||
<span id="trophy-badge" class="nav-badge" style="display: none;">0</span>
|
||||
<button class="nav-btn" data-tab="boardroom">
|
||||
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||||
<span>Boardroom & IPO</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="helpers">
|
||||
<iconify-icon icon="game-icons:artificial-intelligence"></iconify-icon>
|
||||
<span>Helpers</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="tree">
|
||||
<iconify-icon icon="game-icons:spider-web"></iconify-icon>
|
||||
<span>Network</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="offshore">
|
||||
<iconify-icon icon="game-icons:briefcase"></iconify-icon>
|
||||
<span>Offshore</span>
|
||||
<button class="nav-btn" data-tab="milestones">
|
||||
<iconify-icon icon="lucide:trophy"></iconify-icon>
|
||||
<span>Milestones</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
<div class="sidebar-divider"></div>
|
||||
|
||||
<!-- Authentication Modal -->
|
||||
<div id="auth-modal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="sidebar-section-title">Industry Sectors</div>
|
||||
<div class="sidebar-sectors-list">
|
||||
<div class="sidebar-sector-item">
|
||||
<iconify-icon icon="lucide:wheat" style="color: #16a34a;"></iconify-icon>
|
||||
<span>Agriculture & Food</span>
|
||||
</div>
|
||||
<div class="sidebar-sector-item">
|
||||
<iconify-icon icon="lucide:trees" style="color: #ca8a04;"></iconify-icon>
|
||||
<span>Forestry & Materials</span>
|
||||
</div>
|
||||
<div class="sidebar-sector-item">
|
||||
<iconify-icon icon="lucide:pickaxe" style="color: #475569;"></iconify-icon>
|
||||
<span>Mining & Metallurgy</span>
|
||||
</div>
|
||||
<div class="sidebar-sector-item">
|
||||
<iconify-icon icon="lucide:flask-conical" style="color: #0284c7;"></iconify-icon>
|
||||
<span>Chemicals & Polymers</span>
|
||||
</div>
|
||||
<div class="sidebar-sector-item">
|
||||
<iconify-icon icon="lucide:cpu" style="color: #7c3aed;"></iconify-icon>
|
||||
<span>High-Tech Electronics</span>
|
||||
</div>
|
||||
<div class="sidebar-sector-item">
|
||||
<iconify-icon icon="lucide:truck" style="color: #ea580c;"></iconify-icon>
|
||||
<span>Heavy Industry</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Center Column: Main Interactive Viewport -->
|
||||
<main id="main-viewport">
|
||||
<!-- Rendered dynamically by app.js (Production, Contracts, Boardroom, Milestones) -->
|
||||
</main>
|
||||
|
||||
<!-- Right Column: Strategic Inspector & Supply Chain Panel (Desktop only) -->
|
||||
<aside id="desktop-inspector-panel">
|
||||
<!-- Rendered dynamically by inspectorView.js -->
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Mobile Bottom Navigation Bar (Visible on mobile viewports) -->
|
||||
<nav id="mobile-bottom-nav">
|
||||
<button class="nav-tab active" data-tab="production">
|
||||
<iconify-icon icon="lucide:layout-grid"></iconify-icon>
|
||||
<span>Production</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="contracts">
|
||||
<iconify-icon icon="lucide:scroll-text"></iconify-icon>
|
||||
<span>Contracts</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="boardroom">
|
||||
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||||
<span>Boardroom</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="milestones">
|
||||
<iconify-icon icon="lucide:trophy"></iconify-icon>
|
||||
<span>Milestones</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Toast Notifications Container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- Authentication & Cloud Sync Modal -->
|
||||
<div id="auth-modal" class="modal-overlay">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<iconify-icon icon="lucide:shield-alert" style="font-size: 1.6rem; color: var(--brand-gold);"></iconify-icon>
|
||||
<div class="modal-title">Encrypted Terminal Login</div>
|
||||
<iconify-icon icon="lucide:shield-check" class="modal-icon"></iconify-icon>
|
||||
<div>
|
||||
<h3 class="modal-title">Executive Cloud Terminal</h3>
|
||||
<p class="modal-subtitle">Save your production empire and sync across devices.</p>
|
||||
</div>
|
||||
<p style="font-size: 0.78rem; color: var(--text-secondary); line-height: 1.4;">
|
||||
No email. No phone. Only a username and password to secure your syndicate records.
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">Operator Alias</label>
|
||||
<input type="text" id="auth-username" class="input-field" placeholder="e.g. ElHeisenberg" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">Cipher Code (Password)</label>
|
||||
<input type="password" id="auth-password" class="input-field" placeholder="••••••••">
|
||||
<div class="input-field-group">
|
||||
<label>Executive Username</label>
|
||||
<input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username">
|
||||
</div>
|
||||
|
||||
<!-- HONEYPOT TRAP (Bots fill this invisible input) -->
|
||||
<div class="hp-trap" aria-hidden="true">
|
||||
<div class="input-field-group">
|
||||
<label>Security Password</label>
|
||||
<input type="password" id="auth-password" class="modal-input" placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<!-- Honeypot field for bot protection -->
|
||||
<div style="display: none;" aria-hidden="true">
|
||||
<input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<button id="btn-login-submit" class="btn-tactical btn-primary-action" style="flex: 1; padding: 10px;">Login</button>
|
||||
<button id="btn-register-submit" class="btn-tactical" style="flex: 1; padding: 10px;">Register</button>
|
||||
<div class="modal-actions-row">
|
||||
<button id="btn-login-submit" class="btn-primary-corp">Login</button>
|
||||
<button id="btn-register-submit" class="btn-corp-secondary">Register New</button>
|
||||
</div>
|
||||
<button id="btn-close-auth" class="btn-tactical" style="margin-top: 4px;">Play as Guest</button>
|
||||
|
||||
<button id="btn-close-auth" class="btn-text-close">Continue as Guest</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Police Raid Quick-Time Modal -->
|
||||
<div id="raid-modal" class="modal-overlay">
|
||||
<div class="modal-content" style="border-color: var(--neon-red);">
|
||||
<div class="modal-header">
|
||||
<iconify-icon icon="game-icons:police-badge" style="font-size: 2rem; color: var(--neon-red);"></iconify-icon>
|
||||
<div>
|
||||
<div class="modal-title" style="color: var(--neon-red);">FEDERAL INTERCEPT!</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary);">DEA raid in progress!</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: 0.8rem; line-height: 1.4;">
|
||||
Police sirens outside your warehouse! Decide quickly before your stash is seized!
|
||||
</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<button id="btn-raid-bribe" class="btn-tactical btn-sell-action" style="padding: 10px;">
|
||||
Pay Bribe ($<span id="raid-bribe-cost">500</span>)
|
||||
<span class="btn-subtext">Clears all heat immediately</span>
|
||||
</button>
|
||||
<button id="btn-raid-stash" class="btn-tactical" style="padding: 10px;">
|
||||
Flush & Hide Stash
|
||||
<span class="btn-subtext">Save 100% inventory, heat drops to 20%</span>
|
||||
</button>
|
||||
<button id="btn-raid-shootout" class="btn-tactical btn-primary-action" style="padding: 10px;">
|
||||
Run the Blockade
|
||||
<span class="btn-subtext">50% chance 2x payout, 50% chance loss</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Application Entry Point (Native ES Module) -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
|
||||
<!-- Custom Tactical Confirmation / Alert Modal (Replaces browser alert and confirm) -->
|
||||
<div id="confirm-modal" class="modal-overlay">
|
||||
<div class="modal-content" style="border-color: var(--brand-gold);">
|
||||
<div class="modal-header">
|
||||
<iconify-icon id="confirm-modal-icon" icon="lucide:alert-circle" style="font-size: 1.8rem; color: var(--brand-gold);"></iconify-icon>
|
||||
<div>
|
||||
<div id="confirm-modal-title" class="modal-title">CONFIRMATION</div>
|
||||
<div id="confirm-modal-subtitle" style="font-size: 0.72rem; color: var(--text-secondary);">Syndicate Protocol</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="confirm-modal-message" style="font-size: 0.82rem; line-height: 1.5; color: var(--text-primary); margin: 4px 0 10px;">
|
||||
Are you sure you wish to proceed?
|
||||
</p>
|
||||
<div id="confirm-modal-actions" style="display: flex; gap: 8px;">
|
||||
<button id="btn-confirm-cancel" class="btn-tactical" style="flex: 1; padding: 10px;">Cancel</button>
|
||||
<button id="btn-confirm-ok" class="btn-tactical btn-sell-action" style="flex: 1; padding: 10px;">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Settings & Notation Preferences Modal -->
|
||||
<div id="settings-modal" class="modal-overlay">
|
||||
<div class="modal-content" style="border-color: var(--neon-cyan);">
|
||||
<div class="modal-header">
|
||||
<iconify-icon icon="lucide:sliders" style="font-size: 1.8rem; color: var(--neon-cyan);"></iconify-icon>
|
||||
<div>
|
||||
<div class="modal-title">TERMINAL SETTINGS</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-secondary);">Number Display & Preferences</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; margin: 6px 0;">
|
||||
<div>
|
||||
<div style="font-family: var(--font-mono); font-size: 0.78rem; font-weight: bold; color: var(--text-primary); margin-bottom: 6px;">
|
||||
NUMBER NOTATION STYLE:
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<label style="display: flex; align-items: center; gap: 10px; background: #0c1218; padding: 10px; border-radius: 8px; border: 1px solid #1e2c3a; cursor: pointer;">
|
||||
<input type="radio" name="notation-choice" value="standard" style="accent-color: var(--neon-cyan);">
|
||||
<div>
|
||||
<div style="font-weight: bold; font-size: 0.82rem; color: #fff;">Idle Game Progression (Recommended)</div>
|
||||
<div style="font-size: 0.7rem; color: var(--text-secondary); font-family: var(--font-mono);">
|
||||
e.g. 1.25M, 45.50B, 120.00T, 10.00Qa, 5.00Qi...
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 10px; background: #0c1218; padding: 10px; border-radius: 8px; border: 1px solid #1e2c3a; cursor: pointer;">
|
||||
<input type="radio" name="notation-choice" value="scientific" style="accent-color: var(--neon-cyan);">
|
||||
<div>
|
||||
<div style="font-weight: bold; font-size: 0.82rem; color: #fff;">Scientific Notation</div>
|
||||
<div style="font-size: 0.7rem; color: var(--text-secondary); font-family: var(--font-mono);">
|
||||
e.g. 1.25e6, 4.55e10, 1.20e14, 5.00e18...
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #090e13; border: 1px solid #182533; border-radius: 8px; padding: 10px; font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary);">
|
||||
<span>Precision: </span><span style="color: var(--neon-cyan);">2 Decimal Places</span> (Fixed)<br>
|
||||
<span>Preview: </span><span id="settings-preview-val" style="color: #4ade80;">$1,234,567.89 ➔ $1.23M</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-close-settings" class="btn-tactical btn-primary-action" style="padding: 10px;">Save & Return</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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