From a7dd5314030baf85361eb838bac3b745ccfb2d4b Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 15 Oct 2026 15:26:08 +0200 Subject: [PATCH] feat: initial commit for El Puro idle game with Docker stack, honeypot auth, and mobile OS UI --- .env.example | 9 + .gitignore | 23 + backend/Dockerfile | 20 + backend/auth.py | 46 ++ backend/database.py | 33 ++ backend/main.py | 113 +++++ backend/models.py | 19 + backend/requirements.txt | 7 + docker-compose.yml | 32 ++ frontend/css/style.css | 541 ++++++++++++++++++++++++ frontend/index.html | 159 +++++++ frontend/js/api.js | 95 +++++ frontend/js/config.js | 322 ++++++++++++++ frontend/js/game.js | 822 ++++++++++++++++++++++++++++++++++++ nginx/default.conf.template | 27 ++ 15 files changed, 2268 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/css/style.css create mode 100644 frontend/index.html create mode 100644 frontend/js/api.js create mode 100644 frontend/js/config.js create mode 100644 frontend/js/game.js create mode 100644 nginx/default.conf.template diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f26bac --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..986f6e6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..893e421 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..90b762c --- /dev/null +++ b/backend/auth.py @@ -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", + ) diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..bb08230 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..62a4e07 --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..105c45b --- /dev/null +++ b/backend/models.py @@ -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] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a4b6302 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bba8aa7 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..b75d4b6 --- /dev/null +++ b/frontend/css/style.css @@ -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); } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1e2a1cb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,159 @@ + + + + + + EL PURO - SilkNet OS + + + + + + + + + + +
+ + +
+
+ + SECURE + + [ANON] +
+
+ 15:00 + +
+
+ + +
+
+
Dirty Cash
+
$0.00
+
+$0.00/s
+
+
+
Clean Bonds
+
+ + 0 +
+
+0% Boost
+
+
+ + +
+
+ POLICE HEAT + 0% +
+
+
+
+
+ + +
+ + +
+ +
+ + + + +
+ + + + + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..3f1a342 --- /dev/null +++ b/frontend/js/api.js @@ -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); + } + } +}; diff --git a/frontend/js/config.js b/frontend/js/config.js new file mode 100644 index 0000000..8ab7eaa --- /dev/null +++ b/frontend/js/config.js @@ -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' } +]; diff --git a/frontend/js/game.js b/frontend/js/game.js new file mode 100644 index 0000000..ee73186 --- /dev/null +++ b/frontend/js/game.js @@ -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 = ` +
+ ACTIVE LABORATORIES + SYNTHESIS LEVEL: ${G.cleanBonds} +
+ `; + + 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 += ` +
+
+
+
+ +
+
+
${d.name}
+
CLASSIFIED FORMULA
+
+
+
+
+ +
+
+ `; + return; + } + + html += ` +
+
+
+
+ +
+
+
${d.name}
+
${d.workerName}s: ${data.workers}
+
+
+
+ +
+
+ ${d.rawName} + ${data.raw} +
+
+ ${d.productName} + ${data.product}g +
+
+ +
+ + + +
+ +
+ + +
+
+ `; + }); + + dom.viewport.innerHTML = html; +} + +// 2. STREETS VIEW (Dealers & Blends) +function renderStreetsView() { + let html = ` +
+ STREET DISTRIBUTION & DEALER NETWORK +
+ `; + + 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 += ` +
+
+
+
+ +
+
+
${d.name}
+
${d.dealerName}s: ${data.dealers}
+
+
+
+ $${unitPrice.toLocaleString()}/g +
+
+ +
+
+ In Stash + ${data.product}g +
+
+ Deal Speed + ${(data.dealers * d.sellRate * getDealerSpeedMultiplier()).toFixed(1)}/s +
+
+ +
+ + +
+
+ `; + }); + + // RECIPE SYNTHESIS SECTION + html += ` +
+ + DESIGNER COCKTAILS & BLENDS +
+ `; + + 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 `${amt}g ${drugDef.name} (${has}g)`; + }).join(' + '); + + html += ` +
+
+
+
+ +
+
+
${r.name}
+
${r.description}
+
+
+
+
+ Recipe: ${ingrLabels} +
+ +
+ `; + }); + + dom.viewport.innerHTML = html; +} + +// 3. SKILL TREE (SPIDERWEB) +function renderSkillTreeView() { + let html = ` +
+ UNDERWORLD TALENT SPIDERWEB +
+ `; + + 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 += ` +
+ + ${b.name.toUpperCase()} +
+ `; + + 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 += ` +
+
+
+
+ ${s.name} ${isUnlocked ? '✓' : ''} +
+
${s.desc}
+
+
+ ${isUnlocked ? 'ACQUIRED' : '$' + s.cost.toLocaleString()} +
+
+ ${!isUnlocked ? ` + + ` : ''} +
+ `; + }); + }); + + 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 = ` +
+ SWISS ACCOUNTS & MONEY LAUNDERING +
+ +
+ +
Launder Dirty Syndicate Cash
+

+ Reset dirty cash, lab equipment, and street dealers in exchange for permanent Clean Offshore Bonds. + Each Bond permanently boosts production (+3%) and sale values (+4%). +

+
+
CLEAN BONDS UPON LAUNDERING:
+
+${nextBonds} Bonds
+
Career Volume: $${Math.floor(G.totalEarned).toLocaleString()}
+
+ +
+ +
+ GLOBAL SYNDICATE ACHIEVEMENTS +
+ `; + + ACHIEVEMENTS.forEach(a => { + const has = G.achievements[a.id]; + html += ` +
+
+
+ ${has ? '🏆 ' : '🔒 '}${a.name} +
+
${a.desc}
+
+
+ ${a.reward} +
+
+ `; + }); + + 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); +}); diff --git a/nginx/default.conf.template b/nginx/default.conf.template new file mode 100644 index 0000000..1ac5d45 --- /dev/null +++ b/nginx/default.conf.template @@ -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; + } +}