diff --git a/backend/engine.py b/backend/engine.py new file mode 100644 index 0000000..819fb45 --- /dev/null +++ b/backend/engine.py @@ -0,0 +1,248 @@ +import math +import time + +# Drug Catalog mirroring config.js +DRUG_CONFIGS = { + 'weed': {'rawCost': 10, 'sellPrice': 16, 'produceRate': 1.0, 'sellRate': 1.0, 'workerCost': 100, 'dealerCost': 150, 'unlockCost': 0}, + 'shrooms': {'rawCost': 35, 'sellPrice': 65, 'produceRate': 0.8, 'sellRate': 0.8, 'workerCost': 450, 'dealerCost': 650, 'unlockCost': 500}, + 'adderall': {'rawCost': 120, 'sellPrice': 240, 'produceRate': 0.6, 'sellRate': 0.6, 'workerCost': 2000, 'dealerCost': 3000, 'unlockCost': 2500}, + 'meth': {'rawCost': 400, 'sellPrice': 950, 'produceRate': 0.5, 'sellRate': 0.5, 'workerCost': 8000, 'dealerCost': 12000, 'unlockCost': 10000}, + 'mdma': {'rawCost': 1200, 'sellPrice': 3200, 'produceRate': 0.4, 'sellRate': 0.4, 'workerCost': 28000, 'dealerCost': 40000, 'unlockCost': 35000}, + 'ketamine': {'rawCost': 3800, 'sellPrice': 11000, 'produceRate': 0.35, 'sellRate': 0.35, 'workerCost': 95000, 'dealerCost': 140000, 'unlockCost': 120000}, + 'cocaine': {'rawCost': 12000, 'sellPrice': 38000, 'produceRate': 0.3, 'sellRate': 0.3, 'workerCost': 320000, 'dealerCost': 480000, 'unlockCost': 400000}, + 'crack': {'rawCost': 35000, 'sellPrice': 115000, 'produceRate': 0.25, 'sellRate': 0.25, 'workerCost': 900000, 'dealerCost': 1400000, 'unlockCost': 1200000}, + 'heroin': {'rawCost': 110000, 'sellPrice': 420000, 'produceRate': 0.2, 'sellRate': 0.2, 'workerCost': 3500000, 'dealerCost': 5500000, 'unlockCost': 4500000}, + 'fentanyl': {'rawCost': 450000, 'sellPrice': 2100000, 'produceRate': 0.15, 'sellRate': 0.15, 'workerCost': 15000000, 'dealerCost': 24000000, 'unlockCost': 20000000} +} + +SYNTHESIS_RECIPES = { + 'calvin_klein': {'ingredients': {'cocaine': 5, 'ketamine': 5}, 'payout': 95000}, + 'speedball': {'ingredients': {'heroin': 3, 'cocaine': 3}, 'payout': 850000}, + 'candy_flip': {'ingredients': {'shrooms': 15, 'mdma': 10}, 'payout': 75000} +} + +SKILL_COSTS = { + 'chem_1': 500, 'chem_2': 15000, 'chem_3': 250000, + 'king_1': 1000, 'king_2': 35000, 'king_3': 600000, + 'smug_1': 2000, 'smug_2': 50000, 'smug_3': 1000000 +} + +def clean_round(val: float) -> float: + """Rounds float gracefully to 1 decimal place without floating point anomalies.""" + if math.isnan(val) or math.isinf(val) or val < 0: + return 0.0 + return round(float(val), 1) + +def get_initial_state() -> dict: + state = { + 'cash': 25.0, + 'cleanBonds': 0, + 'heat': 0.0, + 'totalEarned': 0.0, + 'lastTick': time.time(), + 'drugs': {}, + 'skills': {s: False for s in SKILL_COSTS}, + 'achievements': {}, + 'synthesis': {r: 0 for r in SYNTHESIS_RECIPES} + } + for drug_id, cfg in DRUG_CONFIGS.items(): + state['drugs'][drug_id] = { + 'unlocked': (drug_id == 'weed'), + 'raw': 0.0, + 'product': 0.0, + 'workers': 0, + 'dealers': 0 + } + return state + +def get_multipliers(state: dict): + bonds = state.get('cleanBonds', 0) + skills = state.get('skills', {}) + achievements = state.get('achievements', {}) + + prod_m = 1.0 + (bonds * 0.03) + if skills.get('chem_1'): prod_m += 0.15 + if achievements.get('millionaire'): prod_m += 0.20 + + sell_m = 1.0 + (bonds * 0.04) + if skills.get('king_2'): sell_m += 0.25 + if achievements.get('first_hit'): sell_m += 0.05 + if achievements.get('cartel_tier'): sell_m += 0.15 + + raw_m = 0.85 if skills.get('chem_2') else 1.0 + + dealer_m = 1.0 + if skills.get('king_1'): dealer_m += 0.20 + if skills.get('king_3'): dealer_m += 0.50 + + return prod_m, sell_m, raw_m, dealer_m + +def process_offline_and_ticks(state: dict) -> dict: + now = time.time() + last = state.get('lastTick', now) + delta = min(max(0.0, now - last), 86400.0) # Cap at 24h offline + state['lastTick'] = now + + if delta <= 0: + return sanitize_state(state) + + prod_m, sell_m, raw_m, dealer_m = get_multipliers(state) + + for drug_id, data in state.get('drugs', {}).items(): + cfg = DRUG_CONFIGS.get(drug_id) + if not cfg or not data.get('unlocked'): + continue + + # Workers production + workers = data.get('workers', 0) + raw = data.get('raw', 0.0) + if workers > 0 and raw > 0: + potential_prod = workers * cfg['produceRate'] * prod_m * delta + actual_prod = min(raw, potential_prod) + data['raw'] = max(0.0, raw - actual_prod) + data['product'] = data.get('product', 0.0) + actual_prod + + # Dealers selling + dealers = data.get('dealers', 0) + product = data.get('product', 0.0) + if dealers > 0 and product > 0: + potential_sell = dealers * cfg['sellRate'] * dealer_m * delta + actual_sell = min(product, potential_sell) + data['product'] = max(0.0, product - actual_sell) + rev = actual_sell * cfg['sellPrice'] * sell_m + state['cash'] = state.get('cash', 0.0) + rev + state['totalEarned'] = state.get('totalEarned', 0.0) + rev + + # Heat decay + state['heat'] = max(0.0, state.get('heat', 0.0) - (0.5 * delta)) + + return sanitize_state(state) + +def apply_actions(state: dict, actions: list) -> dict: + """Applies and authorizes player actions sequentially against server authority.""" + prod_m, sell_m, raw_m, dealer_m = get_multipliers(state) + + for act in actions: + action_type = act.get('type') + drug_id = act.get('drug_id') + + if action_type == 'buy_raw' and drug_id in DRUG_CONFIGS: + cfg = DRUG_CONFIGS[drug_id] + cost = cfg['rawCost'] * raw_m + amount = max(1, int(act.get('amount', 1))) + total_cost = cost * amount + if state['cash'] >= total_cost: + state['cash'] -= total_cost + state['drugs'][drug_id]['raw'] = state['drugs'][drug_id].get('raw', 0.0) + amount + + elif action_type == 'manufacture' and drug_id in DRUG_CONFIGS: + data = state['drugs'][drug_id] + amount = max(1, int(act.get('amount', 1))) + actual = min(amount, int(data.get('raw', 0))) + if actual > 0: + data['raw'] -= actual + yield_count = actual + if state.get('skills', {}).get('chem_3'): + # Purity proc bonus + yield_count *= 1.3 + data['product'] = data.get('product', 0.0) + yield_count + state['heat'] = min(100.0, state.get('heat', 0.0) + (0.5 * actual)) + + elif action_type == 'sell_manual' and drug_id in DRUG_CONFIGS: + data = state['drugs'][drug_id] + cfg = DRUG_CONFIGS[drug_id] + amount = max(1, int(act.get('amount', 1))) + actual = min(amount, int(data.get('product', 0))) + if actual > 0: + data['product'] -= actual + profit = actual * cfg['sellPrice'] * sell_m + state['cash'] += profit + state['totalEarned'] += profit + state['heat'] = min(100.0, state.get('heat', 0.0) + (0.8 * actual)) + + elif action_type == 'hire_worker' and drug_id in DRUG_CONFIGS: + cfg = DRUG_CONFIGS[drug_id] + count = state['drugs'][drug_id].get('workers', 0) + cost = math.floor(cfg['workerCost'] * math.pow(1.15, count)) + if state['cash'] >= cost: + state['cash'] -= cost + state['drugs'][drug_id]['workers'] = count + 1 + + elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS: + cfg = DRUG_CONFIGS[drug_id] + count = state['drugs'][drug_id].get('dealers', 0) + cost = math.floor(cfg['dealerCost'] * math.pow(1.15, count)) + if state['cash'] >= cost: + state['cash'] -= cost + state['drugs'][drug_id]['dealers'] = count + 1 + + elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS: + cfg = DRUG_CONFIGS[drug_id] + cost = cfg['unlockCost'] + if state['cash'] >= cost and not state['drugs'][drug_id].get('unlocked'): + state['cash'] -= cost + state['drugs'][drug_id]['unlocked'] = True + + elif action_type == 'blend_recipe': + recipe_id = act.get('recipe_id') + if recipe_id in SYNTHESIS_RECIPES: + r = SYNTHESIS_RECIPES[recipe_id] + can_blend = True + for ingr_id, needed in r['ingredients'].items(): + if state['drugs'].get(ingr_id, {}).get('product', 0) < needed: + can_blend = False + break + if can_blend: + for ingr_id, needed in r['ingredients'].items(): + state['drugs'][ingr_id]['product'] -= needed + state['cash'] += r['payout'] + state['totalEarned'] += r['payout'] + state['heat'] = min(100.0, state.get('heat', 0.0) + 5.0) + + elif action_type == 'buy_skill': + skill_id = act.get('skill_id') + if skill_id in SKILL_COSTS and not state.get('skills', {}).get(skill_id): + cost = SKILL_COSTS[skill_id] + if state['cash'] >= cost: + state['cash'] -= cost + state.setdefault('skills', {})[skill_id] = True + + elif action_type == 'bribe_police': + bribe_cost = max(100.0, state['cash'] * 0.15) + if state['cash'] >= bribe_cost: + state['cash'] -= bribe_cost + state['heat'] = 0.0 + + elif action_type == 'flush_stash': + state['heat'] = 20.0 + + elif action_type == 'launder_prestige': + bonds_to_earn = math.floor(math.sqrt(max(0.0, state.get('totalEarned', 0.0)) / 25000.0)) + gain = max(0, bonds_to_earn - state.get('cleanBonds', 0)) + if gain >= 1: + state['cleanBonds'] += gain + state['cash'] = 50.0 + state['heat'] = 0.0 + for d_id, d_data in state['drugs'].items(): + d_data['unlocked'] = (d_id == 'weed') + d_data['raw'] = 0.0 + d_data['product'] = 0.0 + d_data['workers'] = 0 + d_data['dealers'] = 0 + + return sanitize_state(state) + +def sanitize_state(state: dict) -> dict: + """Enforces max 1 decimal and prevents negative or runaway numbers.""" + state['cash'] = clean_round(state.get('cash', 0.0)) + state['heat'] = clean_round(min(100.0, max(0.0, state.get('heat', 0.0)))) + state['totalEarned'] = clean_round(state.get('totalEarned', 0.0)) + state['cleanBonds'] = int(state.get('cleanBonds', 0)) + + for d_id, data in state.get('drugs', {}).items(): + data['raw'] = clean_round(data.get('raw', 0.0)) + data['product'] = clean_round(data.get('product', 0.0)) + data['workers'] = int(data.get('workers', 0)) + data['dealers'] = int(data.get('dealers', 0)) + + return state diff --git a/backend/main.py b/backend/main.py index 62a4e07..c1acc03 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,12 +3,12 @@ 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 models import RegisterRequest, LoginRequest, AuthResponse, SaveGameRequest, SyncTickRequest from auth import hash_password, verify_password, create_access_token, get_current_user_id +import engine -app = FastAPI(title="El Puro API", version="1.0.0") +app = FastAPI(title="El Puro API", version="1.1.0") -# Enable CORS for local and proxy flexibility app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -23,13 +23,11 @@ def startup_event(): @app.get("/api/health") def health_check(): - return {"status": "ok", "app": "El Puro", "version": "1.0.0"} + return {"status": "ok", "app": "El Puro", "version": "1.1.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." @@ -49,6 +47,13 @@ def register(req: RegisterRequest): (clean_username, hashed) ) user_id = cursor.lastrowid + + # Initialize clean server authoritative game state + init_state = engine.get_initial_state() + cursor.execute( + "INSERT INTO saves (user_id, save_data, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + (user_id, json.dumps(init_state)) + ) conn.commit() except sqlite3.IntegrityError: raise HTTPException( @@ -88,17 +93,57 @@ def get_game_state(user_id: int = Depends(get_current_user_id)): row = cursor.fetchone() if not row: - return {"has_save": False, "save_data": None} + init_state = engine.get_initial_state() + return {"has_save": True, "save_data": init_state, "updated_at": None} try: data = json.loads(row["save_data"]) + # Process authoritative offline progress up to now + data = engine.process_offline_and_ticks(data) return {"has_save": True, "save_data": data, "updated_at": row["updated_at"]} except Exception: - return {"has_save": False, "save_data": None} + return {"has_save": True, "save_data": engine.get_initial_state(), "updated_at": None} + +@app.post("/api/game/tick") +def sync_tick(req: SyncTickRequest, user_id: int = Depends(get_current_user_id)): + """Authoritative periodic tick (called every 5-10s): applies queued actions and computes tick progress.""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT save_data FROM saves WHERE user_id = ?", (user_id,)) + row = cursor.fetchone() + + if row: + try: + state = json.loads(row["save_data"]) + except Exception: + state = engine.get_initial_state() + else: + state = engine.get_initial_state() + + # 1. Apply authorized player actions + if req.actions: + state = engine.apply_actions(state, req.actions) + + # 2. Advance production, automation, and offline ticks + state = engine.process_offline_and_ticks(state) + + # 3. Persist authoritative clean state + 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.dumps(state))) + conn.commit() + + return {"status": "ok", "save_data": state} @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) + # Sanitize data before persisting + clean_state = engine.sanitize_state(req.save_data) + json_data = json.dumps(clean_state) with get_db() as conn: cursor = conn.cursor() cursor.execute(""" @@ -110,4 +155,4 @@ def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_use """, (user_id, json_data)) conn.commit() - return {"status": "saved"} + return {"status": "saved", "save_data": clean_state} diff --git a/backend/models.py b/backend/models.py index 105c45b..0900ec3 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List class RegisterRequest(BaseModel): username: str = Field(..., min_length=3, max_length=32) @@ -15,5 +15,8 @@ class AuthResponse(BaseModel): token: str username: str +class SyncTickRequest(BaseModel): + actions: List[Dict[str, Any]] = [] + class SaveGameRequest(BaseModel): save_data: Dict[str, Any] diff --git a/frontend/js/api.js b/frontend/js/api.js index 3f1a342..5995c42 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -75,6 +75,31 @@ const API = { } }, + async syncTick(actions = []) { + const token = this.getToken(); + if (!token) return null; + + try { + const res = await fetch('/api/game/tick', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ actions }) + }); + if (!res.ok) { + if (res.status === 401) this.logout(); + return null; + } + const data = await res.json(); + return data.save_data; + } catch (e) { + console.warn('Network tick failed, queuing actions', e); + return null; + } + }, + async saveRemote(saveData) { const token = this.getToken(); if (!token) return; diff --git a/frontend/js/game.js b/frontend/js/game.js index ee73186..2463668 100644 --- a/frontend/js/game.js +++ b/frontend/js/game.js @@ -1,10 +1,10 @@ -// EL PURO - SilkNet OS Core Engine +// EL PURO - SilkNet OS Core Engine (Authoritative Server Synchronization) const INITIAL_GAME_STATE = { - cash: 25.00, + cash: 25.0, cleanBonds: 0, - heat: 0, - totalEarned: 0, + heat: 0.0, + totalEarned: 0.0, activeTab: 'lab', drugs: {}, skills: {}, @@ -17,8 +17,8 @@ const INITIAL_GAME_STATE = { DRUG_CATALOG.forEach(d => { INITIAL_GAME_STATE.drugs[d.id] = { unlocked: d.unlockedDefault, - raw: 0, - product: 0, + raw: 0.0, + product: 0.0, workers: 0, dealers: 0 }; @@ -37,9 +37,22 @@ SYNTHESIS_RECIPES.forEach(r => { }); let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE)); -let cashRateTracker = 0; -let lastSaveTime = Date.now(); -let lastRaidCheck = Date.now(); +let cashRateTracker = 0.0; +let pendingActions = []; // Actions queued between 6-second authoritative server ticks +let isSyncing = false; + +// --- NUMBER FORMATTING HELPER (Max 1 decimal, graceful fallback) --- +function fmtNum(val) { + if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0'; + const rounded = Math.round(Number(val) * 10) / 10; + // If it's an integer, show with .0 for uniform currency or clean integer for items + return rounded.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); +} + +function fmtInt(val) { + if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0'; + return Math.floor(Math.max(0, Number(val))).toLocaleString(); +} // --- DOM CACHE --- const dom = { @@ -61,7 +74,6 @@ const dom = { // --- 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; @@ -70,7 +82,6 @@ function getProductionMultiplier() { 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; @@ -152,7 +163,7 @@ function renderLabView() { `; DRUG_CATALOG.forEach(d => { - const data = G.drugs[d.id]; + const data = G.drugs[d.id] || { raw: 0, product: 0, workers: 0, dealers: 0, unlocked: false }; const rawCost = Math.floor(d.rawCost * getRawCostMultiplier()); const workerCost = getWorkerCost(d.id); @@ -199,11 +210,11 @@ function renderLabView() {
${d.rawName} - ${data.raw} + ${fmtNum(data.raw)}
${d.productName} - ${data.product}g + ${fmtNum(data.product)}g
@@ -246,7 +257,7 @@ function renderStreetsView() { `; - DRUG_CATALOG.filter(d => G.drugs[d.id].unlocked).forEach(d => { + DRUG_CATALOG.filter(d => G.drugs[d.id] && 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()); @@ -271,11 +282,11 @@ function renderStreetsView() {
In Stash - ${data.product}g + ${fmtNum(data.product)}g
Deal Speed - ${(data.dealers * d.sellRate * getDealerSpeedMultiplier()).toFixed(1)}/s + ${fmtNum(data.dealers * d.sellRate * getDealerSpeedMultiplier())}/s
@@ -286,7 +297,7 @@ function renderStreetsView() { @@ -309,7 +320,7 @@ function renderStreetsView() { 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)`; + return `${amt}g ${drugDef.name} (${fmtNum(has)}g)`; }).join(' + '); html += ` @@ -412,7 +423,7 @@ function renderOffshoreView() {
CLEAN BONDS UPON LAUNDERING:
+${nextBonds} Bonds
-
Career Volume: $${Math.floor(G.totalEarned).toLocaleString()}
+
Career Volume: $${fmtNum(G.totalEarned)}