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

@ -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)