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.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}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue