feat: initial commit for El Puro idle game with Docker stack, honeypot auth, and mobile OS UI

This commit is contained in:
Christian 2026-10-15 15:26:08 +02:00
commit a7dd531403
15 changed files with 2268 additions and 0 deletions

9
.env.example Normal file
View file

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

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Environment files
.env
# Database & Persistent storage
data/
*.db
*.sqlite
*.sqlite3
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
ENV/
# OS / Editor files
.DS_Store
Thumbs.db
.vscode/
.idea/

20
backend/Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM python:3.14-slim
WORKDIR /app
# Install system dependencies if required
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATA_DIR=/app/data
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

46
backend/auth.py Normal file
View file

@ -0,0 +1,46 @@
import os
import bcrypt
from datetime import datetime, timedelta, timezone
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")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 365
security = HTTPBearer()
def hash_password(password: str) -> str:
# Truncate password to 72 bytes as per bcrypt spec
pwd_bytes = password.encode('utf-8')[:72]
salt = bcrypt.gensalt()
return bcrypt.hashpw(pwd_bytes, salt).decode('utf-8')
def verify_password(plain_password: str, hashed_password: str) -> bool:
pwd_bytes = plain_password.encode('utf-8')[:72]
hashed_bytes = hashed_password.encode('utf-8')
return bcrypt.checkpw(pwd_bytes, hashed_bytes)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Security(security)) -> int:
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
)
return int(user_id)
except (JWTError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)

33
backend/database.py Normal file
View file

@ -0,0 +1,33 @@
import sqlite3
import os
DB_DIR = os.getenv("DATA_DIR", "/app/data")
DB_PATH = os.path.join(DB_DIR, "elpuro.db")
def get_db():
os.makedirs(DB_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
os.makedirs(DB_DIR, exist_ok=True)
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS saves (
user_id INTEGER PRIMARY KEY,
save_data TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
""")
conn.commit()

113
backend/main.py Normal file
View file

@ -0,0 +1,113 @@
import json
import sqlite3
from fastapi import FastAPI, HTTPException, status, Depends
from fastapi.middleware.cors import CORSMiddleware
from database import init_db, get_db
from models import RegisterRequest, LoginRequest, AuthResponse, SaveGameRequest
from auth import hash_password, verify_password, create_access_token, get_current_user_id
app = FastAPI(title="El Puro API", version="1.0.0")
# Enable CORS for local and proxy flexibility
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def startup_event():
init_db()
@app.get("/api/health")
def health_check():
return {"status": "ok", "app": "El Puro", "version": "1.0.0"}
@app.post("/api/auth/register", response_model=AuthResponse)
def register(req: RegisterRequest):
# HONEYPOT TRAP: If website_hp is populated, a bot filled it!
if req.website_hp and req.website_hp.strip():
# Reject silently with generic bad request
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Registration verification failed."
)
clean_username = req.username.strip()
if not clean_username:
raise HTTPException(status_code=400, detail="Username cannot be blank.")
hashed = hash_password(req.password)
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
(clean_username, hashed)
)
user_id = cursor.lastrowid
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken."
)
token = create_access_token({"sub": str(user_id), "username": clean_username})
return AuthResponse(token=token, username=clean_username)
@app.post("/api/auth/login", response_model=AuthResponse)
def login(req: LoginRequest):
clean_username = req.username.strip()
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, username, password_hash FROM users WHERE username = ?",
(clean_username,)
)
row = cursor.fetchone()
if not row or not verify_password(req.password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password."
)
user_id = row["id"]
token = create_access_token({"sub": str(user_id), "username": clean_username})
return AuthResponse(token=token, username=clean_username)
@app.get("/api/game/state")
def get_game_state(user_id: int = Depends(get_current_user_id)):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT save_data, updated_at FROM saves WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
if not row:
return {"has_save": False, "save_data": None}
try:
data = json.loads(row["save_data"])
return {"has_save": True, "save_data": data, "updated_at": row["updated_at"]}
except Exception:
return {"has_save": False, "save_data": None}
@app.post("/api/game/save")
def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_user_id)):
json_data = json.dumps(req.save_data)
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO saves (user_id, save_data, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
save_data = excluded.save_data,
updated_at = CURRENT_TIMESTAMP
""", (user_id, json_data))
conn.commit()
return {"status": "saved"}

19
backend/models.py Normal file
View file

@ -0,0 +1,19 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=32)
password: str = Field(..., min_length=4, max_length=128)
# Honeypot field: real users leave this empty. Bots populate it.
website_hp: Optional[str] = None
class LoginRequest(BaseModel):
username: str
password: str
class AuthResponse(BaseModel):
token: str
username: str
class SaveGameRequest(BaseModel):
save_data: Dict[str, Any]

7
backend/requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.9.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.1
python-multipart>=0.0.12

32
docker-compose.yml Normal file
View file

@ -0,0 +1,32 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
- SECRET_KEY=${SECRET_KEY:-el_puro_secret_key_change_in_production_jwt_99_1_pure}
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
networks:
- elpuro-net
frontend:
image: nginx:alpine
restart: unless-stopped
ports:
- "${HOST_PORT:-8080}:80"
environment:
- DOMAIN_NAME=${DOMAIN_NAME:-localhost}
volumes:
- ./frontend:/usr/share/nginx/html:ro
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
depends_on:
- backend
networks:
- elpuro-net
networks:
elpuro-net:
driver: bridge

541
frontend/css/style.css Normal file
View file

