Integrate per-save identity and interaction UI

This commit is contained in:
Alexander Sellite 2026-09-01 17:30:50 -04:00
parent ac82a28f3b
commit 4fda6e97f6
58 changed files with 3471 additions and 461 deletions

View file

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

View file

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