diff --git a/main.tscn b/main.tscn index faf5efb..30817c0 100644 --- a/main.tscn +++ b/main.tscn @@ -8,6 +8,7 @@ [ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"] [ext_resource type="PackedScene" uid="uid://dvd3qjpjor4sq" path="res://world/resource_nodes/ResourceNode.tscn" id="8_r3s0u"] [ext_resource type="Script" uid="uid://bbwol3mrjgn2x" path="res://world/active_world_adapter.gd" id="9_adapter"] +[ext_resource type="Script" path="res://simulation/persistence/SaveSlotController.gd" id="10_save"] [sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"] vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5) @@ -115,6 +116,10 @@ pantry_marker = NodePath("../ActivityMarkers/PantryMarker") script = ExtResource("3_lquwl") active_world_adapter = NodePath("../ActiveWorldAdapter") +[node name="SaveSlotController" type="Node" parent="." node_paths=PackedStringArray("simulation_manager")] +script = ExtResource("10_save") +simulation_manager = NodePath("../SimulationManager") + [node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent")] script = ExtResource("4_1bvp3") npc_visual_scene = ExtResource("5_lquwl") diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index c7703f8..3251226 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -11,6 +11,7 @@ signal npc_target_requested(npc: SimNPC) signal npc_travel_requested(npc: SimNPC, target_position: Vector3) signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float) signal economic_event_recorded(event: EconomicEventRecord) +signal state_restored var village := SimVillage.new() @@ -749,4 +750,5 @@ func restore_state(record: SimulationStateRecord) -> bool: register_loaded_resource_nodes() village_changed.emit(village) + state_restored.emit() return true diff --git a/simulation/persistence/SaveSlotController.gd b/simulation/persistence/SaveSlotController.gd new file mode 100644 index 0000000..e153224 --- /dev/null +++ b/simulation/persistence/SaveSlotController.gd @@ -0,0 +1,21 @@ +extends Node + +@export var simulation_manager: Node + +var store := SaveSlotStore.new() + +func _unhandled_key_input(event: InputEvent) -> void: + if not event.pressed or event.echo: + return + if event.keycode == KEY_F5: + if store.save(simulation_manager): + print("[SaveSlot] Quicksave written") + else: + push_error("[SaveSlot] " + store.last_error) + get_viewport().set_input_as_handled() + elif event.keycode == KEY_F9: + if store.load_into(simulation_manager): + print("[SaveSlot] Quicksave restored") + else: + push_error("[SaveSlot] " + store.last_error) + get_viewport().set_input_as_handled() diff --git a/simulation/persistence/SaveSlotController.gd.uid b/simulation/persistence/SaveSlotController.gd.uid new file mode 100644 index 0000000..03cd328 --- /dev/null +++ b/simulation/persistence/SaveSlotController.gd.uid @@ -0,0 +1 @@ +uid://cwvdljsh148wh diff --git a/simulation/persistence/SaveSlotStore.gd b/simulation/persistence/SaveSlotStore.gd new file mode 100644 index 0000000..7b6e6ca --- /dev/null +++ b/simulation/persistence/SaveSlotStore.gd @@ -0,0 +1,131 @@ +class_name SaveSlotStore +extends RefCounted + +const DEFAULT_DIRECTORY := "user://saves" +const DEFAULT_SLOT := "quicksave" +const MAX_SAVE_BYTES := 16 * 1024 * 1024 + +var save_directory: String +var last_error := "" + +func _init(directory: String = DEFAULT_DIRECTORY) -> void: + save_directory = directory + +func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: + last_error = "" + if manager == null or not manager.has_method("serialize_state"): + return _fail("Save source cannot serialize simulation state") + if not _is_valid_slot_name(slot_name): + return _fail("Invalid save slot name") + + var json_text: String = manager.serialize_state() + if json_text.to_utf8_buffer().size() > MAX_SAVE_BYTES: + return _fail("Save exceeds the supported size limit") + if SimulationStateRecord.from_json(json_text) == null: + return _fail("Simulation produced an invalid save record") + if not _ensure_directory(): + return false + + var final_path := get_slot_path(slot_name) + var temporary_path := final_path + ".tmp" + var backup_path := final_path + ".bak" + _remove_if_present(temporary_path) + + var file := FileAccess.open(temporary_path, FileAccess.WRITE) + if file == null: + return _fail("Could not open temporary save file") + file.store_string(json_text) + file.flush() + file.close() + if _read_record(temporary_path) == null: + _remove_if_present(temporary_path) + return _fail("Temporary save failed validation") + + var had_existing := FileAccess.file_exists(final_path) + if had_existing: + _remove_if_present(backup_path) + if DirAccess.rename_absolute(final_path, backup_path) != OK: + _remove_if_present(temporary_path) + return _fail("Could not preserve the previous save") + if DirAccess.rename_absolute(temporary_path, final_path) != OK: + if had_existing: + DirAccess.rename_absolute(backup_path, final_path) + _remove_if_present(temporary_path) + return _fail("Could not install the new save") + _remove_if_present(backup_path) + return true + +func load_into(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool: + last_error = "" + if manager == null or not manager.has_method("restore_state"): + return _fail("Save target cannot restore simulation state") + if not _is_valid_slot_name(slot_name): + return _fail("Invalid save slot name") + + var final_path := get_slot_path(slot_name) + var record := _read_record(final_path) + if record == null: + record = _read_record(final_path + ".bak") + if record == null: + return _fail("Save slot is missing, invalid, or unsupported") + if not bool(manager.restore_state(record)): + return _fail("Simulation refused the validated save record") + return true + +func has_slot(slot_name: String = DEFAULT_SLOT) -> bool: + if not _is_valid_slot_name(slot_name): + return false + var final_path := get_slot_path(slot_name) + return ( + _read_record(final_path) != null + or _read_record(final_path + ".bak") != null + ) + +func delete_slot(slot_name: String = DEFAULT_SLOT) -> bool: + if not _is_valid_slot_name(slot_name): + return false + var final_path := get_slot_path(slot_name) + _remove_if_present(final_path) + _remove_if_present(final_path + ".tmp") + _remove_if_present(final_path + ".bak") + return true + +func get_slot_path(slot_name: String = DEFAULT_SLOT) -> String: + return save_directory.path_join(slot_name + ".json") + +func _read_record(path: String) -> SimulationStateRecord: + if not FileAccess.file_exists(path): + return null + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return null + if file.get_length() > MAX_SAVE_BYTES: + file.close() + return null + var json_text := file.get_as_text() + file.close() + return SimulationStateRecord.from_json(json_text) + +func _ensure_directory() -> bool: + var absolute_directory := ProjectSettings.globalize_path(save_directory) + var error := DirAccess.make_dir_recursive_absolute(absolute_directory) + if error != OK and error != ERR_ALREADY_EXISTS: + return _fail("Could not create the save directory") + return true + +func _is_valid_slot_name(slot_name: String) -> bool: + if slot_name.is_empty() or slot_name.length() > 32: + return false + var allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + for character in slot_name: + if not allowed.contains(character): + return false + return true + +func _remove_if_present(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + +func _fail(message: String) -> bool: + last_error = message + return false diff --git a/simulation/persistence/SaveSlotStore.gd.uid b/simulation/persistence/SaveSlotStore.gd.uid new file mode 100644 index 0000000..07c5e81 --- /dev/null +++ b/simulation/persistence/SaveSlotStore.gd.uid @@ -0,0 +1 @@ +uid://3w3wsbtr4gpw diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd index 26cafc5..b0f5430 100644 --- a/tests/npc_visual_lifecycle_test.gd +++ b/tests/npc_visual_lifecycle_test.gd @@ -99,6 +99,23 @@ func _run() -> void: "Active position callback should synchronize simulation state" ) + var saved_state: String = manager.serialize_state() + var visual_before_restore: Node3D = view.active_npc_visuals[npc.id] + npc.hunger = 99.0 + _check( + manager.restore_state_from_json(saved_state), + "Visual lifecycle scenario should restore valid state" + ) + var visual_after_restore: Node3D = view.active_npc_visuals[npc.id] + _check( + visual_after_restore != visual_before_restore, + "State restoration should rebuild active NPC presentation" + ) + _check( + visual_after_restore.global_position.is_equal_approx(npc.position), + "Rebuilt presentation should use the restored authoritative position" + ) + if failures.is_empty(): print("[TEST] NPC visual lifecycle passed") quit(0) diff --git a/tests/save_slot_persistence_test.gd b/tests/save_slot_persistence_test.gd new file mode 100644 index 0000000..8479599 --- /dev/null +++ b/tests/save_slot_persistence_test.gd @@ -0,0 +1,75 @@ +extends SceneTree + +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var test_directory := "user://save_slot_test_%s" % Time.get_ticks_usec() + var store := SaveSlotStore.new(test_directory) + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.debug_logs = false + root.add_child(manager) + manager.set_process(false) + + for step in 8: + manager.simulate_tick() + var saved_checksum: String = manager.get_state_checksum() + _check(store.save(manager), "A valid simulation should save atomically") + _check(store.has_slot(), "The validated quicksave should be discoverable") + + for step in 5: + manager.simulate_tick() + _check( + manager.get_state_checksum() != saved_checksum, + "The scenario should change after the save point" + ) + _check(store.load_into(manager), "A valid quicksave should load") + _check( + manager.get_state_checksum() == saved_checksum, + "Loading should restore the exact saved simulation" + ) + + var final_path := store.get_slot_path() + var backup_path := final_path + ".bak" + _check( + DirAccess.rename_absolute(final_path, backup_path) == OK, + "Test should reproduce the atomic replacement recovery window" + ) + _check( + store.load_into(manager), + "A preserved previous save should recover an interrupted replacement" + ) + _check( + DirAccess.rename_absolute(backup_path, final_path) == OK, + "Test should restore the primary save path" + ) + + var unchanged_checksum: String = manager.get_state_checksum() + var file := FileAccess.open(final_path, FileAccess.WRITE) + file.store_string("{\"schema\":\"broken\"}") + file.close() + _check(not store.load_into(manager), "Invalid save data should be rejected") + _check( + manager.get_state_checksum() == unchanged_checksum, + "A rejected save must not partially mutate the simulation" + ) + _check( + not store.save(manager, "../escape"), + "Slot names must not escape the save directory" + ) + + store.delete_slot() + DirAccess.remove_absolute(ProjectSettings.globalize_path(test_directory)) + if failures.is_empty(): + print("[TEST] Save-slot persistence passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/save_slot_persistence_test.gd.uid b/tests/save_slot_persistence_test.gd.uid new file mode 100644 index 0000000..ff676d4 --- /dev/null +++ b/tests/save_slot_persistence_test.gd.uid @@ -0,0 +1 @@ +uid://c7ow4dj42sfhl diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 3aa39be..b1e77f1 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -41,6 +41,10 @@ func initialize_world_view() -> void: push_error( "WorldViewManager: SimulationManager has no npc_inventory_changed signal" ) + if simulation_manager.has_signal("state_restored"): + simulation_manager.state_restored.connect(_on_simulation_state_restored) + else: + push_error("WorldViewManager: SimulationManager has no state_restored signal") spawn_initial_npcs() func spawn_initial_npcs() -> void: @@ -180,3 +184,9 @@ func _on_npc_inventory_changed( push_error( "WorldViewManager: NPCVisual has no set_carried_food_visible method" ) + +func _on_simulation_state_restored() -> void: + for visual in active_npc_visuals.values(): + visual.queue_free() + active_npc_visuals.clear() + spawn_initial_npcs()