Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
591 lines
18 KiB
GDScript
591 lines
18 KiB
GDScript
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
|