906 lines
30 KiB
GDScript
906 lines
30 KiB
GDScript
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 := "전기 완결" if is_campaign_complete() else "진군 중"
|
|
return "%s · %s · 군자금 %d냥 · 봉인 %d/%d" % [
|
|
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 {}
|