1036 lines
34 KiB
GDScript
1036 lines
34 KiB
GDScript
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()
|