feat: add validated quicksave persistence
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7ow4dj42sfhl
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user