From 81cf7f2129a69139ca52212c0427b0420d0bdfc9 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 15 Oct 2026 18:17:21 +0200 Subject: [PATCH] feat(progression): add unit milestones, multi-tiered trophies, buy multipliers and offshore perk shop --- backend/engine.py | 290 ++++++++++++++--- frontend/css/style.css | 209 +++++++++++++ frontend/index.html | 17 + frontend/js/config.js | 217 ++++++++++++- frontend/js/game.js | 693 ++++++++++++++++++++++++++++++++--------- 5 files changed, 1216 insertions(+), 210 deletions(-) diff --git a/backend/engine.py b/backend/engine.py index 16848d8..19897b7 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -1,7 +1,6 @@ import math import time -# Drug Catalog mirroring config.js # Drug Catalog mirroring config.js DRUG_CONFIGS = { 'weed': {'rawCost': 10, 'sellPrice': 16, 'produceRate': 1.0, 'sellRate': 1.0, 'supplyRate': 1.0, 'supplierCost': 75, 'workerCost': 100, 'dealerCost': 150, 'unlockCost': 0}, @@ -28,23 +27,70 @@ SKILL_COSTS = { 'smug_1': 2000, 'smug_2': 50000, 'smug_3': 1000000 } +UNIT_MILESTONES = [1, 5, 10, 25, 50, 100, 200, 300, 400, 500, 750, 1000, 1500, 2000] + +OFFSHORE_PERK_COSTS = { + 'swiss_vault': [1, 3, 8, 20, 50], + 'auto_contracts': [2, 5, 12, 30], + 'corrupt_judiciary': [2, 6, 15, 40], + 'bulk_discount': [5, 15, 35, 80], + 'compound_interest': [4, 10, 25, 60, 150] +} + +ACHIEVEMENT_TIER_REQS = { + 'dirty_empire': [10000, 250000, 5000000, 100000000, 1000000000], + 'master_synthesizer': [100, 1000, 15000, 200000, 2500000], + 'street_monopoly': [100, 1500, 25000, 300000, 3000000], + 'supply_baron': [250, 3000, 50000, 500000], + 'vault_hoarder': [50000, 500000, 10000000, 250000000], + 'syndicate_army': [25, 100, 500, 2500], + 'purity_legend': [5, 25, 100, 500], + 'shadow_cartel': [3, 10, 30], + 'swiss_kingpin': [1, 3, 10, 25], + 'formula_expansion': [3, 6, 8, 10] +} + 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_unit_milestone_multiplier(count: int) -> float: + """Calculates 2x multiplier for each milestone threshold achieved.""" + reached = sum(1 for m in UNIT_MILESTONES if count >= m) + return float(math.pow(2.0, reached)) + +def get_all_tier_milestone_multiplier(state: dict, unit_key: str) -> float: + """Calculates 2x global set multiplier when all unlocked drugs reach a milestone.""" + unlocked = [d for d in state.get('drugs', {}).values() if d.get('unlocked')] + if not unlocked: + return 1.0 + min_count = min(d.get(unit_key, 0) for d in unlocked) + reached = sum(1 for m in UNIT_MILESTONES if min_count >= m) + return float(math.pow(2.0, reached)) + def get_initial_state() -> dict: state = { 'cash': 25.0, 'cleanBonds': 0, + 'totalCleanBonds': 0, 'heat': 0.0, 'totalEarned': 0.0, - 'lastTick': time.time(), - 'drugs': {}, + 'stats': { + 'totalProduced': 0.0, + 'totalSold': 0.0, + 'totalSupplied': 0.0, + 'purityProcs': 0, + 'raidsSurvived': 0, + 'prestigeCount': 0 + }, + 'achievements': {}, # { ach_id: claimed_tier_int } + 'offshorePerks': {}, # { perk_id: level_int } 'skills': {s: False for s in SKILL_COSTS}, - 'achievements': {}, - 'synthesis': {r: 0 for r in SYNTHESIS_RECIPES} + 'synthesis': {r: 0 for r in SYNTHESIS_RECIPES}, + 'drugs': {}, + 'lastTick': time.time() } for drug_id, cfg in DRUG_CONFIGS.items(): state['drugs'][drug_id] = { @@ -58,26 +104,100 @@ def get_initial_state() -> dict: return state def get_multipliers(state: dict): - bonds = state.get('cleanBonds', 0) + # Total lifetime bonds provide permanent base boost + total_bonds = max(state.get('totalCleanBonds', 0), state.get('cleanBonds', 0)) skills = state.get('skills', {}) - achievements = state.get('achievements', {}) + ach = state.get('achievements', {}) + perks = state.get('offshorePerks', {}) - prod_m = 1.0 + (bonds * 0.03) + # Compound interest perk raises bond sale value + compound_lvl = perks.get('compound_interest', 0) + bond_sell_rate = 0.04 + (compound_lvl * 0.02) + bond_prod_rate = 0.03 + + prod_m = 1.0 + (total_bonds * bond_prod_rate) if skills.get('chem_1'): prod_m += 0.15 - if achievements.get('millionaire'): prod_m += 0.20 - sell_m = 1.0 + (bonds * 0.04) + # Achievement bonuses for production + synth_tier = ach.get('master_synthesizer', 0) + prod_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, synth_tier)] + formula_tier = ach.get('formula_expansion', 0) + prod_m += [0.0, 0.05, 0.10, 0.15, 0.30][min(4, formula_tier)] + + # Sales multiplier + sell_m = 1.0 + (total_bonds * bond_sell_rate) 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 + empire_tier = ach.get('dirty_empire', 0) + sell_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, empire_tier)] + # Raw material cost multiplier raw_m = 0.85 if skills.get('chem_2') else 1.0 + vault_tier = ach.get('vault_hoarder', 0) + raw_discount = [0.0, 0.05, 0.10, 0.15, 0.25][min(4, vault_tier)] + raw_m = max(0.1, raw_m - raw_discount) + # Dealer speed multiplier dealer_m = 1.0 if skills.get('king_1'): dealer_m += 0.20 if skills.get('king_3'): dealer_m += 0.50 + street_tier = ach.get('street_monopoly', 0) + dealer_m += [0.0, 0.05, 0.10, 0.15, 0.25, 0.50][min(5, street_tier)] - return prod_m, sell_m, raw_m, dealer_m + # Supplier speed multiplier + supply_tier = ach.get('supply_baron', 0) + supplier_m = 1.0 + [0.0, 0.05, 0.10, 0.15, 0.25][min(4, supply_tier)] + + # Syndicate Army all speed multiplier + army_tier = ach.get('syndicate_army', 0) + all_unit_m = 1.0 + [0.0, 0.05, 0.10, 0.15, 0.25][min(4, army_tier)] + + dealer_m *= all_unit_m + supplier_m *= all_unit_m + worker_m = all_unit_m + + return prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m + +def get_current_stat_value(state: dict, stat_key: str) -> float: + """Helper to query current progress for an achievement category.""" + if stat_key == 'totalEarned': + return float(state.get('totalEarned', 0.0)) + elif stat_key == 'cash': + return float(state.get('cash', 0.0)) + elif stat_key == 'totalPersonnel': + total = 0 + for d in state.get('drugs', {}).values(): + total += d.get('suppliers', 0) + d.get('workers', 0) + d.get('dealers', 0) + return float(total) + elif stat_key == 'unlockedCount': + return float(sum(1 for d in state.get('drugs', {}).values() if d.get('unlocked'))) + else: + return float(state.get('stats', {}).get(stat_key, 0.0)) + +def validate_achievement_claim(state: dict, ach_id: str, tier: int) -> bool: + reqs = ACHIEVEMENT_TIER_REQS.get(ach_id) + if not reqs or tier < 1 or tier > len(reqs): + return False + + current_claimed = state.get('achievements', {}).get(ach_id, 0) + if tier != current_claimed + 1: + return False + + req_val = reqs[tier - 1] + stat_key_map = { + 'dirty_empire': 'totalEarned', + 'master_synthesizer': 'totalProduced', + 'street_monopoly': 'totalSold', + 'supply_baron': 'totalSupplied', + 'vault_hoarder': 'cash', + 'syndicate_army': 'totalPersonnel', + 'purity_legend': 'purityProcs', + 'shadow_cartel': 'raidsSurvived', + 'swiss_kingpin': 'prestigeCount', + 'formula_expansion': 'unlockedCount' + } + stat_key = stat_key_map.get(ach_id, 'totalEarned') + current_val = get_current_stat_value(state, stat_key) + return current_val >= req_val def process_offline_and_ticks(state: dict) -> dict: now = time.time() @@ -88,7 +208,14 @@ def process_offline_and_ticks(state: dict) -> dict: if delta <= 0: return sanitize_state(state) - prod_m, sell_m, raw_m, dealer_m = get_multipliers(state) + prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m = get_multipliers(state) + + # All-tier milestone multipliers + all_tier_sup = get_all_tier_milestone_multiplier(state, 'suppliers') + all_tier_work = get_all_tier_milestone_multiplier(state, 'workers') + all_tier_deal = get_all_tier_milestone_multiplier(state, 'dealers') + + stats = state.setdefault('stats', {}) for drug_id, data in state.get('drugs', {}).items(): cfg = DRUG_CONFIGS.get(drug_id) @@ -98,54 +225,48 @@ def process_offline_and_ticks(state: dict) -> dict: # Suppliers automatic raw cultivation / procurement suppliers = data.get('suppliers', 0) if suppliers > 0: - supply_speed = suppliers * cfg.get('supplyRate', 1.0) * prod_m - data['raw'] = data.get('raw', 0.0) + (supply_speed * delta) + unit_milestone_m = get_unit_milestone_multiplier(suppliers) + supply_speed = suppliers * cfg.get('supplyRate', 1.0) * prod_m * supplier_m * unit_milestone_m * all_tier_sup + added_raw = supply_speed * delta + data['raw'] = data.get('raw', 0.0) + added_raw + stats['totalSupplied'] = stats.get('totalSupplied', 0.0) + added_raw # Workers production (Refining raw -> product) 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 + unit_milestone_m = get_unit_milestone_multiplier(workers) + potential_prod = workers * cfg['produceRate'] * prod_m * worker_m * unit_milestone_m * all_tier_work * delta actual_prod = min(raw, potential_prod) data['raw'] = max(0.0, raw - actual_prod) data['product'] = data.get('product', 0.0) + actual_prod + stats['totalProduced'] = stats.get('totalProduced', 0.0) + actual_prod # Dealers selling (Product -> Cash) 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 + unit_milestone_m = get_unit_milestone_multiplier(dealers) + potential_sell = dealers * cfg['sellRate'] * dealer_m * unit_milestone_m * all_tier_deal * 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 + stats['totalSold'] = stats.get('totalSold', 0.0) + actual_sell # Heat decay state['heat'] = max(0.0, state.get('heat', 0.0) - (0.5 * delta)) - check_server_achievements(state) - return sanitize_state(state) -def check_server_achievements(state: dict): - ach = state.setdefault('achievements', {}) - if state.get('totalEarned', 0) > 0: - ach['first_hit'] = True - if state.get('drugs', {}).get('weed', {}).get('workers', 0) >= 10: - ach['grower_op'] = True - if state.get('drugs', {}).get('meth', {}).get('unlocked'): - ach['blue_sky'] = True - if state.get('drugs', {}).get('cocaine', {}).get('unlocked'): - ach['cartel_tier'] = True - if state.get('cash', 0) >= 1000000: - ach['millionaire'] = True - if state.get('cleanBonds', 0) >= 1: - ach['laundered'] = True - 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) + prod_m, sell_m, raw_m, dealer_m, supplier_m, worker_m = get_multipliers(state) + + # Unit price scaling factor (1.15 down to 1.11 via bulk_discount perk) + discount_lvl = state.get('offshorePerks', {}).get('bulk_discount', 0) + scaling_r = max(1.10, 1.15 - (discount_lvl * 0.01)) for act in actions: action_type = act.get('type') @@ -159,6 +280,7 @@ def apply_actions(state: dict, actions: list) -> dict: if state['cash'] >= total_cost: state['cash'] -= total_cost state['drugs'][drug_id]['raw'] = state['drugs'][drug_id].get('raw', 0.0) + amount + state.setdefault('stats', {})['totalSupplied'] = state['stats'].get('totalSupplied', 0.0) + amount elif action_type == 'manufacture' and drug_id in DRUG_CONFIGS: data = state['drugs'][drug_id] @@ -168,10 +290,16 @@ def apply_actions(state: dict, actions: list) -> dict: data['raw'] -= actual yield_count = actual if state.get('skills', {}).get('chem_3'): - # Purity proc bonus + # 10% chance on client for 3x yield yield_count *= 1.3 + state.setdefault('stats', {})['purityProcs'] = state['stats'].get('purityProcs', 0) + 1 data['product'] = data.get('product', 0.0) + yield_count - state['heat'] = min(100.0, state.get('heat', 0.0) + (0.5 * actual)) + state.setdefault('stats', {})['totalProduced'] = state['stats'].get('totalProduced', 0.0) + yield_count + + # Swiss vault secrecy perk reduces heat generation + vault_lvl = state.get('offshorePerks', {}).get('swiss_vault', 0) + heat_rise = max(0.2, (0.5 * actual) * (1.0 - vault_lvl * 0.12)) + state['heat'] = min(100.0, state.get('heat', 0.0) + heat_rise) elif action_type == 'sell_manual' and drug_id in DRUG_CONFIGS: data = state['drugs'][drug_id] @@ -183,7 +311,11 @@ def apply_actions(state: dict, actions: list) -> dict: 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)) + state.setdefault('stats', {})['totalSold'] = state['stats'].get('totalSold', 0.0) + actual + + vault_lvl = state.get('offshorePerks', {}).get('swiss_vault', 0) + heat_rise = max(0.3, (0.8 * actual) * (1.0 - vault_lvl * 0.12)) + state['heat'] = min(100.0, state.get('heat', 0.0) + heat_rise) elif action_type == 'hire_supplier' and drug_id in DRUG_CONFIGS: cfg = DRUG_CONFIGS[drug_id] @@ -191,7 +323,7 @@ def apply_actions(state: dict, actions: list) -> dict: base_cost = cfg.get('supplierCost', 75) for _ in range(hire_qty): count = state['drugs'][drug_id].get('suppliers', 0) - cost = math.floor(base_cost * math.pow(1.15, count)) + cost = math.floor(base_cost * math.pow(scaling_r, count)) if state['cash'] >= cost: state['cash'] -= cost state['drugs'][drug_id]['suppliers'] = count + 1 @@ -204,7 +336,7 @@ def apply_actions(state: dict, actions: list) -> dict: base_cost = cfg['workerCost'] for _ in range(hire_qty): count = state['drugs'][drug_id].get('workers', 0) - cost = math.floor(base_cost * math.pow(1.15, count)) + cost = math.floor(base_cost * math.pow(scaling_r, count)) if state['cash'] >= cost: state['cash'] -= cost state['drugs'][drug_id]['workers'] = count + 1 @@ -217,7 +349,7 @@ def apply_actions(state: dict, actions: list) -> dict: base_cost = cfg['dealerCost'] for _ in range(hire_qty): count = state['drugs'][drug_id].get('dealers', 0) - cost = math.floor(base_cost * math.pow(1.15, count)) + cost = math.floor(base_cost * math.pow(scaling_r, count)) if state['cash'] >= cost: state['cash'] -= cost state['drugs'][drug_id]['dealers'] = count + 1 @@ -230,6 +362,12 @@ def apply_actions(state: dict, actions: list) -> dict: if state['cash'] >= cost and not state['drugs'][drug_id].get('unlocked'): state['cash'] -= cost state['drugs'][drug_id]['unlocked'] = True + + # Procurement pipeline perk gives free starter units! + starter_units = state.get('offshorePerks', {}).get('auto_contracts', 0) + if starter_units > 0: + state['drugs'][drug_id]['suppliers'] = state['drugs'][drug_id].get('suppliers', 0) + starter_units + state['drugs'][drug_id]['workers'] = state['drugs'][drug_id].get('workers', 0) + starter_units elif action_type == 'blend_recipe': recipe_id = act.get('recipe_id') @@ -255,28 +393,72 @@ def apply_actions(state: dict, actions: list) -> dict: state['cash'] -= cost state.setdefault('skills', {})[skill_id] = True + elif action_type == 'claim_achievement': + ach_id = act.get('ach_id') + tier = int(act.get('tier', 1)) + if validate_achievement_claim(state, ach_id, tier): + state.setdefault('achievements', {})[ach_id] = tier + + elif action_type == 'buy_offshore_perk': + perk_id = act.get('perk_id') + if perk_id in OFFSHORE_PERK_COSTS: + costs = OFFSHORE_PERK_COSTS[perk_id] + current_lvl = state.setdefault('offshorePerks', {}).get(perk_id, 0) + if current_lvl < len(costs): + cost = costs[current_lvl] + if state.get('cleanBonds', 0) >= cost: + state['cleanBonds'] -= cost + state['offshorePerks'][perk_id] = current_lvl + 1 + elif action_type == 'bribe_police': - bribe_cost = max(100.0, state['cash'] * 0.15) + base_bribe = max(100.0, state['cash'] * 0.15) + judiciary_lvl = state.get('offshorePerks', {}).get('corrupt_judiciary', 0) + bribe_cost = max(25.0, base_bribe * (1.0 - judiciary_lvl * 0.20)) if state['cash'] >= bribe_cost: state['cash'] -= bribe_cost state['heat'] = 0.0 + state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1 elif action_type == 'flush_stash': state['heat'] = 20.0 + state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1 + + elif action_type == 'breakout_raid': + success = bool(act.get('success', False)) + if success: + state['heat'] = 0.0 + state.setdefault('stats', {})['raidsSurvived'] = state['stats'].get('raidsSurvived', 0) + 1 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)) + gain = max(0, bonds_to_earn - state.get('totalCleanBonds', 0)) if gain >= 1: - state['cleanBonds'] += gain - state['cash'] = 50.0 + state['totalCleanBonds'] = state.get('totalCleanBonds', 0) + gain + state['cleanBonds'] = state.get('cleanBonds', 0) + gain + + stats = state.setdefault('stats', {}) + stats['prestigeCount'] = stats.get('prestigeCount', 0) + 1 + + # Calculate start cash with bond and swiss kingpin bonuses + start_cash = 50.0 + (state['totalCleanBonds'] * 25.0) + swiss_tier = state.get('achievements', {}).get('swiss_kingpin', 0) + if swiss_tier >= 1: start_cash += 250.0 + if swiss_tier >= 2: start_cash += 2500.0 + if swiss_tier >= 3: start_cash += 25000.0 + if swiss_tier >= 4: start_cash += 250000.0 + + state['cash'] = start_cash state['heat'] = 0.0 + + # Starter units from auto_contracts perk + free_starter = state.get('offshorePerks', {}).get('auto_contracts', 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['suppliers'] = 0 - d_data['workers'] = 0 + d_data['suppliers'] = free_starter if d_data['unlocked'] else 0 + d_data['workers'] = free_starter if d_data['unlocked'] else 0 d_data['dealers'] = 0 return sanitize_state(state) @@ -286,7 +468,17 @@ def sanitize_state(state: dict) -> dict: 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)) + state['cleanBonds'] = max(0, int(state.get('cleanBonds', 0))) + state['totalCleanBonds'] = max(state['cleanBonds'], int(state.get('totalCleanBonds', 0))) + + stats = state.setdefault('stats', {}) + for k in ['totalProduced', 'totalSold', 'totalSupplied']: + stats[k] = clean_round(stats.get(k, 0.0)) + for k in ['purityProcs', 'raidsSurvived', 'prestigeCount']: + stats[k] = int(stats.get(k, 0)) + + state.setdefault('achievements', {}) + state.setdefault('offshorePerks', {}) for d_id, data in state.get('drugs', {}).items(): data['raw'] = clean_round(data.get('raw', 0.0)) diff --git a/frontend/css/style.css b/frontend/css/style.css index 589a455..e3284e3 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -258,6 +258,215 @@ html, body { font-weight: 700; } +/* Buy Multiplier Selector Bar */ +#buy-multiplier-bar { + background: #090c0f; + border-top: 1px solid rgba(36, 45, 54, 0.5); + padding: 6px 14px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.buy-multi-label { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-secondary); + font-weight: 700; + letter-spacing: 0.5px; +} + +.buy-multi-group { + display: flex; + gap: 4px; +} + +.btn-multi-pill { + background: #141b22; + color: var(--text-secondary); + border: 1px solid #283442; + border-radius: 6px; + padding: 4px 10px; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; +} + +.btn-multi-pill:hover { + color: var(--text-primary); + border-color: var(--border-accent); +} + +.btn-multi-pill.active { + background: #252814; + border-color: var(--brand-gold); + color: var(--brand-gold); + box-shadow: 0 0 8px rgba(234, 179, 8, 0.25); +} + +/* Nav Badge for unclaimed Trophies */ +.nav-badge { + position: absolute; + top: 4px; + right: 22%; + background: var(--neon-red); + color: #fff; + font-size: 0.6rem; + font-weight: 800; + padding: 1px 5px; + border-radius: 10px; + min-width: 16px; + text-align: center; + box-shadow: 0 0 8px rgba(239, 68, 68, 0.8); + animation: pulse 1.8s infinite; +} + +/* Milestone Indicator */ +.milestone-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-family: var(--font-mono); + font-size: 0.65rem; + background: #121d26; + border: 1px solid #233546; + border-radius: 4px; + padding: 1px 6px; + color: var(--neon-cyan); +} + +/* Trophy Cabinet & Achievements */ +.trophy-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + overflow: hidden; +} + +.trophy-card.completed { + border-color: #22c55e; + background: #0d1a14; +} + +.trophy-card.claimable { + border-color: var(--brand-gold); + box-shadow: 0 0 12px rgba(234, 179, 8, 0.3); + animation: glow-gold 2s infinite alternate; +} + +@keyframes glow-gold { + from { box-shadow: 0 0 6px rgba(234, 179, 8, 0.2); } + to { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } +} + +.trophy-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.trophy-stars { + color: var(--brand-gold); + font-size: 0.85rem; + letter-spacing: 2px; +} + +.trophy-progress-track { + width: 100%; + height: 6px; + background: #161e27; + border-radius: 3px; + overflow: hidden; +} + +.trophy-progress-fill { + height: 100%; + background: linear-gradient(90deg, #06b6d4, #22c55e); + transition: width 0.3s ease; +} + +.btn-claim-reward { + background: linear-gradient(135deg, #eab308 0%, #ca8a04 100%); + color: #000; + font-weight: 800; + font-family: var(--font-mono); + border: none; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + text-align: center; + box-shadow: 0 0 12px rgba(234, 179, 8, 0.6); + transition: transform 0.1s, filter 0.15s; +} + +.btn-claim-reward:active { + transform: scale(0.96); +} + +/* Swiss Offshore Perk Shop */ +.perk-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-top: 10px; +} + +.perk-card { + background: #0d1217; + border: 1px solid #1f2a36; + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.perk-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.perk-lvl-badge { + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: bold; + color: var(--neon-cyan); + background: #101c26; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid #1a3246; +} + +.btn-perk-buy { + background: #122332; + border: 1px solid #234562; + color: var(--neon-cyan); + padding: 8px; + border-radius: 6px; + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s; +} + +.btn-perk-buy:hover:not(:disabled) { + background: #18334a; +} + +.btn-perk-buy:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* Main Viewport Content */ #main-viewport { flex: 1; diff --git a/frontend/index.html b/frontend/index.html index 8b4438c..bb7f3b7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -71,6 +71,18 @@ + + +
+ BUY QTY: +
+ + + + + +
+
@@ -91,6 +103,11 @@ Streets + - @@ -291,8 +420,13 @@ function renderStreetsView() { 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 dealers = data.dealers || 0; + const dealMilestoneM = getUnitMilestoneMultiplier(dealers); + const allTierDealM = getAllTierMilestoneMultiplier('dealers'); + const dealInfo = getUnitPurchaseInfo(d.id, 'dealers'); + const nextDealM = getNextMilestone(dealers); const unitPrice = Math.floor(d.sellPrice * getSellPriceMultiplier()); + const dealSpeed = dealers * d.sellRate * getDealerSpeedMultiplier() * dealMilestoneM * allTierDealM; html += `
@@ -303,7 +437,10 @@ function renderStreetsView() {
${d.name}
-
${d.dealerName}s: ${data.dealers}
+
+ ${d.dealerName}s: ${dealers} + M: ${dealers}/${nextDealM} (${dealMilestoneM}x) +
@@ -318,14 +455,14 @@ function renderStreetsView() {
Deal Speed - ${fmtNum(data.dealers * d.sellRate * getDealerSpeedMultiplier())}/s + ${fmtNum(dealSpeed)}/s
- + ` : isMaxed ? ` + MAX TIER REACHED + ` : ''} +
+ + `; + }); + + dom.viewport.innerHTML = html; +} + +// 4. OFFSHORE (PRESTIGE & SWISS PERK SHOP) function renderOffshoreView() { + const totalLifetimeBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0); const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000)); - const nextBonds = Math.max(0, bondsToEarn - G.cleanBonds); + const nextBonds = Math.max(0, bondsToEarn - totalLifetimeBonds); + const prodBoostPct = totalLifetimeBonds * 3; + const compoundLvl = (G.offshorePerks && G.offshorePerks['compound_interest']) || 0; + const salesRate = 4 + (compoundLvl * 2); + const salesBoostPct = totalLifetimeBonds * salesRate; let html = `
SWISS ACCOUNTS & MONEY LAUNDERING
-
- -
Launder Dirty Syndicate Cash
-

