feat: server-authoritative ticks (6s cadence), live DOM updates without request floods, and 1-decimal rounding
This commit is contained in:
parent
4d1d0527ee
commit
0a7fb7322c
5 changed files with 484 additions and 101 deletions
248
backend/engine.py
Normal file
248
backend/engine.py
Normal 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
|
||||||
|
|
@ -3,12 +3,12 @@ import sqlite3
|
||||||
from fastapi import FastAPI, HTTPException, status, Depends
|
from fastapi import FastAPI, HTTPException, status, Depends
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from database import init_db, get_db
|
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
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
|
|
@ -23,13 +23,11 @@ def startup_event():
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health_check():
|
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)
|
@app.post("/api/auth/register", response_model=AuthResponse)
|
||||||
def register(req: RegisterRequest):
|
def register(req: RegisterRequest):
|
||||||
# HONEYPOT TRAP: If website_hp is populated, a bot filled it!
|
|
||||||
if req.website_hp and req.website_hp.strip():
|
if req.website_hp and req.website_hp.strip():
|
||||||
# Reject silently with generic bad request
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Registration verification failed."
|
detail="Registration verification failed."
|
||||||
|
|
@ -49,6 +47,13 @@ def register(req: RegisterRequest):
|
||||||
(clean_username, hashed)
|
(clean_username, hashed)
|
||||||
)
|
)
|
||||||
user_id = cursor.lastrowid
|
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()
|
conn.commit()
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -88,17 +93,57 @@ def get_game_state(user_id: int = Depends(get_current_user_id)):
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if not row:
|
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:
|
try:
|
||||||
data = json.loads(row["save_data"])
|
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"]}
|
return {"has_save": True, "save_data": data, "updated_at": row["updated_at"]}
|
||||||
except Exception:
|
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")
|
@app.post("/api/game/save")
|
||||||
def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_user_id)):
|
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:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|
@ -110,4 +155,4 @@ def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_use
|
||||||
""", (user_id, json_data))
|
""", (user_id, json_data))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return {"status": "saved"}
|
return {"status": "saved", "save_data": clean_state}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str = Field(..., min_length=3, max_length=32)
|
username: str = Field(..., min_length=3, max_length=32)
|
||||||
|
|
@ -15,5 +15,8 @@ class AuthResponse(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
class SyncTickRequest(BaseModel):
|
||||||
|
actions: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
class SaveGameRequest(BaseModel):
|
class SaveGameRequest(BaseModel):
|
||||||
save_data: Dict[str, Any]
|
save_data: Dict[str, Any]
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
async saveRemote(saveData) {
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// EL PURO - SilkNet OS Core Engine
|
// EL PURO - SilkNet OS Core Engine (Authoritative Server Synchronization)
|
||||||
|
|
||||||
const INITIAL_GAME_STATE = {
|
const INITIAL_GAME_STATE = {
|
||||||
cash: 25.00,
|
cash: 25.0,
|
||||||
cleanBonds: 0,
|
cleanBonds: 0,
|
||||||
heat: 0,
|
heat: 0.0,
|
||||||
totalEarned: 0,
|
totalEarned: 0.0,
|
||||||
activeTab: 'lab',
|
activeTab: 'lab',
|
||||||
drugs: {},
|
drugs: {},
|
||||||
skills: {},
|
skills: {},
|
||||||
|
|
@ -17,8 +17,8 @@ const INITIAL_GAME_STATE = {
|
||||||
DRUG_CATALOG.forEach(d => {
|
DRUG_CATALOG.forEach(d => {
|
||||||
INITIAL_GAME_STATE.drugs[d.id] = {
|
INITIAL_GAME_STATE.drugs[d.id] = {
|
||||||
unlocked: d.unlockedDefault,
|
unlocked: d.unlockedDefault,
|
||||||
raw: 0,
|
raw: 0.0,
|
||||||
product: 0,
|
product: 0.0,
|
||||||
workers: 0,
|
workers: 0,
|
||||||
dealers: 0
|
dealers: 0
|
||||||
};
|
};
|
||||||
|
|
@ -37,9 +37,22 @@ SYNTHESIS_RECIPES.forEach(r => {
|
||||||
});
|
});
|
||||||
|
|
||||||
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
|
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
|
||||||
let cashRateTracker = 0;
|
let cashRateTracker = 0.0;
|
||||||
let lastSaveTime = Date.now();
|
let pendingActions = []; // Actions queued between 6-second authoritative server ticks
|
||||||
let lastRaidCheck = Date.now();
|
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 ---
|
// --- DOM CACHE ---
|
||||||
const dom = {
|
const dom = {
|
||||||
|
|
@ -61,7 +74,6 @@ const dom = {
|
||||||
// --- MULTIPLIERS & BONUSES ---
|
// --- MULTIPLIERS & BONUSES ---
|
||||||
function getProductionMultiplier() {
|
function getProductionMultiplier() {
|
||||||
let m = 1.0;
|
let m = 1.0;
|
||||||
// Clean bonds: +3% production each
|
|
||||||
m += G.cleanBonds * 0.03;
|
m += G.cleanBonds * 0.03;
|
||||||
if (G.skills['chem_1']) m += 0.15;
|
if (G.skills['chem_1']) m += 0.15;
|
||||||
if (G.achievements['millionaire']) m += 0.20;
|
if (G.achievements['millionaire']) m += 0.20;
|
||||||
|
|
@ -70,7 +82,6 @@ function getProductionMultiplier() {
|
||||||
|
|
||||||
function getSellPriceMultiplier() {
|
function getSellPriceMultiplier() {
|
||||||
let m = 1.0;
|
let m = 1.0;
|
||||||
// Clean bonds: +4% sell price each
|
|
||||||
m += G.cleanBonds * 0.04;
|
m += G.cleanBonds * 0.04;
|
||||||
if (G.skills['king_2']) m += 0.25;
|
if (G.skills['king_2']) m += 0.25;
|
||||||
if (G.achievements['first_hit']) m += 0.05;
|
if (G.achievements['first_hit']) m += 0.05;
|
||||||
|
|
@ -152,7 +163,7 @@ function renderLabView() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
DRUG_CATALOG.forEach(d => {
|
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 rawCost = Math.floor(d.rawCost * getRawCostMultiplier());
|
||||||
const workerCost = getWorkerCost(d.id);
|
const workerCost = getWorkerCost(d.id);
|
||||||
|
|
||||||
|
|
@ -199,11 +210,11 @@ function renderLabView() {
|
||||||
<div class="card-stats-row">
|
<div class="card-stats-row">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">${d.rawName}</span>
|
<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>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">${d.productName}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -246,7 +257,7 @@ function renderStreetsView() {
|
||||||
</div>
|
</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 data = G.drugs[d.id];
|
||||||
const dealerCost = getDealerCost(d.id);
|
const dealerCost = getDealerCost(d.id);
|
||||||
const unitPrice = Math.floor(d.sellPrice * getSellPriceMultiplier());
|
const unitPrice = Math.floor(d.sellPrice * getSellPriceMultiplier());
|
||||||
|
|
@ -271,11 +282,11 @@ function renderStreetsView() {
|
||||||
<div class="card-stats-row">
|
<div class="card-stats-row">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">In Stash</span>
|
<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>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Deal Speed</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -286,7 +297,7 @@ function renderStreetsView() {
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
|
<button class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
|
||||||
Sell 10x Stash
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -309,7 +320,7 @@ function renderStreetsView() {
|
||||||
const ingrLabels = Object.entries(r.ingredients).map(([id, amt]) => {
|
const ingrLabels = Object.entries(r.ingredients).map(([id, amt]) => {
|
||||||
const has = G.drugs[id] ? G.drugs[id].product : 0;
|
const has = G.drugs[id] ? G.drugs[id].product : 0;
|
||||||
const drugDef = DRUG_CATALOG.find(d => d.id === id);
|
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(' + ');
|
}).join(' + ');
|
||||||
|
|
||||||
html += `
|
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="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: 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: 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>
|
</div>
|
||||||
<button class="btn-tactical btn-sell-action" style="padding: 12px; font-size: 0.9rem;" onclick="launderMoney()" ${nextBonds < 1 ? 'disabled' : ''}>
|
<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'}
|
${nextBonds >= 1 ? 'Launder Cash & Reset Syndicate' : 'Need More Volume to Launder'}
|
||||||
|
|
@ -444,15 +455,20 @@ function renderOffshoreView() {
|
||||||
dom.viewport.innerHTML = html;
|
dom.viewport.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CORE ACTIONS ---
|
// --- OPTIMISTIC CLIENT ACTIONS (Queued for Server Authority) ---
|
||||||
|
function queueAction(action) {
|
||||||
|
pendingActions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
function buyRaw(drugId) {
|
function buyRaw(drugId) {
|
||||||
const d = DRUG_CATALOG.find(x => x.id === drugId);
|
const d = DRUG_CATALOG.find(x => x.id === drugId);
|
||||||
const cost = Math.floor(d.rawCost * getRawCostMultiplier());
|
const cost = Math.floor(d.rawCost * getRawCostMultiplier());
|
||||||
if (G.cash >= cost) {
|
if (G.cash >= cost) {
|
||||||
|
// Optimistic local UI update
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
G.drugs[drugId].raw += 1;
|
G.drugs[drugId].raw += 1;
|
||||||
updateHud();
|
queueAction({ type: 'buy_raw', drug_id: drugId, amount: 1 });
|
||||||
renderViewport();
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,31 +477,30 @@ function manufacture(drugId) {
|
||||||
if (data.raw >= 1) {
|
if (data.raw >= 1) {
|
||||||
data.raw -= 1;
|
data.raw -= 1;
|
||||||
let yieldCount = 1;
|
let yieldCount = 1;
|
||||||
// 99.1% crystallization perk
|
|
||||||
if (G.skills['chem_3'] && Math.random() < 0.10) {
|
if (G.skills['chem_3'] && Math.random() < 0.10) {
|
||||||
yieldCount = 3;
|
yieldCount = 3;
|
||||||
showToast('99.1% Purity Proc! 3x Yield!', false);
|
showToast('99.1% Purity Proc! 3x Yield!', false);
|
||||||
}
|
}
|
||||||
data.product += yieldCount;
|
data.product += yieldCount;
|
||||||
addHeat(0.5);
|
addHeat(0.5);
|
||||||
updateHud();
|
queueAction({ type: 'manufacture', drug_id: drugId, amount: 1 });
|
||||||
renderViewport();
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sellManual(drugId, amount = 1) {
|
function sellManual(drugId, amount = 1) {
|
||||||
const d = DRUG_CATALOG.find(x => x.id === drugId);
|
const d = DRUG_CATALOG.find(x => x.id === drugId);
|
||||||
const data = G.drugs[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) {
|
if (actual > 0) {
|
||||||
data.product -= actual;
|
data.product -= actual;
|
||||||
const profit = actual * Math.floor(d.sellPrice * getSellPriceMultiplier());
|
const profit = actual * Math.floor(d.sellPrice * getSellPriceMultiplier());
|
||||||
G.cash += profit;
|
G.cash += profit;
|
||||||
G.totalEarned += profit;
|
G.totalEarned += profit;
|
||||||
addHeat(actual * 0.8);
|
addHeat(actual * 0.8);
|
||||||
|
queueAction({ type: 'sell_manual', drug_id: drugId, amount: actual });
|
||||||
checkAchievements();
|
checkAchievements();
|
||||||
updateHud();
|
updateLiveInventory();
|
||||||
renderViewport();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -494,9 +509,10 @@ function hireWorker(drugId) {
|
||||||
if (G.cash >= cost) {
|
if (G.cash >= cost) {
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
G.drugs[drugId].workers += 1;
|
G.drugs[drugId].workers += 1;
|
||||||
|
queueAction({ type: 'hire_worker', drug_id: drugId });
|
||||||
checkAchievements();
|
checkAchievements();
|
||||||
updateHud();
|
|
||||||
renderViewport();
|
renderViewport();
|
||||||
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,8 +521,9 @@ function hireDealer(drugId) {
|
||||||
if (G.cash >= cost) {
|
if (G.cash >= cost) {
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
G.drugs[drugId].dealers += 1;
|
G.drugs[drugId].dealers += 1;
|
||||||
updateHud();
|
queueAction({ type: 'hire_dealer', drug_id: drugId });
|
||||||
renderViewport();
|
renderViewport();
|
||||||
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,9 +533,10 @@ function unlockDrug(drugId) {
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
G.drugs[drugId].unlocked = true;
|
G.drugs[drugId].unlocked = true;
|
||||||
showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`);
|
showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`);
|
||||||
|
queueAction({ type: 'unlock_drug', drug_id: drugId });
|
||||||
checkAchievements();
|
checkAchievements();
|
||||||
updateHud();
|
|
||||||
renderViewport();
|
renderViewport();
|
||||||
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,9 +550,10 @@ function blendRecipe(recipeId) {
|
||||||
G.cash += r.sellPrice;
|
G.cash += r.sellPrice;
|
||||||
G.totalEarned += r.sellPrice;
|
G.totalEarned += r.sellPrice;
|
||||||
addHeat(5);
|
addHeat(5);
|
||||||
|
queueAction({ type: 'blend_recipe', recipe_id: recipeId });
|
||||||
showToast(`Synthesized & Sold ${r.name} for +$${r.sellPrice.toLocaleString()}!`);
|
showToast(`Synthesized & Sold ${r.name} for +$${r.sellPrice.toLocaleString()}!`);
|
||||||
updateHud();
|
|
||||||
renderViewport();
|
renderViewport();
|
||||||
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,9 +562,10 @@ function buySkill(skillId) {
|
||||||
if (G.cash >= s.cost && !G.skills[skillId]) {
|
if (G.cash >= s.cost && !G.skills[skillId]) {
|
||||||
G.cash -= s.cost;
|
G.cash -= s.cost;
|
||||||
G.skills[skillId] = true;
|
G.skills[skillId] = true;
|
||||||
|
queueAction({ type: 'buy_skill', skill_id: skillId });
|
||||||
showToast(`Skill Activated: ${s.name}`);
|
showToast(`Skill Activated: ${s.name}`);
|
||||||
updateHud();
|
|
||||||
renderViewport();
|
renderViewport();
|
||||||
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -555,29 +575,30 @@ function launderMoney() {
|
||||||
if (gain < 1) return;
|
if (gain < 1) return;
|
||||||
|
|
||||||
if (confirm(`Launder all assets for +${gain} Clean Offshore Bonds? Your syndicate operations will reset.`)) {
|
if (confirm(`Launder all assets for +${gain} Clean Offshore Bonds? Your syndicate operations will reset.`)) {
|
||||||
|
queueAction({ type: 'launder_prestige' });
|
||||||
G.cleanBonds += gain;
|
G.cleanBonds += gain;
|
||||||
G.cash = 50.00;
|
G.cash = 50.0;
|
||||||
G.heat = 0;
|
G.heat = 0.0;
|
||||||
DRUG_CATALOG.forEach(d => {
|
DRUG_CATALOG.forEach(d => {
|
||||||
G.drugs[d.id] = {
|
G.drugs[d.id] = {
|
||||||
unlocked: d.unlockedDefault,
|
unlocked: d.unlockedDefault,
|
||||||
raw: 0,
|
raw: 0.0,
|
||||||
product: 0,
|
product: 0.0,
|
||||||
workers: 0,
|
workers: 0,
|
||||||
dealers: 0
|
dealers: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
checkAchievements();
|
checkAchievements();
|
||||||
showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`);
|
showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`);
|
||||||
updateHud();
|
|
||||||
renderViewport();
|
renderViewport();
|
||||||
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- POLICE HEAT & RAIDS ---
|
// --- POLICE HEAT & RAIDS ---
|
||||||
function addHeat(amount) {
|
function addHeat(amount) {
|
||||||
let riseMod = G.skills['smug_1'] ? 0.65 : 1.0;
|
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() {
|
function checkRaidEvent() {
|
||||||
|
|
@ -594,20 +615,22 @@ function triggerRaidModal() {
|
||||||
document.getElementById('btn-raid-bribe').onclick = () => {
|
document.getElementById('btn-raid-bribe').onclick = () => {
|
||||||
if (G.cash >= bribeCost) {
|
if (G.cash >= bribeCost) {
|
||||||
G.cash -= bribeCost;
|
G.cash -= bribeCost;
|
||||||
G.heat = 0;
|
G.heat = 0.0;
|
||||||
|
queueAction({ type: 'bribe_police' });
|
||||||
dom.raidModal.classList.remove('active');
|
dom.raidModal.classList.remove('active');
|
||||||
showToast('Sheriff bribed. Heat cleared.');
|
showToast('Sheriff bribed. Heat cleared.');
|
||||||
updateHud();
|
updateLiveInventory();
|
||||||
} else {
|
} else {
|
||||||
showToast('Insufficient cash for bribe!', true);
|
showToast('Insufficient cash for bribe!', true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('btn-raid-stash').onclick = () => {
|
document.getElementById('btn-raid-stash').onclick = () => {
|
||||||
G.heat = 20;
|
G.heat = 20.0;
|
||||||
|
queueAction({ type: 'flush_stash' });
|
||||||
dom.raidModal.classList.remove('active');
|
dom.raidModal.classList.remove('active');
|
||||||
showToast('Stash flushed & secured. Heat diminished.');
|
showToast('Stash flushed & secured. Heat diminished.');
|
||||||
updateHud();
|
updateLiveInventory();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('btn-raid-shootout').onclick = () => {
|
document.getElementById('btn-raid-shootout').onclick = () => {
|
||||||
|
|
@ -615,18 +638,16 @@ function triggerRaidModal() {
|
||||||
if (Math.random() < 0.5) {
|
if (Math.random() < 0.5) {
|
||||||
const reward = Math.floor(G.cash * 0.5);
|
const reward = Math.floor(G.cash * 0.5);
|
||||||
G.cash += reward;
|
G.cash += reward;
|
||||||
G.heat = 0;
|
G.heat = 0.0;
|
||||||
showToast(`Broke through blockade! Seized +$${reward.toLocaleString()}!`);
|
showToast(`Broke through blockade! Seized +$${reward.toLocaleString()}!`);
|
||||||
} else {
|
} else {
|
||||||
// Seize 30% of inventory
|
|
||||||
DRUG_CATALOG.forEach(d => {
|
DRUG_CATALOG.forEach(d => {
|
||||||
G.drugs[d.id].product = Math.floor(G.drugs[d.id].product * 0.7);
|
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);
|
showToast('Agents seized 30% of stored product!', true);
|
||||||
}
|
}
|
||||||
updateHud();
|
updateLiveInventory();
|
||||||
renderViewport();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -636,15 +657,15 @@ function checkAchievements() {
|
||||||
G.achievements['first_hit'] = true;
|
G.achievements['first_hit'] = true;
|
||||||
showToast('Achievement: First Batch!');
|
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;
|
G.achievements['grower_op'] = true;
|
||||||
showToast('Achievement: Green Thumb!');
|
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;
|
G.achievements['blue_sky'] = true;
|
||||||
showToast('Achievement: Say My Name!');
|
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;
|
G.achievements['cartel_tier'] = true;
|
||||||
showToast('Achievement: El Patrón!');
|
showToast('Achievement: El Patrón!');
|
||||||
}
|
}
|
||||||
|
|
@ -658,8 +679,8 @@ function checkAchievements() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TICK & GAME LOOP ---
|
// --- LIGHTWEIGHT LOCAL TICK (Smooth 60FPS UI feedback between server syncs) ---
|
||||||
function tick(deltaSec) {
|
function clientTick(deltaSec) {
|
||||||
let earnedThisTick = 0;
|
let earnedThisTick = 0;
|
||||||
const prodMulti = getProductionMultiplier();
|
const prodMulti = getProductionMultiplier();
|
||||||
const sellMulti = getSellPriceMultiplier();
|
const sellMulti = getSellPriceMultiplier();
|
||||||
|
|
@ -667,21 +688,21 @@ function tick(deltaSec) {
|
||||||
|
|
||||||
DRUG_CATALOG.forEach(d => {
|
DRUG_CATALOG.forEach(d => {
|
||||||
const data = G.drugs[d.id];
|
const data = G.drugs[d.id];
|
||||||
if (!data.unlocked) return;
|
if (!data || !data.unlocked) return;
|
||||||
|
|
||||||
// Auto Production
|
// Auto Production
|
||||||
if (data.workers > 0 && data.raw > 0) {
|
if (data.workers > 0 && data.raw > 0) {
|
||||||
const prodPerSec = data.workers * d.produceRate * prodMulti;
|
const prodPerSec = data.workers * d.produceRate * prodMulti;
|
||||||
const toMake = Math.min(data.raw, prodPerSec * deltaSec);
|
const toMake = Math.min(data.raw, prodPerSec * deltaSec);
|
||||||
data.raw -= toMake;
|
data.raw = Math.max(0, data.raw - toMake);
|
||||||
data.product += toMake;
|
data.product = data.product + toMake;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto Sales
|
// Auto Sales
|
||||||
if (data.dealers > 0 && data.product > 0) {
|
if (data.dealers > 0 && data.product > 0) {
|
||||||
const sellPerSec = data.dealers * d.sellRate * dealerSpeed;
|
const sellPerSec = data.dealers * d.sellRate * dealerSpeed;
|
||||||
const toSell = Math.min(data.product, sellPerSec * deltaSec);
|
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);
|
const revenue = toSell * Math.floor(d.sellPrice * sellMulti);
|
||||||
G.cash += revenue;
|
G.cash += revenue;
|
||||||
G.totalEarned += revenue;
|
G.totalEarned += revenue;
|
||||||
|
|
@ -689,31 +710,70 @@ function tick(deltaSec) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Heat decay
|
|
||||||
if (G.heat > 0) {
|
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) {
|
if (deltaSec > 0) {
|
||||||
cashRateTracker = cashRateTracker * 0.85 + (earnedThisTick / deltaSec) * 0.15;
|
cashRateTracker = cashRateTracker * 0.85 + (earnedThisTick / deltaSec) * 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic raid check
|
// Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
|
||||||
if (Date.now() - lastRaidCheck > 12000) {
|
updateLiveInventory();
|
||||||
lastRaidCheck = Date.now();
|
|
||||||
checkRaidEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHud();
|
function updateLiveInventory() {
|
||||||
}
|
dom.cash.textContent = fmtNum(G.cash);
|
||||||
|
dom.rate.textContent = fmtNum(cashRateTracker);
|
||||||
function updateHud() {
|
dom.bonds.textContent = fmtInt(G.cleanBonds);
|
||||||
dom.cash.textContent = G.cash.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
dom.multiplier.textContent = fmtInt(G.cleanBonds * 4);
|
||||||
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.heatPct.textContent = `${Math.round(G.heat)}%`;
|
||||||
dom.heatBar.style.width = `${Math.min(100, 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
|
// Game loop with requestAnimationFrame
|
||||||
|
|
@ -722,39 +782,36 @@ function gameLoop(now) {
|
||||||
const delta = (now - lastFrameTime) / 1000;
|
const delta = (now - lastFrameTime) / 1000;
|
||||||
lastFrameTime = now;
|
lastFrameTime = now;
|
||||||
if (delta > 0 && delta < 5) {
|
if (delta > 0 && delta < 5) {
|
||||||
tick(delta);
|
clientTick(delta);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server tick: exactly every 6 seconds to prevent flooding server
|
||||||
|
setInterval(authoritativeServerTick, 6000);
|
||||||
|
|
||||||
// Clock updates
|
// Clock updates
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
dom.clock.textContent = d.toTimeString().substring(0, 5);
|
dom.clock.textContent = d.toTimeString().substring(0, 5);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Auto-save every 5 seconds (Local & Cloud)
|
// Periodic raid check (every 15 seconds)
|
||||||
setInterval(async () => {
|
setInterval(checkRaidEvent, 15000);
|
||||||
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
|
|
||||||
if (API.getToken()) {
|
|
||||||
await API.saveRemote(G);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// --- INITIALIZATION & EVENTS ---
|
// --- INITIALIZATION & EVENTS ---
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Check auth status
|
|
||||||
const username = API.getUsername();
|
const username = API.getUsername();
|
||||||
if (username) {
|
if (username) {
|
||||||
dom.userBadge.textContent = `[${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;
|
let loaded = false;
|
||||||
if (API.getToken()) {
|
if (API.getToken()) {
|
||||||
const remote = await API.loadRemoteSave();
|
const remote = await API.loadRemoteSave();
|
||||||
if (remote) {
|
if (remote) {
|
||||||
G = { ...INITIAL_GAME_STATE, ...remote };
|
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: 'lab' };
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -762,9 +819,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const local = localStorage.getItem('el_puro_local_save');
|
const local = localStorage.getItem('el_puro_local_save');
|
||||||
if (local) {
|
if (local) {
|
||||||
try {
|
try {
|
||||||
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local) };
|
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local), activeTab: 'lab' };
|
||||||
} catch (e) {
|
} 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.userBadge.textContent = `[${res.username}]`;
|
||||||
dom.authModal.classList.remove('active');
|
dom.authModal.classList.remove('active');
|
||||||
showToast(`Connected: Welcome ${res.username}`);
|
showToast(`Connected: Welcome ${res.username}`);
|
||||||
// Sync game save from cloud
|
// Fetch server authoritative state
|
||||||
const remote = await API.loadRemoteSave();
|
const remote = await API.loadRemoteSave();
|
||||||
if (remote) {
|
if (remote) {
|
||||||
G = { ...INITIAL_GAME_STATE, ...remote };
|
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: G.activeTab };
|
||||||
renderViewport();
|
renderViewport();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -802,7 +859,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
document.getElementById('btn-register-submit').addEventListener('click', async () => {
|
document.getElementById('btn-register-submit').addEventListener('click', async () => {
|
||||||
const u = document.getElementById('auth-username').value.trim();
|
const u = document.getElementById('auth-username').value.trim();
|
||||||
const p = document.getElementById('auth-password').value;
|
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);
|
if (!u || !p) return showToast('Enter username and password', true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -810,13 +867,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
dom.userBadge.textContent = `[${res.username}]`;
|
dom.userBadge.textContent = `[${res.username}]`;
|
||||||
dom.authModal.classList.remove('active');
|
dom.authModal.classList.remove('active');
|
||||||
showToast(`Syndicate registered: ${res.username}`);
|
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) {
|
} catch (e) {
|
||||||
showToast(e.message, true);
|
showToast(e.message, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderViewport();
|
renderViewport();
|
||||||
updateHud();
|
updateLiveInventory();
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue