extends RefCounted const DATA := preload("res://scripts/game/save_data.gd") const PATH := "user://last_game.res" const BACKUP := "user://last_game_backup.res" const TEMP := "user://last_game_pending.res" const DIRECTORY := "user://saves" const STATE_FIELDS := ["selected_ship_path", "system_level", "system_seed", "active_distant_map", "distant_home", "highest_system", "system_seeds", "cleared_systems", "explored_pois", "asteroid_retry_seeds", "exploration_wreck_bonuses", "hud_panel_state"] static func read_save(path: String = PATH) -> Resource: if not FileAccess.file_exists(path): return null var file := FileAccess.open(path, FileAccess.READ) if file == null: return null var header := file.get_buffer(4).get_string_from_ascii() file.close() if header not in ["RSRC", "RSCC"]: return null var data := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) if data == null or data.get_script() != DATA or data.version != 1: return null if not data.captain is CaptainProgress or data.state.get("system_seed", 0) == 0: return null for key in ["cargo", "weapon", "module", "extension"]: if not data.inventory.get(key) is Array: return null return data static func available() -> bool: return not list_saves().is_empty() static func valid_slot(id: String) -> bool: return RegEx.create_from_string("^slot_[a-zA-Z0-9_-]{1,72}$").search(id) != null static func slot_path(id: String) -> String: return DIRECTORY.path_join(id+".res") if valid_slot(id) else "" static func list_saves() -> Array[Dictionary]: var entries: Array[Dictionary] = [] var ids: Array[String] = ["auto"] if DirAccess.dir_exists_absolute(DIRECTORY): for file in DirAccess.get_files_at(DIRECTORY): if not file.ends_with(".res") or file.ends_with(".pending.res"): continue var id := file.trim_suffix(".backup.res") if file.ends_with(".backup.res") else file.trim_suffix(".res") if valid_slot(id) and id not in ids: ids.append(id) for id in ids: var path := PATH if id == "auto" else slot_path(id) var backup := BACKUP if id == "auto" else path.trim_suffix(".res")+".backup.res" var data := read_save(path) var recovered := false if data == null: data = read_save(backup) recovered = data != null if data == null: continue var timestamp: float = data.saved_unix if data.saved_unix > 0 else float(FileAccess.get_modified_time(backup if recovered else path)) entries.append({"id":id,"path":backup if recovered else path,"name":data.save_name if not data.save_name.is_empty() else ("Automatyczny zapis" if id == "auto" else "Zapis rozgrywki"),"saved_at":data.saved_at,"timestamp":timestamp,"level":int(data.state.system_level),"captain_level":data.captain.level,"credits":data.captain.credits,"ship_path":str(data.state.get("selected_ship_path","")),"recovered":recovered}) entries.sort_custom(func(a: Dictionary,b: Dictionary) -> bool: return float(a.timestamp) > float(b.timestamp)) return entries static func write(state: Node, game: Node, save_name: String = "", id: String = "") -> Error: var named := not save_name.is_empty() if named and (save_name.strip_edges().is_empty() or save_name.length() > 48): return ERR_INVALID_PARAMETER if not id.is_empty() and (not named or not valid_slot(id)): return ERR_INVALID_PARAMETER if game == null or not game.has_node("PlayerShip") or bool(game.get("_transitioning")): return ERR_UNAVAILABLE var ship := game.get_node("PlayerShip") if bool(ship.get("_is_destroyed")): return ERR_UNAVAILABLE var data := DATA.new() data.saved_at = Time.get_datetime_string_from_system() data.saved_unix = Time.get_unix_time_from_system() data.save_name = save_name.strip_edges() if named else "Automatyczny zapis" data.slot_id = id if not id.is_empty() else ("slot_%d_%08x" % [Time.get_ticks_usec(),randi()] if named else "auto") for key in STATE_FIELDS: data.state[key] = state.get(key) data.captain = state.captain.duplicate(true) data.captain.skill_offers.assign(state.captain.skill_offers) # A saved surface expedition returns safely to orbit with its current cargo. if state.captain.expedition.active: for key in ["active", "cargo", "cargo_modules", "cargo_records", "cargo_credits", "cargo_parts", "cargo_missiles", "active_profile", "visit_seed"]: var value: Variant = state.captain.expedition.get(key) data.captain.expedition.set(key, value.duplicate(true) if value is Dictionary or value is Array else value) data.captain.expedition.finish_visit(data.captain, true) data.skill_offers.assign(data.captain.skill_offers) var inventory := ship.get_node("ShipInventory") var previous: Dictionary = state.restart_inventory inventory.preserve_for_restart() data.inventory = state.restart_inventory state.restart_inventory = previous if named: var directory_error := DirAccess.make_dir_recursive_absolute(DIRECTORY) if directory_error != OK: return directory_error return _commit(data, slot_path(data.slot_id), slot_path(data.slot_id).trim_suffix(".res")+".backup.res", slot_path(data.slot_id).trim_suffix(".res")+".pending.res") return _commit(data, PATH, BACKUP, TEMP) static func _commit(data: Resource, path: String, backup: String, temporary: String) -> Error: var error := ResourceSaver.save(data, temporary) if error != OK: return error if read_save(temporary) == null: return ERR_FILE_CORRUPT if read_save(path) != null: error = DirAccess.copy_absolute(path, backup) if error != OK: return error return DirAccess.rename_absolute(temporary, path) static func restore_state(state: Node, id: String = "") -> bool: var entries := list_saves() var data: Resource for entry in entries: if id.is_empty() or entry.id == id: data = read_save(entry.path) break if data == null: return false for key in STATE_FIELDS: if data.state.has(key): state.set(key, data.state[key]) state.captain = data.captain state.captain.skill_offers.assign(data.skill_offers) state.captain.reset_combat_state() state.restart_inventory = data.inventory state.return_to_freighter = false state.distant_warp_arrival = false state.hull_bastion_used = false # Loading always starts the current sector again, with fresh encounters. if state.active_distant_map.is_empty(): state.cleared_systems.erase(state.system_level) state.reset_current_system_exploration() return true