class_name UnlockSimulatorCached extends Control # CACHED VERSION - Uses aggressive chunk-based caching for 10-100x speedup # Load the actual game resources var unlock_collection: UnlockDataCollection = load("res://resources/UnlockData.tres") var inventory_resource: InventoryResource = load("res://resources/InventoryData.tres") # Results tracking var all_results: Array[Dictionary] = [] var results_mutex: Mutex = Mutex.new() # Chunk-based caching for intermediate simulation states # Key: "unlock_id:rank,..." sorted string # Value: {cache_key, ticks, currency, wood, stock, current_ranks, modifiers} var simulation_cache: Dictionary = {} var cache_mutex: Mutex = Mutex.new() # Global unlock struct cache (build once, clone per simulation) var global_unlock_structs: Array[Dictionary] = [] var global_unlock_structs_mutex: Mutex = Mutex.new() var unlock_structs_initialized: bool = false # Manual thread pool var num_threads: int = 14 # Increase this for more CPU usage 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 active_threads: int = 0 var threads_done: bool = false # Pre-calculated cost arrays for faster lookup - OPTIMIZATION var cost_cache: Dictionary = {} 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("=== CACHED Unlock Simulator Started ===") print("Using aggressive chunk-based caching for 10-100x speedup") var cpu_count = OS.get_processor_count() print("CPU cores detected: %d" % cpu_count) print("Creating %d worker threads (adjust num_threads variable for more/less)" % num_threads) # Update UI status_label.text = "Status: Starting cached simulation..." results_label.text = "[b]CACHED 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: # Only update progress once per second var current_time = Time.get_ticks_msec() if current_time - last_progress_time >= 1000: last_progress_time = current_time update_progress() func update_progress(): """Update progress display""" var current_count = 0 completed_mutex.lock() current_count = completed_count completed_mutex.unlock() # Check if all work is complete 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 # Calculate cache hit rate 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 # Format ETA 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 ]) # Update UI 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 # Update cache stats 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 worker_thread(thread_id: int): """Worker thread function that pulls tasks from the queue""" # Local batch storage to reduce mutex contention - OPTIMIZATION var local_results: Array[Dictionary] = [] var local_count: int = 0 var batch_size: int = 10 # Process 10 before syncing while true: # Get next task from queue var task_data = null queue_mutex.lock() if task_queue.size() > 0: task_data = task_queue.pop_front() queue_mutex.unlock() # If no more tasks, flush results and exit if task_data == null: if local_results.size() > 0: results_mutex.lock() all_results.append_array(local_results) results_mutex.unlock() # CRITICAL FIX: Update completed count when flushing final batch completed_mutex.lock() completed_count += local_results.size() completed_mutex.unlock() break # Process the task var result = simulate_rank_combination_pure(task_data.combo, task_data.unlock_data, 1000000) # Store in local batch local_results.append(result) local_count += 1 # Flush batch periodically to reduce mutex contention if local_results.size() >= batch_size: results_mutex.lock() all_results.append_array(local_results) results_mutex.unlock() # CRITICAL FIX: Update progress counter with mutex protection completed_mutex.lock() completed_count += local_results.size() completed_mutex.unlock() local_results.clear() local_count = 0 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 (~10-15 lookups)""" 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 # STRATEGY: Try progressively shorter prefixes by dropping unlocks from the END # This is the most common pattern: {1,2,3,4,5} → {1,2,3,4} → {1,2,3} → {1,2} → {1} # Covers 80%+ of cache reuse because combinations are generated in sorted order 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 # Keep best match (longest prefix) if rank_sum > best_rank_sum: best_match = cached_entry best_rank_sum = rank_sum # Early exit if we found a substantial match 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""" # Don't cache if all targets reached if targets_remaining == 0: return false # Cache aggressively for early states var total_ranks = 0 var active_unlocks = 0 for rank in current_ranks.values(): if rank > 0: total_ranks += rank active_unlocks += 1 # Cache if: multiple unlocks active OR significant progress on one return (active_unlocks >= 2) or (total_ranks >= 2) func simulate_rank_combination_pure(rank_targets: Dictionary, unlock_data_array: Array, max_ticks: int, track_purchases: bool = false) -> Dictionary: """Optimized pure simulation function with struct-based unlocks""" var currency: float = 0.0 var stock: float = 0.0 var wood: float = 0.0 # Purchase tracking (only enabled for top results) var purchases: Array[Dictionary] = [] # GLOBAL STRUCT CACHE - Build once, clone per simulation # This avoids rebuilding cost/effect tables for every simulation var unlocks: Array[Dictionary] = [] var unlock_by_id: Dictionary = {} if not unlock_structs_initialized: global_unlock_structs_mutex.lock() if not unlock_structs_initialized: # Double-check pattern for unlock_data in unlock_data_array: var base_mods = unlock_data.base_modifiers # Pre-calculate cost table for first 20 ranks (avoid pow() in hot loop) var cost_table: Array[float] = [] if unlock_data.is_scaling: # Use cost_ladder if defined, otherwise use exponential scaling if unlock_data.has("cost_ladder") and unlock_data.cost_ladder.size() > 0: # Use fixed cost ladder for cost in unlock_data.cost_ladder: cost_table.append(float(cost)) # Fill remaining slots with last cost for safety while cost_table.size() < 21: cost_table.append(cost_table[cost_table.size() - 1]) else: # Fallback to exponential scaling var base_cost_float = float(unlock_data.base_cost) var mult = unlock_data.cost_scaling_multiplier for r in range(21): # Pre-calc ranks 0-20 cost_table.append(base_cost_float * pow(mult, r)) else: cost_table.append(float(unlock_data.base_cost)) # Pre-calculate effect scale factors for first 20 ranks var effect_scale_table: Array[float] = [] var effect_mult = unlock_data.effect_scaling_multiplier for r in range(21): if r == 0: effect_scale_table.append(0.0) # No effect at rank 0 elif r == 1: effect_scale_table.append(1.0) # Base effect at rank 1 else: effect_scale_table.append(pow(effect_mult, r - 1)) var unlock_struct = { "id": unlock_data.unlock_id, "name": unlock_data.unlock_name, "base_cost": unlock_data.base_cost, "is_scaling": unlock_data.is_scaling, "max_rank": unlock_data.max_rank, "cost_multiplier": unlock_data.cost_scaling_multiplier, "effect_multiplier": unlock_data.effect_scaling_multiplier, "base_mods": base_mods, "cost_table": cost_table, # Pre-calculated costs "effect_scale_table": effect_scale_table, # Pre-calculated effect scales # Pre-calculate whether this unlock affects each modifier (avoids string lookups) "affects_sale_price": base_mods.has("sale_price_modifier"), "affects_efficiency": base_mods.has("efficiency_modifier"), "affects_wood_per_click": base_mods.has("wood_per_click_modifier"), "affects_purchase_rate": base_mods.has("purchase_rate_modifier"), "affects_autowood": base_mods.has("autowood_modifier"), "is_multicraft": base_mods.has("multicraft_increase_modifier"), # Cache base modifier values to avoid dictionary lookups "sale_price_value": base_mods.get("sale_price_modifier", 1.0), "efficiency_value": base_mods.get("efficiency_modifier", 1.0), "wood_per_click_value": base_mods.get("wood_per_click_modifier", 1.0), "purchase_rate_value": base_mods.get("purchase_rate_modifier", 1.0), "autowood_value": base_mods.get("autowood_modifier", 0.0) } global_unlock_structs.append(unlock_struct) unlock_structs_initialized = true global_unlock_structs_mutex.unlock() # Clone structs for this simulation (fast shallow copy) for template in global_unlock_structs: var unlock = template.duplicate(false) # Shallow copy unlock.current_rank = 0 # Reset rank for this simulation unlocks.append(unlock) unlock_by_id[unlock.id] = unlock var ticks: int = 0 # Removed purchases array - it's only needed for debug output and slows down simulation # Track how many targets still need to be reached - OPTIMIZATION var targets_remaining: int = 0 var current_ranks: Dictionary = {} var active_unlock_ids: Array = [] # Only check unlocks that haven't reached target yet for unlock_id in rank_targets.keys(): current_ranks[unlock_id] = 0 targets_remaining += rank_targets[unlock_id] active_unlock_ids.append(unlock_id) # Modifiers as individual variables for faster access - MAJOR OPTIMIZATION 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 # Pre-calculate constants var wood_per_click_base: float = Global.wood_per_click var cost_per_whittle: float = Global.cost_per_whittle var base_sale_price: float = Global.base_sale_price var base_purchase_rate: float = Global.base_purchase_rate var wholesale_id: int = Global.wholesale_unlock_id var wholesale_size: float = Global.wholesale_bundle_size var wholesale_mult: float = Global.wholesale_discount_multiplier # CACHE LOOKUP: Try to load from cached intermediate state # NOTE: Disable cache when tracking purchases to ensure all purchases are recorded var cached_state = null if not track_purchases: cached_state = try_load_best_prefix_from_cache(rank_targets) if cached_state != null: # Restore full state from cache ticks = cached_state.ticks currency = cached_state.currency stock = cached_state.stock wood = cached_state.wood # Restore modifiers sale_price_mod = cached_state.modifiers.sale_price_mod efficiency_mod = cached_state.modifiers.efficiency_mod wood_per_click_mod = cached_state.modifiers.wood_per_click_mod purchase_rate_mod = cached_state.modifiers.purchase_rate_mod autowood_mod = cached_state.modifiers.autowood_mod multicraft_rank = cached_state.modifiers.multicraft_rank wholesale_unlocked = cached_state.modifiers.wholesale_unlocked # Restore unlock ranks for unlock_id in cached_state.current_ranks.keys(): if unlock_by_id.has(unlock_id): unlock_by_id[unlock_id].current_rank = cached_state.current_ranks[unlock_id] current_ranks[unlock_id] = cached_state.current_ranks[unlock_id] # Recalculate targets_remaining and active_unlock_ids targets_remaining = 0 active_unlock_ids.clear() for unlock_id in rank_targets.keys(): if not current_ranks.has(unlock_id): current_ranks[unlock_id] = 0 var remaining = rank_targets[unlock_id] - current_ranks[unlock_id] if remaining > 0: targets_remaining += remaining active_unlock_ids.append(unlock_id) # PRE-CALCULATE all next costs to avoid repeated lookups in main loop var next_costs: Array[float] = [] next_costs.resize(active_unlock_ids.size()) for i in range(active_unlock_ids.size()): var unlock = unlock_by_id[active_unlock_ids[i]] var current_rank: int = unlock.current_rank if current_rank < unlock.cost_table.size(): next_costs[i] = unlock.cost_table[current_rank] else: next_costs[i] = unlock.base_cost * pow(unlock.cost_multiplier, current_rank) while ticks < max_ticks: # Find cheapest affordable unlock using pre-calculated costs var cheapest_unlock_id: int = -1 var cheapest_cost: float = INF var cheapest_unlock = null var cheapest_index: int = -1 if targets_remaining > 0: for i in range(active_unlock_ids.size()): if next_costs[i] < cheapest_cost and currency >= next_costs[i]: cheapest_cost = next_costs[i] cheapest_unlock_id = active_unlock_ids[i] cheapest_unlock = unlock_by_id[cheapest_unlock_id] cheapest_index = i # If we can't afford anything and all targets are met, skip to earning 1M if cheapest_unlock == null and targets_remaining == 0: if currency >= 1000000.0: break # Skip ahead: calculate ticks needed to reach 1M currency # Use current production rate to estimate var currency_needed = 1000000.0 - currency var price_per_item = base_sale_price * sale_price_mod var items_per_tick = max(1.0, floor(base_purchase_rate * purchase_rate_mod)) 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)) ticks += ticks_needed currency += revenue_per_tick * ticks_needed break # Purchase the cheapest unlock if found if cheapest_unlock != null: currency -= cheapest_cost cheapest_unlock.current_rank += 1 current_ranks[cheapest_unlock_id] += 1 targets_remaining -= 1 # Update wholesale cache if cheapest_unlock_id == wholesale_id: wholesale_unlocked = true # OPTIMIZED modifier update - use ratio instead of recalculating from scratch var rank: int = cheapest_unlock.current_rank var prev_rank: int = rank - 1 if cheapest_unlock.is_multicraft: multicraft_rank = rank # Get scale factors from pre-calculated tables var old_scale: float = cheapest_unlock.effect_scale_table[prev_rank] if prev_rank < cheapest_unlock.effect_scale_table.size() else 0.0 var new_scale: float = cheapest_unlock.effect_scale_table[rank] if rank < cheapest_unlock.effect_scale_table.size() else pow(cheapest_unlock.effect_multiplier, rank - 1) # Apply incremental changes using ratio if cheapest_unlock.affects_sale_price: var base_bonus: float = cheapest_unlock.sale_price_value - 1.0 var old_mult: float = 1.0 + base_bonus * old_scale var new_mult: float = 1.0 + base_bonus * new_scale sale_price_mod = sale_price_mod * (new_mult / old_mult) if cheapest_unlock.affects_efficiency: var base_bonus: float = cheapest_unlock.efficiency_value - 1.0 var old_mult: float = 1.0 + base_bonus * old_scale var new_mult: float = 1.0 + base_bonus * new_scale efficiency_mod = efficiency_mod * (new_mult / old_mult) if cheapest_unlock.affects_wood_per_click: var base_bonus: float = cheapest_unlock.wood_per_click_value - 1.0 var old_mult: float = 1.0 + base_bonus * old_scale var new_mult: float = 1.0 + base_bonus * new_scale wood_per_click_mod = wood_per_click_mod * (new_mult / old_mult) if cheapest_unlock.affects_purchase_rate: var base_bonus: float = cheapest_unlock.purchase_rate_value - 1.0 var old_mult: float = 1.0 + base_bonus * old_scale var new_mult: float = 1.0 + base_bonus * new_scale purchase_rate_mod = purchase_rate_mod * (new_mult / old_mult) if cheapest_unlock.affects_autowood: autowood_mod = autowood_mod - cheapest_unlock.autowood_value * prev_rank + cheapest_unlock.autowood_value * rank # Track purchase if enabled if track_purchases: if purchases.size() == 0: print("DEBUG: First purchase being tracked!") purchases.append({ "unlock_id": cheapest_unlock_id, "unlock_name": cheapest_unlock.name, "rank": rank, "cost": cheapest_cost, "tick": ticks, "currency_after": currency }) # Update next cost for this unlock or remove from active list if current_ranks[cheapest_unlock_id] >= rank_targets[cheapest_unlock_id]: # Target reached - swap with last element and shrink array 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 var new_rank = cheapest_unlock.current_rank if new_rank < cheapest_unlock.cost_table.size(): next_costs[cheapest_index] = cheapest_unlock.cost_table[new_rank] else: next_costs[cheapest_index] = cheapest_unlock.base_cost * pow(cheapest_unlock.cost_multiplier, new_rank) # Removed purchase tracking for performance - only track in debug mode if needed # Don't append to purchases array on every purchase # CACHE INSERTION: Cache this state if valuable if should_cache_state(current_ranks, targets_remaining): var cache_key = get_cache_key(current_ranks) cache_mutex.lock() if not simulation_cache.has(cache_key): simulation_cache[cache_key] = { "cache_key": cache_key, "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 } } cache_mutex.unlock() # Simulate one tick - HEAVILY OPTIMIZED # 1. Generate wood var wood_per_click_modified = wood_per_click_base * wood_per_click_mod # Manual clicks based on tick range (pre-calculate to avoid repeated conditions) var manual_clicks: float = 1.0 if ticks < 120 else (0.5 if ticks < 300 else (0.25 if (ticks < 600 and autowood_mod < 0.2) else 0.0)) # Total wood generation var wood_gen: float = manual_clicks * wood_per_click_modified if autowood_mod > 0.0: wood_gen += max(wood_per_click_modified * autowood_mod, 1.0) wood += wood_gen # 2. Whittle wood into stock - MATCHES tick_process.gd:19-32 # Base whittling action (always happens once) var multicraft_actions = 1 + multicraft_rank # 1 base + multicraft ranks # Each whittle action: items_produced_per_tick = cost_per_whittle * efficiency_modifier var items_per_whittle = cost_per_whittle * efficiency_mod for action in range(multicraft_actions): if wood >= 1: # Need at least 1 wood to whittle # How much wood needed for this whittle (matches tick_process.gd:63-65) var wood_needed = ceil(items_per_whittle) var wood_to_use = min(wood, wood_needed) var items_produced = wood_to_use # 1 wood = 1 item always wood -= wood_to_use stock += items_produced else: break # Not enough wood for more whittle actions # 3. Sell stock for currency - MATCHES tick_process.gd:34-58 var price_per_item = base_sale_price * sale_price_mod # 3a. Wholesale selling (if unlocked) - matches tick_process.gd:36-42 # Sell ALL possible 100-item bundles at 1.2x price if wholesale_unlocked: while stock >= wholesale_size: stock -= wholesale_size currency += wholesale_size * price_per_item * wholesale_mult # 3b. Regular selling - matches tick_process.gd:45-58 if stock > 0: var purchase_rate = base_purchase_rate * purchase_rate_mod var max_stock_to_sell = floor(purchase_rate) # Always sell at least 1, up to the max 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 var success = currency >= 1000000.0 var result = { "rank_targets": rank_targets, "success": success, "ticks": ticks if success else -1, "final_currency": currency, "time_formatted": format_time(ticks) if success else "Failed" } # Include purchase timeline if tracking was enabled if track_purchases: result["purchases"] = purchases print("DEBUG: track_purchases=true, purchases.size()=%d" % purchases.size()) return result 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, respecting max_ranks from resource file Args: unlimited_scaling_cap: Maximum rank to test for unlocks with unlimited scaling (default: 5) Lower values = faster testing, higher = more comprehensive Cap=3: ~13K combos (~28 sec) | Cap=5: ~47K combos (~93 sec) Cap=7: ~111K combos (~3.7min) | Cap=10: ~287K combos (~9.6min) """ var combinations: Array[Dictionary] = [] # Build constraint list from resource file 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 # One-shot unlocks else: max_rank = unlimited_scaling_cap # Configurable cap for unlimited scaling 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]) # Recursive generation _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(): # Skip all-zeros combination 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 serialize_unlock_data() -> Array: """Convert unlock collection to serializable data for threads""" var unlock_data = [] for unlock in unlock_collection.unlocks: unlock_data.append({ "unlock_id": unlock.unlock_id, "unlock_name": unlock.unlock_name, "base_cost": unlock.base_cost, "is_scaling": unlock.is_scaling, "max_rank": unlock.max_rank, "cost_scaling_multiplier": unlock.cost_scaling_multiplier, "effect_scaling_multiplier": unlock.effect_scaling_multiplier, "cost_ladder": unlock.cost_ladder.duplicate() if unlock.cost_ladder.size() > 0 else [], "base_modifiers": unlock.base_modifiers.duplicate() }) return unlock_data 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) # Serialize unlock data for threads var unlock_data = serialize_unlock_data() # CACHE WARMUP: Pre-populate cache with common single-unlock states print("\n=== Cache Warmup ===") print("Pre-populating cache with common prefixes...") for unlock in unlock_collection.unlocks: var max_warmup_rank = 3 if unlock.max_rank > 0: max_warmup_rank = min(unlock.max_rank, 3) for rank in range(1, max_warmup_rank + 1): var warmup_target = {unlock.unlock_id: rank} simulate_rank_combination_pure(warmup_target, unlock_data, 1000000) print("Cache warmup complete. Cache size: %d entries" % simulation_cache.size()) # Generate all combinations (configurable cap for unlimited scaling) var unlimited_cap = 5 # Adjust this to test more/fewer ranks: 3=fast, 5=balanced, 7+=comprehensive 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, "unlock_data": unlock_data }) # 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 finish_processing(): """Called when all processing is complete""" print("\nAll combinations complete! Waiting for threads to finish...") # Wait for all 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 # SAFETY CHECK: Verify result count matches 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("This indicates a threading issue where some results weren't flushed") # Print results print("\n=== RESULTS ===") print("Total time: %.1f seconds" % total_time) print("Total combinations tested: %d (expected %d)" % [actual_results, total_combinations]) # Cache statistics 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()) # Update UI status 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 # Build results text for UI 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: # Sort by ticks (fastest first) successful.sort_custom(func(a, b): return a.ticks < b.ticks) # Re-simulate top 10 with detailed purchase tracking print("\n=== RE-SIMULATING TOP 10 WITH PURCHASE TRACKING ===") var unlock_data = serialize_unlock_data() var top_10_detailed: Array = [] for i in range(min(10, successful.size())): var result = successful[i] print("Re-simulating #%d with track_purchases=true..." % (i + 1)) var detailed_result = simulate_rank_combination_pure(result.rank_targets, unlock_data, 1000000, true) print(" Result has purchases key: %s" % detailed_result.has("purchases")) if detailed_result.has("purchases"): print(" Purchases array size: %d" % detailed_result.purchases.size()) 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]) # Format ranks with unlock names 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)) # Add to UI 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 # Add purchase timeline 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" # Update results UI results_label.text = results_text func get_unlock_name_by_id(unlock_id: int) -> String: """Helper function to get unlock name by ID""" for unlock in unlock_collection.unlocks: if unlock.unlock_id == unlock_id: return unlock.unlock_name return "Unknown" func _exit_tree(): # Clean up threads monitoring_active = false for thread in threads: if thread.is_alive(): thread.wait_to_finish()