feat: add validated quicksave persistence

This commit is contained in:
2026-07-05 18:10:33 +02:00
parent 2fcf641152
commit 08b7bee0e5
10 changed files with 264 additions and 0 deletions
+5
View File
@@ -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")
+2
View File
@@ -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
@@ -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()
@@ -0,0 +1 @@
uid://cwvdljsh148wh
+131
View File
@@ -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
@@ -0,0 +1 @@
uid://3w3wsbtr4gpw
+17
View File
@@ -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)
+75
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://c7ow4dj42sfhl
+10
View File
@@ -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()