Files
heros/scripts/core/campaign_state.gd
2026-06-18 03:17:25 +09:00

612 lines
20 KiB
GDScript

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_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_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 {}