class_name PlayerSaveSlotCatalog extends RefCounted const FORMAT_VERSION: int = 1 const MAX_SLOTS: int = 32 const MAX_NAME_LENGTH: int = 32 const LEGACY_SAVE_ID: StringName = &"player_save" signal slots_changed var _data_root: PlayerDataRoot var _save_manager: PlayerSaveManager var _manifest_path := "" var _slots_directory := "" var _slots: Array[Dictionary] = [] var _active_slot_id := "" var _expected_hash := "" var _error_message := "" func configure( data_root: PlayerDataRoot, save_manager: PlayerSaveManager, ) -> bool: if data_root == null or save_manager == null or data_root.root_path.is_empty(): return false _data_root = data_root _save_manager = save_manager _manifest_path = data_root.path_for(&"save_slots") _slots_directory = data_root.root_path.path_join("player/saves") _slots.clear() _active_slot_id = "" _expected_hash = "" _error_message = "" if DirAccess.make_dir_recursive_absolute(_slots_directory) != OK: _error_message = "the save-slot directory could not be created." return false _recover_interrupted_manifest_write() var loaded: bool = _load_manifest() if not loaded and FileAccess.file_exists(_manifest_path): if not _preserve_invalid_manifest(): _error_message = "the invalid save-slot catalog could not be preserved." return false var slot_count_before_discovery: int = _slots.size() var active_before_discovery: String = _active_slot_id _discover_untracked_saves() if _active_slot_id.is_empty() and not _slots.is_empty(): _active_slot_id = str(_slots.front().get("slot_id", "")) if ( not loaded or _slots.size() != slot_count_before_discovery or _active_slot_id != active_before_discovery ): if not _save_manifest(): return false return _select_configured_storage() func get_error_message() -> String: return _error_message func list_slots() -> Array[Dictionary]: var result: Array[Dictionary] = [] for entry: Dictionary in _slots: result.append(_build_slot_summary(entry)) result.sort_custom( func(a: Dictionary, b: Dictionary) -> bool: var a_played: int = int(a.get("last_played_at_unix", 0)) var b_played: int = int(b.get("last_played_at_unix", 0)) if a_played == b_played: return int(a.get("created_at_unix", 0)) > int( b.get("created_at_unix", 0) ) return a_played > b_played ) return result func get_slot(slot_id: String) -> Dictionary: var entry: Dictionary = _find_slot(slot_id) return _build_slot_summary(entry) if not entry.is_empty() else {} func get_active_slot_id() -> String: return _active_slot_id func has_slots() -> bool: return not _slots.is_empty() func ensure_active_slot() -> Dictionary: if not _active_slot_id.is_empty() and not _find_slot(_active_slot_id).is_empty(): return {"ok": true, "slot_id": _active_slot_id} var created: Dictionary = create_empty_slot(_next_default_name()) if not bool(created.get("ok", false)): return created var slot_id: String = str(created.get("slot_id", "")) if not activate_slot(slot_id): return {"ok": false, "message": "the new save slot could not be selected."} return {"ok": true, "slot_id": slot_id} func create_empty_slot(display_name: String) -> Dictionary: if _slots.size() >= MAX_SLOTS: return {"ok": false, "message": "the maximum number of save slots has been reached."} var clean_name: String = normalized_name(display_name) if clean_name.is_empty(): return {"ok": false, "message": "enter a name for this save slot."} var slot_id: String = _generate_slot_id() var now: int = int(Time.get_unix_time_from_system()) _slots.append({ "slot_id": slot_id, "display_name": clean_name, "created_at_unix": now, "last_played_at_unix": 0, "legacy": false, }) if not _save_manifest(): _slots.pop_back() return {"ok": false, "message": "the save-slot catalog could not be updated."} slots_changed.emit() return {"ok": true, "slot_id": slot_id} func duplicate_slot( source_slot_id: String, display_name: String, world_layout: StringName, world_seed: int, ) -> Dictionary: if _slots.size() >= MAX_SLOTS: return {"ok": false, "message": "the maximum number of save slots has been reached."} var source: Dictionary = _find_slot(source_slot_id) var clean_name: String = normalized_name(display_name) if source.is_empty() or clean_name.is_empty(): return {"ok": false, "message": "the selected save slot cannot be duplicated."} var source_path: String = _existing_slot_path(source) if source_path.is_empty(): return {"ok": false, "message": "the selected save slot has no progression to duplicate."} var slot_id: String = _generate_slot_id() var destination: String = _slots_directory.path_join(slot_id + ".nfsave") var copied: Dictionary = _save_manager.copy_progression_to_path( source_path, destination, source_path.ends_with(".json"), world_layout, world_seed, ) if not bool(copied.get("ok", false)): return copied var now: int = int(Time.get_unix_time_from_system()) _slots.append({ "slot_id": slot_id, "display_name": clean_name, "created_at_unix": now, "last_played_at_unix": 0, "legacy": false, }) if not _save_manifest(): _slots.pop_back() _remove_if_present(destination) return {"ok": false, "message": "the duplicated slot could not be recorded."} slots_changed.emit() return {"ok": true, "slot_id": slot_id} func import_slot(archive_path: String, display_name: String) -> Dictionary: if _slots.size() >= MAX_SLOTS: return {"ok": false, "message": "the maximum number of save slots has been reached."} var clean_name: String = normalized_name(display_name) if clean_name.is_empty(): return {"ok": false, "message": "the imported save needs a slot name."} var slot_id: String = _generate_slot_id() var destination: String = _slots_directory.path_join(slot_id + ".nfsave") var installed: Dictionary = _save_manager.install_progression_archive_at_path( archive_path, destination, ) if not bool(installed.get("ok", false)): return installed var now: int = int(Time.get_unix_time_from_system()) _slots.append({ "slot_id": slot_id, "display_name": clean_name, "created_at_unix": now, "last_played_at_unix": 0, "legacy": false, }) if not _save_manifest(): _slots.pop_back() _remove_if_present(destination) return {"ok": false, "message": "the imported slot could not be recorded."} slots_changed.emit() installed["slot_id"] = slot_id return installed func export_slot(slot_id: String, destination_path: String) -> Dictionary: var entry: Dictionary = _find_slot(slot_id) var source_path: String = _existing_slot_path(entry) if entry.is_empty() or source_path.is_empty(): return {"ok": false, "message": "the selected save slot cannot be exported."} return _save_manager.export_progression_archive_from_path( source_path, destination_path, source_path.ends_with(".json"), ) func activate_slot(slot_id: String) -> bool: var entry: Dictionary = _find_slot(slot_id) if entry.is_empty(): return false var previous_id: String = _active_slot_id var previous_path: String = _configured_storage_path(previous_id) var next_path: String = _slot_primary_path(entry) if not _save_manager.select_storage(next_path, _data_root): return false _active_slot_id = slot_id if _save_manifest(): slots_changed.emit() return true _active_slot_id = previous_id _save_manager.select_storage(previous_path, _data_root) return false func mark_played(slot_id: String) -> bool: var index: int = _find_slot_index(slot_id) if index < 0: return false var previous_played_at: int = int( _slots[index].get("last_played_at_unix", 0) ) var previous_active: String = _active_slot_id _slots[index]["last_played_at_unix"] = int(Time.get_unix_time_from_system()) _active_slot_id = slot_id if not _save_manifest(): _slots[index]["last_played_at_unix"] = previous_played_at _active_slot_id = previous_active return false slots_changed.emit() return true func rename_slot(slot_id: String, display_name: String) -> bool: var index: int = _find_slot_index(slot_id) var clean_name: String = normalized_name(display_name) if index < 0 or clean_name.is_empty(): return false var previous: String = str(_slots[index].get("display_name", "")) _slots[index]["display_name"] = clean_name if not _save_manifest(): _slots[index]["display_name"] = previous return false slots_changed.emit() return true func delete_slot(slot_id: String) -> bool: var index: int = _find_slot_index(slot_id) if index < 0: return false var previous_slots: Array[Dictionary] = [] for previous_entry: Dictionary in _slots: previous_slots.append(previous_entry.duplicate(true)) var previous_active: String = _active_slot_id var entry: Dictionary = _slots[index] var source_path: String = _existing_slot_path(entry) var preserved_path := "" if not source_path.is_empty(): preserved_path = _deleted_backup_path(entry, source_path) if not _rename_file(source_path, preserved_path): return false _slots.remove_at(index) if _active_slot_id == slot_id: _active_slot_id = ( str(_slots.front().get("slot_id", "")) if not _slots.is_empty() else "" ) if not _select_configured_storage() or not _save_manifest(): _slots = previous_slots _active_slot_id = previous_active _select_configured_storage() if not preserved_path.is_empty(): _rename_file(preserved_path, source_path) return false for auxiliary: String in _slot_auxiliary_paths(entry): _remove_if_present(auxiliary) slots_changed.emit() return true static func normalized_name(value: String) -> String: var clean_name: String = value.strip_edges().left(MAX_NAME_LENGTH) if clean_name.is_empty(): return "" for index: int in clean_name.length(): var codepoint: int = clean_name.unicode_at(index) if codepoint < 32 or codepoint == 127: return "" return clean_name func _build_slot_summary(entry: Dictionary) -> Dictionary: if entry.is_empty(): return {} var summary: Dictionary = entry.duplicate(true) var path: String = _existing_slot_path(entry) var inspection: PlayerSaveInspection = _save_manager.inspect_progression_at_path( path, path.ends_with(".json"), ) summary["active"] = str(entry.get("slot_id", "")) == _active_slot_id summary["has_save"] = inspection.can_continue() summary["save_status"] = inspection.status summary["status_message"] = inspection.message summary["catch_count"] = inspection.catch_count summary["wallet_balance"] = inspection.wallet_balance summary["discovered_species_count"] = inspection.discovered_species_count summary["player_level"] = inspection.player_level summary["world_layout"] = String(inspection.world_layout) summary["world_seed"] = inspection.world_seed return summary func _load_manifest() -> bool: if not FileAccess.file_exists(_manifest_path): return false var file := FileAccess.open(_manifest_path, FileAccess.READ) if file == null: return false var parser := JSON.new() var error: Error = parser.parse(file.get_as_text()) file.close() if error != OK or typeof(parser.data) != TYPE_DICTIONARY: return false var data: Dictionary = parser.data if int(data.get("format_version", -1)) != FORMAT_VERSION: return false if typeof(data.get("slots")) != TYPE_ARRAY: return false var loaded_slots: Array[Dictionary] = [] var seen_ids: Dictionary[String, bool] = {} for value: Variant in data["slots"]: if typeof(value) != TYPE_DICTIONARY: return false var entry: Dictionary = (value as Dictionary).duplicate(true) if not _valid_slot_entry(entry): return false var slot_id: String = str(entry["slot_id"]) if seen_ids.has(slot_id): return false seen_ids[slot_id] = true loaded_slots.append(entry) _slots = loaded_slots _active_slot_id = str(data.get("active_slot_id", "")) if not _active_slot_id.is_empty() and not seen_ids.has(_active_slot_id): _active_slot_id = "" _expected_hash = PortableFileGuard.hash_file(_manifest_path) return true func _save_manifest() -> bool: var data: Dictionary = { "format_version": FORMAT_VERSION, "active_slot_id": _active_slot_id, "slots": _slots, } var result: Dictionary = PortableFileGuard.write_guarded( _manifest_path, JSON.stringify(data, "\t").to_utf8_buffer(), _expected_hash, _data_root.conflict_directory(), _data_root.device_id, ) if bool(result.get("conflict", false)): _data_root.report_conflict( str(result.get("message", "")), str(result.get("conflict_path", "")), ) if bool(result.get("ok", false)): _expected_hash = str(result.get("hash", "")) return true _error_message = "the save-slot catalog could not be written safely." return false func _discover_untracked_saves() -> void: var known_ids: Dictionary[String, bool] = {} var has_legacy: bool = false for entry: Dictionary in _slots: known_ids[str(entry.get("slot_id", ""))] = true has_legacy = has_legacy or bool(entry.get("legacy", false)) var legacy_primary: String = _data_root.path_for(LEGACY_SAVE_ID) var legacy_plaintext: String = legacy_primary.get_base_dir().path_join( PlayerSaveManager.LEGACY_SAVE_FILENAME ) if ( not has_legacy and ( FileAccess.file_exists(legacy_primary) or FileAccess.file_exists(legacy_plaintext) ) ): var legacy_id: String = _generate_slot_id() _slots.append({ "slot_id": legacy_id, "display_name": _next_default_name(), "created_at_unix": int(Time.get_unix_time_from_system()), "last_played_at_unix": 0, "legacy": true, }) known_ids[legacy_id] = true var directory := DirAccess.open(_slots_directory) if directory == null: return directory.list_dir_begin() var filename: String = directory.get_next() while not filename.is_empty(): if not directory.current_is_dir() and filename.ends_with(".nfsave"): var slot_id: String = filename.trim_suffix(".nfsave") if _valid_slot_id(slot_id) and not known_ids.has(slot_id): _slots.append({ "slot_id": slot_id, "display_name": _next_default_name(), "created_at_unix": int(Time.get_unix_time_from_system()), "last_played_at_unix": 0, "legacy": false, }) known_ids[slot_id] = true filename = directory.get_next() directory.list_dir_end() func _select_configured_storage() -> bool: return _save_manager.select_storage( _configured_storage_path(_active_slot_id), _data_root, ) func _configured_storage_path(slot_id: String) -> String: var entry: Dictionary = _find_slot(slot_id) if not entry.is_empty(): return _slot_primary_path(entry) return _data_root.path_for(LEGACY_SAVE_ID) func _slot_primary_path(entry: Dictionary) -> String: if bool(entry.get("legacy", false)): return _data_root.path_for(LEGACY_SAVE_ID) return _slots_directory.path_join(str(entry.get("slot_id", "")) + ".nfsave") func _existing_slot_path(entry: Dictionary) -> String: if entry.is_empty(): return "" var primary: String = _slot_primary_path(entry) if FileAccess.file_exists(primary): return primary if bool(entry.get("legacy", false)): var plaintext: String = primary.get_base_dir().path_join( PlayerSaveManager.LEGACY_SAVE_FILENAME ) if FileAccess.file_exists(plaintext): return plaintext return "" func _slot_auxiliary_paths(entry: Dictionary) -> Array[String]: var primary: String = _slot_primary_path(entry) var paths: Array[String] = [ primary + ".tmp", primary + ".backup", primary + ".codec.tmp", ] if bool(entry.get("legacy", false)): var plaintext: String = primary.get_base_dir().path_join( PlayerSaveManager.LEGACY_SAVE_FILENAME ) paths.append_array([ plaintext + ".tmp", plaintext + ".backup", ]) return paths func _deleted_backup_path(entry: Dictionary, source_path: String) -> String: var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-") var extension: String = ".json" if source_path.ends_with(".json") else ".nfsave" var destination: String = _data_root.root_path.path_join( "backups/saves/deleted-slot-%s-%s%s" % [str(entry.get("slot_id", "")).left(8), timestamp, extension] ) if FileAccess.file_exists(destination): destination = destination.trim_suffix(extension) + ( "-%d%s" % [Time.get_ticks_usec(), extension] ) return destination func _find_slot(slot_id: String) -> Dictionary: var index: int = _find_slot_index(slot_id) return _slots[index] if index >= 0 else {} func _find_slot_index(slot_id: String) -> int: for index: int in _slots.size(): if str(_slots[index].get("slot_id", "")) == slot_id: return index return -1 func _next_default_name() -> String: var used: Dictionary[String, bool] = {} for entry: Dictionary in _slots: used[str(entry.get("display_name", "")).to_lower()] = true var number: int = 1 while used.has("save %d" % number): number += 1 return "save %d" % number func _generate_slot_id() -> String: var slot_id: String = Crypto.new().generate_random_bytes(16).hex_encode() while _find_slot_index(slot_id) >= 0: slot_id = Crypto.new().generate_random_bytes(16).hex_encode() return slot_id func _valid_slot_entry(entry: Dictionary) -> bool: var display_name: String = str(entry.get("display_name", "")) return ( _valid_slot_id(str(entry.get("slot_id", ""))) and display_name.length() <= MAX_NAME_LENGTH and not normalized_name(display_name).is_empty() and typeof(entry.get("created_at_unix")) in [TYPE_INT, TYPE_FLOAT] and typeof(entry.get("last_played_at_unix")) in [TYPE_INT, TYPE_FLOAT] and typeof(entry.get("legacy")) == TYPE_BOOL ) func _valid_slot_id(slot_id: String) -> bool: if slot_id.length() != 32: return false for index: int in slot_id.length(): var character: String = slot_id.substr(index, 1).to_lower() if character not in "0123456789abcdef": return false return true func _recover_interrupted_manifest_write() -> void: var temporary: String = _manifest_path + ".tmp" var backup: String = _manifest_path + ".backup" if FileAccess.file_exists(_manifest_path): _remove_if_present(temporary) _remove_if_present(backup) return if FileAccess.file_exists(backup): _rename_file(backup, _manifest_path) _remove_if_present(temporary) func _preserve_invalid_manifest() -> bool: var destination: String = _data_root.migration_backup_directory().path_join( "invalid-save-slots-%s.json" % Time.get_datetime_string_from_system().replace(":", "-") ) if not _rename_file(_manifest_path, destination): return false _expected_hash = "" _slots.clear() _active_slot_id = "" return true func _rename_file(source: String, destination: String) -> bool: if source.is_empty() or destination.is_empty(): return false if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK: return false return DirAccess.rename_absolute(source, destination) == OK func _remove_if_present(path: String) -> bool: return not FileAccess.file_exists(path) or DirAccess.remove_absolute(path) == OK