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:
parent
1db1a5b754
commit
3b84bfe3a0
97 changed files with 7869 additions and 982 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue