feat: server-authoritative ticks (6s cadence), live DOM updates without request floods, and 1-decimal rounding

This commit is contained in:
Christian 2026-10-15 16:01:24 +02:00
parent 4d1d0527ee
commit 0a7fb7322c
5 changed files with 484 additions and 101 deletions

248
backend/engine.py Normal file
View file

@ -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

View file

@ -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}

View file

@ -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]

View file

@ -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;

View file

@ -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() {
<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>
<span class="stat-value" id="val-raw-${d.id}">${fmtNum(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>
<span class="stat-value" id="val-prod-${d.id}">${fmtNum(data.product)}g</span>
</div>
</div>
@ -246,7 +257,7 @@ function renderStreetsView() {
</div>
`;
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() {
<div class="card-stats-row">
<div class="stat-item">
<span class="stat-label">In Stash</span>
<span class="stat-value">${data.product}g</span>
<span class="stat-value" id="val-prod-street-${d.id}">${fmtNum(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>
<span class="stat-value">${fmtNum(data.dealers * d.sellRate * getDealerSpeedMultiplier())}/s</span>
</div>
</div>
@ -286,7 +297,7 @@ function renderStreetsView() {
</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>
<span class="btn-subtext">+$${(unitPrice * Math.min(10, Math.floor(data.product))).toLocaleString()}</span>
</button>
</div>
</div>
@ -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 `<span style="color: ${has >= amt ? '#22c55e' : '#ef4444'}">${amt}g ${drugDef.name} (${has}g)</span>`;
return `<span style="color: ${has >= amt ? '#22c55e' : '#ef4444'}">${amt}g ${drugDef.name} (${fmtNum(has)}g)</span>`;
}).join(' + ');
html += `
@ -412,7 +423,7 @@ function renderOffshoreView() {
<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 style="font-size: 0.68rem; color: var(--text-muted);">Career Volume: $${fmtNum(G.totalEarned)}</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'}
@ -444,15 +455,20 @@ function renderOffshoreView() {
dom.viewport.innerHTML = html;
}
// --- CORE ACTIONS ---
// --- OPTIMISTIC CLIENT ACTIONS (Queued for Server Authority) ---
function queueAction(action) {
pendingActions.push(action);
}
function buyRaw(drugId) {
const d = DRUG_CATALOG.find(x => x.id === drugId);
const cost = Math.floor(d.rawCost * getRawCostMultiplier());
if (G.cash >= cost) {
// Optimistic local UI update
G.cash -= cost;
G.drugs[drugId].raw += 1;
updateHud();
renderViewport();
queueAction({ type: 'buy_raw', drug_id: drugId, amount: 1 });
updateLiveInventory();
}
}
@ -461,31 +477,30 @@ function manufacture(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();
queueAction({ type: 'manufacture', drug_id: drugId, amount: 1 });
updateLiveInventory();
}
}
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);
const actual = Math.min(amount, Math.floor(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);
queueAction({ type: 'sell_manual', drug_id: drugId, amount: actual });
checkAchievements();
updateHud();
renderViewport();
updateLiveInventory();
}
}
@ -494,9 +509,10 @@ function hireWorker(drugId) {
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].workers += 1;
queueAction({ type: 'hire_worker', drug_id: drugId });
checkAchievements();
updateHud();
renderViewport();
updateLiveInventory();
}
}
@ -505,8 +521,9 @@ function hireDealer(drugId) {
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].dealers += 1;
updateHud();
queueAction({ type: 'hire_dealer', drug_id: drugId });
renderViewport();
updateLiveInventory();
}
}
@ -516,9 +533,10 @@ function unlockDrug(drugId) {
G.cash -= cost;
G.drugs[drugId].unlocked = true;
showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`);
queueAction({ type: 'unlock_drug', drug_id: drugId });
checkAchievements();
updateHud();
renderViewport();
updateLiveInventory();
}
}
@ -532,9 +550,10 @@ function blendRecipe(recipeId) {
G.cash += r.sellPrice;
G.totalEarned += r.sellPrice;
addHeat(5);
queueAction({ type: 'blend_recipe', recipe_id: recipeId });
showToast(`Synthesized & Sold ${r.name} for +$${r.sellPrice.toLocaleString()}!`);
updateHud();
renderViewport();
updateLiveInventory();
}
}
@ -543,9 +562,10 @@ function buySkill(skillId) {
if (G.cash >= s.cost && !G.skills[skillId]) {
G.cash -= s.cost;
G.skills[skillId] = true;
queueAction({ type: 'buy_skill', skill_id: skillId });
showToast(`Skill Activated: ${s.name}`);
updateHud();
renderViewport();
updateLiveInventory();
}
}
@ -555,29 +575,30 @@ function launderMoney() {
if (gain < 1) return;
if (confirm(`Launder all assets for +${gain} Clean Offshore Bonds? Your syndicate operations will reset.`)) {
queueAction({ type: 'launder_prestige' });
G.cleanBonds += gain;
G.cash = 50.00;
G.heat = 0;
G.cash = 50.0;
G.heat = 0.0;
DRUG_CATALOG.forEach(d => {
G.drugs[d.id] = {
unlocked: d.unlockedDefault,
raw: 0,
product: 0,
raw: 0.0,
product: 0.0,
workers: 0,
dealers: 0
};
});
checkAchievements();
showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`);
updateHud();
renderViewport();
updateLiveInventory();
}
}
// --- 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));
G.heat = Math.min(100.0, G.heat + (amount * riseMod));
}
function checkRaidEvent() {
@ -594,20 +615,22 @@ function triggerRaidModal() {
document.getElementById('btn-raid-bribe').onclick = () => {
if (G.cash >= bribeCost) {
G.cash -= bribeCost;
G.heat = 0;
G.heat = 0.0;
queueAction({ type: 'bribe_police' });
dom.raidModal.classList.remove('active');
showToast('Sheriff bribed. Heat cleared.');
updateHud();
updateLiveInventory();
} else {
showToast('Insufficient cash for bribe!', true);
}
};
document.getElementById('btn-raid-stash').onclick = () => {
G.heat = 20;
G.heat = 20.0;
queueAction({ type: 'flush_stash' });
dom.raidModal.classList.remove('active');
showToast('Stash flushed & secured. Heat diminished.');
updateHud();
updateLiveInventory();
};
document.getElementById('btn-raid-shootout').onclick = () => {
@ -615,18 +638,16 @@ function triggerRaidModal() {
if (Math.random() < 0.5) {
const reward = Math.floor(G.cash * 0.5);
G.cash += reward;
G.heat = 0;
G.heat = 0.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;
G.heat = 30.0;
showToast('Agents seized 30% of stored product!', true);
}
updateHud();
renderViewport();
updateLiveInventory();
};
}
@ -636,15 +657,15 @@ function checkAchievements() {
G.achievements['first_hit'] = true;
showToast('Achievement: First Batch!');
}
if (G.drugs['weed'].workers >= 10 && !G.achievements['grower_op']) {
if (G.drugs['weed'] && 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']) {
if (G.drugs['meth'] && 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']) {
if (G.drugs['cocaine'] && G.drugs['cocaine'].unlocked && !G.achievements['cartel_tier']) {
G.achievements['cartel_tier'] = true;
showToast('Achievement: El Patrón!');
}
@ -658,8 +679,8 @@ function checkAchievements() {
}
}
// --- TICK & GAME LOOP ---
function tick(deltaSec) {
// --- LIGHTWEIGHT LOCAL TICK (Smooth 60FPS UI feedback between server syncs) ---
function clientTick(deltaSec) {
let earnedThisTick = 0;
const prodMulti = getProductionMultiplier();
const sellMulti = getSellPriceMultiplier();
@ -667,21 +688,21 @@ function tick(deltaSec) {
DRUG_CATALOG.forEach(d => {
const data = G.drugs[d.id];
if (!data.unlocked) return;
if (!data || !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;
data.raw = Math.max(0, data.raw - toMake);
data.product = 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;
data.product = Math.max(0, data.product - toSell);
const revenue = toSell * Math.floor(d.sellPrice * sellMulti);
G.cash += revenue;
G.totalEarned += revenue;
@ -689,31 +710,70 @@ function tick(deltaSec) {
}
});
// Heat decay
if (G.heat > 0) {
G.heat = Math.max(0, G.heat - (0.5 * deltaSec));
G.heat = Math.max(0.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();
// Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
updateLiveInventory();
}
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));
function updateLiveInventory() {
dom.cash.textContent = fmtNum(G.cash);
dom.rate.textContent = fmtNum(cashRateTracker);
dom.bonds.textContent = fmtInt(G.cleanBonds);
dom.multiplier.textContent = fmtInt(G.cleanBonds * 4);
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
// Surgically update raw and product text fields if they exist in DOM
DRUG_CATALOG.forEach(d => {
const rawEl = document.getElementById(`val-raw-${d.id}`);
if (rawEl && G.drugs[d.id]) {
rawEl.textContent = fmtNum(G.drugs[d.id].raw);
}
const prodEl = document.getElementById(`val-prod-${d.id}`);
if (prodEl && G.drugs[d.id]) {
prodEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
}
const streetProdEl = document.getElementById(`val-prod-street-${d.id}`);
if (streetProdEl && G.drugs[d.id]) {
streetProdEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
}
});
}
// --- AUTHORITATIVE SERVER TICK (Every 6 seconds) ---
async function authoritativeServerTick() {
if (isSyncing) return;
isSyncing = true;
// Grab queued actions and reset local queue
const actionsToSend = [...pendingActions];
pendingActions = [];
if (API.getToken()) {
const serverState = await API.syncTick(actionsToSend);
if (serverState) {
// Reconcile authoritative server state (protects against browser devtools tampering)
G = {
...INITIAL_GAME_STATE,
...serverState,
activeTab: G.activeTab // Preserve UI view tab
};
checkAchievements();
updateLiveInventory();
}
} else {
// Guest mode / offline fallback: persist locally
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
}
isSyncing = false;
}
// Game loop with requestAnimationFrame
@ -722,39 +782,36 @@ function gameLoop(now) {
const delta = (now - lastFrameTime) / 1000;
lastFrameTime = now;
if (delta > 0 && delta < 5) {
tick(delta);
clientTick(delta);
}
requestAnimationFrame(gameLoop);
}
// Server tick: exactly every 6 seconds to prevent flooding server
setInterval(authoritativeServerTick, 6000);
// 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);
// Periodic raid check (every 15 seconds)
setInterval(checkRaidEvent, 15000);
// --- 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
// Load initial authoritative save from server if logged in
let loaded = false;
if (API.getToken()) {
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote };
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: 'lab' };
loaded = true;
}
}
@ -762,9 +819,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const local = localStorage.getItem('el_puro_local_save');
if (local) {
try {
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local) };
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local), activeTab: 'lab' };
} catch (e) {
console.warn('Corrupt local save, initializing new game');
console.warn('Corrupt local save, using initial state');
}
}
}
@ -788,10 +845,10 @@ document.addEventListener('DOMContentLoaded', async () => {
dom.userBadge.textContent = `[${res.username}]`;
dom.authModal.classList.remove('active');
showToast(`Connected: Welcome ${res.username}`);
// Sync game save from cloud
// Fetch server authoritative state
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote };
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: G.activeTab };
renderViewport();
}
} catch (e) {
@ -802,7 +859,7 @@ document.addEventListener('DOMContentLoaded', async () => {
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!
const hp = document.getElementById('auth-website-hp').value;
if (!u || !p) return showToast('Enter username and password', true);
try {
@ -810,13 +867,18 @@ document.addEventListener('DOMContentLoaded', async () => {
dom.userBadge.textContent = `[${res.username}]`;
dom.authModal.classList.remove('active');
showToast(`Syndicate registered: ${res.username}`);
await API.saveRemote(G);
// Fetch clean server initialized state
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: G.activeTab };
renderViewport();
}
} catch (e) {
showToast(e.message, true);
}
});
renderViewport();
updateHud();
updateLiveInventory();
requestAnimationFrame(gameLoop);
});