Integrate per-save identity and interaction UI
This commit is contained in:
parent
ac82a28f3b
commit
4fda6e97f6
58 changed files with 3471 additions and 461 deletions
|
|
@ -35,6 +35,13 @@ const WorldLayoutType = preload("res://world/world_layout.gd")
|
|||
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
|
||||
const PlayerType = preload("res://player/player.gd")
|
||||
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
|
||||
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
|
||||
const NetworkProfilePreferencesType = preload(
|
||||
"res://network/network_profile_preferences.gd"
|
||||
)
|
||||
const PlayerAppearanceStoreType = preload(
|
||||
"res://progression/player_appearance_store.gd"
|
||||
)
|
||||
|
||||
const SAVE_VERSION: int = 11
|
||||
const LEGACY_SAVE_FILENAME := "player_save.json"
|
||||
|
|
@ -78,6 +85,9 @@ class LoadSnapshot:
|
|||
)
|
||||
var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
|
||||
var home_data: Dictionary = PlayerHomeStateType.default_save_data()
|
||||
var has_character_data: bool = false
|
||||
var character_profile_data: Dictionary = {}
|
||||
var appearance_data: Dictionary = {}
|
||||
|
||||
|
||||
@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5
|
||||
|
|
@ -99,6 +109,9 @@ var _world_weather: WorldWeatherServiceType
|
|||
var _jobs: PlayerJobServiceType
|
||||
var _player: PlayerType
|
||||
var _home_state: PlayerHomeStateType
|
||||
var _network_profile: NetworkProfilePreferencesType
|
||||
var _appearance_store: PlayerAppearanceStoreType
|
||||
var _legacy_character_data: Dictionary = {}
|
||||
var _autosave_timer: Timer
|
||||
var _is_configured: bool = false
|
||||
var _is_restoring: bool = false
|
||||
|
|
@ -179,6 +192,8 @@ func setup(
|
|||
jobs: PlayerJobServiceType,
|
||||
player: PlayerType,
|
||||
home_state: PlayerHomeStateType,
|
||||
network_profile: NetworkProfilePreferencesType = null,
|
||||
appearance_store: PlayerAppearanceStoreType = null,
|
||||
) -> void:
|
||||
_inventory = inventory
|
||||
_collection_log = collection_log
|
||||
|
|
@ -197,6 +212,9 @@ func setup(
|
|||
_jobs = jobs
|
||||
_player = player
|
||||
_home_state = home_state
|
||||
_network_profile = network_profile
|
||||
_appearance_store = appearance_store
|
||||
capture_legacy_character_fallback()
|
||||
_is_configured = (
|
||||
_inventory != null
|
||||
and _collection_log != null
|
||||
|
|
@ -263,6 +281,132 @@ func setup(
|
|||
_player.active_lure_changed.connect(_on_active_tackle_changed)
|
||||
if not _home_state.changed.is_connected(_mark_dirty):
|
||||
_home_state.changed.connect(_mark_dirty)
|
||||
if (
|
||||
_network_profile != null
|
||||
and not _network_profile.profile_changed.is_connected(_mark_dirty)
|
||||
):
|
||||
_network_profile.profile_changed.connect(_mark_dirty)
|
||||
if (
|
||||
_appearance_store != null
|
||||
and not _appearance_store.appearance_changed.is_connected(_mark_dirty)
|
||||
):
|
||||
_appearance_store.appearance_changed.connect(_mark_dirty)
|
||||
|
||||
|
||||
func capture_legacy_character_fallback() -> void:
|
||||
_legacy_character_data = _current_character_data()
|
||||
|
||||
|
||||
func load_character_profile() -> bool:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return true
|
||||
_recover_interrupted_write()
|
||||
var read_path: String = _read_path()
|
||||
var previous_restoring: bool = _is_restoring
|
||||
_is_restoring = true
|
||||
if read_path.is_empty():
|
||||
var reset_ok: bool = _reset_character_profile()
|
||||
_is_restoring = previous_restoring
|
||||
return reset_ok
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
read_path,
|
||||
read_path == _legacy_save_path(),
|
||||
)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
_is_restoring = previous_restoring
|
||||
return false
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
var version: int = _read_integer(save_data.get("save_version"), -1)
|
||||
if version < 1 or version > SAVE_VERSION:
|
||||
_is_restoring = previous_restoring
|
||||
return false
|
||||
if version != SAVE_VERSION:
|
||||
save_data = _migrate_save(save_data, version)
|
||||
if save_data.is_empty():
|
||||
_is_restoring = previous_restoring
|
||||
return false
|
||||
var character: Dictionary = _character_data_from_save(save_data)
|
||||
var restored: bool = (
|
||||
not character.is_empty()
|
||||
and _restore_character_data(character)
|
||||
)
|
||||
_is_restoring = previous_restoring
|
||||
return restored
|
||||
|
||||
|
||||
func _current_character_data() -> Dictionary:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return {}
|
||||
var profile: Dictionary = _network_profile.to_save_data()
|
||||
var appearance: Dictionary = _appearance_store.get_snapshot()
|
||||
if (
|
||||
not NetworkProfilePreferencesType.validate_save_data(profile)
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"profile": profile,
|
||||
"appearance": appearance,
|
||||
}
|
||||
|
||||
|
||||
func _character_data_from_save(save_data: Dictionary) -> Dictionary:
|
||||
if not save_data.has("character"):
|
||||
return _legacy_character_data.duplicate(true)
|
||||
if typeof(save_data.get("character")) != TYPE_DICTIONARY:
|
||||
return {}
|
||||
var character: Dictionary = save_data["character"]
|
||||
if (
|
||||
not NetworkProfilePreferencesType.validate_save_data(
|
||||
character.get("profile")
|
||||
)
|
||||
or typeof(character.get("appearance")) != TYPE_DICTIONARY
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(
|
||||
character.get("appearance")
|
||||
)
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"profile": (character["profile"] as Dictionary).duplicate(true),
|
||||
"appearance": CharacterCustomizationCatalog.sanitized_snapshot(
|
||||
character["appearance"]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
func _restore_character_data(character: Dictionary) -> bool:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return true
|
||||
# The selected progression save is authoritative here. Applying it must not
|
||||
# rewrite the compatibility profile mirrors: their guarded writes may be
|
||||
# stale after a Syncthing edit, and that must not make a valid slot unloadable.
|
||||
var previous_profile: Dictionary = _network_profile.to_save_data()
|
||||
var previous_appearance: Dictionary = _appearance_store.get_snapshot()
|
||||
if not _network_profile.restore_from_save_data(
|
||||
character.get("profile"), false
|
||||
):
|
||||
return false
|
||||
if _appearance_store.restore_from_save_data(
|
||||
character.get("appearance"), false
|
||||
):
|
||||
return true
|
||||
_network_profile.restore_from_save_data(previous_profile, false)
|
||||
_appearance_store.restore_from_save_data(previous_appearance, false)
|
||||
return false
|
||||
|
||||
|
||||
func _reset_character_profile() -> bool:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return true
|
||||
var previous_profile: Dictionary = _network_profile.to_save_data()
|
||||
var previous_appearance: Dictionary = _appearance_store.get_snapshot()
|
||||
if not _network_profile.reset_to_defaults(false):
|
||||
return false
|
||||
if _appearance_store.reset_to_defaults(false):
|
||||
return true
|
||||
_network_profile.restore_from_save_data(previous_profile, false)
|
||||
_appearance_store.restore_from_save_data(previous_appearance, false)
|
||||
return false
|
||||
|
||||
|
||||
func load_player_data() -> bool:
|
||||
|
|
@ -315,6 +459,12 @@ func load_player_data() -> bool:
|
|||
return false
|
||||
|
||||
_is_restoring = true
|
||||
var character_restored: bool = true
|
||||
if snapshot.has_character_data:
|
||||
character_restored = _restore_character_data({
|
||||
"profile": snapshot.character_profile_data,
|
||||
"appearance": snapshot.appearance_data,
|
||||
})
|
||||
var inventory_restored: bool = _inventory.replace_all_catches(
|
||||
snapshot.catches,
|
||||
snapshot.next_catch_sequence
|
||||
|
|
@ -390,13 +540,14 @@ func load_player_data() -> bool:
|
|||
or not world_weather_restored
|
||||
or not jobs_restored
|
||||
or not home_restored
|
||||
or not character_restored
|
||||
):
|
||||
push_error(
|
||||
(
|
||||
"Validated player save could not be restored: "
|
||||
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
|
||||
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
|
||||
+ "time=%s weather=%s jobs=%s home=%s"
|
||||
+ "time=%s weather=%s jobs=%s home=%s character=%s"
|
||||
)
|
||||
% [
|
||||
inventory_restored,
|
||||
|
|
@ -414,6 +565,7 @@ func load_player_data() -> bool:
|
|||
world_weather_restored,
|
||||
jobs_restored,
|
||||
home_restored,
|
||||
character_restored,
|
||||
]
|
||||
)
|
||||
return false
|
||||
|
|
@ -669,6 +821,7 @@ func copy_progression_to_path(
|
|||
func initialize_new_game(
|
||||
world_seed: int = DEFAULT_WORLD_SEED,
|
||||
world_layout: StringName = WorldLayoutType.GENERATED,
|
||||
reset_character_profile: bool = true,
|
||||
) -> bool:
|
||||
if not _is_configured:
|
||||
return false
|
||||
|
|
@ -679,6 +832,12 @@ func initialize_new_game(
|
|||
):
|
||||
return false
|
||||
_automatic_saving_blocked = false
|
||||
if reset_character_profile:
|
||||
_is_restoring = true
|
||||
var character_reset: bool = _reset_character_profile()
|
||||
_is_restoring = false
|
||||
if not character_reset:
|
||||
return false
|
||||
_restore_defaults()
|
||||
_world_layout = world_layout
|
||||
_world_seed = world_seed
|
||||
|
|
@ -872,7 +1031,7 @@ func _build_save_dictionary() -> Dictionary:
|
|||
):
|
||||
return {}
|
||||
|
||||
return {
|
||||
var save_data: Dictionary = {
|
||||
"save_version": SAVE_VERSION,
|
||||
"wallet": {
|
||||
"balance": _wallet.get_balance(),
|
||||
|
|
@ -916,6 +1075,12 @@ func _build_save_dictionary() -> Dictionary:
|
|||
"jobs": _jobs.to_save_data(),
|
||||
"home": _home_state.to_save_data(),
|
||||
}
|
||||
var character: Dictionary = _current_character_data()
|
||||
if _network_profile != null and _appearance_store != null:
|
||||
if character.is_empty():
|
||||
return {}
|
||||
save_data["character"] = character
|
||||
return save_data
|
||||
|
||||
|
||||
func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
||||
|
|
@ -998,6 +1163,17 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
return null
|
||||
|
||||
var snapshot := LoadSnapshot.new()
|
||||
var character: Dictionary = _character_data_from_save(save_data)
|
||||
if save_data.has("character") and character.is_empty():
|
||||
return null
|
||||
if not character.is_empty():
|
||||
snapshot.has_character_data = true
|
||||
snapshot.character_profile_data = (
|
||||
character["profile"] as Dictionary
|
||||
).duplicate(true)
|
||||
snapshot.appearance_data = (
|
||||
character["appearance"] as Dictionary
|
||||
).duplicate(true)
|
||||
if world_data.has("layout"):
|
||||
if not WorldLayoutType.is_valid(world_data["layout"]):
|
||||
return null
|
||||
|
|
@ -1208,7 +1384,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
)
|
||||
continue
|
||||
var maximum: int = (
|
||||
item_data.max_stack if item_data.stackable else 1
|
||||
mini(
|
||||
item_data.max_stack,
|
||||
DecorCatalogType.MAX_OWNED_PER_PRODUCT,
|
||||
)
|
||||
if DecorCatalogType.has_product(item_id)
|
||||
else item_data.max_stack if item_data.stackable else 1
|
||||
)
|
||||
if quantity > maximum:
|
||||
push_warning(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ var _manifest_path := ""
|
|||
var _slots_directory := ""
|
||||
var _slots: Array[Dictionary] = []
|
||||
var _active_slot_id := ""
|
||||
var _online_slot_id := ""
|
||||
var _expected_hash := ""
|
||||
var _error_message := ""
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ func configure(
|
|||
_slots_directory = data_root.root_path.path_join("player/saves")
|
||||
_slots.clear()
|
||||
_active_slot_id = ""
|
||||
_online_slot_id = ""
|
||||
_expected_hash = ""
|
||||
_error_message = ""
|
||||
if DirAccess.make_dir_recursive_absolute(_slots_directory) != OK:
|
||||
|
|
@ -43,13 +45,17 @@ func configure(
|
|||
return false
|
||||
var slot_count_before_discovery: int = _slots.size()
|
||||
var active_before_discovery: String = _active_slot_id
|
||||
var online_before_discovery: String = _online_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 _online_slot_id.is_empty() and not _slots.is_empty():
|
||||
_online_slot_id = _active_slot_id
|
||||
if (
|
||||
not loaded
|
||||
or _slots.size() != slot_count_before_discovery
|
||||
or _active_slot_id != active_before_discovery
|
||||
or _online_slot_id != online_before_discovery
|
||||
):
|
||||
if not _save_manifest():
|
||||
return false
|
||||
|
|
@ -86,6 +92,24 @@ func get_active_slot_id() -> String:
|
|||
return _active_slot_id
|
||||
|
||||
|
||||
func get_online_slot_id() -> String:
|
||||
return _online_slot_id
|
||||
|
||||
|
||||
func mark_slot_for_online(slot_id: String) -> bool:
|
||||
if _find_slot(slot_id).is_empty():
|
||||
return false
|
||||
if _online_slot_id == slot_id:
|
||||
return true
|
||||
var previous: String = _online_slot_id
|
||||
_online_slot_id = slot_id
|
||||
if not _save_manifest():
|
||||
_online_slot_id = previous
|
||||
return false
|
||||
slots_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func has_slots() -> bool:
|
||||
return not _slots.is_empty()
|
||||
|
||||
|
|
@ -102,6 +126,26 @@ func ensure_active_slot() -> Dictionary:
|
|||
return {"ok": true, "slot_id": slot_id}
|
||||
|
||||
|
||||
func ensure_online_slot() -> Dictionary:
|
||||
var slot_id: String = _online_slot_id
|
||||
if slot_id.is_empty() or _find_slot(slot_id).is_empty():
|
||||
var active: Dictionary = ensure_active_slot()
|
||||
if not bool(active.get("ok", false)):
|
||||
return active
|
||||
slot_id = str(active.get("slot_id", ""))
|
||||
if not mark_slot_for_online(slot_id):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "the online progression could not be selected.",
|
||||
}
|
||||
if _active_slot_id != slot_id and not activate_slot(slot_id):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "the online progression could not be opened.",
|
||||
}
|
||||
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."}
|
||||
|
|
@ -117,8 +161,12 @@ func create_empty_slot(display_name: String) -> Dictionary:
|
|||
"last_played_at_unix": 0,
|
||||
"legacy": false,
|
||||
})
|
||||
var previous_online: String = _online_slot_id
|
||||
if _online_slot_id.is_empty():
|
||||
_online_slot_id = slot_id
|
||||
if not _save_manifest():
|
||||
_slots.pop_back()
|
||||
_online_slot_id = previous_online
|
||||
return {"ok": false, "message": "the save-slot catalog could not be updated."}
|
||||
slots_changed.emit()
|
||||
return {"ok": true, "slot_id": slot_id}
|
||||
|
|
@ -267,6 +315,7 @@ func delete_slot(slot_id: String) -> bool:
|
|||
for previous_entry: Dictionary in _slots:
|
||||
previous_slots.append(previous_entry.duplicate(true))
|
||||
var previous_active: String = _active_slot_id
|
||||
var previous_online: String = _online_slot_id
|
||||
var entry: Dictionary = _slots[index]
|
||||
var source_path: String = _existing_slot_path(entry)
|
||||
var preserved_path := ""
|
||||
|
|
@ -281,9 +330,18 @@ func delete_slot(slot_id: String) -> bool:
|
|||
if not _slots.is_empty()
|
||||
else ""
|
||||
)
|
||||
if _online_slot_id == slot_id:
|
||||
_online_slot_id = (
|
||||
_active_slot_id
|
||||
if not _active_slot_id.is_empty()
|
||||
else 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
|
||||
_online_slot_id = previous_online
|
||||
_select_configured_storage()
|
||||
if not preserved_path.is_empty():
|
||||
_rename_file(preserved_path, source_path)
|
||||
|
|
@ -315,6 +373,7 @@ func _build_slot_summary(entry: Dictionary) -> Dictionary:
|
|||
path.ends_with(".json"),
|
||||
)
|
||||
summary["active"] = str(entry.get("slot_id", "")) == _active_slot_id
|
||||
summary["online"] = str(entry.get("slot_id", "")) == _online_slot_id
|
||||
summary["has_save"] = inspection.can_continue()
|
||||
summary["save_status"] = inspection.status
|
||||
summary["status_message"] = inspection.message
|
||||
|
|
@ -360,6 +419,9 @@ func _load_manifest() -> bool:
|
|||
_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 = ""
|
||||
_online_slot_id = str(data.get("online_slot_id", ""))
|
||||
if not _online_slot_id.is_empty() and not seen_ids.has(_online_slot_id):
|
||||
_online_slot_id = ""
|
||||
_expected_hash = PortableFileGuard.hash_file(_manifest_path)
|
||||
return true
|
||||
|
||||
|
|
@ -368,6 +430,7 @@ func _save_manifest() -> bool:
|
|||
var data: Dictionary = {
|
||||
"format_version": FORMAT_VERSION,
|
||||
"active_slot_id": _active_slot_id,
|
||||
"online_slot_id": _online_slot_id,
|
||||
"slots": _slots,
|
||||
}
|
||||
var result: Dictionary = PortableFileGuard.write_guarded(
|
||||
|
|
@ -576,6 +639,7 @@ func _preserve_invalid_manifest() -> bool:
|
|||
_expected_hash = ""
|
||||
_slots.clear()
|
||||
_active_slot_id = ""
|
||||
_online_slot_id = ""
|
||||
return true
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue