extends RefCounted class_name CampaignState const SAVE_PATH := "user://campaign_save.json" const SAVE_VERSION := 3 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 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 := "" 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() 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)) 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_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 = "" 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: if not FileAccess.file_exists(SAVE_PATH): return false var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH)) if typeof(parsed) != TYPE_DICTIONARY: return false 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", "")) 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) return true func save_game() -> bool: var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: push_error("Unable to save campaign state: %s" % SAVE_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 := battle_state.get_rewards() var post_battle_choices := battle_state.get_post_battle_choices() 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) if not already_completed: roster = battle_state.get_player_roster_snapshot() inventory = battle_state.get_inventory_snapshot() if not already_completed: completed_scenarios.append(battle_state.battle_id) reward_gold = int(rewards.get("gold", 0)) 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": not already_completed and saved, "already_completed": already_completed, "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 and not already_completed 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 and not already_completed else [], "choice_applied": not saved or already_completed 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 try_buy_item(item_id: String, price: int) -> bool: if item_id.is_empty() or price <= 0 or gold < price: return false var previous_count := int(inventory.get(item_id, 0)) gold -= price inventory[item_id] = previous_count + 1 if save_game(): return true gold += price if previous_count > 0: inventory[item_id] = previous_count else: inventory.erase(item_id) 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_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 } func _copy_dictionary(value) -> Dictionary: if typeof(value) == TYPE_DICTIONARY: return value.duplicate(true) return {}