extends RefCounted class_name CampaignState const SAVE_PATH := "user://campaign_save.json" const MANUAL_SAVE_PATH := "user://campaign_manual_save.json" const SAVE_VERSION := 5 var save_version := SAVE_VERSION var campaign_id := "" var campaign_title := "" var start_scenario_id := "" var scenario_order: Array[String] = [] var scenario_paths := {} var scenario_titles := {} var chapter_order: Array[String] = [] var chapter_titles := {} var chapter_scenario_ids := {} var scenario_chapter_ids := {} var initial_joined_officers: Array[String] = [] var current_scenario_id := "" var completed_scenarios: Array[String] = [] var gold := 0 var inventory := {} var roster := {} var flags := {} var joined_officers: Array[String] = [] var applied_post_battle_choices := {} var pending_post_battle_choice_scenario_id := "" var shop_purchases := {} var claimed_camp_conversation_effects := {} func load_campaign(path: String) -> bool: var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) if typeof(parsed) != TYPE_DICTIONARY: push_error("Campaign file is not valid JSON: %s" % path) return false campaign_id = str(parsed.get("id", "")) campaign_title = str(parsed.get("title", "Campaign")) start_scenario_id = str(parsed.get("start_scenario", "")) scenario_order.clear() scenario_paths.clear() scenario_titles.clear() chapter_order.clear() chapter_titles.clear() chapter_scenario_ids.clear() scenario_chapter_ids.clear() initial_joined_officers.clear() for officer_id in parsed.get("initial_joined_officers", []): var normalized := str(officer_id) if normalized.is_empty() or initial_joined_officers.has(normalized): continue initial_joined_officers.append(normalized) for scenario in parsed.get("scenarios", []): if typeof(scenario) != TYPE_DICTIONARY: continue var scenario_id := str(scenario.get("id", "")) var scenario_path := str(scenario.get("path", "")) if scenario_id.is_empty() or scenario_path.is_empty(): continue scenario_order.append(scenario_id) scenario_paths[scenario_id] = scenario_path scenario_titles[scenario_id] = str(scenario.get("title", scenario_id)) _load_chapters(parsed.get("chapters", [])) if start_scenario_id.is_empty() and not scenario_order.is_empty(): start_scenario_id = scenario_order[0] return not scenario_order.is_empty() func _load_chapters(chapters) -> void: if typeof(chapters) != TYPE_ARRAY: push_error("Campaign chapters must be an array.") return for chapter in chapters: if typeof(chapter) != TYPE_DICTIONARY: push_error("Skipping malformed campaign chapter.") continue var chapter_id := str(chapter.get("id", "")) var start_scenario := str(chapter.get("start_scenario", "")) var end_scenario := str(chapter.get("end_scenario", "")) var start_index := scenario_order.find(start_scenario) var end_index := scenario_order.find(end_scenario) if chapter_id.is_empty(): push_error("Skipping campaign chapter with empty id.") continue if chapter_order.has(chapter_id): push_error("Skipping duplicate campaign chapter: %s." % chapter_id) continue if start_index == -1 or end_index == -1 or end_index < start_index: push_error("Skipping campaign chapter with invalid scenario range: %s." % chapter_id) continue var scenario_ids: Array[String] = [] var overlaps_existing_chapter := false for index in range(start_index, end_index + 1): var scenario_id := scenario_order[index] scenario_ids.append(scenario_id) if scenario_chapter_ids.has(scenario_id): push_error("Skipping campaign chapter %s because it overlaps scenario %s." % [chapter_id, scenario_id]) overlaps_existing_chapter = true if overlaps_existing_chapter: continue for scenario_id in scenario_ids: scenario_chapter_ids[scenario_id] = chapter_id chapter_order.append(chapter_id) chapter_titles[chapter_id] = str(chapter.get("title", chapter_id)) chapter_scenario_ids[chapter_id] = scenario_ids func load_or_start(scenario_id: String) -> void: var loaded_save := load_game() if not loaded_save: start_new(scenario_id) else: if current_scenario_id.is_empty(): current_scenario_id = scenario_id var save_needs_update := false if loaded_save: save_needs_update = _clear_invalid_pending_post_battle_choice() or save_needs_update _normalize_current_scenario() if loaded_save: save_needs_update = _backfill_applied_post_battle_choices() or save_needs_update save_needs_update = _reconcile_completed_officer_transitions() or save_needs_update if save_needs_update and not save_game(): push_error("Unable to save reconciled campaign officer state.") func start_new(scenario_id: String) -> void: current_scenario_id = scenario_id completed_scenarios.clear() gold = 0 inventory.clear() roster.clear() flags.clear() joined_officers.clear() applied_post_battle_choices.clear() pending_post_battle_choice_scenario_id = "" shop_purchases.clear() claimed_camp_conversation_effects.clear() for officer_id in initial_joined_officers: joined_officers.append(officer_id) func reset_save(scenario_id: String) -> bool: var save_file := ProjectSettings.globalize_path(SAVE_PATH) if FileAccess.file_exists(SAVE_PATH): var error := DirAccess.remove_absolute(save_file) if error != OK: push_error("Unable to delete campaign save: %s" % save_file) return false start_new(scenario_id) return save_game() func load_game() -> bool: return _load_game_from_path(SAVE_PATH) func save_game() -> bool: return _save_game_to_path(SAVE_PATH) func has_manual_save() -> bool: return FileAccess.file_exists(MANUAL_SAVE_PATH) func save_manual_game() -> bool: return _save_game_to_path(MANUAL_SAVE_PATH) func load_manual_game() -> bool: if not has_manual_save(): return false var previous_state := to_dict() if not _load_game_from_path(MANUAL_SAVE_PATH): return false _clear_invalid_pending_post_battle_choice() _normalize_current_scenario() _backfill_applied_post_battle_choices() _reconcile_completed_officer_transitions() if save_game(): return true _apply_save_data(previous_state) return false func get_manual_save_summary() -> Dictionary: if not has_manual_save(): return {} var parsed = JSON.parse_string(FileAccess.get_file_as_string(MANUAL_SAVE_PATH)) if typeof(parsed) != TYPE_DICTIONARY: return {} var completed_count := 0 var completed = parsed.get("completed_scenarios", []) if typeof(completed) == TYPE_ARRAY: completed_count = completed.size() var current_id := str(parsed.get("current_scenario_id", "")) return { "save_version": int(parsed.get("save_version", 1)), "current_scenario_id": current_id, "current_scenario_title": get_scenario_title(current_id), "completed_count": completed_count, "total_count": scenario_order.size(), "gold": int(parsed.get("gold", 0)), "pending_choice": not str(parsed.get("pending_post_battle_choice_scenario_id", "")).is_empty() } func _load_game_from_path(path: String) -> bool: if not FileAccess.file_exists(path): return false var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) if typeof(parsed) != TYPE_DICTIONARY: return false _apply_save_data(parsed) return true func _save_game_to_path(path: String) -> bool: var file := FileAccess.open(path, FileAccess.WRITE) if file == null: push_error("Unable to save campaign state: %s" % path) return false file.store_string(JSON.stringify(to_dict())) return true func apply_battle_result(battle_state) -> Dictionary: if battle_state.battle_status != "victory": return { "saved": false, "rewards_applied": false, "already_completed": false, "gold": 0, "items": [], "joined_officers": [], "left_officers": [], "progression_events": [] } var already_completed := completed_scenarios.has(battle_state.battle_id) var rewards: Dictionary = battle_state.get_rewards() var post_battle_choices: Array = battle_state.get_post_battle_choices() if already_completed: var return_scenario_id := current_scenario_id if not is_campaign_complete() else "" return { "saved": true, "rewards_applied": false, "already_completed": true, "gold": 0, "items": [], "joined_officers": [], "left_officers": [], "progression_events": [], "next_scenario_id": return_scenario_id, "next_scenario_title": get_scenario_title(return_scenario_id), "scenario_id": battle_state.battle_id, "campaign_complete": return_scenario_id.is_empty(), "post_battle_choices": [], "choice_applied": true, "choice_label": "" } var previous_completed := completed_scenarios.duplicate() var previous_gold := gold var previous_inventory := inventory.duplicate(true) var previous_roster := roster.duplicate(true) var previous_current_scenario_id := current_scenario_id var previous_joined_officers := joined_officers.duplicate() var previous_pending_post_battle_choice_scenario_id := pending_post_battle_choice_scenario_id var rewarded_items: Array = [] var joined_reward_officers: Array = [] var left_reward_officers: Array = [] var reward_gold := 0 var next_scenario_id := get_next_scenario_id(battle_state.battle_id) roster = battle_state.get_player_roster_snapshot() inventory = battle_state.get_inventory_snapshot() completed_scenarios.append(battle_state.battle_id) reward_gold = int(rewards.get("gold", 0)) + int(battle_state.get_battle_gold_reward()) gold += reward_gold for item_id in rewards.get("items", []): var key := str(item_id) inventory[key] = int(inventory.get(key, 0)) + 1 rewarded_items.append(key) joined_reward_officers = _apply_join_officers(rewards.get("join_officers", [])) left_reward_officers = _apply_leave_officers(rewards.get("leave_officers", [])) pending_post_battle_choice_scenario_id = battle_state.battle_id if not post_battle_choices.is_empty() else "" if not next_scenario_id.is_empty(): current_scenario_id = next_scenario_id else: current_scenario_id = battle_state.battle_id var saved := save_game() if not saved: completed_scenarios.clear() for scenario_id in previous_completed: completed_scenarios.append(str(scenario_id)) gold = previous_gold inventory = previous_inventory roster = previous_roster current_scenario_id = previous_current_scenario_id pending_post_battle_choice_scenario_id = previous_pending_post_battle_choice_scenario_id joined_officers.clear() for officer_id in previous_joined_officers: joined_officers.append(str(officer_id)) reward_gold = 0 rewarded_items.clear() joined_reward_officers.clear() left_reward_officers.clear() return { "saved": saved, "rewards_applied": saved, "already_completed": false, "gold": reward_gold, "items": rewarded_items, "joined_officers": joined_reward_officers, "left_officers": left_reward_officers, "progression_events": battle_state.get_progression_events() if saved else [], "next_scenario_id": next_scenario_id, "next_scenario_title": get_scenario_title(next_scenario_id), "scenario_id": battle_state.battle_id, "campaign_complete": next_scenario_id.is_empty(), "post_battle_choices": post_battle_choices if saved else [], "choice_applied": not saved or post_battle_choices.is_empty(), "choice_label": "" } func get_roster_overrides() -> Dictionary: return roster.duplicate(true) func get_joined_officers_snapshot() -> Array: return joined_officers.duplicate() func get_inventory_snapshot() -> Dictionary: return inventory.duplicate(true) func get_shop_purchase_count(scenario_id: String, item_id: String) -> int: var scenario_key := str(scenario_id) var item_key := str(item_id) if scenario_key.is_empty() or item_key.is_empty(): return 0 if not shop_purchases.has(scenario_key) or typeof(shop_purchases[scenario_key]) != TYPE_DICTIONARY: return 0 return int(shop_purchases[scenario_key].get(item_key, 0)) func has_claimed_camp_conversation_effects(scenario_id: String, conversation_id: String) -> bool: var scenario_key := scenario_id.strip_edges() var conversation_key := conversation_id.strip_edges() if scenario_key.is_empty() or conversation_key.is_empty(): return false if not claimed_camp_conversation_effects.has(scenario_key): return false if typeof(claimed_camp_conversation_effects[scenario_key]) != TYPE_DICTIONARY: return false return bool((claimed_camp_conversation_effects[scenario_key] as Dictionary).get(conversation_key, false)) func try_claim_camp_conversation_effects(scenario_id: String, conversation: Dictionary) -> bool: var scenario_key := scenario_id.strip_edges() var conversation_key := str(conversation.get("id", "")).strip_edges() if scenario_key.is_empty() or conversation_key.is_empty(): return false if completed_scenarios.has(scenario_key): return false if has_claimed_camp_conversation_effects(scenario_key, conversation_key): return false var effects_value = conversation.get("effects", []) if typeof(effects_value) != TYPE_ARRAY: return false var previous_inventory := inventory.duplicate(true) var previous_claimed_effects := claimed_camp_conversation_effects.duplicate(true) var applied := false for effect in effects_value: if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")).strip_edges() != "grant_item": continue var item_key := str(effect.get("item_id", effect.get("id", ""))).strip_edges() if item_key.is_empty(): continue var count := maxi(1, int(effect.get("count", 1))) inventory[item_key] = int(inventory.get(item_key, 0)) + count applied = true if not applied: return false if not claimed_camp_conversation_effects.has(scenario_key) or typeof(claimed_camp_conversation_effects[scenario_key]) != TYPE_DICTIONARY: claimed_camp_conversation_effects[scenario_key] = {} var scenario_claims: Dictionary = claimed_camp_conversation_effects[scenario_key] scenario_claims[conversation_key] = true if save_game(): return true inventory = previous_inventory claimed_camp_conversation_effects = previous_claimed_effects return false func try_buy_item(item_id: String, price: int, scenario_id: String = "", stock_limit: int = -1) -> bool: if item_id.is_empty() or price <= 0 or gold < price: return false var scenario_key := str(scenario_id) if stock_limit >= 0 and not scenario_key.is_empty() and get_shop_purchase_count(scenario_key, item_id) >= stock_limit: return false var previous_count := int(inventory.get(item_id, 0)) var previous_shop_purchases: Dictionary = shop_purchases.duplicate(true) gold -= price inventory[item_id] = previous_count + 1 if stock_limit >= 0 and not scenario_key.is_empty(): if not shop_purchases.has(scenario_key) or typeof(shop_purchases[scenario_key]) != TYPE_DICTIONARY: shop_purchases[scenario_key] = {} var scenario_purchases: Dictionary = shop_purchases[scenario_key] scenario_purchases[item_id] = int(scenario_purchases.get(item_id, 0)) + 1 if save_game(): return true gold += price if previous_count > 0: inventory[item_id] = previous_count else: inventory.erase(item_id) shop_purchases = previous_shop_purchases return false func try_sell_item(item_id: String, sale_price: int) -> bool: if item_id.is_empty() or sale_price <= 0: return false var previous_count := int(inventory.get(item_id, 0)) if previous_count <= 0: return false var previous_gold := gold gold += sale_price if previous_count > 1: inventory[item_id] = previous_count - 1 else: inventory.erase(item_id) if save_game(): return true gold = previous_gold inventory[item_id] = previous_count return false func try_save_prebattle_loadout(roster_snapshot: Dictionary, inventory_snapshot: Dictionary) -> bool: var previous_roster := roster.duplicate(true) var previous_inventory := inventory.duplicate(true) for roster_key in roster_snapshot.keys(): var entry = roster_snapshot[roster_key] if typeof(entry) == TYPE_DICTIONARY: roster[str(roster_key)] = entry.duplicate(true) inventory = inventory_snapshot.duplicate(true) if save_game(): return true roster = previous_roster inventory = previous_inventory return false func try_apply_post_battle_choice(choice: Dictionary, scenario_id: String = "") -> bool: var previous_flags := flags.duplicate(true) var previous_gold := gold var previous_inventory := inventory.duplicate(true) var previous_joined_officers := joined_officers.duplicate() var previous_applied_post_battle_choices := applied_post_battle_choices.duplicate(true) var previous_pending_post_battle_choice_scenario_id := pending_post_battle_choice_scenario_id var previous_current_scenario_id := current_scenario_id var set_flags: Dictionary = choice.get("set_flags", {}) for flag_name in set_flags.keys(): flags[str(flag_name)] = set_flags[flag_name] var reward_gold := int(choice.get("gold", 0)) if reward_gold > 0: gold += reward_gold for item_id in choice.get("items", []): var key := str(item_id) if key.is_empty(): continue inventory[key] = int(inventory.get(key, 0)) + 1 _apply_join_officers(choice.get("join_officers", [])) _apply_leave_officers(choice.get("leave_officers", [])) var choice_id := str(choice.get("id", "")) if not scenario_id.is_empty() and not choice_id.is_empty(): applied_post_battle_choices[scenario_id] = choice_id if pending_post_battle_choice_scenario_id == scenario_id: pending_post_battle_choice_scenario_id = "" var next_scenario_id := get_next_scenario_id(scenario_id) current_scenario_id = next_scenario_id if not next_scenario_id.is_empty() else scenario_id if save_game(): return true flags = previous_flags gold = previous_gold inventory = previous_inventory applied_post_battle_choices = previous_applied_post_battle_choices pending_post_battle_choice_scenario_id = previous_pending_post_battle_choice_scenario_id current_scenario_id = previous_current_scenario_id joined_officers.clear() for officer_id in previous_joined_officers: joined_officers.append(str(officer_id)) return false func has_pending_post_battle_choice() -> bool: if pending_post_battle_choice_scenario_id.is_empty(): return false if not scenario_paths.has(pending_post_battle_choice_scenario_id): return false if not completed_scenarios.has(pending_post_battle_choice_scenario_id): return false if applied_post_battle_choices.has(pending_post_battle_choice_scenario_id): return false return true func get_pending_post_battle_choice_scenario_id() -> String: return pending_post_battle_choice_scenario_id func get_pending_post_battle_choice_summary() -> Dictionary: if pending_post_battle_choice_scenario_id.is_empty(): return {} var scenario_data := _load_scenario_definition(pending_post_battle_choice_scenario_id) if scenario_data.is_empty(): return {} var choices = scenario_data.get("post_battle_choices", []) if typeof(choices) != TYPE_ARRAY or choices.is_empty(): return {} var next_scenario_id := get_next_scenario_id(pending_post_battle_choice_scenario_id) return { "saved": true, "rewards_applied": false, "already_completed": false, "pending_choice": true, "gold": 0, "items": [], "joined_officers": [], "left_officers": [], "progression_events": [], "next_scenario_id": next_scenario_id, "next_scenario_title": get_scenario_title(next_scenario_id), "scenario_id": pending_post_battle_choice_scenario_id, "campaign_complete": next_scenario_id.is_empty(), "post_battle_choices": choices, "choice_applied": false, "choice_label": "" } func _clear_invalid_pending_post_battle_choice() -> bool: if pending_post_battle_choice_scenario_id.is_empty(): return false if not scenario_paths.has(pending_post_battle_choice_scenario_id): pending_post_battle_choice_scenario_id = "" return true if not completed_scenarios.has(pending_post_battle_choice_scenario_id): pending_post_battle_choice_scenario_id = "" return true if applied_post_battle_choices.has(pending_post_battle_choice_scenario_id): pending_post_battle_choice_scenario_id = "" return true if get_pending_post_battle_choice_summary().is_empty(): pending_post_battle_choice_scenario_id = "" return true return false func _apply_join_officers(source) -> Array: var result := [] if typeof(source) != TYPE_ARRAY: return result for officer_id in source: var key := str(officer_id) if key.is_empty() or joined_officers.has(key): continue joined_officers.append(key) result.append(key) return result func _apply_leave_officers(source) -> Array: var result := [] if typeof(source) != TYPE_ARRAY: return result for officer_id in source: var key := str(officer_id) if key.is_empty() or not joined_officers.has(key): continue joined_officers.erase(key) result.append(key) return result func _reconcile_completed_officer_transitions() -> bool: var changed := false for scenario_id in scenario_order: if not completed_scenarios.has(scenario_id): continue var scenario_data := _load_scenario_definition(scenario_id) if scenario_data.is_empty(): continue var rewards = scenario_data.get("rewards", {}) if typeof(rewards) == TYPE_DICTIONARY: changed = _apply_officer_transitions_from_block(rewards) or changed var applied_choice := _get_reconciled_post_battle_choice(scenario_id, scenario_data) if not applied_choice.is_empty(): changed = _apply_officer_transitions_from_block(applied_choice) or changed return changed func _backfill_applied_post_battle_choices() -> bool: var changed := false for scenario_id in scenario_order: if not completed_scenarios.has(scenario_id) or applied_post_battle_choices.has(scenario_id): continue if scenario_id == pending_post_battle_choice_scenario_id: continue var scenario_data := _load_scenario_definition(scenario_id) if scenario_data.is_empty(): continue var choices = scenario_data.get("post_battle_choices", []) if typeof(choices) != TYPE_ARRAY: continue var matching_choices := _matching_choices_from_flags(choices) if matching_choices.size() == 1: var choice_id := str(matching_choices[0].get("id", "")) if not choice_id.is_empty(): applied_post_battle_choices[scenario_id] = choice_id changed = true return changed func _get_reconciled_post_battle_choice(scenario_id: String, scenario_data: Dictionary) -> Dictionary: if scenario_id == pending_post_battle_choice_scenario_id: return {} var choices = scenario_data.get("post_battle_choices", []) if typeof(choices) != TYPE_ARRAY: return {} var applied_choice_id := str(applied_post_battle_choices.get(scenario_id, "")) if not applied_choice_id.is_empty(): for choice in choices: if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == applied_choice_id: return choice return {} var matching_choices := _matching_choices_from_flags(choices) if matching_choices.size() == 1: return matching_choices[0] return {} func _load_scenario_definition(scenario_id: String) -> Dictionary: var path := get_scenario_path(scenario_id) if path.is_empty(): return {} var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) if typeof(parsed) != TYPE_DICTIONARY: push_error("Scenario file is not valid JSON: %s" % path) return {} return parsed func _matching_choices_from_flags(choices: Array) -> Array: var result := [] for choice in choices: if typeof(choice) != TYPE_DICTIONARY or not _choice_flags_match(choice): continue result.append(choice) return result func _choice_flags_match(choice: Dictionary) -> bool: var set_flags = choice.get("set_flags", {}) if typeof(set_flags) != TYPE_DICTIONARY or set_flags.is_empty(): return false for flag_name in set_flags.keys(): var key := str(flag_name) if not flags.has(key) or flags[key] != set_flags[flag_name]: return false return true func _apply_officer_transitions_from_block(block: Dictionary) -> bool: var joined := _apply_join_officers(block.get("join_officers", [])) var left := _apply_leave_officers(block.get("leave_officers", [])) return not joined.is_empty() or not left.is_empty() func get_flags_snapshot() -> Dictionary: return flags.duplicate(true) func is_scenario_completed(scenario_id: String) -> bool: return completed_scenarios.has(scenario_id) func get_start_scenario_id() -> String: return start_scenario_id func get_scenario_path(scenario_id: String) -> String: return str(scenario_paths.get(scenario_id, "")) func get_current_scenario_path() -> String: if current_scenario_id.is_empty(): current_scenario_id = get_start_scenario_id() return get_scenario_path(current_scenario_id) func get_scenario_title(scenario_id: String) -> String: if scenario_id.is_empty(): return "" return str(scenario_titles.get(scenario_id, scenario_id)) func get_scenario_position(scenario_id: String) -> int: var index := scenario_order.find(scenario_id) if index == -1: return 0 return index + 1 func get_scenario_count() -> int: return scenario_order.size() func get_chapter_count() -> int: return chapter_order.size() func get_chapter_id_for_scenario(scenario_id: String) -> String: return str(scenario_chapter_ids.get(scenario_id, "")) func get_chapter_title_for_scenario(scenario_id: String) -> String: var chapter_id := get_chapter_id_for_scenario(scenario_id) if chapter_id.is_empty(): return "" return str(chapter_titles.get(chapter_id, chapter_id)) func get_chapter_position_for_scenario(scenario_id: String) -> int: var chapter_id := get_chapter_id_for_scenario(scenario_id) var index := chapter_order.find(chapter_id) if index == -1: return 0 return index + 1 func get_scenario_position_in_chapter(scenario_id: String) -> int: var chapter_id := get_chapter_id_for_scenario(scenario_id) if chapter_id.is_empty() or not chapter_scenario_ids.has(chapter_id): return 0 var scenario_ids: Array = chapter_scenario_ids[chapter_id] var index := scenario_ids.find(scenario_id) if index == -1: return 0 return index + 1 func get_scenario_count_in_chapter(scenario_id: String) -> int: var chapter_id := get_chapter_id_for_scenario(scenario_id) if chapter_id.is_empty() or not chapter_scenario_ids.has(chapter_id): return 0 var scenario_ids: Array = chapter_scenario_ids[chapter_id] return scenario_ids.size() func get_chapter_progress_entries() -> Array: var entries := [] for chapter_index in range(chapter_order.size()): var chapter_id := chapter_order[chapter_index] var scenario_ids: Array = chapter_scenario_ids.get(chapter_id, []) var completed_count := 0 var current_in_chapter := false var current_scenario_title := "" var scenarios := [] for scenario_id in scenario_ids: var completed := completed_scenarios.has(scenario_id) var current: bool = scenario_id == current_scenario_id if completed_scenarios.has(scenario_id): completed_count += 1 if current: current_in_chapter = true current_scenario_title = get_scenario_title(scenario_id) scenarios.append({ "id": scenario_id, "title": get_scenario_title(scenario_id), "position": get_scenario_position_in_chapter(scenario_id), "total": scenario_ids.size(), "completed": completed, "current": current, "available": completed or current }) entries.append({ "id": chapter_id, "title": str(chapter_titles.get(chapter_id, chapter_id)), "position": chapter_index + 1, "chapter_count": chapter_order.size(), "completed": completed_count, "total": scenario_ids.size(), "current": current_in_chapter, "current_scenario_title": current_scenario_title, "scenarios": scenarios }) return entries func get_next_scenario_id(scenario_id: String) -> String: var index := scenario_order.find(scenario_id) if index == -1 or index + 1 >= scenario_order.size(): return "" return scenario_order[index + 1] func is_campaign_complete() -> bool: if has_pending_post_battle_choice(): return false if scenario_order.is_empty(): return false for scenario_id in scenario_order: if not completed_scenarios.has(scenario_id): return false return true func get_progress_text() -> String: var current_title := get_scenario_title(current_scenario_id) if current_title.is_empty(): current_title = get_scenario_title(get_start_scenario_id()) var complete_text := "complete" if is_campaign_complete() else "in progress" return "%s | %s | Gold %d | %d/%d complete" % [ campaign_title, complete_text, gold, completed_scenarios.size(), scenario_order.size() ] func _normalize_current_scenario() -> void: if current_scenario_id.is_empty(): current_scenario_id = get_start_scenario_id() if completed_scenarios.has(current_scenario_id): var next_id := get_next_scenario_id(current_scenario_id) if not next_id.is_empty(): current_scenario_id = next_id func to_dict() -> Dictionary: return { "save_version": SAVE_VERSION, "current_scenario_id": current_scenario_id, "completed_scenarios": completed_scenarios.duplicate(), "gold": gold, "inventory": inventory.duplicate(true), "roster": roster.duplicate(true), "flags": flags.duplicate(true), "joined_officers": joined_officers.duplicate(), "applied_post_battle_choices": applied_post_battle_choices.duplicate(true), "pending_post_battle_choice_scenario_id": pending_post_battle_choice_scenario_id, "shop_purchases": shop_purchases.duplicate(true), "claimed_camp_conversation_effects": claimed_camp_conversation_effects.duplicate(true) } func _apply_save_data(parsed: Dictionary) -> void: save_version = int(parsed.get("save_version", 1)) current_scenario_id = str(parsed.get("current_scenario_id", "")) completed_scenarios.clear() for scenario_id in parsed.get("completed_scenarios", []): completed_scenarios.append(str(scenario_id)) gold = int(parsed.get("gold", 0)) inventory = _copy_dictionary(parsed.get("inventory", {})) roster = _copy_dictionary(parsed.get("roster", {})) flags = _copy_dictionary(parsed.get("flags", {})) applied_post_battle_choices = _copy_dictionary(parsed.get("applied_post_battle_choices", {})) pending_post_battle_choice_scenario_id = str(parsed.get("pending_post_battle_choice_scenario_id", "")) shop_purchases = _copy_dictionary(parsed.get("shop_purchases", {})) claimed_camp_conversation_effects = _copy_dictionary(parsed.get("claimed_camp_conversation_effects", {})) joined_officers.clear() var saved_joined = parsed.get("joined_officers", initial_joined_officers) for officer_id in saved_joined: var normalized := str(officer_id) if normalized.is_empty() or joined_officers.has(normalized): continue joined_officers.append(normalized) func _copy_dictionary(value) -> Dictionary: if typeof(value) == TYPE_DICTIONARY: return value.duplicate(true) return {}