@ -0,0 +1,541 @@
:root {
--bg-main: #07090b;
--bg-card: #101418;
--bg-card-sub: #171d23;
--bg-header: #0b0e12;
--border-color: #242d36;
--border-accent: #384654;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
--brand-gold: #eab308;
--neon-green: #22c55e;
--neon-cyan: #06b6d4;
--neon-purple: #a855f7;
--neon-red: #ef4444;
--font-mono: 'JetBrains Mono', 'Share Tech Mono', monospace;
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
body {
background-color: var(--bg-main);
color: var(--text-primary);
font-family: var(--font-ui);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
/* Burner Phone Container (Mobile viewport lock) */
#burner-device {
width: 100%;
max-width: 480px;
height: 100vh;
max-height: 940px;
background: var(--bg-main);
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--border-color);
box-shadow: 0 0 40px rgba(0,0,0,0.85);
overflow: hidden;
}
@media (min-width: 520px) {
#burner-device {
border-radius: 28px;
margin: 20px 0;
border: 2px solid #2a3440;
}
}
/* System Status Bar (Top) */
#system-bar {
background: var(--bg-header);
border-bottom: 1px solid var(--border-color);
padding: 6px 14px;
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
letter-spacing: 0.5px;
z-index: 20;
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 8px;
}
.secure-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--neon-green);
font-weight: bold;
}
.secure-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--neon-green);
box-shadow: 0 0 6px var(--neon-green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.9); }
}
/* Hero Financial HUD */
#hud-summary {
background: linear-gradient(180deg, #12171e 0%, #0d1117 100%);
border-bottom: 1px solid var(--border-color);
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: flex-end;
position: relative;
}
.hud-title {
font-family: var(--font-mono);
font-size: 0.7rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 1px;
}
.hud-cash-val {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 800;
color: var(--brand-gold);
text-shadow: 0 0 10px rgba(234, 179, 8, 0.2);
line-height: 1.1;
}
.hud-rate-val {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--neon-green);
margin-top: 2px;
}
.hud-bonds {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.bonds-val {
font-family: var(--font-mono);
font-size: 1.1rem;
font-weight: bold;
color: var(--neon-cyan);
display: flex;
align-items: center;
gap: 4px;
}
/* Police Heat Bar */
#heat-container {
background: #090c0f;
padding: 4px 16px 8px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 3px;
}
.heat-label-row {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--text-secondary);
}
.heat-bar-track {
width: 100%;
height: 6px;
background: #1c232b;
border-radius: 3px;
overflow: hidden;
}
#heat-bar-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #22c55e 0%, #eab308 60%, #ef4444 100%);
transition: width 0.3s ease;
}
/* Main Viewport Content */
#main-viewport {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 90px; /* Space for bottom nav */
}
/* Bottom Tab Navigation Bar */
#bottom-nav {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 70px;
background: rgba(11, 14, 18, 0.95);
backdrop-filter: blur(8px);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 30;
padding-bottom: 6px;
}
.nav-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
font-size: 0.7rem;
font-family: var(--font-mono);
padding: 8px 0;
transition: color 0.15s, transform 0.1s;
}
.nav-tab:active {
transform: scale(0.94);
}
.nav-tab.active {
color: var(--brand-gold);
}
.nav-tab.active iconify-icon {
filter: drop-shadow(0 0 6px var(--brand-gold));
}
.nav-tab iconify-icon {
font-size: 1.45rem;
}
/* Card Components */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px 14px;
position: relative;
overflow: hidden;
transition: border-color 0.2s;
}
.card:hover {
border-color: var(--border-accent);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.card-title-group {
display: flex;
align-items: center;
gap: 8px;
}
.drug-icon-box {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--bg-card-sub);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
}
.drug-icon-box iconify-icon {
font-size: 1.5rem;
}
.card-title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.3px;
}
.card-stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: var(--bg-card-sub);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 10px;
font-family: var(--font-mono);
font-size: 0.78rem;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.65rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.stat-value {
font-weight: 700;
color: var(--text-primary);
}
/* Buttons */
.btn-grid-actions {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
margin-bottom: 8px;
}
.btn-grid-auto {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.btn-tactical {
background: #192028;
color: var(--text-primary);
border: 1px solid #2d3844;
border-radius: 8px;
padding: 8px 4px;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
transition: background 0.15s, border-color 0.15s, transform 0.05s;
text-align: center;
}
.btn-tactical:active:not(:disabled) {
transform: scale(0.96);
background: #25313d;
}
.btn-tactical:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(0.7);
}
.btn-subtext {
font-size: 0.62rem;
color: var(--text-secondary);
}
.btn-primary-action {
background: #1b2e21;
border-color: #2e593b;
color: #4ade80;
}
.btn-primary-action:hover:not(:disabled) {
background: #24422e;
}
.btn-sell-action {
background: #2d2613;
border-color: #63501a;
color: #facc15;
}
/* Locked Card State */
.card.locked {
background: #0d1014;
border-style: dashed;
}
.unlock-overlay-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px 0;
}
.btn-unlock {
background: #201a08;
border: 1px solid var(--brand-gold);
color: var(--brand-gold);
padding: 10px 18px;
border-radius: 8px;
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 0 14px rgba(234, 179, 8, 0.15);
}
/* Modal Windows (Auth & Raids) */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(6px);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: #10151c;
border: 2px solid var(--border-accent);
border-radius: 16px;
width: 100%;
max-width: 380px;
padding: 22px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 0 35px rgba(0,0,0,0.9);
}
.modal-header {
display: flex;
align-items: center;
gap: 10px;
}
.modal-title {
font-family: var(--font-mono);
font-size: 1.1rem;
font-weight: bold;
color: var(--text-primary);
}
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.input-label {
font-size: 0.7rem;
color: var(--text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
}
.input-field {
background: #090c0f;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
color: #fff;
font-family: var(--font-mono);
font-size: 0.9rem;
outline: none;
}
.input-field:focus {
border-color: var(--brand-gold);
}
/* Honeypot field: invisible to human users, accessible to bots */
.hp-trap {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
position: absolute !important;
left: -9999px !important;
}
/* Toast Messages */
#toast-container {
position: absolute;
top: 60px;
left: 14px;
right: 14px;
z-index: 80;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
background: rgba(16, 21, 28, 0.95);
border: 1px solid var(--border-accent);
border-left: 4px solid var(--neon-green);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.8rem;
font-family: var(--font-mono);
color: var(--text-primary);
box-shadow: 0 4px 14px rgba(0,0,0,0.5);
animation: slideDown 0.3s ease;
}
.toast.danger {
border-left-color: var(--neon-red);
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}

