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.
This commit is contained in:
Alexander Sellite 2026-08-23 20:48:38 -04:00
parent 1db1a5b754
commit 3b84bfe3a0
97 changed files with 7869 additions and 982 deletions

View file

@ -14,6 +14,10 @@ var detected_version: int = -1
var catch_count: int = 0
var wallet_balance: int = 0
var discovered_species_count: int = 0
var total_experience: int = 0
var player_level: int = 1
var world_layout: StringName = &"generated_world"
var world_seed: int = 0
var has_primary_file: bool = false
var message: String = ""

View file

@ -49,6 +49,7 @@ class LoadSnapshot:
var catches: Array[FishCatchType] = []
var discovered_ids: Array[StringName] = []
var discovered_quality_masks: Dictionary[StringName, int] = {}
var catch_counts: Dictionary[StringName, int] = {}
var wallet_balance: int = 0
var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = []
@ -109,8 +110,22 @@ var _world_seed: int = DEFAULT_WORLD_SEED
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
if not select_storage(path, data_root):
push_error("PlayerSaveManager could not configure progression storage.")
func select_storage(path: String, data_root: PlayerDataRoot) -> bool:
if path.is_empty() or data_root == null:
return false
if _autosave_enabled or _is_dirty:
return false
if _autosave_timer != null:
_autosave_timer.stop()
_save_path = path
_data_root = data_root
_expected_hash = ""
_automatic_saving_blocked = false
return true
func _temp_path() -> String:
@ -297,9 +312,10 @@ func load_player_data() -> bool:
snapshot.next_catch_sequence
)
var collection_restored: bool = (
_collection_log.replace_discovery_state(
_collection_log.replace_collection_state(
snapshot.discovered_ids,
snapshot.discovered_quality_masks,
snapshot.catch_counts,
)
)
var wallet_restored: bool = _wallet.restore_balance(
@ -398,17 +414,30 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new()
_recover_interrupted_write()
var read_path: String = _read_path()
result.has_primary_file = not read_path.is_empty()
return inspect_progression_at_path(
read_path,
read_path == _legacy_save_path(),
)
func inspect_progression_at_path(
path: String,
allow_legacy_plaintext: bool = false,
) -> SaveInspectionType:
var result := SaveInspectionType.new()
if not path.is_empty():
_remove_if_present(path + ".codec.tmp")
_recover_path_write(path)
result.has_primary_file = not path.is_empty() and FileAccess.file_exists(path)
if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING
result.message = "no save found."
return result
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
path,
allow_legacy_plaintext,
)
if not bool(decoded.get("ok", false)):
result.status = SaveInspectionType.Status.IO_ERROR
@ -439,6 +468,12 @@ func inspect_save() -> SaveInspectionType:
result.catch_count = snapshot.catches.size()
result.wallet_balance = snapshot.wallet_balance
result.discovered_species_count = snapshot.discovered_ids.size()
result.total_experience = snapshot.total_experience
result.player_level = PlayerExperienceType.level_for_total_experience(
snapshot.total_experience
)
result.world_layout = snapshot.world_layout
result.world_seed = snapshot.world_seed
result.message = "save ready."
return result
@ -451,31 +486,45 @@ func export_progression_archive(path: String) -> Dictionary:
var source_path: String = _read_path()
if source_path.is_empty():
return {"ok": false, "message": "there is no progression to export."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
return export_progression_archive_from_path(
source_path,
path,
source_path == _legacy_save_path(),
)
func export_progression_archive_from_path(
source_path: String,
destination_path: String,
allow_legacy_plaintext: bool = false,
) -> Dictionary:
if source_path.is_empty() or destination_path.is_empty():
return {"ok": false, "message": "progression export is unavailable."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
allow_legacy_plaintext,
)
if not bool(decoded.get("ok", false)):
return {"ok": false, "message": "the active progression could not be read."}
return {"ok": false, "message": "the selected progression could not be read."}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive(
prepared["data"],
path + ".codec.tmp",
destination_path + ".codec.tmp",
)
if bytes.is_empty():
return {"ok": false, "message": "the progression archive could not be encoded."}
var result: Dictionary = PortableFileGuard.write_guarded(
path,
destination_path,
bytes,
PortableFileGuard.hash_file(path),
PortableFileGuard.hash_file(destination_path),
_data_root.conflict_directory(),
_data_root.device_id,
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be written."}
var verified: Dictionary = inspect_progression_archive(path)
var verified: Dictionary = inspect_progression_archive(destination_path)
if not bool(verified.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be verified."}
verified["message"] = "progression archive created."
@ -525,6 +574,83 @@ func import_progression_archive(path: String) -> Dictionary:
}
func install_progression_archive_at_path(
archive_path: String,
destination_path: String,
) -> Dictionary:
if not _is_configured or destination_path.is_empty():
return {"ok": false, "message": "progression import is unavailable."}
var inspected: Dictionary = inspect_progression_archive(archive_path)
if not bool(inspected.get("ok", false)):
return inspected
var result: Dictionary = _write_save_data_at_path(
inspected["data"],
destination_path,
PortableFileGuard.hash_file(destination_path),
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the imported progression could not be installed."}
return {
"ok": true,
"message": "progression imported as a new save slot.",
"catch_count": inspected["catch_count"],
"wallet_balance": inspected["wallet_balance"],
"discovered_species_count": inspected["discovered_species_count"],
"world_layout": inspected["world_layout"],
"world_seed": inspected["world_seed"],
}
func copy_progression_to_path(
source_path: String,
destination_path: String,
allow_legacy_plaintext: bool,
world_layout: StringName,
world_seed: int,
) -> Dictionary:
if (
not _is_configured
or source_path.is_empty()
or destination_path.is_empty()
or not WorldLayoutType.is_valid(world_layout)
or world_seed <= 0
or world_seed > MAX_WORLD_SEED
):
return {"ok": false, "message": "progression duplication is unavailable."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
allow_legacy_plaintext,
)
if not bool(decoded.get("ok", false)):
return {"ok": false, "message": "the selected progression could not be read."}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
var save_data: Dictionary = (prepared["data"] as Dictionary).duplicate(true)
var world_data: Dictionary = {}
if typeof(save_data.get("world")) == TYPE_DICTIONARY:
world_data = (save_data["world"] as Dictionary).duplicate(true)
world_data["layout"] = String(world_layout)
world_data["seed"] = world_seed
save_data["world"] = world_data
prepared = _prepare_external_save_data(save_data)
if not bool(prepared.get("ok", false)):
return prepared
var result: Dictionary = _write_save_data_at_path(
prepared["data"],
destination_path,
PortableFileGuard.hash_file(destination_path),
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the duplicated progression could not be written."}
return {
"ok": true,
"message": "save slot duplicated.",
"world_layout": String(world_layout),
"world_seed": world_seed,
}
func initialize_new_game(
world_seed: int = DEFAULT_WORLD_SEED,
world_layout: StringName = WorldLayoutType.GENERATED,
@ -678,6 +804,20 @@ func _build_save_dictionary() -> Dictionary:
):
return {}
serialized_quality_masks[String(fish_id)] = quality_mask
var serialized_catch_counts: Dictionary = {}
var catch_counts: Dictionary[StringName, int] = (
_collection_log.get_catch_counts()
)
for fish_id: StringName in catch_counts:
var catch_count: int = catch_counts[fish_id]
if (
fish_id.is_empty()
or catch_count <= 0
or catch_count > CollectionLogType.MAX_CATCH_COUNT
or not _collection_log.has_discovered(fish_id)
):
return {}
serialized_catch_counts[String(fish_id)] = catch_count
var serialized_items: Array[Dictionary] = []
for owned: OwnedItemType in _bag.get_all_items():
if owned == null or not owned.is_valid():
@ -725,6 +865,7 @@ func _build_save_dictionary() -> Dictionary:
"collection": {
"discovered_fish_ids": discovered_strings,
"discovered_quality_masks": serialized_quality_masks,
"catch_counts": serialized_catch_counts,
},
"inventory": {
"next_catch_sequence": _inventory.get_next_catch_sequence(),
@ -926,6 +1067,30 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
):
return null
snapshot.discovered_quality_masks[fish_id] = mask
var has_saved_catch_counts: bool = collection_data.has("catch_counts")
if (
has_saved_catch_counts
and typeof(collection_data.get("catch_counts")) != TYPE_DICTIONARY
):
return null
if has_saved_catch_counts:
var saved_catch_counts: Dictionary = collection_data["catch_counts"]
for key: Variant in saved_catch_counts:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]:
return null
var fish_id := StringName(str(key))
var catch_count: int = _read_integer(
saved_catch_counts[key],
-1,
CollectionLogType.MAX_CATCH_COUNT,
)
if (
fish_id.is_empty()
or not seen_discoveries.has(fish_id)
or catch_count <= 0
):
return null
snapshot.catch_counts[fish_id] = catch_count
var seen_ids: Dictionary[StringName, bool] = {}
var seen_sequences: Dictionary[int, bool] = {}
@ -976,6 +1141,13 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
fish_catch.catch_sequence
)
snapshot.catches.append(fish_catch)
if (
not has_saved_catch_counts
and seen_discoveries.has(fish_catch.fish_id)
):
snapshot.catch_counts[fish_catch.fish_id] = (
int(snapshot.catch_counts.get(fish_catch.fish_id, 0)) + 1
)
snapshot.next_catch_sequence = maxi(
requested_next_sequence,
@ -1504,15 +1676,23 @@ func _recover_path_write(path: String) -> void:
func _write_current_save_data(
save_data: Dictionary,
expected_hash: String,
) -> Dictionary:
return _write_save_data_at_path(save_data, _save_path, expected_hash)
func _write_save_data_at_path(
save_data: Dictionary,
destination_path: String,
expected_hash: String,
) -> Dictionary:
var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save(
save_data,
_codec_scratch_path(),
destination_path + ".codec.tmp",
)
if bytes.is_empty():
return {"ok": false}
return PortableFileGuard.write_guarded(
_save_path,
destination_path,
bytes,
expected_hash,
_data_root.conflict_directory(),
@ -1651,6 +1831,7 @@ func _restore_defaults() -> void:
var empty_catches: Array[FishCatchType] = []
var empty_discoveries: Array[StringName] = []
var empty_quality_masks: Dictionary[StringName, int] = {}
var empty_catch_counts: Dictionary[StringName, int] = {}
var default_items: Array[OwnedItemType] = []
var basic_rod := OwnedItemType.new()
basic_rod.item_id = BASIC_ROD_ID
@ -1661,9 +1842,10 @@ func _restore_defaults() -> void:
default_slots.fill(StringName())
default_slots[0] = BASIC_ROD_ID
_inventory.replace_all_catches(empty_catches, 1)
_collection_log.replace_discovery_state(
_collection_log.replace_collection_state(
empty_discoveries,
empty_quality_masks,
empty_catch_counts,
)
_wallet.restore_balance(0)
_bag.replace_all_items(default_items)

View file

@ -0,0 +1,591 @@
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

View file

@ -0,0 +1 @@
uid://djfeifv7xrqdt