+

+ +
Launder Dirty Syndicate Cash
+

Reset dirty cash, lab equipment, and street dealers in exchange for permanent Clean Offshore Bonds. - Each Bond permanently boosts production (+3%) and sale values (+4%). + Total lifetime bonds grant permanent passive boosts across all runs.

-
-
CLEAN BONDS UPON LAUNDERING:
+
+
CLEAN BONDS GAINED UPON LAUNDERING:
+${nextBonds} Bonds
-
Career Volume: $${fmtNum(G.totalEarned)}
+
+ Career Volume: $${fmtNum(G.totalEarned)} • Lifetime Bonds: ${totalLifetimeBonds} +
+
+ Current Passive Boost: +${prodBoostPct}% Production • +${salesBoostPct}% Sales Value +
-
-
- GLOBAL SYNDICATE ACHIEVEMENTS +
+ + + SWISS BLACK MARKET PERKS + + Spendable: ${G.cleanBonds || 0} Bonds
+
`; - ACHIEVEMENTS.forEach(a => { - const has = G.achievements[a.id]; + OFFSHORE_PERKS.forEach(perk => { + const currentLvl = (G.offshorePerks && G.offshorePerks[perk.id]) || 0; + const maxLvl = perk.costs.length; + const isMaxed = currentLvl >= maxLvl; + const nextCost = isMaxed ? null : perk.costs[currentLvl]; + const canBuy = !isMaxed && (G.cleanBonds || 0) >= nextCost; + html += ` -
-
-
- ${has ? '🏆 ' : '🔒 '}${a.name} +
+
+
+ +
+
${perk.name}
+
${perk.desc}
+
-
${a.desc}
+ ${currentLvl > 0 ? `LVL ${currentLvl}/${maxLvl}` : 'UNLOCKED 0'}
-
- ${a.reward} +
+ + ${currentLvl > 0 ? perk.formatBonus(currentLvl) : 'Inactive'} + +
`; }); + html += `
`; dom.viewport.innerHTML = html; } @@ -517,13 +772,11 @@ function queueAction(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; - queueAction({ type: 'buy_raw', drug_id: drugId, amount: 1 }); + const rawInfo = getRawPurchaseInfo(drugId); + if (G.cash >= rawInfo.totalCost && rawInfo.qty > 0) { + G.cash -= rawInfo.totalCost; + G.drugs[drugId].raw += rawInfo.qty; + queueAction({ type: 'buy_raw', drug_id: drugId, amount: rawInfo.qty }); updateLiveInventory(); } } @@ -536,10 +789,15 @@ function manufacture(drugId) { if (G.skills['chem_3'] && Math.random() < 0.10) { yieldCount = 3; showToast('99.1% Purity Proc! 3x Yield!', false); + if (!G.stats) G.stats = {}; + G.stats.purityProcs = (G.stats.purityProcs || 0) + 1; } data.product += yieldCount; + if (!G.stats) G.stats = {}; + G.stats.totalProduced = (G.stats.totalProduced || 0) + yieldCount; addHeat(0.5); queueAction({ type: 'manufacture', drug_id: drugId, amount: 1 }); + checkAchievements(); updateLiveInventory(); } } @@ -553,6 +811,8 @@ function sellManual(drugId, amount = 1) { const profit = actual * Math.floor(d.sellPrice * getSellPriceMultiplier()); G.cash += profit; G.totalEarned += profit; + if (!G.stats) G.stats = {}; + G.stats.totalSold = (G.stats.totalSold || 0) + actual; addHeat(actual * 0.8); queueAction({ type: 'sell_manual', drug_id: drugId, amount: actual }); checkAchievements(); @@ -561,12 +821,12 @@ function sellManual(drugId, amount = 1) { } function hireSupplier(drugId) { - const cost = getSupplierCost(drugId); - if (G.cash >= cost) { - G.cash -= cost; + const supInfo = getUnitPurchaseInfo(drugId, 'suppliers'); + if (G.cash >= supInfo.totalCost && supInfo.qty > 0) { + G.cash -= supInfo.totalCost; if (!G.drugs[drugId].suppliers) G.drugs[drugId].suppliers = 0; - G.drugs[drugId].suppliers += 1; - queueAction({ type: 'hire_supplier', drug_id: drugId, count: 1 }); + G.drugs[drugId].suppliers += supInfo.qty; + queueAction({ type: 'hire_supplier', drug_id: drugId, count: supInfo.qty }); checkAchievements(); renderViewport(); updateLiveInventory(); @@ -574,11 +834,11 @@ function hireSupplier(drugId) { } function hireWorker(drugId) { - const cost = getWorkerCost(drugId); - if (G.cash >= cost) { - G.cash -= cost; - G.drugs[drugId].workers += 1; - queueAction({ type: 'hire_worker', drug_id: drugId, count: 1 }); + const workInfo = getUnitPurchaseInfo(drugId, 'workers'); + if (G.cash >= workInfo.totalCost && workInfo.qty > 0) { + G.cash -= workInfo.totalCost; + G.drugs[drugId].workers = (G.drugs[drugId].workers || 0) + workInfo.qty; + queueAction({ type: 'hire_worker', drug_id: drugId, count: workInfo.qty }); checkAchievements(); renderViewport(); updateLiveInventory(); @@ -586,11 +846,44 @@ function hireWorker(drugId) { } function hireDealer(drugId) { - const cost = getDealerCost(drugId); - if (G.cash >= cost) { - G.cash -= cost; - G.drugs[drugId].dealers += 1; - queueAction({ type: 'hire_dealer', drug_id: drugId, count: 1 }); + const dealInfo = getUnitPurchaseInfo(drugId, 'dealers'); + if (G.cash >= dealInfo.totalCost && dealInfo.qty > 0) { + G.cash -= dealInfo.totalCost; + G.drugs[drugId].dealers = (G.drugs[drugId].dealers || 0) + dealInfo.qty; + queueAction({ type: 'hire_dealer', drug_id: drugId, count: dealInfo.qty }); + checkAchievements(); + renderViewport(); + updateLiveInventory(); + } +} + +function claimAchievement(achId, tier) { + if (!G.achievements) G.achievements = {}; + const currentTier = G.achievements[achId] || 0; + if (tier === currentTier + 1) { + G.achievements[achId] = tier; + queueAction({ type: 'claim_achievement', ach_id: achId, tier }); + const achDef = ACHIEVEMENT_TIERS.find(a => a.id === achId); + const tierDef = achDef ? achDef.tiers[tier - 1] : null; + showToast(`🏆 Claimed: ${achDef.name} - ${tierDef ? tierDef.name : ''}!`); + checkAchievements(); + renderViewport(); + updateLiveInventory(); + } +} + +function buyOffshorePerk(perkId) { + if (!G.offshorePerks) G.offshorePerks = {}; + const perk = OFFSHORE_PERKS.find(p => p.id === perkId); + if (!perk) return; + const currentLvl = G.offshorePerks[perkId] || 0; + if (currentLvl >= perk.costs.length) return; + const cost = perk.costs[currentLvl]; + if ((G.cleanBonds || 0) >= cost) { + G.cleanBonds -= cost; + G.offshorePerks[perkId] = currentLvl + 1; + queueAction({ type: 'buy_offshore_perk', perk_id: perkId }); + showToast(`Invested in ${perk.name} (Lvl ${currentLvl + 1})!`); renderViewport(); updateLiveInventory(); } @@ -601,6 +894,14 @@ function unlockDrug(drugId) { if (G.cash >= cost && !G.drugs[drugId].unlocked) { G.cash -= cost; G.drugs[drugId].unlocked = true; + + // Check Procurement Pipeline perk + const starterUnits = (G.offshorePerks && G.offshorePerks['auto_contracts']) || 0; + if (starterUnits > 0) { + G.drugs[drugId].suppliers = (G.drugs[drugId].suppliers || 0) + starterUnits; + G.drugs[drugId].workers = (G.drugs[drugId].workers || 0) + starterUnits; + } + showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`); queueAction({ type: 'unlock_drug', drug_id: drugId }); checkAchievements(); @@ -639,27 +940,40 @@ function buySkill(skillId) { } function launderMoney() { + const totalLifetimeBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0); const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000)); - const gain = Math.max(0, bondsToEarn - G.cleanBonds); + const gain = Math.max(0, bondsToEarn - totalLifetimeBonds); 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 with permanent perks retained.`)) { queueAction({ type: 'launder_prestige' }); - G.cleanBonds += gain; - G.cash = 50.0; + G.totalCleanBonds = totalLifetimeBonds + gain; + G.cleanBonds = (G.cleanBonds || 0) + gain; + if (!G.stats) G.stats = {}; + G.stats.prestigeCount = (G.stats.prestigeCount || 0) + 1; + + let startCash = 50.0 + (G.totalCleanBonds * 25.0); + const swissTier = (G.achievements && G.achievements['swiss_kingpin']) || 0; + if (swissTier >= 1) startCash += 250.0; + if (swissTier >= 2) startCash += 2500.0; + if (swissTier >= 3) startCash += 25000.0; + if (swissTier >= 4) startCash += 250000.0; + + G.cash = startCash; G.heat = 0.0; + const starterUnits = (G.offshorePerks && G.offshorePerks['auto_contracts']) || 0; DRUG_CATALOG.forEach(d => { G.drugs[d.id] = { unlocked: d.unlockedDefault, raw: 0.0, product: 0.0, - suppliers: 0, - workers: 0, + suppliers: d.unlockedDefault ? starterUnits : 0, + workers: d.unlockedDefault ? starterUnits : 0, dealers: 0 }; }); checkAchievements(); - showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`); + showToast(`Laundered! You now hold ${G.cleanBonds} Spendable Bonds ($${startCash.toLocaleString()} starting cash).`); renderViewport(); updateLiveInventory(); } @@ -721,31 +1035,38 @@ function triggerRaidModal() { }; } -// --- ACHIEVEMENTS CHECK --- +// --- ACHIEVEMENTS CHECK & BADGE UPDATE --- function checkAchievements() { - if (G.totalEarned > 0 && !G.achievements['first_hit']) { - G.achievements['first_hit'] = true; - showToast('Achievement: First Batch!'); - } - 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'] && G.drugs['meth'].unlocked && !G.achievements['blue_sky']) { - G.achievements['blue_sky'] = true; - showToast('Achievement: Say My Name!'); - } - if (G.drugs['cocaine'] && G.drugs['cocaine'].unlocked && !G.achievements['cartel_tier']) { - G.achievements['cartel_tier'] = true; - showToast('Achievement: El Patrón!'); - } - if (G.cash >= 1000000 && !G.achievements['millionaire']) { - G.achievements['millionaire'] = true; - showToast('Achievement: Seven Figures!'); - } - if (G.cleanBonds >= 1 && !G.achievements['laundered']) { - G.achievements['laundered'] = true; - showToast('Achievement: Clean Hands!'); + let unreadCount = 0; + ACHIEVEMENT_TIERS.forEach(ach => { + const currentTier = (G.achievements && G.achievements[ach.id]) || 0; + const maxTier = ach.tiers.length; + if (currentTier < maxTier) { + let currentVal = 0; + if (ach.statKey === 'totalEarned') currentVal = G.totalEarned || 0; + else if (ach.statKey === 'cash') currentVal = G.cash || 0; + else if (ach.statKey === 'totalPersonnel') { + currentVal = Object.values(G.drugs || {}).reduce((sum, d) => sum + (d.suppliers || 0) + (d.workers || 0) + (d.dealers || 0), 0); + } else if (ach.statKey === 'unlockedCount') { + currentVal = Object.values(G.drugs || {}).filter(d => d.unlocked).length; + } else { + currentVal = (G.stats && G.stats[ach.statKey]) || 0; + } + + const nextReq = ach.tiers[currentTier].req; + if (currentVal >= nextReq) { + unreadCount += 1; + } + } + }); + + if (dom.trophyBadge) { + if (unreadCount > 0) { + dom.trophyBadge.textContent = unreadCount; + dom.trophyBadge.style.display = 'block'; + } else { + dom.trophyBadge.style.display = 'none'; + } } } @@ -755,6 +1076,12 @@ function clientTick(deltaSec) { const prodMulti = getProductionMultiplier(); const sellMulti = getSellPriceMultiplier(); const dealerSpeed = getDealerSpeedMultiplier(); + const supMulti = getSupplierSpeedMultiplier(); + const workMulti = getWorkerSpeedMultiplier(); + + const allTierSupM = getAllTierMilestoneMultiplier('suppliers'); + const allTierWorkM = getAllTierMilestoneMultiplier('workers'); + const allTierDealM = getAllTierMilestoneMultiplier('dealers'); DRUG_CATALOG.forEach(d => { const data = G.drugs[d.id]; @@ -763,28 +1090,39 @@ function clientTick(deltaSec) { // Auto Supply const suppliers = data.suppliers || 0; if (suppliers > 0) { + const supMilestoneM = getUnitMilestoneMultiplier(suppliers); const supplyRate = d.supplyRate || 1.0; - const toSupply = suppliers * supplyRate * prodMulti * deltaSec; + const toSupply = suppliers * supplyRate * prodMulti * supMulti * supMilestoneM * allTierSupM * deltaSec; data.raw = data.raw + toSupply; + if (!G.stats) G.stats = {}; + G.stats.totalSupplied = (G.stats.totalSupplied || 0) + toSupply; } // Auto Production - if (data.workers > 0 && data.raw > 0) { - const prodPerSec = data.workers * d.produceRate * prodMulti; + const workers = data.workers || 0; + if (workers > 0 && data.raw > 0) { + const workMilestoneM = getUnitMilestoneMultiplier(workers); + const prodPerSec = workers * d.produceRate * prodMulti * workMulti * workMilestoneM * allTierWorkM; const toMake = Math.min(data.raw, prodPerSec * deltaSec); data.raw = Math.max(0, data.raw - toMake); data.product = data.product + toMake; + if (!G.stats) G.stats = {}; + G.stats.totalProduced = (G.stats.totalProduced || 0) + toMake; } // Auto Sales - if (data.dealers > 0 && data.product > 0) { - const sellPerSec = data.dealers * d.sellRate * dealerSpeed; + const dealers = data.dealers || 0; + if (dealers > 0 && data.product > 0) { + const dealMilestoneM = getUnitMilestoneMultiplier(dealers); + const sellPerSec = dealers * d.sellRate * dealerSpeed * dealMilestoneM * allTierDealM; const toSell = Math.min(data.product, sellPerSec * deltaSec); data.product = Math.max(0, data.product - toSell); const revenue = toSell * Math.floor(d.sellPrice * sellMulti); G.cash += revenue; G.totalEarned += revenue; earnedThisTick += revenue; + if (!G.stats) G.stats = {}; + G.stats.totalSold = (G.stats.totalSold || 0) + toSell; } }); @@ -796,20 +1134,26 @@ function clientTick(deltaSec) { cashRateTracker = cashRateTracker * 0.85 + (earnedThisTick / deltaSec) * 0.15; } - // Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML) + // Direct DOM text updates updateLiveInventory(); } 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.bonds.textContent = fmtInt(G.cleanBonds || 0); + const totalLifetimeBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0); + const compoundLvl = (G.offshorePerks && G.offshorePerks['compound_interest']) || 0; + const bondRate = 4 + (compoundLvl * 2); + dom.multiplier.textContent = fmtInt(totalLifetimeBonds * bondRate); dom.heatPct.textContent = `${Math.round(G.heat)}%`; dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`; - const rawCostMulti = getRawCostMultiplier(); const prodMulti = getProductionMultiplier(); + const supMulti = getSupplierSpeedMultiplier(); + const workMulti = getWorkerSpeedMultiplier(); + const allTierSupM = getAllTierMilestoneMultiplier('suppliers'); + const allTierWorkM = getAllTierMilestoneMultiplier('workers'); let stashChipsHtml = ''; // Surgically update raw, product, buttons, and stash chips @@ -843,32 +1187,42 @@ function updateLiveInventory() { const autoSupplyEl = document.getElementById(`val-auto-supply-${d.id}`); if (autoSupplyEl) { - const currentSupplySpeed = (drugData.suppliers || 0) * (d.supplyRate || 1.0) * prodMulti; + const suppliers = drugData.suppliers || 0; + const supMilestoneM = getUnitMilestoneMultiplier(suppliers); + const currentSupplySpeed = suppliers * (d.supplyRate || 1.0) * prodMulti * supMulti * supMilestoneM * allTierSupM; autoSupplyEl.textContent = `(+${fmtNum(currentSupplySpeed)} r/s)`; - autoSupplyEl.style.color = (drugData.suppliers || 0) > 0 ? '#38bdf8' : '#8b949e'; + autoSupplyEl.style.color = suppliers > 0 ? '#38bdf8' : '#8b949e'; } const workerCountEl = document.getElementById(`val-worker-count-${d.id}`); - if (workerCountEl) workerCountEl.textContent = `${d.workerName}s: ${drugData.workers}`; + if (workerCountEl) workerCountEl.textContent = `${d.workerName}s: ${drugData.workers || 0}`; const autoProdEl = document.getElementById(`val-auto-prod-${d.id}`); if (autoProdEl) { - const currentSpeed = drugData.workers * d.produceRate * prodMulti; + const workers = drugData.workers || 0; + const workMilestoneM = getUnitMilestoneMultiplier(workers); + const currentSpeed = workers * d.produceRate * prodMulti * workMulti * workMilestoneM * allTierWorkM; autoProdEl.textContent = `(${fmtNum(currentSpeed)}g/s)`; - autoProdEl.style.color = (drugData.workers > 0 && drugData.raw > 0) ? '#4ade80' : '#8b949e'; + autoProdEl.style.color = (workers > 0 && drugData.raw > 0) ? '#4ade80' : '#8b949e'; } - // Live button state updates (enable/disable as resources change) - const rawCost = Math.floor(d.rawCost * rawCostMulti); - const supplierCost = getSupplierCost(d.id); - const workerCost = getWorkerCost(d.id); - const dealerCost = getDealerCost(d.id); + // Multi-buy pricing & button states + const rawInfo = getRawPurchaseInfo(d.id); + const supInfo = getUnitPurchaseInfo(d.id, 'suppliers'); + const workInfo = getUnitPurchaseInfo(d.id, 'workers'); + const dealInfo = getUnitPurchaseInfo(d.id, 'dealers'); const btnBuyRaw = document.getElementById(`btn-buy-raw-${d.id}`); - if (btnBuyRaw) btnBuyRaw.disabled = (G.cash < rawCost); + if (btnBuyRaw) { + btnBuyRaw.disabled = (G.cash < rawInfo.totalCost); + btnBuyRaw.innerHTML = `Buy Raw (+${rawInfo.qty})$${rawInfo.totalCost.toLocaleString()}`; + } const btnHireSupplier = document.getElementById(`btn-hire-supplier-${d.id}`); - if (btnHireSupplier) btnHireSupplier.disabled = (G.cash < supplierCost); + if (btnHireSupplier) { + btnHireSupplier.disabled = (G.cash < supInfo.totalCost); + btnHireSupplier.innerHTML = `Hire ${d.supplierName} (+${supInfo.qty})$${supInfo.totalCost.toLocaleString()}`; + } const btnMake = document.getElementById(`btn-make-${d.id}`); if (btnMake) btnMake.disabled = (drugData.raw < 1); @@ -877,10 +1231,16 @@ function updateLiveInventory() { if (btnSell) btnSell.disabled = (drugData.product < 1); const btnHireWorker = document.getElementById(`btn-hire-worker-${d.id}`); - if (btnHireWorker) btnHireWorker.disabled = (G.cash < workerCost); + if (btnHireWorker) { + btnHireWorker.disabled = (G.cash < workInfo.totalCost); + btnHireWorker.innerHTML = `Hire ${d.workerName} (+${workInfo.qty})$${workInfo.totalCost.toLocaleString()}`; + } const btnHireDealer = document.getElementById(`btn-hire-dealer-${d.id}`); - if (btnHireDealer) btnHireDealer.disabled = (G.cash < dealerCost); + if (btnHireDealer) { + btnHireDealer.disabled = (G.cash < dealInfo.totalCost); + btnHireDealer.innerHTML = `Hire ${d.dealerName} (+${dealInfo.qty})$${dealInfo.totalCost.toLocaleString()}`; + } const btnSell10x = document.getElementById(`btn-sell-10x-${d.id}`); if (btnSell10x) btnSell10x.disabled = (drugData.product < 10); @@ -918,6 +1278,18 @@ function applyLocalActions(state, actions) { actions.forEach(act => { const dId = act.drug_id; const dCfg = DRUG_CATALOG.find(x => x.id === dId); + if (act.type === 'claim_achievement') { + if (!state.achievements) state.achievements = {}; + state.achievements[act.ach_id] = act.tier; + return; + } + if (act.type === 'buy_offshore_perk') { + if (!state.offshorePerks) state.offshorePerks = {}; + const curLvl = state.offshorePerks[act.perk_id] || 0; + state.offshorePerks[act.perk_id] = curLvl + 1; + return; + } + if (!state.drugs || !state.drugs[dId]) return; if (act.type === 'buy_raw' && dCfg) { @@ -946,9 +1318,10 @@ function applyLocalActions(state, actions) { } } else if (act.type === 'hire_supplier' && dCfg) { const count = act.count || 1; + const baseCost = dCfg.supplierCost || 75; for (let i = 0; i < count; i++) { const currentCount = state.drugs[dId].suppliers || 0; - const cost = Math.floor((dCfg.supplierCost || 75) * Math.pow(1.15, currentCount)); + const cost = Math.floor(baseCost * Math.pow(getPriceScaling(), currentCount)); if (state.cash >= cost) { state.cash -= cost; state.drugs[dId].suppliers = currentCount + 1; @@ -956,9 +1329,10 @@ function applyLocalActions(state, actions) { } } else if (act.type === 'hire_worker' && dCfg) { const count = act.count || 1; + const baseCost = dCfg.workerCost; for (let i = 0; i < count; i++) { const currentCount = state.drugs[dId].workers || 0; - const cost = Math.floor(dCfg.workerCost * Math.pow(1.15, currentCount)); + const cost = Math.floor(baseCost * Math.pow(getPriceScaling(), currentCount)); if (state.cash >= cost) { state.cash -= cost; state.drugs[dId].workers = currentCount + 1; @@ -966,9 +1340,10 @@ function applyLocalActions(state, actions) { } } else if (act.type === 'hire_dealer' && dCfg) { const count = act.count || 1; + const baseCost = dCfg.dealerCost; for (let i = 0; i < count; i++) { const currentCount = state.drugs[dId].dealers || 0; - const cost = Math.floor(dCfg.dealerCost * Math.pow(1.15, currentCount)); + const cost = Math.floor(baseCost * Math.pow(getPriceScaling(), currentCount)); if (state.cash >= cost) { state.cash -= cost; state.drugs[dId].dealers = currentCount + 1; @@ -1002,14 +1377,14 @@ async function triggerServerSync() { const reconciled = { ...INITIAL_GAME_STATE, ...serverState, + buyMode: G.buyMode, activeTab: G.activeTab }; - // In-flight actions have been committed to the server! + // In-flight actions have been committed to the server inFlightActions = []; - // Apply any new actions that user clicked while the request was in flight! - // This guarantees zero dropped actions or rollbacks. + // Apply any new actions that user clicked while the request was in flight if (pendingActions.length > 0) { applyLocalActions(reconciled, pendingActions); } @@ -1095,6 +1470,17 @@ document.addEventListener('DOMContentLoaded', async () => { b.addEventListener('click', () => setTab(b.dataset.tab)); }); + // Setup Buy Multiplier Pill Buttons + document.querySelectorAll('.btn-multi-pill').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + G.buyMode = btn.dataset.mode; + renderViewport(); + updateLiveInventory(); + }); + }); + // Auth Modal handling dom.userBadge.addEventListener('click', () => dom.authModal.classList.add('active')); document.getElementById('btn-close-auth').addEventListener('click', () => dom.authModal.classList.remove('active')); @@ -1143,6 +1529,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); renderViewport(); + checkAchievements(); updateLiveInventory(); requestAnimationFrame(gameLoop); });