feat: initial commit for El Puro idle game with Docker stack, honeypot auth, and mobile OS UI
This commit is contained in:
commit
a7dd531403
15 changed files with 2268 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal 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
23
.gitignore
vendored
Normal 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
20
backend/Dockerfile
Normal 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
46
backend/auth.py
Normal 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
33
backend/database.py
Normal 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
113
backend/main.py
Normal 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
19
backend/models.py
Normal 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
7
backend/requirements.txt
Normal 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
32
docker-compose.yml
Normal 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
541
frontend/css/style.css
Normal 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
159
frontend/index.html
Normal 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
95
frontend/js/api.js
Normal 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
322
frontend/js/config.js
Normal 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
822
frontend/js/game.js
Normal 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);
|
||||
});
|
||||
27
nginx/default.conf.template
Normal file
27
nginx/default.conf.template
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue