From 90d6c5c926b231e8c497a9b1d7ca72ea1f44cf0b Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 28 Jan 2026 15:26:12 +0000 Subject: [PATCH] busted simulation --- scenes/simulator.tscn | 2 +- scripts/sim_direct.gd | 1036 ++++++++++++++++++++++++ scripts/sim_direct.gd.uid | 1 + scripts/tick_process.gd | 70 +- scripts/unlock_data_lightweight.gd | 138 ++++ scripts/unlock_data_lightweight.gd.uid | 1 + 6 files changed, 1215 insertions(+), 33 deletions(-) create mode 100644 scripts/sim_direct.gd create mode 100644 scripts/sim_direct.gd.uid create mode 100644 scripts/unlock_data_lightweight.gd create mode 100644 scripts/unlock_data_lightweight.gd.uid diff --git a/scenes/simulator.tscn b/scenes/simulator.tscn index 0774d06..d5b2274 100644 --- a/scenes/simulator.tscn +++ b/scenes/simulator.tscn @@ -1,6 +1,6 @@ [gd_scene format=3 uid="uid://br6hgvb4buyji"] -[ext_resource type="Script" uid="uid://bup76ad02kuse" path="res://scripts/sim_cached.gd" id="1_sim"] +[ext_resource type="Script" uid="uid://citjokiv6skqi" path="res://scripts/sim_direct.gd" id="1_sim"] [node name="Simulator" type="Control" unique_id=1833845714] layout_mode = 3 diff --git a/scripts/sim_direct.gd b/scripts/sim_direct.gd new file mode 100644 index 0000000..aa28717 --- /dev/null +++ b/scripts/sim_direct.gd @@ -0,0 +1,1036 @@ +class_name SimulatorDirect +extends Control + +# DIRECT SIMULATOR - Uses real TickProcess with isolated state +# Zero code duplication via dependency injection + +# Load the actual game resources +var unlock_collection: UnlockDataCollection = load("res://resources/UnlockData.tres") + +# Results tracking +var all_results: Array[Dictionary] = [] +var results_mutex: Mutex = Mutex.new() + +# Chunk-based caching for intermediate simulation states +var simulation_cache: Dictionary = {} +var cache_mutex: Mutex = Mutex.new() + +# Global unlock template cache (build once, clone per simulation) +# Pre-calculated lookup tables (built once from UnlockDataResource, then used statically) +var precalc_costs: Dictionary = {} # {unlock_id: [cost_at_rank_0, cost_at_rank_1, ...]} +var precalc_modifiers: Dictionary = {} # {unlock_id: {rank: {"modifier_name": value}}} +var precalc_base_modifiers: Dictionary = {} # {unlock_id: base_modifiers_dict} +var precalc_max_ranks: Dictionary = {} # {unlock_id: max_rank} +var precalc_is_scaling: Dictionary = {} # {unlock_id: is_scaling} +var precalc_unlock_names: Dictionary = {} # {unlock_id: name} +var precalc_initialized: bool = false +var precalc_mutex: Mutex = Mutex.new() + +# Manual thread pool +var num_threads: int = 14 +var threads: Array[Thread] = [] +var task_queue: Array[Dictionary] = [] +var queue_mutex: Mutex = Mutex.new() +var completed_count: int = 0 +var completed_mutex: Mutex = Mutex.new() +var threads_done: bool = false + +var start_time: int = 0 +var total_combinations: int = 0 +var last_progress_time: int = 0 +var monitoring_active: bool = false +var cache_hits: int = 0 +var cache_misses: int = 0 + +# UI References +@onready var status_label = $MarginContainer/VBoxContainer/StatusPanel/VBox/StatusLabel +@onready var progress_label = $MarginContainer/VBoxContainer/StatusPanel/VBox/ProgressLabel +@onready var progress_bar = $MarginContainer/VBoxContainer/StatusPanel/VBox/ProgressBar +@onready var rate_label = $MarginContainer/VBoxContainer/StatusPanel/VBox/RateLabel +@onready var eta_label = $MarginContainer/VBoxContainer/StatusPanel/VBox/ETALabel +@onready var cache_hits_label = $MarginContainer/VBoxContainer/CachePanel/VBox/CacheHitsLabel +@onready var cache_misses_label = $MarginContainer/VBoxContainer/CachePanel/VBox/CacheMissesLabel +@onready var cache_rate_label = $MarginContainer/VBoxContainer/CachePanel/VBox/CacheRateLabel +@onready var cache_size_label = $MarginContainer/VBoxContainer/CachePanel/VBox/CacheSizeLabel +@onready var results_label = $MarginContainer/VBoxContainer/ResultsPanel/VBox/ScrollContainer/ResultsLabel + +func _ready(): + GameManager.tick.stop() + print("=== DIRECT Unlock Simulator Started ===") + print("Using real TickProcess with isolated state via dependency injection") + var cpu_count = OS.get_processor_count() + print("CPU cores detected: %d" % cpu_count) + print("Creating %d worker threads" % num_threads) + + # Update UI + status_label.text = "Status: Starting direct simulation..." + results_label.text = "[b]DIRECT Unlock Simulator Started[/b]\n\nCPU cores: %d\nWorker threads: %d\n\nGenerating combinations..." % [cpu_count, num_threads] + + run_comprehensive_test() + +func _process(_delta): + if monitoring_active: + var current_time = Time.get_ticks_msec() + if current_time - last_progress_time >= 1000: + last_progress_time = current_time + update_progress() + +# ============================================================================= +# ISOLATED GAME STATE CLASS +# ============================================================================= + +class IsolatedGameState: + """Isolated game state using pre-calculated lookup tables (NO object creation!)""" + + # Reference to pre-calculated tables (shared, read-only) + var costs: Dictionary # {unlock_id: [cost_array]} + var modifiers: Dictionary # {unlock_id: {rank: mods_dict}} + var max_ranks: Dictionary # {unlock_id: max_rank} + var is_scaling: Dictionary # {unlock_id: bool} + var base_mods: Dictionary # {unlock_id: base_modifiers} + + # Local inventory state (not Inventory singleton) + var currency: float = 0.0 + var wood: float = 0.0 + var stock: float = 0.0 + + # Cached modifiers (not Unlocks.current_modifiers) + var sale_price_mod: float = 1.0 + var efficiency_mod: float = 1.0 + var wood_per_click_mod: float = 1.0 + var purchase_rate_mod: float = 1.0 + var autowood_mod: float = 0.0 + var multicraft_rank: int = 0 + var wholesale_unlocked: bool = false + + # Simulation state + var ticks: int = 0 + var current_ranks: Dictionary = {} + + # Cache FakeUnlock objects to avoid repeated creation (OPTIMIZATION) + var fake_unlock_cache: Dictionary = {} # {unlock_id: FakeUnlock} + + func _init(precalc_costs: Dictionary, precalc_modifiers: Dictionary, precalc_max_ranks: Dictionary, precalc_is_scaling: Dictionary, precalc_base_mods: Dictionary): + """Initialize with references to pre-calculated lookup tables""" + costs = precalc_costs + modifiers = precalc_modifiers + max_ranks = precalc_max_ranks + is_scaling = precalc_is_scaling + base_mods = precalc_base_mods + + # ============================================================================= + # UNLOCKS SINGLETON INTERFACE REPLACEMENT + # ============================================================================= + + func get_unlock_by_id(unlock_id: int): + """Replacement for Unlocks.get_unlock_by_id() - returns cached fake unlock""" + if not current_ranks.has(unlock_id): + return null + + # Reuse cached FakeUnlock object (just update its state) + var fake = fake_unlock_cache.get(unlock_id) + if fake == null: + fake = FakeUnlock.new() + fake.unlock_id = unlock_id + fake.state = self + fake_unlock_cache[unlock_id] = fake + + # Update state from current_ranks + fake.current_rank = current_ranks.get(unlock_id, 0) + fake.is_unlocked = fake.current_rank > 0 + return fake + + # Nested class for fake unlock objects (uses parent's lookup tables) + class FakeUnlock: + var unlock_id: int + var current_rank: int + var is_unlocked: bool + var state: IsolatedGameState + + func get_next_cost() -> int: + var cost_array = state.costs.get(unlock_id, []) + if current_rank < cost_array.size(): + return cost_array[current_rank] + return 999999999 + + func get_current_modifiers() -> Dictionary: + if not is_unlocked or current_rank == 0: + return {} + var rank_mods = state.modifiers.get(unlock_id, {}) + return rank_mods.get(current_rank, {}) + + func get_modifiers_at_rank(rank: int) -> Dictionary: + """Get modifiers at a specific rank (for calculating deltas)""" + if rank == 0: + return {} + var rank_mods = state.modifiers.get(unlock_id, {}) + return rank_mods.get(rank, {}) + + func can_rank_up() -> bool: + var max_rank = state.max_ranks.get(unlock_id, -1) + if max_rank > 0 and current_rank >= max_rank: + return false + return true + + func unlock() -> bool: + if not can_rank_up(): + return false + current_rank += 1 + is_unlocked = true + state.current_ranks[unlock_id] = current_rank + return true + + func get_modifier_value(modifier_key: String) -> float: + """Replacement for Unlocks.get_modifier_value()""" + match modifier_key: + "sale_price_modifier": return sale_price_mod + "efficiency_modifier": return efficiency_mod + "wood_per_click_modifier": return wood_per_click_mod + "purchase_rate_modifier": return purchase_rate_mod + "autowood_modifier": return autowood_mod + _: return 1.0 + + func get_wood_per_click() -> float: + """Replacement for Unlocks.get_wood_per_click()""" + return Global.wood_per_click * wood_per_click_mod + + func get_items_produced_per_tick() -> float: + """Replacement for Unlocks.get_items_produced_per_tick()""" + return Global.cost_per_whittle * efficiency_mod + + func get_sale_price_per_item() -> float: + """Replacement for Unlocks.get_sale_price_per_item()""" + return Global.base_sale_price * sale_price_mod + + # ============================================================================= + # INVENTORY SINGLETON INTERFACE REPLACEMENT + # ============================================================================= + + func add_wood(amount: float): + """Replacement for Inventory.add_wood()""" + wood += amount + + func get_wood() -> float: + """Replacement for Inventory.get_wood()""" + return wood + + func spend_wood(amount: float) -> bool: + """Replacement for Inventory.spend_wood()""" + if wood >= amount: + wood -= amount + return true + return false + + func add_stock(amount: float): + """Replacement for Inventory.add_stock()""" + stock += amount + + func get_stock() -> float: + """Replacement for Inventory.get_stock()""" + return stock + + func spend_stock(amount: float) -> bool: + """Replacement for Inventory.spend_stock()""" + if stock >= amount: + stock -= amount + return true + return false + + func add_currency(amount: float): + """Replacement for Inventory.add_currency()""" + currency += amount + + func spend_currency(amount: float) -> bool: + """Replacement for Inventory.spend_currency()""" + if currency >= amount: + currency -= amount + return true + return false + + # ============================================================================= + # UNLOCK PURCHASE AND MODIFIER MANAGEMENT + # ============================================================================= + + func purchase_unlock(unlock_id: int) -> bool: + """Purchase an unlock and update modifiers""" + var unlock = get_unlock_by_id(unlock_id) + if not unlock or not unlock.can_rank_up(): + return false + + var cost = unlock.get_next_cost() + if not spend_currency(cost): + return false + + var prev_rank = unlock.current_rank + unlock.unlock() + current_ranks[unlock_id] = unlock.current_rank + + # Update modifier cache + update_modifiers_for_unlock(unlock, prev_rank) + + # Update special flags + if unlock_id == Global.wholesale_unlock_id: + wholesale_unlocked = true + if unlock_id == Global.multicraft_unlock_id: + multicraft_rank = unlock.current_rank + if unlock_id == Global.autowood_unlock_id: + # Autowood is additive, not multiplicative + autowood_mod = calculate_autowood_modifier() + + return true + + func update_modifiers_for_unlock(unlock, prev_rank: int): + """Update cached modifiers when unlock rank changes""" + var old_mods = unlock.get_modifiers_at_rank(prev_rank) + var new_mods = unlock.get_current_modifiers() + + # Calculate ratio for multiplicative modifiers + for key in new_mods.keys(): + var old_val = old_mods.get(key, 1.0) + var new_val = new_mods[key] + var ratio = new_val / old_val if old_val != 0 else new_val + + match key: + "sale_price_modifier": + sale_price_mod *= ratio + "efficiency_modifier": + efficiency_mod *= ratio + "wood_per_click_modifier": + wood_per_click_mod *= ratio + "purchase_rate_modifier": + purchase_rate_mod *= ratio + + func calculate_autowood_modifier() -> float: + """Calculate autowood modifier from scratch (additive)""" + var total = 0.0 + for unlock_id in current_ranks.keys(): + var rank = current_ranks[unlock_id] + if rank > 0: + var rank_mods = modifiers.get(unlock_id, {}) + var mods = rank_mods.get(rank, {}) + if mods.has("autowood_modifier"): + total += mods["autowood_modifier"] + return total + + # ============================================================================= + # INLINED TICK LOGIC (optimized for performance, matches TickProcess exactly) + # ============================================================================= + + func execute_tick(): + """Execute one game tick - INLINED for performance (no TickProcess overhead)""" + # 1. Generate wood from autowood + if autowood_mod > 0.0: + var wood_to_gather = max(get_wood_per_click() * autowood_mod, 1.0) + wood += wood_to_gather + + # 2. Whittle wood into stock + if wood >= 1: + # Base whittling action + var items_produced_per_tick = get_items_produced_per_tick() + var wood_needed = ceil(items_produced_per_tick) + var wood_to_whittle = min(wood, wood_needed) + var items_produced = wood_to_whittle + wood -= wood_to_whittle + stock += items_produced + + # Multicraft additional whittles + for i in range(multicraft_rank): + if wood >= 1: + wood_needed = ceil(items_produced_per_tick) + wood_to_whittle = min(wood, wood_needed) + items_produced = wood_to_whittle + wood -= wood_to_whittle + stock += items_produced + else: + break + + # 3. Sell stock for currency + if stock > 0: + var price_per_item = get_sale_price_per_item() + + # 3a. Wholesale selling (if unlocked) + if wholesale_unlocked: + while stock >= Global.wholesale_bundle_size: + stock -= Global.wholesale_bundle_size + currency += Global.wholesale_bundle_size * price_per_item * Global.wholesale_discount_multiplier + + # 3b. Regular selling + if stock > 0: + var purchase_rate = Global.base_purchase_rate * purchase_rate_mod + var max_stock_to_sell = floor(purchase_rate) + var actual_stock_to_sell = min(stock, max(1.0, max_stock_to_sell)) + stock -= actual_stock_to_sell + currency += actual_stock_to_sell * price_per_item + + ticks += 1 + + # ============================================================================= + # RESET FOR REUSE + # ============================================================================= + + func reset_for_new_simulation(): + """Reset state for a new simulation (OPTIMIZATION: reuse same IsolatedGameState)""" + # Reset inventory + currency = 0.0 + wood = 0.0 + stock = 0.0 + + # Reset modifiers + sale_price_mod = 1.0 + efficiency_mod = 1.0 + wood_per_click_mod = 1.0 + purchase_rate_mod = 1.0 + autowood_mod = 0.0 + multicraft_rank = 0 + wholesale_unlocked = false + + # Reset simulation state + ticks = 0 + current_ranks.clear() + + # ============================================================================= + # SNAPSHOT AND RESTORE FOR CACHING + # ============================================================================= + + func snapshot_for_cache() -> Dictionary: + """Create a snapshot for cache storage""" + return { + "ticks": ticks, + "currency": currency, + "stock": stock, + "wood": wood, + "current_ranks": current_ranks.duplicate(), + "modifiers": { + "sale_price_mod": sale_price_mod, + "efficiency_mod": efficiency_mod, + "wood_per_click_mod": wood_per_click_mod, + "purchase_rate_mod": purchase_rate_mod, + "autowood_mod": autowood_mod, + "multicraft_rank": multicraft_rank, + "wholesale_unlocked": wholesale_unlocked + } + } + + func restore_from_cache(snapshot: Dictionary): + """Restore state from a cache snapshot""" + ticks = snapshot.ticks + currency = snapshot.currency + stock = snapshot.stock + wood = snapshot.wood + + # Restore modifiers + var mods = snapshot.modifiers + sale_price_mod = mods.sale_price_mod + efficiency_mod = mods.efficiency_mod + wood_per_click_mod = mods.wood_per_click_mod + purchase_rate_mod = mods.purchase_rate_mod + autowood_mod = mods.autowood_mod + multicraft_rank = mods.multicraft_rank + wholesale_unlocked = mods.wholesale_unlocked + + # Restore unlock ranks + current_ranks = snapshot.current_ranks.duplicate() + for unlock_id in current_ranks.keys(): + var unlock = get_unlock_by_id(unlock_id) + if unlock: + var target_rank = current_ranks[unlock_id] + unlock.current_rank = target_rank + unlock.is_unlocked = target_rank > 0 + +# ============================================================================= +# TEMPLATE MANAGEMENT +# ============================================================================= + +func build_precalculated_tables(): + """Pre-calculate all costs and modifiers from real UnlockDataResource objects""" + precalc_mutex.lock() + if precalc_initialized: + precalc_mutex.unlock() + return + + print("Pre-calculating unlock tables from UnlockDataResource...") + var start_time = Time.get_ticks_msec() + + for unlock in unlock_collection.unlocks: + var unlock_id = unlock.unlock_id + precalc_unlock_names[unlock_id] = unlock.unlock_name + precalc_is_scaling[unlock_id] = unlock.is_scaling + precalc_max_ranks[unlock_id] = unlock.max_rank if unlock.is_scaling else 1 + precalc_base_modifiers[unlock_id] = unlock.base_modifiers.duplicate(true) + + # Calculate cost ladder for all possible ranks + var cost_array = [] + var max_rank_to_calc = unlock.max_rank if (unlock.is_scaling and unlock.max_rank > 0) else (100 if unlock.is_scaling else 1) + for rank in range(max_rank_to_calc + 1): + unlock.current_rank = rank + cost_array.append(unlock.get_next_cost()) + precalc_costs[unlock_id] = cost_array + + # Calculate modifier values for all possible ranks + var mods_by_rank = {} + for rank in range(max_rank_to_calc + 1): + unlock.current_rank = rank + unlock.is_unlocked = rank > 0 + mods_by_rank[rank] = unlock.get_current_modifiers() + precalc_modifiers[unlock_id] = mods_by_rank + + # Reset unlock state + unlock.current_rank = 0 + unlock.is_unlocked = false + + precalc_initialized = true + var elapsed = Time.get_ticks_msec() - start_time + print("Pre-calculation complete in %d ms for %d unlocks" % [elapsed, precalc_costs.size()]) + precalc_mutex.unlock() + +func create_isolated_state() -> IsolatedGameState: + """Create a new isolated game state using pre-calculated tables""" + if not precalc_initialized: + build_precalculated_tables() + return IsolatedGameState.new(precalc_costs, precalc_modifiers, precalc_max_ranks, precalc_is_scaling, precalc_base_modifiers) + +# ============================================================================= +# CACHE SYSTEM (same as sim_cached.gd) +# ============================================================================= + +func get_cache_key(current_ranks: Dictionary) -> String: + """Generate a cache key from current unlock ranks""" + var sorted_keys = current_ranks.keys() + sorted_keys.sort() + var key_parts = [] + for k in sorted_keys: + key_parts.append(str(k) + ":" + str(current_ranks[k])) + return ",".join(key_parts) + +func try_load_best_prefix_from_cache(rank_targets: Dictionary) -> Variant: + """Balanced cache lookup - fast with good coverage""" + cache_mutex.lock() + + # Try exact match first + var full_key = get_cache_key(rank_targets) + if simulation_cache.has(full_key): + cache_hits += 1 + var result = simulation_cache[full_key] + cache_mutex.unlock() + return result + + # Sort unlock IDs for consistent ordering + var unlock_ids = rank_targets.keys() + unlock_ids.sort() + var num_unlocks = unlock_ids.size() + + var best_match = null + var best_rank_sum = 0 + + # Try progressively shorter prefixes + for prefix_len in range(num_unlocks - 1, 0, -1): + var subset = {} + for i in range(prefix_len): + subset[unlock_ids[i]] = rank_targets[unlock_ids[i]] + + var key = get_cache_key(subset) + if simulation_cache.has(key): + var cached_entry = simulation_cache[key] + var rank_sum = 0 + for r in cached_entry.current_ranks.values(): + rank_sum += r + + if rank_sum > best_rank_sum: + best_match = cached_entry + best_rank_sum = rank_sum + if prefix_len >= num_unlocks - 2: + break + + if best_match != null: + cache_hits += 1 + else: + cache_misses += 1 + cache_mutex.unlock() + + return best_match + +func should_cache_state(current_ranks: Dictionary, targets_remaining: int) -> bool: + """Decide if this state is worth caching""" + if targets_remaining == 0: + return false + + var total_ranks = 0 + var active_unlocks = 0 + + for rank in current_ranks.values(): + if rank > 0: + total_ranks += rank + active_unlocks += 1 + + return (active_unlocks >= 2) or (total_ranks >= 2) + +# ============================================================================= +# MAIN SIMULATION FUNCTION +# ============================================================================= + +func simulate_rank_combination_direct( + rank_targets: Dictionary, + max_ticks: int, + track_purchases: bool = false, + _unused_tick_process = null, # Kept for API compatibility but not used + _unused_state = null # Kept for API compatibility but not used +) -> Dictionary: + """Pure simulation using isolated state with inlined tick logic""" + + # Always create fresh isolated state to avoid thread conflicts + var state = create_isolated_state() + + # Initialize targets + var targets_remaining = 0 + var active_unlock_ids: Array = [] + for unlock_id in rank_targets.keys(): + state.current_ranks[unlock_id] = 0 + targets_remaining += rank_targets[unlock_id] + active_unlock_ids.append(unlock_id) + + # Purchase tracking + var purchases: Array[Dictionary] = [] + + # Try cache restoration + var cached_state = null + if not track_purchases: + cached_state = try_load_best_prefix_from_cache(rank_targets) + + if cached_state != null: + state.restore_from_cache(cached_state) + + # Recalculate remaining targets + targets_remaining = 0 + active_unlock_ids.clear() + for unlock_id in rank_targets.keys(): + var remaining = rank_targets[unlock_id] - state.current_ranks.get(unlock_id, 0) + if remaining > 0: + targets_remaining += remaining + active_unlock_ids.append(unlock_id) + + # Pre-calculate next costs directly from lookup table + var next_costs: Array[float] = [] + next_costs.resize(active_unlock_ids.size()) + for i in range(active_unlock_ids.size()): + var unlock_id = active_unlock_ids[i] + var current_rank = state.current_ranks.get(unlock_id, 0) + var cost_array = state.costs.get(unlock_id, []) + next_costs[i] = cost_array[current_rank] if current_rank < cost_array.size() else 999999999 + + # Main simulation loop + while state.ticks < max_ticks: + # Find cheapest affordable unlock + var cheapest_index = -1 + var cheapest_cost = INF + var cheapest_unlock_id = -1 + + if targets_remaining > 0: + for i in range(active_unlock_ids.size()): + if next_costs[i] < cheapest_cost and state.currency >= next_costs[i]: + cheapest_cost = next_costs[i] + cheapest_unlock_id = active_unlock_ids[i] + cheapest_index = i + + # Exit early if all targets met and goal reached + if cheapest_index == -1 and targets_remaining == 0: + if state.currency >= 1000000.0: + break + # Skip ahead to 1M + var currency_needed = 1000000.0 - state.currency + var price_per_item = state.get_sale_price_per_item() + var items_per_tick = max(1.0, floor( + Global.base_purchase_rate * state.get_modifier_value("purchase_rate_modifier") + )) + var revenue_per_tick = items_per_tick * price_per_item + + if revenue_per_tick > 0: + var ticks_needed = int(ceil(currency_needed / revenue_per_tick)) + state.ticks += ticks_needed + state.currency += revenue_per_tick * ticks_needed + break + + # Purchase unlock if affordable + if cheapest_index != -1: + state.purchase_unlock(cheapest_unlock_id) + targets_remaining -= 1 + + # Track purchase if enabled + if track_purchases: + var current_rank = state.current_ranks[cheapest_unlock_id] + purchases.append({ + "unlock_id": cheapest_unlock_id, + "unlock_name": get_unlock_name_by_id(cheapest_unlock_id), + "rank": current_rank, + "cost": cheapest_cost, + "tick": state.ticks, + "currency_after": state.currency + }) + + # Update next cost or remove from active list + if state.current_ranks[cheapest_unlock_id] >= rank_targets[cheapest_unlock_id]: + # Target reached + var last_idx = active_unlock_ids.size() - 1 + if cheapest_index != last_idx: + active_unlock_ids[cheapest_index] = active_unlock_ids[last_idx] + next_costs[cheapest_index] = next_costs[last_idx] + active_unlock_ids.resize(last_idx) + next_costs.resize(last_idx) + else: + # Update cost for next rank directly from lookup table + var current_rank = state.current_ranks[cheapest_unlock_id] + var cost_array = state.costs.get(cheapest_unlock_id, []) + next_costs[cheapest_index] = cost_array[current_rank] if current_rank < cost_array.size() else 999999999 + + # Cache this state if valuable + if should_cache_state(state.current_ranks, targets_remaining): + var cache_key = get_cache_key(state.current_ranks) + cache_mutex.lock() + if not simulation_cache.has(cache_key): + simulation_cache[cache_key] = state.snapshot_for_cache() + cache_mutex.unlock() + + # Simulate manual clicking to bootstrap economy (matches sim_cached.gd logic) + # Manual clicks based on tick range (pre-calculate to avoid repeated conditions) + var manual_clicks: float = 1.0 if state.ticks < 120 else (0.5 if state.ticks < 300 else (0.25 if (state.ticks < 600 and state.autowood_mod < 0.2) else 0.0)) + if manual_clicks > 0.0: + var wood_from_clicks = manual_clicks * state.get_wood_per_click() + state.add_wood(wood_from_clicks) + + # Execute one tick using inlined logic (optimized for performance) + state.execute_tick() + + # Build result + var success = state.currency >= 1000000.0 + var result = { + "rank_targets": rank_targets, + "success": success, + "ticks": state.ticks if success else -1, + "final_currency": state.currency, + "time_formatted": format_time(state.ticks) if success else "Failed" + } + + if track_purchases: + result["purchases"] = purchases + + return result + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +func format_time(ticks: int) -> String: + var seconds = ticks + var minutes = seconds / 60 + var hours = minutes / 60 + + if hours > 0: + return "%dh %dm %ds" % [hours, minutes % 60, seconds % 60] + elif minutes > 0: + return "%dm %ds" % [minutes, seconds % 60] + else: + return "%ds" % seconds + +func generate_all_combinations(unlimited_scaling_cap: int = 5) -> Array[Dictionary]: + """Generate combinations for ALL unlocks dynamically""" + var combinations: Array[Dictionary] = [] + + var unlock_constraints = [] + for unlock in unlock_collection.unlocks: + var max_rank: int + if unlock.max_rank > 0: + max_rank = unlock.max_rank + elif not unlock.is_scaling: + max_rank = 1 + else: + max_rank = unlimited_scaling_cap + + unlock_constraints.append({ + "id": unlock.unlock_id, + "name": unlock.unlock_name, + "max_rank": max_rank + }) + + print("\n=== Generating Combinations ===") + print("Reading from resource file: %d unlocks" % unlock_constraints.size()) + for c in unlock_constraints: + print(" - %s (ID %d): 0-%d ranks" % [c.name, c.id, c.max_rank]) + + _generate_combinations_recursive(unlock_constraints, 0, {}, combinations) + + print("Generated %d total combinations" % combinations.size()) + return combinations + +func _generate_combinations_recursive(constraints: Array, index: int, current: Dictionary, output: Array): + """Recursively generate all valid combinations""" + if index >= constraints.size(): + if current.size() > 0: + output.append(current.duplicate()) + return + + var constraint = constraints[index] + for rank in range(constraint.max_rank + 1): + if rank > 0: + current[constraint.id] = rank + + _generate_combinations_recursive(constraints, index + 1, current, output) + + if rank > 0: + current.erase(constraint.id) + +func get_unlock_name_by_id(unlock_id: int) -> String: + """Helper function to get unlock name by ID""" + if not precalc_initialized: + build_precalculated_tables() + return precalc_unlock_names.get(unlock_id, "Unknown") + +# ============================================================================= +# THREADING AND PROGRESS +# ============================================================================= + +func worker_thread(thread_id: int): + """Worker thread function""" + var local_results: Array[Dictionary] = [] + var batch_size: int = 10 + + while true: + var task_data = null + queue_mutex.lock() + if task_queue.size() > 0: + task_data = task_queue.pop_front() + queue_mutex.unlock() + + if task_data == null: + if local_results.size() > 0: + results_mutex.lock() + all_results.append_array(local_results) + results_mutex.unlock() + + completed_mutex.lock() + completed_count += local_results.size() + completed_mutex.unlock() + break + + var result = simulate_rank_combination_direct(task_data.combo, 1000000, false, null, null) + + local_results.append(result) + + if local_results.size() >= batch_size: + results_mutex.lock() + all_results.append_array(local_results) + results_mutex.unlock() + + completed_mutex.lock() + completed_count += local_results.size() + completed_mutex.unlock() + + local_results.clear() + +func update_progress(): + """Update progress display""" + var current_count = 0 + completed_mutex.lock() + current_count = completed_count + completed_mutex.unlock() + + if current_count >= total_combinations: + monitoring_active = false + finish_processing() + return + + var percent = float(current_count) / total_combinations * 100.0 + var elapsed = (Time.get_ticks_msec() - start_time) / 1000.0 + var rate = current_count / elapsed if elapsed > 0 else 0 + var eta_seconds = (total_combinations - current_count) / rate if rate > 0 else 0 + + var total_cache_checks = cache_hits + cache_misses + var cache_hit_rate = (float(cache_hits) / total_cache_checks * 100.0) if total_cache_checks > 0 else 0.0 + + var eta_str = "" + if eta_seconds > 0: + var eta_minutes = int(eta_seconds) / 60 + var eta_secs = int(eta_seconds) % 60 + if eta_minutes > 0: + eta_str = "%dm %ds" % [eta_minutes, eta_secs] + else: + eta_str = "%ds" % eta_secs + else: + eta_str = "calculating..." + + print("Progress: %.1f%% (%d/%d) - %.1f combos/sec - Cache: %.1f%% hits - ETA: %s" % [ + percent, current_count, total_combinations, rate, cache_hit_rate, eta_str + ]) + + status_label.text = "Status: Running simulation..." + progress_label.text = "Progress: %.1f%% (%d/%d)" % [percent, current_count, total_combinations] + progress_bar.value = percent / 100.0 + rate_label.text = "Speed: %.1f combos/sec" % rate + eta_label.text = "ETA: %s" % eta_str + + cache_hits_label.text = "Cache Hits: %d" % cache_hits + cache_misses_label.text = "Cache Misses: %d" % cache_misses + cache_rate_label.text = "Hit Rate: %.1f%%" % cache_hit_rate + cache_mutex.lock() + cache_size_label.text = "Cache Entries: %d" % simulation_cache.size() + cache_mutex.unlock() + +func finish_processing(): + """Called when all processing is complete""" + print("\nAll combinations complete! Waiting for threads to finish...") + + for thread in threads: + thread.wait_to_finish() + threads.clear() + threads_done = true + + print("All threads finished. Processing results...") + + var total_time = (Time.get_ticks_msec() - start_time) / 1000.0 + + results_mutex.lock() + var actual_results = all_results.size() + results_mutex.unlock() + + if actual_results != total_combinations: + print("WARNING: Result count mismatch! Expected %d, got %d" % [total_combinations, actual_results]) + + print("\n=== RESULTS ===") + print("Total time: %.1f seconds" % total_time) + print("Total combinations tested: %d" % actual_results) + + var total_cache_checks = cache_hits + cache_misses + var cache_hit_rate = (float(cache_hits) / total_cache_checks * 100.0) if total_cache_checks > 0 else 0.0 + cache_mutex.lock() + var cache_size = simulation_cache.size() + cache_mutex.unlock() + print("\n=== CACHE STATISTICS ===") + print("Cache hits: %d" % cache_hits) + print("Cache misses: %d" % cache_misses) + print("Hit rate: %.1f%%" % cache_hit_rate) + print("Cache entries stored: %d" % cache_size) + + var successful = all_results.filter(func(r): return r.success) + print("Successful strategies: %d" % successful.size()) + + status_label.text = "Status: Complete!" + progress_label.text = "Progress: 100%% (%d/%d)" % [all_results.size(), total_combinations] + progress_bar.value = 1.0 + eta_label.text = "Total Time: %.1f seconds" % total_time + + var results_text = "[b]SIMULATION COMPLETE[/b]\n\n" + results_text += "[color=green]Total time: %.1f seconds[/color]\n" % total_time + results_text += "Combinations tested: %d\n" % all_results.size() + results_text += "Successful strategies: %d\n\n" % successful.size() + + results_text += "[b]Cache Performance:[/b]\n" + results_text += " Hits: %d\n" % cache_hits + results_text += " Misses: %d\n" % cache_misses + results_text += " [color=cyan]Hit Rate: %.1f%%[/color]\n" % cache_hit_rate + results_text += " Entries: %d\n\n" % cache_size + + if successful.size() > 0: + successful.sort_custom(func(a, b): return a.ticks < b.ticks) + + print("\n=== RE-SIMULATING TOP 10 WITH PURCHASE TRACKING ===") + var top_10_detailed: Array = [] + + for i in range(min(10, successful.size())): + var result = successful[i] + print("Re-simulating #%d..." % (i + 1)) + var detailed_result = simulate_rank_combination_direct(result.rank_targets, 1000000, true) + top_10_detailed.append(detailed_result) + + print("\n=== TOP 10 FASTEST STRATEGIES (WITH PURCHASE TIMELINE) ===") + results_text += "[b]TOP 10 FASTEST STRATEGIES:[/b]\n\n" + + for i in range(top_10_detailed.size()): + var result = top_10_detailed[i] + print("\n#%d: %s (%d ticks)" % [i + 1, result.time_formatted, result.ticks]) + + var rank_display = [] + for unlock_id in result.rank_targets.keys(): + var unlock_name = get_unlock_name_by_id(unlock_id) + var ranks = result.rank_targets[unlock_id] + rank_display.append("%s: %d" % [unlock_name, ranks]) + print("Target Ranks: %s" % ", ".join(rank_display)) + + results_text += "[color=yellow]#%d: %s (%d ticks)[/color]\n" % [i + 1, result.time_formatted, result.ticks] + results_text += " Ranks: %s\n" % ", ".join(rank_display) + results_text += " Currency: %.0f\n" % result.final_currency + + if result.has("purchases") and result.purchases.size() > 0: + print("\nPurchase Timeline:") + results_text += " [b]Purchase Timeline:[/b]\n" + for purchase in result.purchases: + var time_str = format_time(purchase.tick) + print(" %s: %s Rank %d - Cost: %d¥ @ %s" % [ + time_str, purchase.unlock_name, purchase.rank, + purchase.cost, time_str + ]) + results_text += " • %s [color=cyan]%s Rank %d[/color] - %d¥ @ %s\n" % [ + format_time(purchase.tick), purchase.unlock_name, purchase.rank, + purchase.cost, time_str + ] + results_text += "\n" + else: + print("\nNo successful strategies found!") + results_text += "[color=red]No successful strategies found![/color]\n" + + results_label.text = results_text + +func run_comprehensive_test(): + """Test all combinations dynamically generated from resource file""" + print("\n=== Available Unlocks ===") + for unlock in unlock_collection.unlocks: + var max_rank_str = str(unlock.max_rank) if unlock.max_rank > 0 else "unlimited" + print("ID: %d | %s | Base Cost: %d | Scaling: %s | Max Rank: %s" % [ + unlock.unlock_id, + unlock.unlock_name, + unlock.base_cost, + "Yes" if unlock.is_scaling else "No", + max_rank_str + ]) + print(" Modifiers: ", unlock.base_modifiers) + + print("\n=== Global Constants ===") + print("Base Sale Price: %s" % Global.base_sale_price) + print("Base Purchase Rate: %s" % Global.base_purchase_rate) + print("Cost Per Whittle: %s" % Global.cost_per_whittle) + + # Build pre-calculated tables + build_precalculated_tables() + + # Generate combinations + var unlimited_cap = 5 + print("\n=== Generation Settings ===") + print("Unlimited scaling cap: %d ranks" % unlimited_cap) + var combinations = generate_all_combinations(unlimited_cap) + total_combinations = combinations.size() + print("\n=== Testing %d Combinations ===" % total_combinations) + + # Fill task queue + task_queue.clear() + for combo in combinations: + task_queue.append({"combo": combo}) + + # Reset counters + completed_count = 0 + all_results.clear() + threads_done = false + start_time = Time.get_ticks_msec() + last_progress_time = start_time + monitoring_active = true + + # Create and start threads + print("Starting %d worker threads..." % num_threads) + for i in range(num_threads): + var thread = Thread.new() + thread.start(worker_thread.bind(i)) + threads.append(thread) + + print("All threads started, processing...") + +func _exit_tree(): + monitoring_active = false + for thread in threads: + if thread.is_alive(): + thread.wait_to_finish() diff --git a/scripts/sim_direct.gd.uid b/scripts/sim_direct.gd.uid new file mode 100644 index 0000000..e374a83 --- /dev/null +++ b/scripts/sim_direct.gd.uid @@ -0,0 +1 @@ +uid://citjokiv6skqi diff --git a/scripts/tick_process.gd b/scripts/tick_process.gd index 4f67ed6..debd128 100644 --- a/scripts/tick_process.gd +++ b/scripts/tick_process.gd @@ -2,11 +2,16 @@ class_name TickProcess extends Node # Dependency injection - can be overridden for simulation -var unlocks_provider: Node = null -var inventory_provider: Node = null +# Using Variant to accept both Node (singletons) and RefCounted (IsolatedGameState) +var unlocks_provider = null +var inventory_provider = null func _ready(): # Default to singletons if not explicitly set + _ensure_providers_initialized() + +func _ensure_providers_initialized(): + """Ensure providers are set (called before every tick if needed)""" if unlocks_provider == null: unlocks_provider = Unlocks if inventory_provider == null: @@ -19,71 +24,72 @@ func set_providers(unlocks, inventory): func tick(): # Log.pr("Tick Process Ticking...") + _ensure_providers_initialized() # Safety check before each tick do_autowood() do_whittling() do_selling() func do_autowood(): - # If the autowood unlock is unlocked then automatically gain wood based on the modifier - var autowood_unlock = Unlocks.get_unlock_by_id(Global.autowood_unlock_id) + # If the autowood unlock is unlocked then automatically gain wood based on the modifier + var autowood_unlock = unlocks_provider.get_unlock_by_id(Global.autowood_unlock_id) if autowood_unlock and autowood_unlock.is_unlocked: - # Log.pr("Autowood modifier", str(Unlocks.get_modifier_value("autowood_modifier"))) - var wood_to_gather = max(Unlocks.get_wood_per_click() * Unlocks.get_modifier_value("autowood_modifier"), 1) - Inventory.add_wood(wood_to_gather) + # Log.pr("Autowood modifier", str(unlocks_provider.get_modifier_value("autowood_modifier"))) + var wood_to_gather = max(unlocks_provider.get_wood_per_click() * unlocks_provider.get_modifier_value("autowood_modifier"), 1) + inventory_provider.add_wood(wood_to_gather) # Log.pr("Auto-gathered", str(wood_to_gather), "wood via autowood unlock.") func do_whittling(): - # If there's more than 1 whole wood available, then whittle based on the efficiency modifier - if Inventory.get_wood() >= 1: + # If there's more than 1 whole wood available, then whittle based on the efficiency modifier + if inventory_provider.get_wood() >= 1: whittle_max_wood_possible() ## If multicraft is unlocked, whittle additional wood based on multicraft unlock - var multicraft_unlock = Unlocks.get_unlock_by_id(Global.multicraft_unlock_id) + var multicraft_unlock = unlocks_provider.get_unlock_by_id(Global.multicraft_unlock_id) if multicraft_unlock and multicraft_unlock.is_unlocked: var additional_whittles = multicraft_unlock.current_rank # Each rank allows one additional whittling action for i in range(additional_whittles): - if Inventory.get_wood() >= 1: + if inventory_provider.get_wood() >= 1: whittle_max_wood_possible() else: break func do_selling(): - # If the wholesale unlock is purchased, sell blocks of 100 whittled wood if possible - var wholesale_unlock = Unlocks.get_unlock_by_id(Global.wholesale_unlock_id) + # If the wholesale unlock is purchased, sell blocks of 100 whittled wood if possible + var wholesale_unlock = unlocks_provider.get_unlock_by_id(Global.wholesale_unlock_id) if wholesale_unlock and wholesale_unlock.is_unlocked: - while Inventory.get_stock() >= Global.wholesale_bundle_size: - Inventory.spend_stock(Global.wholesale_bundle_size) - var currency_earned = Global.wholesale_bundle_size * Unlocks.get_sale_price_per_item() * Global.wholesale_discount_multiplier - Inventory.add_currency(currency_earned) + while inventory_provider.get_stock() >= Global.wholesale_bundle_size: + inventory_provider.spend_stock(Global.wholesale_bundle_size) + var currency_earned = Global.wholesale_bundle_size * unlocks_provider.get_sale_price_per_item() * Global.wholesale_discount_multiplier + inventory_provider.add_currency(currency_earned) # Log.pr("Sold 100 whittled wood for", str(currency_earned), "currency via wholesale unlock.") # If there's whittled wood available to sell, sell it for currency - if Inventory.get_stock() > 0: - var whittle_wood_to_sell = Inventory.get_stock() - # Sell whatever people are willing to buy - var purchase_rate = Global.base_purchase_rate * Unlocks.get_modifier_value("purchase_rate_modifier") + if inventory_provider.get_stock() > 0: + var whittle_wood_to_sell = inventory_provider.get_stock() + # Sell whatever people are willing to buy + var purchase_rate = Global.base_purchase_rate * unlocks_provider.get_modifier_value("purchase_rate_modifier") var max_stock_to_sell = floor(purchase_rate) - + # Sell up to the max stock to sell this tick, but no more than available stock - # We should always sell at least one, up to the max + # We should always sell at least one, up to the max var actual_stock_to_sell = min(whittle_wood_to_sell, max(1, max_stock_to_sell)) - - Inventory.spend_stock(actual_stock_to_sell) - var currency_earned = actual_stock_to_sell * Unlocks.get_sale_price_per_item() - Inventory.add_currency(currency_earned) + + inventory_provider.spend_stock(actual_stock_to_sell) + var currency_earned = actual_stock_to_sell * unlocks_provider.get_sale_price_per_item() + inventory_provider.add_currency(currency_earned) func whittle_max_wood_possible(): - # Get the items that can be produced per tick - var items_produced_per_tick = Unlocks.get_items_produced_per_tick() + # Get the items that can be produced per tick + var items_produced_per_tick = unlocks_provider.get_items_produced_per_tick() # Log.pr("Items produced per tick:", str(items_produced_per_tick)) var wood_needed = ceil(items_produced_per_tick) # Whittle as much wood as possible this tick, up to the max allowed by efficiency - var wood_to_whittle = min(Inventory.get_wood(), wood_needed) + var wood_to_whittle = min(inventory_provider.get_wood(), wood_needed) var actual_items_produced = wood_to_whittle - Inventory.spend_wood(wood_to_whittle) - Inventory.add_stock(actual_items_produced) + inventory_provider.spend_wood(wood_to_whittle) + inventory_provider.add_stock(actual_items_produced) # Log.pr("Whittled", str(wood_to_whittle), "wood into", str(actual_items_produced), "whittle wood.") diff --git a/scripts/unlock_data_lightweight.gd b/scripts/unlock_data_lightweight.gd new file mode 100644 index 0000000..af1e799 --- /dev/null +++ b/scripts/unlock_data_lightweight.gd @@ -0,0 +1,138 @@ +class_name UnlockDataLightweight +## Lightweight unlock data structure for simulations +## Contains the same calculation logic as UnlockDataResource but without Resource overhead + +var unlock_id: int = 0 +var unlock_name: String = "" +var base_cost: int = 0 +var is_unlocked: bool = false + +# Scaling settings +var is_scaling: bool = false +var current_rank: int = 0 +var max_rank: int = -1 + +# Cost scaling +var cost_scaling_type: int = 1 # 0=Linear, 1=Exponential +var cost_scaling_multiplier: float = 1.5 +var cost_linear_increase: int = 100 +var cost_ladder: Array[int] = [] + +# Effect scaling +var effect_scaling_type: int = 1 # 0=Linear, 1=Exponential +var effect_scaling_multiplier: float = 1.2 +var effect_linear_increase: float = 0.1 + +# Base modifiers +var base_modifiers: Dictionary = {} + +## Static factory method to create from UnlockDataResource (one-time conversion) +static func from_resource(resource: UnlockDataResource) -> UnlockDataLightweight: + var data = UnlockDataLightweight.new() + data.unlock_id = resource.unlock_id + data.unlock_name = resource.unlock_name + data.base_cost = resource.base_cost + data.is_scaling = resource.is_scaling + data.max_rank = resource.max_rank + data.cost_scaling_type = resource.cost_scaling_type + data.cost_scaling_multiplier = resource.cost_scaling_multiplier + data.cost_linear_increase = resource.cost_linear_increase + data.cost_ladder = resource.cost_ladder.duplicate() + data.effect_scaling_type = resource.effect_scaling_type + data.effect_scaling_multiplier = resource.effect_scaling_multiplier + data.effect_linear_increase = resource.effect_linear_increase + data.base_modifiers = resource.base_modifiers.duplicate(true) + # Start fresh + data.is_unlocked = false + data.current_rank = 0 + return data + +## Clone for thread safety (fast - no Resource creation) +func clone() -> UnlockDataLightweight: + var copy = UnlockDataLightweight.new() + copy.unlock_id = unlock_id + copy.unlock_name = unlock_name + copy.base_cost = base_cost + copy.is_scaling = is_scaling + copy.max_rank = max_rank + copy.cost_scaling_type = cost_scaling_type + copy.cost_scaling_multiplier = cost_scaling_multiplier + copy.cost_linear_increase = cost_linear_increase + copy.cost_ladder = cost_ladder # Shared - read-only + copy.effect_scaling_type = effect_scaling_type + copy.effect_scaling_multiplier = effect_scaling_multiplier + copy.effect_linear_increase = effect_linear_increase + copy.base_modifiers = base_modifiers # Shared - read-only + # Mutable state + copy.is_unlocked = false + copy.current_rank = 0 + return copy + +## Same logic as UnlockDataResource.get_next_cost() +func get_next_cost() -> int: + if not is_scaling: + return base_cost + + if cost_ladder.size() > 0 and current_rank < cost_ladder.size(): + return cost_ladder[current_rank] + + if cost_scaling_type == 0: # Linear + return base_cost + (cost_linear_increase * current_rank) + else: # Exponential + return int(base_cost * pow(cost_scaling_multiplier, current_rank)) + +## Same logic as UnlockDataResource.get_current_modifiers() +func get_current_modifiers() -> Dictionary: + if not is_unlocked or current_rank == 0: + return {} + + if current_rank == 1: + return base_modifiers.duplicate() + + return get_modifiers_at_rank(current_rank) + +## Same logic as UnlockDataResource.get_modifiers_at_rank() +func get_modifiers_at_rank(rank: int) -> Dictionary: + if not is_scaling or rank == 0: + return base_modifiers.duplicate() + + if rank == 1: + return base_modifiers.duplicate() + + var scaled_modifiers = {} + for key in base_modifiers.keys(): + var base_value = base_modifiers[key] + + if effect_scaling_type == 0: # Linear + var additional_ranks = rank - 1 + scaled_modifiers[key] = base_value + (effect_linear_increase * additional_ranks) + else: # Exponential + var base_bonus = base_value - 1.0 + var scaled_bonus = base_bonus * pow(effect_scaling_multiplier, rank - 1) + scaled_modifiers[key] = 1.0 + scaled_bonus + + return scaled_modifiers + +## Same logic as UnlockDataResource.can_rank_up() +func can_rank_up() -> bool: + if not is_scaling: + return not is_unlocked + + if max_rank > 0 and current_rank >= max_rank: + return false + + return true + +## Same logic as UnlockDataResource.unlock() +func unlock() -> bool: + if not can_rank_up(): + return false + + if not is_scaling: + is_unlocked = true + current_rank = 1 + else: + current_rank += 1 + is_unlocked = true + + return true diff --git a/scripts/unlock_data_lightweight.gd.uid b/scripts/unlock_data_lightweight.gd.uid new file mode 100644 index 0000000..3336c42 --- /dev/null +++ b/scripts/unlock_data_lightweight.gd.uid @@ -0,0 +1 @@ +uid://yx6cnoob2can