## Central state authority for the idle game. Manages currencies, generators, buffs, goals, research, and persistence. ## Acts as the single source of truth for all gameplay state, emitting signals for UI and other systems to react. class_name LevelGameState extends Node # ============================================================================== # SIGNALS # ============================================================================== ## Emitted when any currency balance changes. [param currency_id] identifies the currency, [param new_amount] is the updated balance. signal currency_changed(currency_id: StringName, new_amount: BigNumber) ## Emitted when a generator's state changes (owned count, unlocked status, etc.). signal generator_state_changed(generator_id: StringName, state: Dictionary) ## Emitted when a global buff level changes. signal buff_level_changed(buff_id: StringName, new_level: int) ## Emitted when a global buff unlock state changes. signal buff_unlocked_changed(buff_id: StringName, unlocked: bool) ## Emitted when a goal is completed. signal goal_completed(goal_id: StringName) ## Emitted when goal progress changes (for UI progress bars). signal goal_progress_changed(goal_id: StringName, progress: float) ## Emitted when research XP changes. signal research_xp_changed(research_id: StringName, new_xp: BigNumber) ## Emitted when a research track levels up. signal research_level_up(research_id: StringName, old_level: int, new_level: int) ## Emitted when worker count changes (derived from currency balance). signal worker_count_changed(currency_id: StringName, new_count: int) ## Emitted when research workers change. signal research_workers_changed(new_count: int) ## Emitted when an prestige buff node is unlocked. signal prestige_buff_unlocked(buff_id: StringName) # ============================================================================== # EXPORTED REFERENCES # ============================================================================== ## Catalog of all defined currencies in the game. @export var currency_catalogue: CurrencyCatalogue ## Catalog of all defined buffs in the game. @export var buff_catalogue: BuffCatalogue ## Catalog of all defined goals in the game. @export var goal_catalogue: GoalCatalogue ## Catalog of all defined research tracks in the game. @export var research_catalogue: ResearchCatalogue ## Catalog of all prestige buff nodes in the graph. @export var prestige_buff_catalogue: PrestigeBuffCatalogue ## File path for save/load persistence. Defaults to user directory. @export var save_file_path: String = "user://level_save.json" # ============================================================================== # STATE VARIABLES # ============================================================================== ## Current balance for each currency (resets on prestige). var _current_currency: Dictionary = {} ## Total currency acquired this run (resets on prestige). var _total_currency_acquired: Dictionary = {} ## All-time currency acquired (never resets, persists across prestige). var _all_time_currency_acquired: Dictionary = {} ## Generator states (owned count, unlocked status, availability). var generator_states: Dictionary = {} ## External save data sections for other systems (e.g., PrestigeManager). var _external_save_sections: Dictionary = {} ## Global buff definitions by ID. var _buff_definitions: Dictionary = {} ## Global buff levels (shared across all targets). var _buff_levels: Dictionary = {} ## Global buff unlock states. var _buff_unlocked: Dictionary = {} ## Global buff active states (true when unlocked and active). var _buff_active: Dictionary = {} ## Goal definitions by ID. var _goal_definitions: Dictionary = {} ## Goal completion state (true if completed). var _goal_completed: Dictionary = {} ## Research XP by research ID (BigNumber). var research_xp: Dictionary = {} ## Research levels by research ID. var research_levels: Dictionary = {} ## Workers assigned to research (total across all tracks). var research_workers: int = 0 ## Ascension buff unlock state: maps node_id (StringName) → bool (true if unlocked). Persists across prestige. var _prestige_buff_unlocked: Dictionary = {} ## Unix timestamp of last save. var last_save_time: int = 0 # ============================================================================== # SAVE / LOAD CONSTANTS # ============================================================================== ## JSON key for save format version. const SAVE_FORMAT_VERSION_KEY: String = "save_format_version" ## Current save format version (7 includes research workers tracking). const CURRENT_SAVE_FORMAT_VERSION: int = 8 ## JSON key for generator owned count. const GENERATOR_OWNED_KEY: String = "owned" ## JSON key for generator purchased count. const GENERATOR_PURCHASED_COUNT_KEY: String = "purchased_count" ## JSON key for generator unlocked status. const GENERATOR_UNLOCKED_KEY: String = "unlocked" ## JSON key for generator availability. const GENERATOR_AVAILABLE_KEY: String = "available" ## JSON key for generator states section. const GENERATOR_STATES_KEY: String = "generator_states" ## JSON key for buff definitions section. const BUFF_DEFINITIONS_KEY: String = "buff_definitions" ## JSON key for buff levels section. const BUFF_LEVELS_KEY: String = "buff_levels" ## JSON key for buff unlocked section. const BUFF_UNLOCKED_KEY: String = "buff_unlocked" ## JSON key for buff active section. const BUFF_ACTIVE_KEY: String = "buff_active" ## JSON key for goals section. const GOALS_KEY: String = "goals" ## JSON key for goal completed array. const GOAL_COMPLETED_KEY: String = "completed" ## JSON key for currencies section. const CURRENCIES_KEY: String = "currencies" ## JSON key for current currency balance. const CURRENT_KEY: String = "current" ## JSON key for total currency acquired. const TOTAL_KEY: String = "total" ## JSON key for all-time currency acquired. const ALL_TIME_KEY: String = "all_time" ## JSON key for last save timestamp. const LAST_SAVE_TIME_KEY: String = "last_save_time" ## JSON key for research XP section. const RESEARCH_XP_KEY: String = "research_xp" ## JSON key for research levels section. const RESEARCH_LEVELS_KEY: String = "research_levels" ## JSON key for research workers section. const RESEARCH_WORKERS_KEY: String = "research_workers" ## JSON key for prestige buff unlocked array. const PRESTIGE_BUFF_UNLOCKED_KEY: String = "prestige_buff_unlocked" ## Reference to the PrestigeManager autoload node (set in _ready). var prestige_manager: PrestigeManager ## Called when the node enters the scene tree. Initializes all game systems, loads saved data, and restores state. func _ready() -> void: prestige_manager = find_child("PrestigeManager") assert(prestige_manager != null) _initialize_currency_maps() _initialize_catalogues() _initialize_prestige_buffs() _load_catalogue_goals() load_game() _try_unlock_all_buffs_from_goals() _initialize_research() #region currency # ============================================================================== # CURRENCY API # ============================================================================== ## Returns the normalized ID from a Currency resource. func get_currency_id(currency: Currency) -> StringName: return _normalize_currency_id(currency.id if currency else &"") ## Parses and normalizes a currency ID from raw input (handles special cases like "gem" -> "gems"). func parse_currency_id(raw_currency: Variant) -> StringName: var currency_text: String = String(raw_currency).to_lower().strip_edges() if currency_text == "gem": currency_text = "gems" return _normalize_currency_id(StringName(currency_text)) ## Checks if a currency ID is known in the catalogue. func is_known_currency_id(currency_id: StringName) -> bool: if not currency_catalogue: return false var ids: Array[StringName] = currency_catalogue.get_all_ids() return ids.has(_normalize_currency_id(currency_id)) ## Returns the Currency resource for a given ID, or null if not found. func get_currency_resource(currency_id: StringName) -> Currency: if not currency_catalogue: return null return currency_catalogue.get_currency_by_id(_normalize_currency_id(currency_id)) ## Returns the display name for a currency ID, falling back to a humanized ID if not found. func get_currency_name(currency_id: StringName) -> String: var currency: Currency = get_currency_resource(currency_id) if currency != null: var display_name: String = String(currency.display_name).strip_edges() if not display_name.is_empty(): return display_name return _humanize_currency_id(currency_id) ## Returns the icon texture for a currency ID, or null if not found. func get_currency_icon(currency_id: StringName) -> Texture2D: var currency: Currency = get_currency_resource(currency_id) if currency == null: return null var raw_icon: Variant = currency.icon if raw_icon is Texture2D: return raw_icon return null ## Adds currency from a Currency resource. Emits [signal currency_changed] on success. func add_currency(currency: Currency, amount: BigNumber) -> void: var currency_id: StringName = get_currency_id(currency) if currency_id == &"": push_warning("Attempted to add currency with an invalid Currency resource.") return add_currency_by_id(currency_id, amount) ## Spends currency from a Currency resource. Returns false if insufficient funds. func spend_currency(currency: Currency, cost: BigNumber) -> bool: var currency_id: StringName = get_currency_id(currency) if currency_id == &"": push_warning("Attempted to spend currency with an invalid Currency resource.") return false return spend_currency_by_id(currency_id, cost) ## Adds currency by ID. Updates current, total, and all-time tracking. Emits [signal currency_changed]. func add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return if amount.mantissa > 0.0: _get_total_currency_ref(normalized_currency_id).add_in_place(amount) _get_all_time_currency_ref(normalized_currency_id).add_in_place(amount) var current: BigNumber = _get_current_currency_ref(normalized_currency_id) current.add_in_place(amount) currency_changed.emit(normalized_currency_id, current) ## Spends currency by ID. Returns true if successful, false if insufficient funds. Emits [signal currency_changed]. func spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return false var current: BigNumber = _get_current_currency_ref(normalized_currency_id) if current.is_greater_than(cost) or current.is_equal_to(cost): var negative_cost: BigNumber = BigNumber.new(-cost.mantissa, cost.exponent) current.add_in_place(negative_cost) currency_changed.emit(normalized_currency_id, current) return true return false ## Returns the current balance for a Currency resource. func get_currency_amount(currency: Currency) -> BigNumber: return get_currency_amount_by_id(get_currency_id(currency)) ## Returns the current balance for a currency ID. func get_currency_amount_by_id(currency_id: StringName) -> BigNumber: return _get_current_currency_ref(currency_id) ## Returns the total acquired this run for a Currency resource. func get_total_currency_acquired(currency: Currency) -> BigNumber: return get_total_currency_acquired_by_id(get_currency_id(currency)) ## Returns the total acquired this run for a currency ID (resets on prestige). func get_total_currency_acquired_by_id(currency_id: StringName) -> BigNumber: return _get_total_currency_ref(currency_id) ## Returns the all-time total for a Currency resource. func get_all_time_currency_acquired(currency: Currency) -> BigNumber: return get_all_time_currency_acquired_by_id(get_currency_id(currency)) ## Returns the all-time total for a currency ID (never resets). func get_all_time_currency_acquired_by_id(currency_id: StringName) -> BigNumber: return _get_all_time_currency_ref(currency_id) #endregion #region generator # ============================================================================== # GENERATOR STATE API # ============================================================================== ## Registers a generator with the game state. Creates default state if not exists, sanitizes existing state otherwise. ## Emits [signal generator_state_changed] on registration or update. func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false, owned: int = 0) -> void: var key: String = String(generator_id) if key.is_empty(): return var default_owned: int = _to_non_negative_int(owned, 0) if generator_states.has(key): var existing_raw: Variant = generator_states.get(key) if existing_raw is Dictionary: generator_states[key] = _sanitize_generator_state(existing_raw, unlocked, available, default_owned) return var new_state: Dictionary = _default_generator_state(unlocked, available, default_owned) generator_states[key] = new_state generator_state_changed.emit(StringName(key), new_state.duplicate(true)) ## Returns a copy of the full state dictionary for a generator. func get_generator_state(generator_id: StringName) -> Dictionary: return _get_generator_state(generator_id).duplicate(true) ## Returns the owned count for a generator. func get_generator_owned(generator_id: StringName) -> int: var state: Dictionary = _get_generator_state(generator_id) return int(state.get(GENERATOR_OWNED_KEY, 0)) ## Sets the owned count for a generator. Also updates purchased_count to be at least the new owned value. func set_generator_owned(generator_id: StringName, value: int) -> void: var state: Dictionary = _get_generator_state(generator_id) var next_owned: int = maxi(value, 0) if int(state.get(GENERATOR_OWNED_KEY, 0)) == next_owned: return state[GENERATOR_OWNED_KEY] = next_owned state[GENERATOR_PURCHASED_COUNT_KEY] = maxi(int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)), next_owned) _set_generator_state(generator_id, state) ## Returns the total purchased count for a generator (lifetime purchases, never decreases). func get_generator_purchased_count(generator_id: StringName) -> int: var state: Dictionary = _get_generator_state(generator_id) return int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) ## Sets the purchased count for a generator. Ensures it never drops below owned count. func set_generator_purchased_count(generator_id: StringName, value: int) -> void: var state: Dictionary = _get_generator_state(generator_id) var min_purchased: int = int(state.get(GENERATOR_OWNED_KEY, 0)) var next_purchased: int = maxi(value, min_purchased) if int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) == next_purchased: return state[GENERATOR_PURCHASED_COUNT_KEY] = next_purchased _set_generator_state(generator_id, state) ## Returns true if a generator is unlocked (visible and accessible to player). func is_generator_unlocked(generator_id: StringName) -> bool: var state: Dictionary = _get_generator_state(generator_id) return bool(state.get(GENERATOR_UNLOCKED_KEY, false)) ## Sets the unlocked state for a generator. Emits [signal generator_state_changed]. func set_generator_unlocked(generator_id: StringName, value: bool) -> void: var state: Dictionary = _get_generator_state(generator_id) if bool(state.get(GENERATOR_UNLOCKED_KEY, false)) == value: return state[GENERATOR_UNLOCKED_KEY] = value _set_generator_state(generator_id, state) ## Returns true if a generator is available (may differ from unlocked for goal-locked generators). func is_generator_available(generator_id: StringName) -> bool: var state: Dictionary = _get_generator_state(generator_id) return bool(state.get(GENERATOR_AVAILABLE_KEY, false)) ## Sets the availability state for a generator. Emits [signal generator_state_changed]. func set_generator_available(generator_id: StringName, value: bool) -> void: var state: Dictionary = _get_generator_state(generator_id) if bool(state.get(GENERATOR_AVAILABLE_KEY, false)) == value: return state[GENERATOR_AVAILABLE_KEY] = value _set_generator_state(generator_id, state) #endregion #region buff # ============================================================================== # BUFF API # ============================================================================== ## Registers a global buff definition for runtime lookup. Initializes level, unlock, and active state. func register_buff(buff: GeneratorBuffData) -> void: if buff == null: return var buff_id: StringName = buff.id if buff_id == &"": push_warning("Attempted to register buff with invalid id") return _buff_definitions[buff_id] = buff if not _buff_levels.has(buff_id): _buff_levels[buff_id] = 0 if not _buff_unlocked.has(buff_id): _buff_unlocked[buff_id] = false if not _buff_active.has(buff_id): _buff_active[buff_id] = false ## Returns the buff definition for a given ID, or null if not registered. func get_buff(buff_id: StringName) -> GeneratorBuffData: return _buff_definitions.get(buff_id, null) ## Returns all registered global buffs as an array. func get_all_buffs() -> Array[GeneratorBuffData]: var result: Array[GeneratorBuffData] = [] for buff in _buff_definitions.values(): if buff is GeneratorBuffData: result.append(buff) return result ## Returns the current level of a global buff (0 if not registered). func get_buff_level(buff_id: StringName) -> int: return _buff_levels.get(buff_id, 0) ## Sets the level of a global buff. Emits [signal buff_level_changed]. func set_buff_level(buff_id: StringName, level: int) -> void: if level < 0: level = 0 var current_level: int = _buff_levels.get(buff_id, 0) if current_level == level: return _buff_levels[buff_id] = level buff_level_changed.emit(buff_id, level) ## Returns true if a global buff is unlocked. func is_buff_unlocked(buff_id: StringName) -> bool: return _buff_unlocked.get(buff_id, false) ## Sets the unlock state for a global buff. Emits [signal buff_unlocked_changed] and activates the buff if unlocked. func set_buff_unlocked(buff_id: StringName, unlocked: bool) -> void: if bool(_buff_unlocked.get(buff_id, false)) == unlocked: return _buff_unlocked[buff_id] = unlocked buff_unlocked_changed.emit(buff_id, unlocked) if unlocked and not _buff_active.get(buff_id, false): _buff_active[buff_id] = true ## Returns true if a global buff is currently active (unlocked and applied). func is_buff_active(buff_id: StringName) -> bool: return _buff_active.get(buff_id, false) ## Returns the list of generators targeted by a global buff. func get_generators_targeted_by(buff_id: StringName) -> Array[StringName]: var buff: GeneratorBuffData = get_buff(buff_id) if buff == null: return [] return buff.get_target_generators() ## Returns all buffs that affect a specific generator. func get_buffs_for_generator(generator_id: StringName) -> Array[GeneratorBuffData]: if buff_catalogue: return buff_catalogue.get_buffs_for_generator(generator_id) var result: Array[GeneratorBuffData] = [] for buff in _buff_definitions.values(): if buff is GeneratorBuffData and buff.targets_generator(generator_id): result.append(buff) return result ## Calculates the effective production multiplier for a generator by combining all active buffs of the specified kind. func get_effective_multiplier(generator_id: StringName, kind: int) -> float: var multiplier: float = 1.0 for buff in get_buffs_for_generator(generator_id): if buff == null: continue if not is_buff_active(buff.id): continue if buff.kind != kind: continue var level: int = get_buff_level(buff.id) if level <= 0: continue var buff_multiplier: float = buff.get_effect_multiplier(level) multiplier *= buff_multiplier return multiplier #endregion #region research # ============================================================================== # RESEARCH API # ============================================================================== ## Initializes all research tracks from the catalogue on game start. func _initialize_research() -> void: if research_catalogue: for research in research_catalogue.get_all_research(): register_research(research.id) ## Registers a research track, initializing XP and level storage. func register_research(research_id: StringName) -> void: if not research_xp.has(research_id): research_xp[research_id] = BigNumber.new(0.0, 0) if not research_levels.has(research_id): research_levels[research_id] = 0 ## Returns the current XP for a research track. func get_research_xp(research_id: StringName) -> BigNumber: var val: Variant = research_xp.get(research_id, BigNumber.new(0.0, 0)) return val as BigNumber ## Returns the current level for a research track. func get_research_level(research_id: StringName) -> int: return research_levels.get(research_id, 0) ## Returns the production multiplier provided by a research track at its current level. func get_research_multiplier(research_id: StringName) -> float: var level: int = get_research_level(research_id) var research: ResearchData = _get_research_data(research_id) if research == null: return 1.0 return research.get_multiplier_for_level(level) ## Returns the total number of workers assigned to research. func get_research_workers() -> int: return research_workers ## Assigns a worker to research (called when spending worker currency on research). func assign_worker_to_research() -> bool: var worker_currency: BigNumber = get_currency_amount_by_id("worker") if worker_currency.mantissa < 1.0: return false spend_currency_by_id("worker", BigNumber.from_float(1.0)) research_workers += 1 research_workers_changed.emit(research_workers) return true ## Removes a worker from research (returns to worker currency). func remove_worker_from_research() -> bool: if research_workers <= 0: return false research_workers -= 1 add_currency_by_id("worker", BigNumber.from_float(1.0)) research_workers_changed.emit(research_workers) return true ## Adds XP to a research track, applying buff modifiers and emitting signals on level up. func add_research_xp(research_id: StringName, xp: BigNumber) -> void: var actual_xp: BigNumber = ResearchBuffCalculator.apply_buffs(self, research_id, xp) var old_level: int = get_research_level(research_id) var current_xp: BigNumber = get_research_xp(research_id) research_xp[research_id] = current_xp.add(actual_xp) var research: ResearchData = _get_research_data(research_id) if research != null: var new_level: int = research.get_level_for_xp(research_xp[research_id]) if new_level > old_level: research_levels[research_id] = new_level research_level_up.emit(research_id, old_level, new_level) research_xp_changed.emit(research_id, research_xp[research_id]) ## Retrieves the ResearchData resource for a given ID from the catalogue. func _get_research_data(research_id: StringName) -> ResearchData: if research_catalogue == null: return null return research_catalogue.get_research_by_id(research_id) ## Returns the current worker count (derived from "worker" currency balance). func get_worker_count() -> int: var worker_amount: BigNumber = get_currency_amount_by_id("worker") return int(worker_amount.mantissa) if worker_amount.mantissa >= 0 else 0 #endregion # ============================================================================== # EXTERNAL SAVE DATA API # ============================================================================== ## Stores external save data from other systems (e.g., PrestigeManager). Creates or overwrites the section. func set_external_save_data(section_key: StringName, payload: Dictionary) -> void: var key: String = String(section_key).strip_edges() if key.is_empty(): return _external_save_sections[key] = payload.duplicate(true) ## Retrieves a saved data section by key. Returns empty dict if not found. func get_external_save_data(section_key: StringName) -> Dictionary: var key: String = String(section_key).strip_edges() if key.is_empty(): return {} var raw_payload: Variant = _external_save_sections.get(key, {}) if raw_payload is Dictionary: return raw_payload.duplicate(true) return {} # ============================================================================== # PRESTIGE RESET API # ============================================================================== ## Resets all progress for a prestige reset. Optionally resets total currency, preserves specified currencies, and controls signal emission. func reset_for_prestige(reset_total_currency: bool = false, emit_currency_signals: bool = true, preserve_currency_ids: Array[StringName] = []) -> void: var currency_ids: Array[StringName] = _collect_currency_ids_for_save() for currency_id in currency_ids: if currency_id in preserve_currency_ids: continue _set_current_currency(currency_id, BigNumber.from_float(0.0)) if reset_total_currency: _set_total_currency(currency_id, BigNumber.from_float(0.0)) generator_states.clear() for goal_id in _goal_definitions.keys(): _goal_completed[goal_id] = false var research_ids: Array = research_xp.keys() research_xp.clear() research_levels.clear() research_workers = 0 for research_id in research_ids: research_xp_changed.emit(research_id, BigNumber.from_float(0.0)) if not emit_currency_signals: return for currency_id in currency_ids: currency_changed.emit(currency_id, _get_current_currency_ref(currency_id)) ## Internal helper: Emits currency_changed signals for all currencies to refresh UI after a prestige load. func emit_currency_changed_for_all() -> void: for currency_id in _collect_currency_ids_for_save(): currency_changed.emit(currency_id, _get_current_currency_ref(currency_id)) # ========================================== # PERSISTENCE (Save/Load) # ========================================== ## Saves all game state to disk as JSON. func save_game() -> void: if save_file_path.is_empty(): push_warning("LevelGameState: save_file_path is empty; skipping save.") return var save_data = { SAVE_FORMAT_VERSION_KEY: CURRENT_SAVE_FORMAT_VERSION, CURRENCIES_KEY: _serialize_currency_balances(), GENERATOR_STATES_KEY: _serialize_generator_states(), BUFF_LEVELS_KEY: _serialize_buff_levels(), BUFF_UNLOCKED_KEY: _serialize_buff_unlocked(), BUFF_ACTIVE_KEY: _serialize_buff_active(), GOALS_KEY: _serialize_goals(), RESEARCH_XP_KEY: _serialize_research_xp(), RESEARCH_LEVELS_KEY: research_levels.duplicate(), RESEARCH_WORKERS_KEY: research_workers, PRESTIGE_BUFF_UNLOCKED_KEY: _serialize_prestige_buff_unlocked(), LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system() } for section_key in _external_save_sections.keys(): var key: String = String(section_key) if key.is_empty(): continue var payload: Variant = _external_save_sections.get(section_key) if not (payload is Dictionary): continue save_data[key] = payload var file: FileAccess = FileAccess.open(save_file_path, FileAccess.WRITE) if file: file.store_string(JSON.stringify(save_data)) else: push_error("LevelGameState: Failed to open save file: %s" % save_file_path) ## Loads all game state from disk JSON. func load_game() -> void: if not FileAccess.file_exists(save_file_path): return var file: FileAccess = FileAccess.open(save_file_path, FileAccess.READ) if file: var parsed: Variant = JSON.parse_string(file.get_as_text()) if parsed is Dictionary: var parsed_data: Dictionary = parsed var version: int = int(parsed_data.get(SAVE_FORMAT_VERSION_KEY, 1)) if version < 2: push_error("Save file version %d too old. Please start fresh." % version) return if version < 3: _migrate_goals_from_version_2(parsed_data) _external_save_sections.clear() if parsed_data.has(CURRENCIES_KEY): _load_currency_balances(parsed_data.get(CURRENCIES_KEY, {})) generator_states = _deserialize_generator_states(parsed_data.get(GENERATOR_STATES_KEY, {})) _deserialize_buff_states(parsed_data) _deserialize_goals(parsed_data.get(GOALS_KEY, {})) if parsed_data.has(RESEARCH_XP_KEY): research_xp = _deserialize_research_xp(parsed_data.get(RESEARCH_XP_KEY, {})) if parsed_data.has(RESEARCH_LEVELS_KEY): research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) if parsed_data.has(RESEARCH_WORKERS_KEY): research_workers = int(parsed_data.get(RESEARCH_WORKERS_KEY, 0)) last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system()) if parsed_data.has(PRESTIGE_BUFF_UNLOCKED_KEY): _deserialize_prestige_buff_unlocked(parsed_data.get(PRESTIGE_BUFF_UNLOCKED_KEY)) for save_key_variant in parsed_data.keys(): var save_key: String = String(save_key_variant) if save_key.is_empty(): continue if save_key in [SAVE_FORMAT_VERSION_KEY, CURRENCIES_KEY, GENERATOR_STATES_KEY, BUFF_LEVELS_KEY, BUFF_UNLOCKED_KEY, BUFF_ACTIVE_KEY, GOALS_KEY, RESEARCH_XP_KEY, RESEARCH_LEVELS_KEY, RESEARCH_WORKERS_KEY, PRESTIGE_BUFF_UNLOCKED_KEY, LAST_SAVE_TIME_KEY]: continue var section_payload: Variant = parsed_data.get(save_key_variant) if section_payload is Dictionary: _external_save_sections[save_key] = section_payload.duplicate(true) ## Internal helper: Loads currency balances from deserialized save data. func _load_currency_balances(raw_currencies: Variant) -> void: if not (raw_currencies is Dictionary): return var currencies: Dictionary = raw_currencies for raw_currency_id in currencies.keys(): var currency_id: StringName = _normalize_currency_id(StringName(String(raw_currency_id))) if currency_id == &"": continue var raw_entry: Variant = currencies.get(raw_currency_id) if not (raw_entry is Dictionary): continue var entry: Dictionary = raw_entry var current_amount: BigNumber = BigNumber.deserialize(entry.get(CURRENT_KEY, {})) var total_amount: BigNumber = _deserialize_total_currency(entry.get(TOTAL_KEY, null), current_amount) var all_time_amount: BigNumber = _deserialize_all_time_currency(entry.get(ALL_TIME_KEY, null), total_amount) _set_current_currency(currency_id, current_amount) _set_total_currency(currency_id, total_amount) _set_all_time_currency(currency_id, all_time_amount) ## Internal helper: Deserializes total currency from save data with validation. func _deserialize_total_currency(raw_total: Variant, current_amount: BigNumber) -> BigNumber: var minimum_total: BigNumber = BigNumber.new(current_amount.mantissa, current_amount.exponent) if minimum_total.mantissa < 0.0: minimum_total = BigNumber.from_float(0.0) if not (raw_total is Dictionary): return minimum_total var parsed_total: BigNumber = BigNumber.deserialize(raw_total) if parsed_total.mantissa < 0.0: return minimum_total if parsed_total.is_less_than(minimum_total): return minimum_total return parsed_total ## Internal helper: Deserializes all-time currency from save data with validation. func _deserialize_all_time_currency(raw_all_time: Variant, total_amount: BigNumber) -> BigNumber: var minimum_all_time: BigNumber = BigNumber.new(total_amount.mantissa, total_amount.exponent) if minimum_all_time.mantissa < 0.0: minimum_all_time = BigNumber.from_float(0.0) if not (raw_all_time is Dictionary): return minimum_all_time var parsed_all_time: BigNumber = BigNumber.deserialize(raw_all_time) if parsed_all_time.mantissa < 0.0: return minimum_all_time if parsed_all_time.is_less_than(minimum_all_time): return minimum_all_time return parsed_all_time ## Internal helper: Serializes all currency balances for saving. func _serialize_currency_balances() -> Dictionary: var result: Dictionary = {} for currency_id in _collect_currency_ids_for_save(): var key: String = String(currency_id) if key.is_empty(): continue result[key] = { CURRENT_KEY: _get_current_currency_ref(currency_id).serialize(), TOTAL_KEY: _get_total_currency_ref(currency_id).serialize(), ALL_TIME_KEY: _get_all_time_currency_ref(currency_id).serialize(), } return result ## Internal helper: Collects all currency IDs for serialization. func _collect_currency_ids_for_save() -> Array[StringName]: var ids: Array[StringName] = [] for raw_id in _current_currency.keys(): var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id))) if normalized_currency_id == &"" or ids.has(normalized_currency_id): continue ids.append(normalized_currency_id) for raw_id in _all_time_currency_acquired.keys(): var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id))) if normalized_currency_id == &"" or ids.has(normalized_currency_id): continue ids.append(normalized_currency_id) if currency_catalogue: for known_currency_id in currency_catalogue.get_all_ids(): var normalized_currency_id: StringName = _normalize_currency_id(known_currency_id) if normalized_currency_id == &"" or ids.has(normalized_currency_id): continue ids.append(normalized_currency_id) return ids ## Internal helper: Initializes all currency maps from the catalogue. func _initialize_currency_maps() -> void: if currency_catalogue: for currency_id in currency_catalogue.get_all_ids(): if currency_id == &"": continue _set_current_currency(currency_id, BigNumber.from_float(0.0)) _set_total_currency(currency_id, BigNumber.from_float(0.0)) _set_all_time_currency(currency_id, BigNumber.from_float(0.0)) ## Internal helper: Initializes all catalogues (buffs, goals). func _initialize_catalogues() -> void: if currency_catalogue: for currency in currency_catalogue.currencies: if currency == null: continue if buff_catalogue: for buff in buff_catalogue.buffs: if buff == null: continue register_buff(buff) if goal_catalogue: for goal in goal_catalogue.goals: if goal == null: continue register_goal(goal) ## Internal helper: Initializes prestige buff unlock state from the catalogue, filling missing node IDs as false. func _initialize_prestige_buffs() -> void: if prestige_buff_catalogue == null: return for node in prestige_buff_catalogue.nodes: if node == null or node.id == &"": continue if not _prestige_buff_unlocked.has(node.id): _prestige_buff_unlocked[node.id] = false ## Internal helper: Loads goal definitions from the catalogue. func _load_catalogue_goals() -> void: if goal_catalogue: for goal in goal_catalogue.goals: if goal == null: continue if not goal.has_id(): continue if _goal_definitions.has(goal.id): continue _goal_definitions[goal.id] = goal _goal_completed[goal.id] = false # ============================================================================== # GOAL EVALUATION HELPERS # ============================================================================== ## Internal helper: Checks if a goal requirement's currency amount has been reached. func is_goal_requirement_met(requirement: GoalRequirementData) -> bool: if requirement == null: return false var currency: Currency = requirement.get_currency() if currency == null: return false var currency_id: StringName = get_currency_id(currency) if currency_id == &"": return false var total_amount: BigNumber = get_total_currency_acquired_by_id(currency_id) var required_amount: BigNumber = requirement.get_amount() return not total_amount.is_less_than(required_amount) ## Internal helper: Returns progress (0.0-1.0) for a goal requirement using logarithmic scaling for large numbers. func get_goal_requirement_progress(requirement: GoalRequirementData) -> float: if requirement == null: return 0.0 var currency: Currency = requirement.get_currency() if currency == null: return 0.0 var currency_id: StringName = get_currency_id(currency) if currency_id == &"": return 0.0 var current_amount: BigNumber = get_total_currency_acquired_by_id(currency_id) var required_amount: BigNumber = requirement.get_amount() if required_amount.mantissa <= 0.0: return 1.0 if current_amount.is_greater_than(required_amount) or current_amount.is_equal_to(required_amount): return 1.0 if current_amount.mantissa <= 0.0: return 0.0 var current_log: float = log(maxf(current_amount.mantissa, 0.0001)) / log(10) + float(current_amount.exponent) var required_log: float = log(maxf(required_amount.mantissa, 0.0001)) / log(10) + float(required_amount.exponent) if required_log <= 0.0: return 1.0 var progress: float = current_log / required_log return clampf(progress, 0.0, 1.0) ## Internal helper: Returns a human-readable summary string for a goal requirement (amount + currency name). func get_goal_requirement_summary(requirement: GoalRequirementData) -> String: if requirement == null: return "" var currency: Currency = requirement.get_currency() if currency == null: return "" var currency_id: StringName = get_currency_id(currency) if currency_id == &"": return "" var currency_name: String = get_currency_name(currency_id) return "%s %s" % [requirement.get_amount().to_string_suffix(2), currency_name] ## Internal helper: Returns true if all requirements for a goal are met. func is_goal_met(goal: GoalData) -> bool: if goal == null: return false for req in goal.get_requirements(): if not is_goal_requirement_met(req): return false return true ## Internal helper: Returns the overall progress (0.0-1.0) for a goal based on its slowest requirement. func get_goal_progress(goal: GoalData) -> float: if goal == null: return 0.0 var requirements: Array[GoalRequirementData] = goal.get_requirements() if requirements.is_empty(): return 0.0 var min_progress: float = 1.0 for req in requirements: if req == null: continue var req_progress: float = get_goal_requirement_progress(req) if req_progress < min_progress: min_progress = req_progress return min_progress ## Internal helper: Normalizes a currency ID (lowercase, trimmed). func _normalize_currency_id(currency_id: StringName) -> StringName: var normalized: String = String(currency_id).to_lower().strip_edges() if normalized.is_empty(): return &"" return StringName(normalized) ## Internal helper: Gets or creates a reference to the current currency balance. func _get_current_currency_ref(currency_id: StringName) -> BigNumber: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return BigNumber.from_float(0.0) var value: Variant = _current_currency.get(normalized_currency_id) if value is BigNumber: return value var fallback: BigNumber = BigNumber.from_float(0.0) _current_currency[normalized_currency_id] = fallback return fallback ## Internal helper: Gets or creates a reference to the total currency acquired this run. func _get_total_currency_ref(currency_id: StringName) -> BigNumber: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return BigNumber.from_float(0.0) var value: Variant = _total_currency_acquired.get(normalized_currency_id) if value is BigNumber: return value var fallback: BigNumber = BigNumber.from_float(0.0) _total_currency_acquired[normalized_currency_id] = fallback return fallback ## Internal helper: Gets or creates a reference to the all-time currency acquired. func _get_all_time_currency_ref(currency_id: StringName) -> BigNumber: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return BigNumber.from_float(0.0) var value: Variant = _all_time_currency_acquired.get(normalized_currency_id) if value is BigNumber: return value var fallback: BigNumber = BigNumber.from_float(0.0) _all_time_currency_acquired[normalized_currency_id] = fallback return fallback ## Internal helper: Sets the current currency balance. func _set_current_currency(currency_id: StringName, value: BigNumber) -> void: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return _current_currency[normalized_currency_id] = BigNumber.new(value.mantissa, value.exponent) ## Internal helper: Sets the total currency acquired this run. func _set_total_currency(currency_id: StringName, value: BigNumber) -> void: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return _total_currency_acquired[normalized_currency_id] = BigNumber.new(value.mantissa, value.exponent) ## Internal helper: Sets the all-time currency acquired. func _set_all_time_currency(currency_id: StringName, value: BigNumber) -> void: var normalized_currency_id: StringName = _normalize_currency_id(currency_id) if normalized_currency_id == &"": return _all_time_currency_acquired[normalized_currency_id] = BigNumber.new(value.mantissa, value.exponent) # ============================================================================== # GENERATOR STATE HELPERS # ============================================================================== ## Internal helper: Creates a default generator state dictionary with the specified values. func _default_generator_state(unlocked: bool, available: bool, owned: int = 0) -> Dictionary: var owned_value: int = _to_non_negative_int(owned, 0) return { GENERATOR_OWNED_KEY: owned_value, GENERATOR_PURCHASED_COUNT_KEY: owned_value, GENERATOR_UNLOCKED_KEY: unlocked, GENERATOR_AVAILABLE_KEY: available, } ## Internal helper: Gets or creates the state dictionary for a generator, ensuring it exists and is valid. func _get_generator_state(generator_id: StringName) -> Dictionary: var key: String = String(generator_id) if key.is_empty(): return _default_generator_state(false, false, 0) if not generator_states.has(key): register_generator(generator_id) var existing_raw: Variant = generator_states.get(key, _default_generator_state(false, false, 0)) if existing_raw is Dictionary: var state: Dictionary = existing_raw var current_unlocked: bool = state.get(GENERATOR_UNLOCKED_KEY, false) var current_available: bool = state.get(GENERATOR_AVAILABLE_KEY, false) var current_owned: int = state.get(GENERATOR_OWNED_KEY, 0) var sanitized: Dictionary = _sanitize_generator_state(state, current_unlocked, current_available, current_owned) generator_states[key] = sanitized return sanitized var fallback: Dictionary = _default_generator_state(false, false, 0) generator_states[key] = fallback return fallback ## Internal helper: Updates a generator's state dictionary and emits the change signal. func _set_generator_state(generator_id: StringName, state: Dictionary) -> void: var key: String = String(generator_id) if key.is_empty(): return var current_unlocked: bool = state.get(GENERATOR_UNLOCKED_KEY, false) var current_available: bool = state.get(GENERATOR_AVAILABLE_KEY, false) var current_owned: int = state.get(GENERATOR_OWNED_KEY, 0) var sanitized: Dictionary = _sanitize_generator_state(state, current_unlocked, current_available, current_owned) generator_states[key] = sanitized generator_state_changed.emit(StringName(key), sanitized.duplicate(true)) # ============================================================================== # GENERATOR SERIALIZATION HELPERS # ============================================================================== ## Internal helper: Serializes all generator states for saving. func _serialize_generator_states() -> Dictionary: var result: Dictionary = {} for key_variant in generator_states.keys(): var key: String = String(key_variant) if key.is_empty(): continue var value: Variant = generator_states.get(key_variant) if value is Dictionary: result[key] = _serialize_generator_state_entry(value) return result ## Internal helper: Deserializes generator states from save data. func _deserialize_generator_states(raw_value: Variant) -> Dictionary: var result: Dictionary = {} if not (raw_value is Dictionary): return result for key_variant in raw_value.keys(): var key: String = String(key_variant) if key.is_empty(): continue var entry: Variant = raw_value.get(key_variant) if entry is Dictionary: result[key] = _deserialize_generator_state_entry(entry) return result ## Internal helper: Serializes a single generator state entry for saving. func _serialize_generator_state_entry(raw_state: Dictionary) -> Dictionary: var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0) var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) var serialized_state: Dictionary = { GENERATOR_OWNED_KEY: owned_value, GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), } if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool: serialized_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY) if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool: serialized_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY) return serialized_state ## Internal helper: Deserializes a single generator state entry from save data. func _deserialize_generator_state_entry(raw_state: Dictionary) -> Dictionary: var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0) var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) var parsed_state: Dictionary = { GENERATOR_OWNED_KEY: owned_value, GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), } if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool: parsed_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY) if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool: parsed_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY) return parsed_state ## Internal helper: Sanitizes a generator state dictionary with default values. func _sanitize_generator_state(raw_state: Dictionary, default_unlocked: bool, default_available: bool, default_owned: int = 0) -> Dictionary: var normalized_default_owned: int = _to_non_negative_int(default_owned, 0) var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, normalized_default_owned), normalized_default_owned) var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) return { GENERATOR_OWNED_KEY: owned_value, GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), GENERATOR_UNLOCKED_KEY: _to_bool(raw_state.get(GENERATOR_UNLOCKED_KEY, default_unlocked), default_unlocked), GENERATOR_AVAILABLE_KEY: _to_bool(raw_state.get(GENERATOR_AVAILABLE_KEY, default_available), default_available), } ## Internal helper: Converts a value to a non-negative integer. func _to_non_negative_int(value: Variant, fallback: int = 0) -> int: if value is int: return maxi(value, 0) if value is float: return maxi(int(value), 0) return maxi(fallback, 0) ## Internal helper: Converts a value to a boolean with a fallback. func _to_bool(value: Variant, fallback: bool) -> bool: if value is bool: return value return fallback ## Internal helper: Serializes all global buff levels for saving. func _serialize_buff_levels() -> Dictionary: var result: Dictionary = {} for buff_id in _buff_levels.keys(): result[buff_id] = _buff_levels[buff_id] return result ## Internal helper: Serializes all global buff unlock states for saving. func _serialize_buff_unlocked() -> Dictionary: var result: Dictionary = {} for buff_id in _buff_unlocked.keys(): if _buff_unlocked[buff_id]: result[buff_id] = true return result ## Internal helper: Serializes all global buff active states for saving. func _serialize_buff_active() -> Dictionary: var result: Dictionary = {} for buff_id in _buff_active.keys(): if _buff_active[buff_id]: result[buff_id] = true return result ## Internal helper: Deserializes all global buff states from save data. func _deserialize_buff_states(parsed_data: Dictionary) -> void: _buff_levels.clear() _buff_unlocked.clear() _buff_active.clear() if parsed_data.has(BUFF_LEVELS_KEY): var raw_levels: Variant = parsed_data.get(BUFF_LEVELS_KEY) if raw_levels is Dictionary: for key_variant in raw_levels.keys(): var key: String = String(key_variant) if not key.is_empty(): var value: Variant = raw_levels.get(key_variant) if value is int: _buff_levels[key] = value if parsed_data.has(BUFF_UNLOCKED_KEY): var raw_unlocked: Variant = parsed_data.get(BUFF_UNLOCKED_KEY) if raw_unlocked is Dictionary: for key_variant in raw_unlocked.keys(): var key: String = String(key_variant) if not key.is_empty(): _buff_unlocked[key] = true if parsed_data.has(BUFF_ACTIVE_KEY): var raw_active: Variant = parsed_data.get(BUFF_ACTIVE_KEY) if raw_active is Dictionary: for key_variant in raw_active.keys(): var key: String = String(key_variant) if not key.is_empty(): _buff_active[key] = true # ============================================================================== # GOALS API # ============================================================================== ## Registers a goal definition and initializes its completion state. func register_goal(goal: GoalData) -> void: if goal == null: return var goal_id: StringName = goal.id if goal_id == &"": push_warning("Attempted to register goal with invalid id") return if _goal_definitions.has(goal_id): return _goal_definitions[goal_id] = goal _goal_completed[goal_id] = false ## Returns the goal definition for a given ID, or null if not registered. func get_goal(goal_id: StringName) -> GoalData: return _goal_definitions.get(goal_id, null) ## Returns all registered goals as an array. func get_all_goals() -> Array[GoalData]: var result: Array[GoalData] = [] for goal in _goal_definitions.values(): if goal is GoalData: result.append(goal) return result ## Returns true if a goal has been completed. func is_goal_completed(goal_id: StringName) -> bool: return _goal_completed.get(goal_id, false) ## Returns the progress (0.0-1.0) for a goal by ID. func get_goal_progress_by_id(goal_id: StringName) -> float: var goal: GoalData = get_goal(goal_id) if goal == null: return 0.0 return get_goal_progress(goal) ## Unlocks a single buff if its unlock goal is completed. func try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void: if buff == null: return if not buff.has_unlock_goal(): return if is_buff_unlocked(buff.id): return var goal_id: StringName = buff.unlock_goal.id if not goal_id.is_empty() and is_goal_completed(goal_id): set_buff_unlocked(buff.id, true) ## Evaluates all goals for completion and unlocks buffs from completed goals. Emits [signal goal_progress_changed]. func evaluate_all_goals() -> void: for goal_id in _goal_completed.keys(): if not _goal_completed[goal_id]: _try_complete_goal(goal_id) _try_unlock_all_buffs_from_goals() goal_progress_changed.emit(&"", 0.0) ## Internal helper: Attempts to complete a goal if its requirements are met and unlock behavior allows auto-completion. func _try_complete_goal(goal_id: StringName) -> void: var goal: GoalData = _goal_definitions.get(goal_id, null) if goal == null: return if _goal_completed[goal_id]: return if is_goal_met(goal): if goal.unlock_behavior == GoalData.UnlockBehavior.MANUAL: return _goal_completed[goal_id] = true goal_completed.emit(goal_id) ## Internal helper: Manually completes a goal (for MANUAL unlock behavior). Emits [signal goal_completed]. func _complete_goal_manually(goal_id: StringName) -> void: var goal: GoalData = _goal_definitions.get(goal_id, null) if goal == null: return if _goal_completed.get(goal_id, false): return if not is_goal_met(goal): return _goal_completed[goal_id] = true goal_completed.emit(goal_id) ## Internal helper: Unlocks all buffs from completed goals. func _try_unlock_all_buffs_from_goals() -> void: for buff in get_all_buffs(): if buff == null: continue try_unlock_buff_from_goal(buff) # ============================================================================== # PRESTIGE BUFF API # ============================================================================== ## Returns the ascension currency ID used for purchasing buff nodes. func get_prestige_currency_id() -> StringName: return &"ascension" ## Attempts to purchase and unlock an prestige buff node. Returns true on success. func purchase_prestige_buff(buff_id: StringName) -> bool: if not can_purchase_prestige_buff(buff_id): return false var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) if node == null: return false var cost: BigNumber = node.get_cost() var currency_id: StringName = get_prestige_currency_id() if not spend_currency_by_id(currency_id, cost): return false _prestige_buff_unlocked[buff_id] = true prestige_buff_unlocked.emit(buff_id) save_game() return true ## Checks whether an prestige buff node can be purchased (all parents unlocked, not already unlocked, enough currency). func can_purchase_prestige_buff(buff_id: StringName) -> bool: var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) if node == null: return false if is_prestige_buff_unlocked(buff_id): return false if not node.all_parents_unlocked(_prestige_buff_unlocked): return false var cost: BigNumber = node.get_cost() var currency_id: StringName = get_prestige_currency_id() var balance: BigNumber = get_currency_amount_by_id(currency_id) return balance.is_greater_than(cost) or balance.is_equal_to(cost) ## Returns true if the given prestige buff node is unlocked. func is_prestige_buff_unlocked(buff_id: StringName) -> bool: return bool(_prestige_buff_unlocked.get(buff_id, false)) ## Returns the fixed BigNumber cost for an prestige buff node. func get_prestige_buff_cost(buff_id: StringName) -> BigNumber: var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) if node == null: return BigNumber.from_float(0.0) return node.get_cost() ## Computes the combined multiplier from all unlocked prestige buff nodes matching the given effect type. ## [param target_id] filters to nodes targeting this ID (or global nodes when empty). func get_prestige_buff_multiplier(effect_type: int, target_id: StringName = &"") -> float: var multiplier: float = 1.0 for node_id in _prestige_buff_unlocked.keys(): if not bool(_prestige_buff_unlocked.get(node_id, false)): continue var node: PrestigeBuffNode = _get_prestige_buff_node(node_id) if node == null: continue if node.effect_type != effect_type: continue if target_id != &"" and node.target_id != &"" and node.target_id != target_id: continue multiplier *= node.effect_value return multiplier ## Returns all prestige buff nodes whose prerequisites are met but are not yet unlocked. func get_available_prestige_buffs() -> Array[PrestigeBuffNode]: if prestige_buff_catalogue == null: return [] var available: Array[PrestigeBuffNode] = [] for node in prestige_buff_catalogue.nodes: if node == null or node.id == &"": continue if is_prestige_buff_unlocked(node.id): continue if node.all_parents_unlocked(_prestige_buff_unlocked): available.append(node) return available ## Returns the PrestigeBuffNode resource for a given ID, or null if not found. func _get_prestige_buff_node(buff_id: StringName) -> PrestigeBuffNode: if prestige_buff_catalogue == null: return null return prestige_buff_catalogue.get_node_by_id(buff_id) # ============================================================================== # PERSISTENCE HELPERS # ============================================================================== ## Serializes goal completion state for saving. func _serialize_goals() -> Dictionary: var result: Dictionary = {} var completed_goals: Array[StringName] = [] for goal_id in _goal_completed.keys(): if _goal_completed[goal_id]: completed_goals.append(goal_id) result[GOAL_COMPLETED_KEY] = completed_goals return result ## Deserializes goal completion state from save data. func _deserialize_goals(raw_value: Variant) -> void: _goal_completed.clear() if not (raw_value is Dictionary): return var raw_completed: Variant = raw_value.get(GOAL_COMPLETED_KEY, []) if raw_completed is Array: for goal_id_variant in raw_completed: var goal_id: String = String(goal_id_variant) if not goal_id.is_empty(): _goal_completed[goal_id] = true for goal_id in _goal_definitions.keys(): if not _goal_completed.has(goal_id): _goal_completed[goal_id] = false ## Internal helper: Migrates goal completion state from save format version 2 (generator unlock-based) to current format. func _migrate_goals_from_version_2(parsed_data: Dictionary) -> void: _goal_completed.clear() var generator_states_data: Dictionary = parsed_data.get(GENERATOR_STATES_KEY, {}) for gen_id_variant in generator_states_data.keys(): var gen_id: String = String(gen_id_variant) if gen_id.is_empty(): continue var state: Dictionary = generator_states_data.get(gen_id_variant, {}) if not state.get(GENERATOR_UNLOCKED_KEY, false): continue var goal_id: StringName = _get_unlock_goal_id_for_generator(gen_id) if not goal_id.is_empty(): _goal_completed[goal_id] = true for goal_id in _goal_definitions.keys(): if not _goal_completed.has(goal_id): _goal_completed[goal_id] = false ## Internal helper: Finds the goal ID that unlocks a specific generator (used in save migration). func _get_unlock_goal_id_for_generator(generator_id: String) -> StringName: if goal_catalogue == null: return &"" for goal in goal_catalogue.goals: if goal == null or not goal.has_id(): continue var goal_id: StringName = goal.id var requirements: Array[GoalRequirementData] = goal.get_requirements() for req in requirements: if req == null or req.get_currency() == null: continue if String(req.get_currency().id) == generator_id: return goal_id return &"" ## Internal helper: Converts a currency ID to a human-readable display name. func _humanize_currency_id(currency_id: StringName) -> String: var normalized: String = String(_normalize_currency_id(currency_id)) if normalized.is_empty(): return "Unknown" return normalized.replace("_", " ").capitalize() ## Serializes research XP data for saving (includes BigNumber mantissa/exponent). func _serialize_research_xp() -> Dictionary: var result: Dictionary = {} for research_id in research_xp.keys(): var xp: BigNumber = research_xp[research_id] result[research_id] = xp.serialize() return result ## Deserializes research XP data from save data. func _deserialize_research_xp(raw: Variant) -> Dictionary: var result: Dictionary = {} if raw is Dictionary: for research_id in raw.keys(): var serialized: Variant = raw[research_id] if serialized is Dictionary: result[research_id] = BigNumber.deserialize(serialized) return result ## Serializes prestige buff unlock state as an array of unlocked node IDs. func _serialize_prestige_buff_unlocked() -> Array: var unlocked: Array = [] for node_id in _prestige_buff_unlocked.keys(): if _prestige_buff_unlocked.get(node_id, false): unlocked.append(node_id) return unlocked ## Deserializes prestige buff unlock state from an array of unlocked node ID strings, then fills missing catalogue entries. func _deserialize_prestige_buff_unlocked(raw: Variant) -> void: _prestige_buff_unlocked.clear() if raw is Array: for element in raw: var node_id: StringName = StringName(String(element)) if node_id != &"": _prestige_buff_unlocked[node_id] = true _initialize_prestige_buffs() # Notify tiles that were waiting for state to load for node_id in _prestige_buff_unlocked.keys(): if _prestige_buff_unlocked.get(node_id, false): prestige_buff_unlocked.emit(node_id)