straywild/save/player_save_slot_catalog.gd
Voyager 3b84bfe3a0 feat: expand progression and multiplayer systems
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.
2026-08-23 20:49:54 -04:00

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