159
frontend/index.html Normal file
View file

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>EL PURO - SilkNet OS</title>
<!-- 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) -->
<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="burner-device">
<!-- Top Status Bar -->
<div id="system-bar">
<div class="status-left">
<span class="secure-indicator">
<span class="secure-dot"></span> SECURE
</span>
<span id="user-badge" style="cursor: pointer;">[ANON]</span>
</div>
<div class="status-right">
<span id="system-clock">15:00</span>
<iconify-icon icon="lucide:battery-medium"></iconify-icon>
</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>
</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>
<!-- Toast Notifications Container -->
<div id="toast-container"></div>
<!-- Main Viewport with Dynamic Views -->
<main id="main-viewport">
<!-- Views will be dynamically injected / rendered here -->
</main>
<!-- Bottom Navigation Bar -->
<nav id="bottom-nav">
<button class="nav-tab active" data-tab="lab">
<iconify-icon icon="game-icons:flasks"></iconify-icon>
<span>Lab</span>
</button>
<button class="nav-tab" data-tab="streets">
<iconify-icon icon="game-icons:cash"></iconify-icon>
<span>Streets</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>
</nav>
</div>
<!-- Authentication Modal -->
<div id="auth-modal" class="modal-overlay">
<div class="modal-content">
<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>
</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>
<!-- HONEYPOT TRAP (Bots fill this invisible input) -->
<div class="hp-trap" aria-hidden="true">
<input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off">
</div>
<div style="display: flex; gap: 8px; margin-top: 8px;">
<button id="btn-login-submit" class="btn-tactical btn-primary-action" style="flex: 1; padding: 10px;">Login</button>
<button id="btn-register-submit" class="btn-tactical" style="flex: 1; padding: 10px;">Register</button>
</div>
<button id="btn-close-auth" class="btn-tactical" style="margin-top: 4px;">Play as Guest</button>
</div>
</div>
<!-- Police Raid Quick-Time Modal -->
<div id="raid-modal" class="modal-overlay">
<div class="modal-content" style="border-color: var(--neon-red);">
<div class="modal-header">
<iconify-icon icon="game-icons:police-badge" style="font-size: 2rem; color: var(--neon-red);"></iconify-icon>
<div>
<div class="modal-title" style="color: var(--neon-red);">FEDERAL INTERCEPT!</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);">DEA raid in progress!</div>
</div>
</div>
<p style="font-size: 0.8rem; line-height: 1.4;">
Police sirens outside your warehouse! Decide quickly before your stash is seized!
</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button id="btn-raid-bribe" class="btn-tactical btn-sell-action" style="padding: 10px;">
Pay Bribe ($<span id="raid-bribe-cost">500</span>)
<span class="btn-subtext">Clears all heat immediately</span>
</button>
<button id="btn-raid-stash" class="btn-tactical" style="padding: 10px;">
Flush & Hide Stash
<span class="btn-subtext">Save 100% inventory, heat drops to 20%</span>
</button>
<button id="btn-raid-shootout" class="btn-tactical btn-primary-action" style="padding: 10px;">
Run the Blockade
<span class="btn-subtext">50% chance 2x payout, 50% chance loss</span>
</button>
</div>
</div>
</div>
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/game.js"></script>
</body>
</html>

95
frontend/js/api.js Normal file
View file

@ -0,0 +1,95 @@
// 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 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);
}
}
};

322
frontend/js/config.js Normal file
View file

@ -0,0 +1,322 @@
// 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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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
}
];
// Achievements with permanent passive bonuses
const ACHIEVEMENTS = [
{ id: 'first_hit', name: 'First Batch', desc: 'Sell your first gram of product.', unlocked: false, reward: '+5% global sell value' },
{ id: 'grower_op', name: 'Green Thumb', desc: 'Hire 10 Weed Growers.', unlocked: false, reward: '+10% weed production' },
{ id: 'blue_sky', name: 'Say My Name', desc: 'Unlock Methamphetamine.', unlocked: false, reward: '-10% meth raw cost' },
{ id: 'cartel_tier', name: 'El Patrón', desc: 'Unlock Cocaine.', unlocked: false, reward: '+15% all sales' },
{ id: 'millionaire', name: 'Seven Figures', desc: 'Amass $1,000,000 cash.', unlocked: false, reward: '+20% worker speed' },
{ id: 'laundered', name: 'Clean Hands', desc: 'Launder your first $1,000,000 offshore.', unlocked: false, reward: '+5% clean bond multiplier' }
];

