267 lines
9.7 KiB
GDScript
267 lines
9.7 KiB
GDScript
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()
|
|
_check(
|
|
manager.request_regional_jajce_delivery(3.0),
|
|
"The quicksave fixture should start one production regional delivery",
|
|
)
|
|
var saved_checksum: String = manager.get_state_checksum()
|
|
var saved_regional_checksum: String = manager.get_regional_state_checksum()
|
|
var saved_world_checksum: String = manager.get_world_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"
|
|
)
|
|
var restore_notifications: Array[StringName] = []
|
|
manager.village_changed.connect(
|
|
func(_village: SimVillage) -> void: restore_notifications.append(&"village")
|
|
)
|
|
manager.state_restored.connect(func() -> void: restore_notifications.append(&"state"))
|
|
_check(store.load_into(manager), "A valid quicksave should load")
|
|
_check(
|
|
restore_notifications == [&"village", &"state"],
|
|
"A committed combined restore should publish its two notifications exactly once",
|
|
)
|
|
_check(
|
|
manager.get_state_checksum() == saved_checksum,
|
|
"Loading should restore the exact saved simulation"
|
|
)
|
|
_check(
|
|
manager.get_regional_state_checksum() == saved_regional_checksum,
|
|
"Loading should restore the exact mid-route regional authority",
|
|
)
|
|
_check(
|
|
manager.get_world_state_checksum() == saved_world_checksum,
|
|
"Loading should restore both authorities from the same manifest",
|
|
)
|
|
|
|
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(
|
|
manager.get_world_state_checksum() == saved_world_checksum,
|
|
"Backup recovery should restore the complete combined manifest",
|
|
)
|
|
_check(
|
|
DirAccess.rename_absolute(backup_path, final_path) == OK,
|
|
"Test should restore the primary save path"
|
|
)
|
|
|
|
var legacy_slot := "legacy_v16"
|
|
var legacy_path := store.get_slot_path(legacy_slot)
|
|
var legacy_local_checksum: String = manager.get_state_checksum()
|
|
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
|
|
_check(legacy_file != null, "Test should create a raw legacy local save")
|
|
if legacy_file != null:
|
|
legacy_file.store_string(manager.serialize_state())
|
|
legacy_file.close()
|
|
manager.simulate_tick()
|
|
_check(
|
|
store.load_into(manager, legacy_slot),
|
|
"Existing raw v16 quicksaves should remain loadable",
|
|
)
|
|
_check(
|
|
manager.get_state_checksum() == legacy_local_checksum,
|
|
"A legacy quicksave should restore its exact local authority",
|
|
)
|
|
var legacy_regional := RegionalSimulationFacade.from_dictionary(manager.get_regional_snapshot())
|
|
_check(legacy_regional != null, "Legacy load should bootstrap a valid regional facade")
|
|
if legacy_regional != null:
|
|
var legacy_service := legacy_regional.get_service()
|
|
var legacy_caravan := legacy_service.get_world_state().get_mobile_group(
|
|
SimulationIds.REGIONAL_CARAVAN_BLUE
|
|
)
|
|
_check(
|
|
legacy_regional.get_last_processed_tick() == manager.tick_count,
|
|
"Legacy regional bootstrap should align with the saved local tick",
|
|
)
|
|
_check(
|
|
legacy_regional.get_world_seed() == manager.simulation_seed,
|
|
"Legacy regional bootstrap should align with the saved world seed",
|
|
)
|
|
_check(
|
|
(
|
|
legacy_service.get_scheduler().is_empty()
|
|
and legacy_service.get_event_store().is_empty()
|
|
),
|
|
"Legacy load must not invent a historical regional journey",
|
|
)
|
|
_check(
|
|
(
|
|
legacy_caravan != null
|
|
and legacy_caravan.is_at_location()
|
|
and (
|
|
legacy_caravan.get_current_location_id()
|
|
== SimulationIds.REGIONAL_LOCATION_TRAVNIK
|
|
)
|
|
),
|
|
"Legacy load should place the idle authored caravan at Travnik",
|
|
)
|
|
store.delete_slot(legacy_slot)
|
|
|
|
var custom_adapter := ActiveWorldAdapter.new()
|
|
_check(
|
|
custom_adapter.configure_context_identity(
|
|
&"save_test_context", &"save_test_world", &"save_test_location"
|
|
),
|
|
"The custom-scope fixture should configure an independent active context",
|
|
)
|
|
root.add_child(custom_adapter)
|
|
var custom_manager: Node = load("res://simulation/SimulationManager.gd").new()
|
|
custom_manager.debug_logs = false
|
|
custom_manager.active_world_adapter = custom_adapter
|
|
root.add_child(custom_manager)
|
|
custom_manager.set_process(false)
|
|
var custom_slot := "custom_scope"
|
|
var custom_saved_local_checksum: String = custom_manager.get_state_checksum()
|
|
var custom_saved_regional_checksum: String = custom_manager.get_regional_state_checksum()
|
|
_check(
|
|
not custom_manager.uses_combined_save_manifest(),
|
|
"A non-Jajce adapter scope should keep the supported local-v16 save boundary",
|
|
)
|
|
_check(
|
|
custom_manager.can_serialize_local_state_only(),
|
|
"A custom scope should allow local-only saving while regional state is reconstructibly idle",
|
|
)
|
|
_check(
|
|
(
|
|
not custom_manager.request_regional_jajce_delivery(1.0)
|
|
and custom_manager.get_regional_state_checksum() == custom_saved_regional_checksum
|
|
),
|
|
"A non-Jajce adapter scope must reject Jajce regional mutation without changing authority",
|
|
)
|
|
_check(
|
|
store.save(custom_manager, custom_slot),
|
|
"A supported non-Jajce adapter scope should still save through SaveSlotStore",
|
|
)
|
|
var custom_file := FileAccess.open(store.get_slot_path(custom_slot), FileAccess.READ)
|
|
_check(custom_file != null, "The custom-scope slot should be readable")
|
|
if custom_file != null:
|
|
var custom_json := custom_file.get_as_text()
|
|
custom_file.close()
|
|
_check(
|
|
(
|
|
SimulationSaveManifest.from_json(custom_json) == null
|
|
and SimulationStateRecord.from_json(custom_json) != null
|
|
),
|
|
"A non-Jajce adapter scope should persist the explicit raw local record",
|
|
)
|
|
custom_manager.simulate_tick()
|
|
_check(
|
|
store.load_into(custom_manager, custom_slot),
|
|
"The custom-scope raw local record should remain loadable",
|
|
)
|
|
_check(
|
|
custom_manager.get_state_checksum() == custom_saved_local_checksum,
|
|
"Custom-scope loading should restore the exact local authority",
|
|
)
|
|
_check(
|
|
custom_manager.get_regional_state_checksum() == custom_saved_regional_checksum,
|
|
"Custom-scope loading should reconstruct the exact idle regional facade",
|
|
)
|
|
var rejected_restore_notifications: Array[StringName] = []
|
|
custom_manager.village_changed.connect(
|
|
func(_village: SimVillage) -> void: rejected_restore_notifications.append(&"village")
|
|
)
|
|
custom_manager.state_restored.connect(
|
|
func() -> void: rejected_restore_notifications.append(&"state")
|
|
)
|
|
var custom_before_rejected_local: String = custom_manager.get_state_checksum()
|
|
var custom_before_rejected_regional: String = custom_manager.get_regional_state_checksum()
|
|
_check(
|
|
not custom_manager.restore_save_manifest(manager.create_save_manifest()),
|
|
"A Jajce manifest should be rejected by a different active adapter scope",
|
|
)
|
|
_check(
|
|
rejected_restore_notifications.is_empty(),
|
|
"A rejected combined restore and its rollback must publish no success notifications",
|
|
)
|
|
_check(
|
|
(
|
|
custom_manager.get_state_checksum() == custom_before_rejected_local
|
|
and custom_manager.get_regional_state_checksum() == custom_before_rejected_regional
|
|
),
|
|
"A rejected combined restore should roll back both custom-scope authorities",
|
|
)
|
|
_check(
|
|
custom_manager._regional_simulation.depart_jajce_delivery(1.0),
|
|
"The fail-closed fixture should create otherwise unreachable live regional state",
|
|
)
|
|
var custom_live_regional_checksum: String = custom_manager.get_regional_state_checksum()
|
|
_check(
|
|
not custom_manager.can_serialize_local_state_only(),
|
|
"Live regional state must not be treated as reconstructible local-only authority",
|
|
)
|
|
_check(
|
|
not store.save(custom_manager, "custom_live"),
|
|
"SaveSlotStore must reject a local-only save that would discard live regional state",
|
|
)
|
|
_check(
|
|
custom_manager.get_regional_state_checksum() == custom_live_regional_checksum,
|
|
"Rejecting the lossy custom-scope save must not mutate regional authority",
|
|
)
|
|
store.delete_slot(custom_slot)
|
|
|
|
var unchanged_world_checksum: String = manager.get_world_state_checksum()
|
|
var rejected_manifest: Dictionary = manager.create_save_manifest().to_dictionary()
|
|
rejected_manifest["regional_state"]["active_location_id"] = String(
|
|
SimulationIds.REGIONAL_LOCATION_TRAVNIK
|
|
)
|
|
rejected_manifest["regional_checksum"] = SimulationSaveManifest._checksum_dictionary(
|
|
rejected_manifest["regional_state"]
|
|
)
|
|
var file := FileAccess.open(final_path, FileAccess.WRITE)
|
|
_check(file != null, "Test should open the primary save for corruption")
|
|
if file != null:
|
|
file.store_string(JSON.stringify(rejected_manifest))
|
|
file.close()
|
|
_check(not store.load_into(manager), "Invalid regional manifest data should be rejected")
|
|
_check(
|
|
manager.get_world_state_checksum() == unchanged_world_checksum,
|
|
"A rejected save must not partially mutate either authority",
|
|
)
|
|
_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)
|