822
frontend/js/game.js Normal file
View file

@ -0,0 +1,822 @@
// EL PURO - SilkNet OS Core Engine
const INITIAL_GAME_STATE = {
cash: 25.00,
cleanBonds: 0,
heat: 0,
totalEarned: 0,
activeTab: 'lab',
drugs: {},
skills: {},
achievements: {},
synthesis: {},
lastTick: Date.now()
};
// Initialize drugs state
DRUG_CATALOG.forEach(d => {
INITIAL_GAME_STATE.drugs[d.id] = {
unlocked: d.unlockedDefault,
raw: 0,
product: 0,
workers: 0,
dealers: 0
};
});
SKILL_TREE.forEach(s => {
INITIAL_GAME_STATE.skills[s.id] = false;
});
ACHIEVEMENTS.forEach(a => {
INITIAL_GAME_STATE.achievements[a.id] = false;
});
SYNTHESIS_RECIPES.forEach(r => {
INITIAL_GAME_STATE.synthesis[r.id] = 0;
});
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
let cashRateTracker = 0;
let lastSaveTime = Date.now();
let lastRaidCheck = Date.now();
// --- DOM CACHE ---
const dom = {
cash: document.getElementById('hud-cash'),
rate: document.getElementById('hud-rate'),
bonds: document.getElementById('hud-bonds'),
multiplier: document.getElementById('hud-multiplier'),
heatPct: document.getElementById('heat-pct'),
heatBar: document.getElementById('heat-bar-fill'),
viewport: document.getElementById('main-viewport'),
clock: document.getElementById('system-clock'),
userBadge: document.getElementById('user-badge'),
authModal: document.getElementById('auth-modal'),
raidModal: document.getElementById('raid-modal'),
raidBribeCost: document.getElementById('raid-bribe-cost'),
toastContainer: document.getElementById('toast-container')
};
// --- MULTIPLIERS & BONUSES ---
function getProductionMultiplier() {
let m = 1.0;
// Clean bonds: +3% production each
m += G.cleanBonds * 0.03;
if (G.skills['chem_1']) m += 0.15;
if (G.achievements['millionaire']) m += 0.20;
return m;
}
function getSellPriceMultiplier() {
let m = 1.0;
// Clean bonds: +4% sell price each
m += G.cleanBonds * 0.04;
if (G.skills['king_2']) m += 0.25;
if (G.achievements['first_hit']) m += 0.05;
if (G.achievements['cartel_tier']) m += 0.15;
return m;
}
function getRawCostMultiplier() {
let m = 1.0;
if (G.skills['chem_2']) m *= 0.85;
return m;
}
function getDealerSpeedMultiplier() {
let m = 1.0;
if (G.skills['king_1']) m += 0.20;
if (G.skills['king_3']) m += 0.50;
return m;
}
// Cost scaling formulas
function getWorkerCost(drugId) {
const config = DRUG_CATALOG.find(d => d.id === drugId);
const count = G.drugs[drugId].workers;
return Math.floor(config.workerCost * Math.pow(1.15, count));
}
function getDealerCost(drugId) {
const config = DRUG_CATALOG.find(d => d.id === drugId);
const count = G.drugs[drugId].dealers;
return Math.floor(config.dealerCost * Math.pow(1.15, count));
}
function getUnlockCost(drugId) {
return DRUG_CATALOG.find(d => d.id === drugId).unlockCost;
}
// --- NOTIFICATIONS (TOASTS) ---
function showToast(msg, isDanger = false) {
const toast = document.createElement('div');
toast.className = `toast ${isDanger ? 'danger' : ''}`;
toast.textContent = msg;
dom.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 2800);
}
// --- TAB SWITCHING ---
function setTab(tabId) {
G.activeTab = tabId;
document.querySelectorAll('.nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tabId);
});
renderViewport();
}
// --- RENDER VIEWPORT ---
function renderViewport() {
if (G.activeTab === 'lab') {
renderLabView();
} else if (G.activeTab === 'streets') {
renderStreetsView();
} else if (G.activeTab === 'tree') {
renderSkillTreeView();
} else if (G.activeTab === 'offshore') {
renderOffshoreView();
}
}
// 1. LAB VIEW
function renderLabView() {
let html = `
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 4px; display: flex; justify-content: space-between;">
<span>ACTIVE LABORATORIES</span>
<span>SYNTHESIS LEVEL: ${G.cleanBonds}</span>
</div>
`;
DRUG_CATALOG.forEach(d => {
const data = G.drugs[d.id];
const rawCost = Math.floor(d.rawCost * getRawCostMultiplier());
const workerCost = getWorkerCost(d.id);
if (!data.unlocked) {
const unlockCost = getUnlockCost(d.id);
html += `
<div class="card locked">
<div class="card-header">
<div class="card-title-group">
<div class="drug-icon-box" style="border-color: ${d.color}; color: ${d.color}">
<iconify-icon icon="${d.icon}"></iconify-icon>
</div>
<div>
<div class="card-title">${d.name}</div>
<div style="font-size: 0.68rem; color: var(--text-secondary); font-family: var(--font-mono);">CLASSIFIED FORMULA</div>
</div>
</div>
</div>
<div class="unlock-overlay-box">
<button class="btn-unlock" onclick="unlockDrug('${d.id}')" ${G.cash < unlockCost ? 'disabled' : ''}>
<iconify-icon icon="lucide:lock-open"></iconify-icon>
Acquire Formula ($${unlockCost.toLocaleString()})
</button>
</div>
</div>
`;
return;
}
html += `
<div class="card" style="border-left: 4px solid ${d.color};">
<div class="card-header">
<div class="card-title-group">
<div class="drug-icon-box" style="border-color: ${d.color}; color: ${d.color}">
<iconify-icon icon="${d.icon}"></iconify-icon>
</div>
<div>
<div class="card-title">${d.name}</div>
<div style="font-size: 0.68rem; color: var(--text-secondary); font-family: var(--font-mono);">${d.workerName}s: ${data.workers}</div>
</div>
</div>
</div>
<div class="card-stats-row">
<div class="stat-item">
<span class="stat-label">${d.rawName}</span>
<span class="stat-value" id="val-raw-${d.id}">${data.raw}</span>
</div>
<div class="stat-item">
<span class="stat-label">${d.productName}</span>
<span class="stat-value" id="val-prod-${d.id}">${data.product}g</span>
</div>
</div>
<div class="btn-grid-actions">
<button class="btn-tactical" onclick="buyRaw('${d.id}')" ${G.cash < rawCost ? 'disabled' : ''}>
Buy Raw
<span class="btn-subtext">$${rawCost.toLocaleString()}</span>
</button>
<button class="btn-tactical btn-primary-action" onclick="manufacture('${d.id}')" ${data.raw < 1 ? 'disabled' : ''}>
Synthesize
<span class="btn-subtext">1 Raw -> 1g</span>
</button>
<button class="btn-tactical btn-sell-action" onclick="sellManual('${d.id}')" ${data.product < 1 ? 'disabled' : ''}>
Quick Deal
<span class="btn-subtext">+$${Math.floor(d.sellPrice * getSellPriceMultiplier()).toLocaleString()}</span>
</button>
</div>
<div class="btn-grid-auto">
<button class="btn-tactical" onclick="hireWorker('${d.id}')" ${G.cash < workerCost ? 'disabled' : ''}>
Hire ${d.workerName}
<span class="btn-subtext">$${workerCost.toLocaleString()}</span>
</button>
<button class="btn-tactical" style="opacity: 0.8; font-size: 0.65rem;" onclick="setTab('streets')">
Manage Dealers ->
</button>
</div>
</div>
`;
});
dom.viewport.innerHTML = html;
}
// 2. STREETS VIEW (Dealers & Blends)
function renderStreetsView() {
let html = `
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 4px;">
STREET DISTRIBUTION & DEALER NETWORK
</div>
`;
DRUG_CATALOG.filter(d => G.drugs[d.id].unlocked).forEach(d => {
const data = G.drugs[d.id];
const dealerCost = getDealerCost(d.id);
const unitPrice = Math.floor(d.sellPrice * getSellPriceMultiplier());
html += `
<div class="card" style="border-left: 4px solid ${d.color};">
<div class="card-header">
<div class="card-title-group">
<div class="drug-icon-box" style="border-color: ${d.color}; color: ${d.color}">
<iconify-icon icon="${d.icon}"></iconify-icon>
</div>
<div>
<div class="card-title">${d.name}</div>
<div style="font-size: 0.68rem; color: var(--text-secondary); font-family: var(--font-mono);">${d.dealerName}s: ${data.dealers}</div>
</div>
</div>
<div style="text-align: right; font-family: var(--font-mono); font-size: 0.8rem; color: var(--brand-gold);">
$${unitPrice.toLocaleString()}/g
</div>
</div>
<div class="card-stats-row">
<div class="stat-item">
<span class="stat-label">In Stash</span>
<span class="stat-value">${data.product}g</span>
</div>
<div class="stat-item">
<span class="stat-label">Deal Speed</span>
<span class="stat-value">${(data.dealers * d.sellRate * getDealerSpeedMultiplier()).toFixed(1)}/s</span>
</div>
</div>
<div class="btn-grid-auto">
<button class="btn-tactical btn-sell-action" onclick="hireDealer('${d.id}')" ${G.cash < dealerCost ? 'disabled' : ''}>
Hire ${d.dealerName}
<span class="btn-subtext">$${dealerCost.toLocaleString()}</span>
</button>
<button class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
Sell 10x Stash
<span class="btn-subtext">+$${(unitPrice * Math.min(10, data.product)).toLocaleString()}</span>
</button>
</div>
</div>
`;
});
// RECIPE SYNTHESIS SECTION
html += `
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--brand-gold); margin: 14px 0 4px; display: flex; align-items: center; gap: 6px;">
<iconify-icon icon="game-icons:cauldron"></iconify-icon>
DESIGNER COCKTAILS & BLENDS
</div>
`;
SYNTHESIS_RECIPES.forEach(r => {
const canCook = Object.entries(r.ingredients).every(([drugId, reqAmount]) => {
return G.drugs[drugId] && G.drugs[drugId].product >= reqAmount;
});
const ingrLabels = Object.entries(r.ingredients).map(([id, amt]) => {
const has = G.drugs[id] ? G.drugs[id].product : 0;
const drugDef = DRUG_CATALOG.find(d => d.id === id);
return `<span style="color: ${has >= amt ? '#22c55e' : '#ef4444'}">${amt}g ${drugDef.name} (${has}g)</span>`;
}).join(' + ');
html += `
<div class="card" style="border: 1px solid #384654;">
<div class="card-header">
<div class="card-title-group">
<div class="drug-icon-box" style="color: var(--brand-gold);">
<iconify-icon icon="${r.icon}"></iconify-icon>
</div>
<div>
<div class="card-title">${r.name}</div>
<div style="font-size: 0.68rem; color: var(--text-secondary);">${r.description}</div>
</div>
</div>
</div>
<div style="font-family: var(--font-mono); font-size: 0.72rem; background: #121820; padding: 6px 10px; border-radius: 6px; margin-bottom: 8px;">
Recipe: ${ingrLabels}
</div>
<button class="btn-tactical btn-sell-action" onclick="blendRecipe('${r.id}')" ${!canCook ? 'disabled' : ''}>
Cook & Distribute Blend
<span class="btn-subtext">Instant Payout: +$${r.sellPrice.toLocaleString()}</span>
</button>
</div>
`;
});
dom.viewport.innerHTML = html;
}
// 3. SKILL TREE (SPIDERWEB)
function renderSkillTreeView() {
let html = `
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 6px;">
UNDERWORLD TALENT SPIDERWEB
</div>
`;
const branches = [
{ id: 'chemist', name: 'Path of the Chemist', icon: 'game-icons:chemical-drop', color: '#00e5ff' },
{ id: 'kingpin', name: 'Path of the Kingpin', icon: 'game-icons:crown', color: '#eab308' },
{ id: 'smuggler', name: 'Path of the Smuggler', icon: 'game-icons:stealth-bomber', color: '#a855f7' }
];
branches.forEach(b => {
html += `
<div style="font-family: var(--font-mono); font-size: 0.75rem; color: ${b.color}; margin: 8px 0 4px; display: flex; align-items: center; gap: 6px;">
<iconify-icon icon="${b.icon}"></iconify-icon>
${b.name.toUpperCase()}
</div>
`;
const skillsInBranch = SKILL_TREE.filter(s => s.branch === b.id);
skillsInBranch.forEach(s => {
const isUnlocked = G.skills[s.id];
const prereqsMet = s.prereqs.every(req => G.skills[req]);
html += `
<div class="card ${isUnlocked ? '' : prereqsMet ? '' : 'locked'}" style="margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px;">
<div>
<div style="font-weight: bold; font-size: 0.85rem; color: ${isUnlocked ? b.color : '#fff'};">
${s.name} ${isUnlocked ? '✓' : ''}
</div>
<div style="font-size: 0.72rem; color: var(--text-secondary);">${s.desc}</div>
</div>
<div style="font-family: var(--font-mono); font-size: 0.8rem; color: var(--brand-gold);">
${isUnlocked ? '<span style="color:#22c55e;">ACQUIRED</span>' : '$' + s.cost.toLocaleString()}
</div>
</div>
${!isUnlocked ? `
<button class="btn-tactical" onclick="buySkill('${s.id}')" ${G.cash < s.cost || !prereqsMet ? 'disabled' : ''}>
${!prereqsMet ? 'Requires Prior Link' : 'Unlock Node ($' + s.cost.toLocaleString() + ')'}
</button>
` : ''}
</div>
`;
});
});
dom.viewport.innerHTML = html;
}
// 4. OFFSHORE (PRESTIGE & MONEY LAUNDERING)
function renderOffshoreView() {
const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000));
const nextBonds = Math.max(0, bondsToEarn - G.cleanBonds);
let html = `
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 6px;">
SWISS ACCOUNTS & MONEY LAUNDERING
</div>
<div class="card" style="border: 2px solid var(--neon-cyan); text-align: center; padding: 20px 14px;">
<iconify-icon icon="game-icons:tied-scroll" style="font-size: 3rem; color: var(--neon-cyan);"></iconify-icon>
<div style="font-size: 1.2rem; font-weight: bold; margin-top: 8px;">Launder Dirty Syndicate Cash</div>
<p style="font-size: 0.78rem; color: var(--text-secondary); margin: 8px 0 14px; line-height: 1.4;">
Reset dirty cash, lab equipment, and street dealers in exchange for permanent <b>Clean Offshore Bonds</b>.
Each Bond permanently boosts production (+3%) and sale values (+4%).
</p>
<div style="background: #090e13; border: 1px solid #1f2a36; border-radius: 8px; padding: 12px; margin-bottom: 14px; font-family: var(--font-mono);">
<div style="font-size: 0.7rem; color: var(--text-secondary);">CLEAN BONDS UPON LAUNDERING:</div>
<div style="font-size: 1.8rem; font-weight: 800; color: var(--neon-cyan);">+${nextBonds} Bonds</div>
<div style="font-size: 0.68rem; color: var(--text-muted);">Career Volume: $${Math.floor(G.totalEarned).toLocaleString()}</div>
</div>
<button class="btn-tactical btn-sell-action" style="padding: 12px; font-size: 0.9rem;" onclick="launderMoney()" ${nextBonds < 1 ? 'disabled' : ''}>
${nextBonds >= 1 ? 'Launder Cash & Reset Syndicate' : 'Need More Volume to Launder'}
</button>
</div>
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin: 16px 0 6px;">
GLOBAL SYNDICATE ACHIEVEMENTS
</div>
`;
ACHIEVEMENTS.forEach(a => {
const has = G.achievements[a.id];
html += `
<div class="card" style="display: flex; justify-content: space-between; align-items: center; opacity: ${has ? '1' : '0.5'};">
<div>
<div style="font-weight: bold; font-size: 0.8rem; color: ${has ? '#eab308' : '#8b949e'};">
${has ? '🏆 ' : '🔒 '}${a.name}
</div>
<div style="font-size: 0.68rem; color: var(--text-secondary);">${a.desc}</div>
</div>
<div style="font-family: var(--font-mono); font-size: 0.68rem; color: var(--neon-green); text-align: right;">
${a.reward}
</div>
</div>
`;
});
dom.viewport.innerHTML = html;
}
// --- CORE ACTIONS ---
function buyRaw(drugId) {
const d = DRUG_CATALOG.find(x => x.id === drugId);
const cost = Math.floor(d.rawCost * getRawCostMultiplier());
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].raw += 1;
updateHud();
renderViewport();
}
}
function manufacture(drugId) {
const data = G.drugs[drugId];
if (data.raw >= 1) {
data.raw -= 1;
let yieldCount = 1;
// 99.1% crystallization perk
if (G.skills['chem_3'] && Math.random() < 0.10) {
yieldCount = 3;
showToast('99.1% Purity Proc! 3x Yield!', false);
}
data.product += yieldCount;
addHeat(0.5);
updateHud();
renderViewport();
}
}
function sellManual(drugId, amount = 1) {
const d = DRUG_CATALOG.find(x => x.id === drugId);
const data = G.drugs[drugId];
const actual = Math.min(amount, data.product);
if (actual > 0) {
data.product -= actual;
const profit = actual * Math.floor(d.sellPrice * getSellPriceMultiplier());
G.cash += profit;
G.totalEarned += profit;
addHeat(actual * 0.8);
checkAchievements();
updateHud();
renderViewport();
}
}
function hireWorker(drugId) {
const cost = getWorkerCost(drugId);
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].workers += 1;
checkAchievements();
updateHud();
renderViewport();
}
}
function hireDealer(drugId) {
const cost = getDealerCost(drugId);
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].dealers += 1;
updateHud();
renderViewport();
}
}
function unlockDrug(drugId) {
const cost = getUnlockCost(drugId);
if (G.cash >= cost && !G.drugs[drugId].unlocked) {
G.cash -= cost;
G.drugs[drugId].unlocked = true;
showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`);
checkAchievements();
updateHud();
renderViewport();
}
}
function blendRecipe(recipeId) {
const r = SYNTHESIS_RECIPES.find(x => x.id === recipeId);
const canCook = Object.entries(r.ingredients).every(([drugId, amt]) => G.drugs[drugId].product >= amt);
if (canCook) {
Object.entries(r.ingredients).forEach(([drugId, amt]) => {
G.drugs[drugId].product -= amt;
});
G.cash += r.sellPrice;
G.totalEarned += r.sellPrice;
addHeat(5);
showToast(`Synthesized & Sold ${r.name} for +$${r.sellPrice.toLocaleString()}!`);
updateHud();
renderViewport();
}
}
function buySkill(skillId) {
const s = SKILL_TREE.find(x => x.id === skillId);
if (G.cash >= s.cost && !G.skills[skillId]) {
G.cash -= s.cost;
G.skills[skillId] = true;
showToast(`Skill Activated: ${s.name}`);
updateHud();
renderViewport();
}
}
function launderMoney() {
const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000));
const gain = Math.max(0, bondsToEarn - G.cleanBonds);
if (gain < 1) return;
if (confirm(`Launder all assets for +${gain} Clean Offshore Bonds? Your syndicate operations will reset.`)) {
G.cleanBonds += gain;
G.cash = 50.00;
G.heat = 0;
DRUG_CATALOG.forEach(d => {
G.drugs[d.id] = {
unlocked: d.unlockedDefault,
raw: 0,
product: 0,
workers: 0,
dealers: 0
};
});
checkAchievements();
showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`);
updateHud();
renderViewport();
}
}
// --- POLICE HEAT & RAIDS ---
function addHeat(amount) {
let riseMod = G.skills['smug_1'] ? 0.65 : 1.0;
G.heat = Math.min(100, G.heat + (amount * riseMod));
}
function checkRaidEvent() {
if (G.heat >= 85 && Math.random() < 0.25) {
triggerRaidModal();
}
}
function triggerRaidModal() {
const bribeCost = Math.max(100, Math.floor(G.cash * 0.15 * (G.skills['smug_2'] ? 0.5 : 1.0)));
dom.raidBribeCost.textContent = bribeCost.toLocaleString();
dom.raidModal.classList.add('active');
document.getElementById('btn-raid-bribe').onclick = () => {
if (G.cash >= bribeCost) {
G.cash -= bribeCost;
G.heat = 0;
dom.raidModal.classList.remove('active');
showToast('Sheriff bribed. Heat cleared.');
updateHud();
} else {
showToast('Insufficient cash for bribe!', true);
}
};
document.getElementById('btn-raid-stash').onclick = () => {
G.heat = 20;
dom.raidModal.classList.remove('active');
showToast('Stash flushed & secured. Heat diminished.');
updateHud();
};
document.getElementById('btn-raid-shootout').onclick = () => {
dom.raidModal.classList.remove('active');
if (Math.random() < 0.5) {
const reward = Math.floor(G.cash * 0.5);
G.cash += reward;
G.heat = 0;
showToast(`Broke through blockade! Seized +$${reward.toLocaleString()}!`);
} else {
// Seize 30% of inventory
DRUG_CATALOG.forEach(d => {
G.drugs[d.id].product = Math.floor(G.drugs[d.id].product * 0.7);
});
G.heat = 30;
showToast('Agents seized 30% of stored product!', true);
}
updateHud();
renderViewport();
};
}
// --- ACHIEVEMENTS CHECK ---
function checkAchievements() {
if (G.totalEarned > 0 && !G.achievements['first_hit']) {
G.achievements['first_hit'] = true;
showToast('Achievement: First Batch!');
}
if (G.drugs['weed'].workers >= 10 && !G.achievements['grower_op']) {
G.achievements['grower_op'] = true;
showToast('Achievement: Green Thumb!');
}
if (G.drugs['meth'].unlocked && !G.achievements['blue_sky']) {
G.achievements['blue_sky'] = true;
showToast('Achievement: Say My Name!');
}
if (G.drugs['cocaine'].unlocked && !G.achievements['cartel_tier']) {
G.achievements['cartel_tier'] = true;
showToast('Achievement: El Patrón!');
}
if (G.cash >= 1000000 && !G.achievements['millionaire']) {
G.achievements['millionaire'] = true;
showToast('Achievement: Seven Figures!');
}
if (G.cleanBonds >= 1 && !G.achievements['laundered']) {
G.achievements['laundered'] = true;
showToast('Achievement: Clean Hands!');
}
}
// --- TICK & GAME LOOP ---
function tick(deltaSec) {
let earnedThisTick = 0;
const prodMulti = getProductionMultiplier();
const sellMulti = getSellPriceMultiplier();
const dealerSpeed = getDealerSpeedMultiplier();
DRUG_CATALOG.forEach(d => {
const data = G.drugs[d.id];
if (!data.unlocked) return;
// Auto Production
if (data.workers > 0 && data.raw > 0) {
const prodPerSec = data.workers * d.produceRate * prodMulti;
const toMake = Math.min(data.raw, prodPerSec * deltaSec);
data.raw -= toMake;
data.product += toMake;
}
// Auto Sales
if (data.dealers > 0 && data.product > 0) {
const sellPerSec = data.dealers * d.sellRate * dealerSpeed;
const toSell = Math.min(data.product, sellPerSec * deltaSec);
data.product -= toSell;
const revenue = toSell * Math.floor(d.sellPrice * sellMulti);
G.cash += revenue;
G.totalEarned += revenue;
earnedThisTick += revenue;
}
});
// Heat decay
if (G.heat > 0) {
G.heat = Math.max(0, G.heat - (0.5 * deltaSec));
}
if (deltaSec > 0) {
cashRateTracker = cashRateTracker * 0.85 + (earnedThisTick / deltaSec) * 0.15;
}
// Periodic raid check
if (Date.now() - lastRaidCheck > 12000) {
lastRaidCheck = Date.now();
checkRaidEvent();
}
updateHud();
}
function updateHud() {
dom.cash.textContent = G.cash.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
dom.rate.textContent = cashRateTracker.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
dom.bonds.textContent = G.cleanBonds;
dom.multiplier.textContent = Math.round((G.cleanBonds * 4));
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
}
// Game loop with requestAnimationFrame
let lastFrameTime = performance.now();
function gameLoop(now) {
const delta = (now - lastFrameTime) / 1000;
lastFrameTime = now;
if (delta > 0 && delta < 5) {
tick(delta);
}
requestAnimationFrame(gameLoop);
}
// Clock updates
setInterval(() => {
const d = new Date();
dom.clock.textContent = d.toTimeString().substring(0, 5);
}, 1000);
// Auto-save every 5 seconds (Local & Cloud)
setInterval(async () => {
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
if (API.getToken()) {
await API.saveRemote(G);
}
}, 5000);
// --- INITIALIZATION & EVENTS ---
document.addEventListener('DOMContentLoaded', async () => {
// Check auth status
const username = API.getUsername();
if (username) {
dom.userBadge.textContent = `[${username}]`;
}
// Load Save: Try cloud first if logged in, fallback to local
let loaded = false;
if (API.getToken()) {
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote };
loaded = true;
}
}
if (!loaded) {
const local = localStorage.getItem('el_puro_local_save');
if (local) {
try {
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local) };
} catch (e) {
console.warn('Corrupt local save, initializing new game');
}
}
}
// Setup navigation tabs
document.querySelectorAll('.nav-tab').forEach(b => {
b.addEventListener('click', () => setTab(b.dataset.tab));
});
// Auth Modal handling
dom.userBadge.addEventListener('click', () => dom.authModal.classList.add('active'));
document.getElementById('btn-close-auth').addEventListener('click', () => dom.authModal.classList.remove('active'));
document.getElementById('btn-login-submit').addEventListener('click', async () => {
const u = document.getElementById('auth-username').value.trim();
const p = document.getElementById('auth-password').value;
if (!u || !p) return showToast('Enter username and password', true);
try {
const res = await API.login(u, p);
dom.userBadge.textContent = `[${res.username}]`;
dom.authModal.classList.remove('active');
showToast(`Connected: Welcome ${res.username}`);
// Sync game save from cloud
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote };
renderViewport();
}
} catch (e) {
showToast(e.message, true);
}
});
document.getElementById('btn-register-submit').addEventListener('click', async () => {
const u = document.getElementById('auth-username').value.trim();
const p = document.getElementById('auth-password').value;
const hp = document.getElementById('auth-website-hp').value; // Honeypot field!
if (!u || !p) return showToast('Enter username and password', true);
try {
const res = await API.register(u, p, hp);
dom.userBadge.textContent = `[${res.username}]`;
dom.authModal.classList.remove('active');
showToast(`Syndicate registered: ${res.username}`);
await API.saveRemote(G);
} catch (e) {
showToast(e.message, true);
}
});
renderViewport();
updateHud();
requestAnimationFrame(gameLoop);
});

View file

@ -0,0 +1,27 @@
server {
listen 80;
server_name ${DOMAIN_NAME};
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Static frontend assets
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to FastAPI backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}