Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
698 lines
30 KiB
GDScript
698 lines
30 KiB
GDScript
extends SceneTree
|
|
|
|
const TEST_PREFIX := "user://saves/s6_runtime_lifecycle"
|
|
const PRODUCTION_AUTOSAVE_PATH := "user://saves/autosave"
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
var _clock: Variant
|
|
var _state: Variant
|
|
var _runtime: Variant
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S6: runtime save lifecycle and recovery safety")
|
|
await _test_autoload_isolation_and_main_opt_in()
|
|
_test_disabled_policy_and_path_timing()
|
|
_test_queued_checkpoint_at_tick_boundary()
|
|
_test_daily_checkpoint_dedup_generations_and_failure_preservation()
|
|
_test_exact_round_trip_recovery_pause_and_performance()
|
|
_test_domain_invalid_formal_falls_back_to_backup()
|
|
_test_multiple_domain_invalid_candidates_fall_back_to_autosave()
|
|
_test_all_domain_invalid_candidates_preserve_complete_live_state()
|
|
_test_content_filter_cannot_be_weakened()
|
|
_test_whitelisted_recovery_repairs()
|
|
_test_non_whitelisted_corruption_is_rejected_atomically()
|
|
_cleanup_runtime()
|
|
_cleanup_test_saves()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_autoload_isolation_and_main_opt_in() -> void:
|
|
var app_clock := root.get_node("GameClock")
|
|
var app_state := root.get_node("GameState")
|
|
var app_runtime := root.get_node("SimulationRuntime")
|
|
app_runtime.configure_save_policy({&"enabled": false})
|
|
var default_generation := SaveService.get_autosave_path(
|
|
PRODUCTION_AUTOSAVE_PATH,
|
|
0
|
|
)
|
|
var before := _package_fingerprint(default_generation)
|
|
app_clock.reset(2, 5, 59)
|
|
app_state.reset()
|
|
app_runtime.initialize(app_clock, app_state, 60_001)
|
|
_expect(not app_runtime.get_save_policy()[&"enabled"], "autoload test runtime starts with disk autosave disabled")
|
|
app_clock.advance_one_minute()
|
|
_expect(_package_fingerprint(default_generation) == before, "disabled autoload reaches 06:00 without touching the production autosave")
|
|
|
|
var main := load("res://scenes/main/main.tscn").instantiate() as Control
|
|
root.add_child(main)
|
|
await process_frame
|
|
_expect(
|
|
app_runtime.get_save_policy()[&"enabled"]
|
|
and app_runtime.get_save_policy()[&"autosave_path"] == PRODUCTION_AUTOSAVE_PATH,
|
|
"playable main scene explicitly opts into the production autosave policy"
|
|
)
|
|
app_runtime.configure_save_policy({&"enabled": false})
|
|
main.queue_free()
|
|
await process_frame
|
|
|
|
|
|
func _test_disabled_policy_and_path_timing() -> void:
|
|
_reset_runtime(60_002, 1, 5, 59)
|
|
var path := TEST_PREFIX + "_disabled"
|
|
SaveService.delete_save(path)
|
|
_expect(
|
|
not _runtime.get_save_policy()[&"enabled"]
|
|
and _runtime.get_save_policy()[&"autosave_path"] == PRODUCTION_AUTOSAVE_PATH,
|
|
"direct Runtime instances remain isolated until explicitly enabled"
|
|
)
|
|
_clock.advance_one_minute()
|
|
_expect(
|
|
not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(SaveService.get_autosave_path(path, 0))),
|
|
"disabled policy writes no implicit autosave"
|
|
)
|
|
var before_count := int(
|
|
_clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"]
|
|
)
|
|
_runtime.path_service.find_path(
|
|
Vector3i(-2, 0, WorldService.LAYER_FRONT),
|
|
Vector3i(2, 0, WorldService.LAYER_BACK),
|
|
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK],
|
|
[&"staff"]
|
|
)
|
|
var after_count := int(
|
|
_clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"]
|
|
)
|
|
_expect(after_count == before_count + 1, "one public path query records one Runtime performance sample")
|
|
var snapshot = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(snapshot), "Runtime restores before reconnecting the Path timing boundary")
|
|
before_count = int(_clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"])
|
|
_runtime.path_service.find_path(Vector3i(-1, 0, 0), Vector3i(1, 0, 0))
|
|
after_count = int(_clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"])
|
|
_expect(after_count == before_count + 1, "restore reconnects Path timing without duplicate signal delivery")
|
|
|
|
|
|
func _test_queued_checkpoint_at_tick_boundary() -> void:
|
|
_reset_runtime(60_003)
|
|
var path := TEST_PREFIX + "_queued"
|
|
SaveService.delete_save(path)
|
|
var queued = _runtime.queue_manual_checkpoint(path, {&"display_name": "Queued Runtime"})
|
|
_expect(queued[&"ok"] and _runtime.get_pending_checkpoint_count() == 1, "manual checkpoint is queued for the next fixed tick boundary")
|
|
_expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(path)), "queued checkpoint does not write before its boundary")
|
|
_clock.advance_one_minute()
|
|
var loaded := SaveService.load_snapshot(path)
|
|
_expect(loaded[&"ok"] and _runtime.get_pending_checkpoint_count() == 0, "queued checkpoint writes exactly once at PUBLISH_SNAPSHOT")
|
|
_expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == 361, "queued package captures the completed minute boundary")
|
|
_expect(_has_domain_event(&"SAVE_CHECKPOINT_SUCCEEDED"), "successful manual checkpoint publishes a DomainEvent")
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_daily_checkpoint_dedup_generations_and_failure_preservation() -> void:
|
|
_reset_runtime(60_004, 1, 5, 59)
|
|
var path := TEST_PREFIX + "_daily"
|
|
SaveService.delete_save(path)
|
|
var configured = _runtime.configure_save_policy({
|
|
&"enabled": true,
|
|
&"daily_checkpoint_enabled": true,
|
|
&"autosave_path": path,
|
|
&"autosave_generations": 3,
|
|
})
|
|
_expect(configured[&"ok"], "daily Runtime save policy accepts a safe explicit slot")
|
|
_clock.advance_one_minute()
|
|
_expect(_runtime.get_last_daily_checkpoint_day() == 1, "day 1 checkpoint runs at exactly 06:00")
|
|
var generation_zero := SaveService.load_snapshot(SaveService.get_autosave_path(path, 0))
|
|
_expect(generation_zero[&"ok"] and int(generation_zero[&"snapshot"][&"clock"][&"absolute_minute"]) == 360, "first daily checkpoint becomes autosave generation zero")
|
|
_runtime.call("_process_save_checkpoints", 360)
|
|
_expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(SaveService.get_autosave_path(path, 1))), "same-day 06:00 callback is deduplicated")
|
|
|
|
_clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY)
|
|
_clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY)
|
|
var expected_minutes := [3240, 1800, 360]
|
|
var all_generations_match := true
|
|
for generation in range(3):
|
|
var loaded := SaveService.load_snapshot(SaveService.get_autosave_path(path, generation))
|
|
all_generations_match = all_generations_match \
|
|
and loaded[&"ok"] \
|
|
and int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_minutes[generation]
|
|
_expect(all_generations_match, "daily checkpoints rotate three deterministic newest-first generations")
|
|
|
|
var newest_world := ProjectSettings.globalize_path(
|
|
SaveService.get_autosave_path(path, 0)
|
|
).path_join(SaveService.WORLD_FILE)
|
|
var newest_before := FileAccess.get_md5(newest_world)
|
|
var pending_path := ProjectSettings.globalize_path(path + ".autosave.pending")
|
|
DirAccess.make_dir_recursive_absolute(pending_path)
|
|
var sentinel := FileAccess.open(pending_path.path_join("not_a_package.txt"), FileAccess.WRITE)
|
|
sentinel.store_string("block promotion")
|
|
sentinel = null
|
|
_clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY)
|
|
_expect(
|
|
not _runtime.latest_save_operation_result[&"ok"]
|
|
and _runtime.get_last_daily_checkpoint_day() == 3,
|
|
"failed day 4 checkpoint remains eligible for retry instead of being marked complete"
|
|
)
|
|
_expect(FileAccess.get_md5(newest_world) == newest_before, "failed autosave leaves the previous newest generation byte-identical")
|
|
_expect(_has_domain_event(&"AUTOSAVE_CHECKPOINT_FAILED"), "autosave failure publishes a structured DomainEvent")
|
|
DirAccess.remove_absolute(pending_path.path_join("not_a_package.txt"))
|
|
DirAccess.remove_absolute(pending_path)
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_exact_round_trip_recovery_pause_and_performance() -> void:
|
|
_reset_runtime(60_005, 3, 7, 15)
|
|
var path := TEST_PREFIX + "_round_trip"
|
|
SaveService.delete_save(path)
|
|
_clock.set_speed(3)
|
|
var expected_snapshot = _runtime.create_snapshot()
|
|
var expected_digest := SimulationDigest.compute(expected_snapshot)
|
|
var first_save = _runtime.save_to_path(path, {&"display_name": "Runtime Exact"})
|
|
_expect(first_save[&"ok"] and not _clock.paused, "save pauses I/O temporarily and restores the running clock")
|
|
_state.add_resource(&"faith", 77)
|
|
_clock.advance_minutes(5)
|
|
var loaded = _runtime.load_from_path(path)
|
|
_expect(loaded[&"ok"] and _runtime.latest_digest == expected_digest, "ordinary Runtime load restores the exact persisted snapshot")
|
|
_expect(_clock.absolute_minute == int(expected_snapshot[&"clock"][&"absolute_minute"]) and not _clock.paused, "load preserves the saved clock minute and pause state")
|
|
var minute_before_process = _clock.absolute_minute
|
|
_expect(_clock.process_real_delta(10.0) == 0 and _clock.absolute_minute == minute_before_process, "first process frame after load never advances an extra minute")
|
|
|
|
var second_save = _runtime.save_to_path(path, {&"display_name": "Runtime New Formal"})
|
|
_expect(second_save[&"ok"], "second formal Runtime save creates a verified prior-generation backup")
|
|
_corrupt_world(path)
|
|
_state.add_resource(&"faith", 5)
|
|
var inventory = _runtime.inspect_recovery_candidates(path)
|
|
_expect(inventory[&"ok"] and inventory[&"selected"][&"id"] == &"backup", "explicit candidate inventory identifies the verified backup")
|
|
var recovered = _runtime.load_with_recovery(path)
|
|
_expect(recovered[&"ok"] and recovered[&"selected"][&"id"] == &"backup", "Runtime recovery skips a corrupt formal package")
|
|
_expect(_runtime.latest_digest == expected_digest, "recovery loads the exact prior formal snapshot without promotion")
|
|
_expect(recovered[&"recovery_report"][&"restore_committed"] and _has_domain_event(&"SAVE_RECOVERY_LOADED"), "successful recovery returns a structured report and publishes a DomainEvent")
|
|
var explicit = _runtime.load_recovery_candidate(path, &"backup")
|
|
_expect(explicit[&"ok"] and explicit[&"read_only"], "caller can explicitly load one named recovery candidate")
|
|
_expect(int(_clock.performance_budget.get_report(&"save")[&"lifetime_sample_count"]) >= 2, "Runtime records actual save operation timing")
|
|
_expect(int(_clock.performance_budget.get_report(&"load")[&"lifetime_sample_count"]) >= 3, "Runtime records load, inventory and recovery timing")
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_domain_invalid_formal_falls_back_to_backup() -> void:
|
|
_reset_runtime(60_009, 2, 8, 0)
|
|
var path := TEST_PREFIX + "_domain_backup"
|
|
SaveService.delete_save(path)
|
|
var backup_snapshot: Dictionary = _runtime.create_snapshot()
|
|
var backup_digest := SimulationDigest.compute(backup_snapshot)
|
|
_expect(
|
|
SaveService.save_snapshot(path, backup_snapshot)[&"ok"],
|
|
"domain fallback fixture writes a valid prior formal package"
|
|
)
|
|
var invalid_formal: Dictionary = backup_snapshot.duplicate(true)
|
|
invalid_formal[&"game_state"][&"resources"][&"gold"] = int(
|
|
invalid_formal[&"game_state"][&"resources"][&"gold"]
|
|
) + 1
|
|
_expect(
|
|
SaveService.save_snapshot(path, invalid_formal)[&"ok"],
|
|
"checksum-valid but ledger-invalid formal package is preserved as a package"
|
|
)
|
|
_state.add_resource(&"faith", 17)
|
|
var strict_guard := _authoritative_live_guard()
|
|
var strict_load: Dictionary = _runtime.load_from_path(path)
|
|
_expect(
|
|
not strict_load[&"ok"] and strict_load[&"code"] == &"RESTORE_FAILED",
|
|
"ordinary load remains strict and does not fall through to a valid backup"
|
|
)
|
|
_expect(
|
|
_authoritative_live_guard() == strict_guard,
|
|
"strict ordinary-load rejection preserves authoritative gameplay state"
|
|
)
|
|
var explicit_formal: Dictionary = _runtime.load_recovery_candidate(path, &"formal")
|
|
_expect(
|
|
not explicit_formal[&"ok"] and explicit_formal[&"code"] == &"RESTORE_FAILED",
|
|
"explicit candidate load remains strict to the requested domain-invalid package"
|
|
)
|
|
var recovered: Dictionary = _runtime.load_with_recovery(path)
|
|
_expect(
|
|
recovered[&"ok"] and recovered[&"selected"][&"id"] == &"backup",
|
|
"Runtime rejects a domain-invalid formal package and automatically selects its valid backup"
|
|
)
|
|
_expect(
|
|
recovered[&"candidates"][0][&"package_status"] == &"VALID"
|
|
and recovered[&"candidates"][0][&"domain_status"] == &"REJECTED"
|
|
and recovered[&"candidates"][0][&"domain_rejection_code"] == &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED"
|
|
and recovered[&"candidates"][0][&"failed_attempt_live_state_preserved"],
|
|
"formal candidate report separates successful package verification from domain rejection"
|
|
)
|
|
_expect(
|
|
recovered[&"candidates"][1][&"domain_status"] == &"VALID"
|
|
and recovered[&"candidates"][1][&"selected"],
|
|
"backup candidate report identifies the first domain-valid committed candidate"
|
|
)
|
|
_expect(
|
|
_runtime.latest_digest == backup_digest,
|
|
"domain fallback commits the exact backup snapshot"
|
|
)
|
|
_expect(
|
|
recovered[&"package_candidate_report"][&"selected"][&"id"] == &"formal"
|
|
and recovered[&"package_recovery_report"][&"source"][&"candidate_id"] == &"backup"
|
|
and recovered[&"restore_safety_report"][&"recovery_mode"],
|
|
"result preserves package-level selection evidence and final Runtime restore-safety evidence"
|
|
)
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_multiple_domain_invalid_candidates_fall_back_to_autosave() -> void:
|
|
_reset_runtime(60_010, 2, 9, 0)
|
|
var path := TEST_PREFIX + "_domain_autosave"
|
|
SaveService.delete_save(path)
|
|
var valid_autosave: Dictionary = _runtime.create_snapshot()
|
|
var valid_digest := SimulationDigest.compute(valid_autosave)
|
|
var invalid_backup: Dictionary = valid_autosave.duplicate(true)
|
|
invalid_backup[&"game_state"][&"resources"][&"gold"] = int(
|
|
invalid_backup[&"game_state"][&"resources"][&"gold"]
|
|
) + 1
|
|
var invalid_formal: Dictionary = valid_autosave.duplicate(true)
|
|
invalid_formal[&"game_state"][&"resources"][&"gold"] = int(
|
|
invalid_formal[&"game_state"][&"resources"][&"gold"]
|
|
) + 2
|
|
_expect(
|
|
SaveService.save_snapshot(path, invalid_backup)[&"ok"]
|
|
and SaveService.save_snapshot(path, invalid_formal)[&"ok"],
|
|
"multiple domain-invalid formal generations remain checksum-valid recovery candidates"
|
|
)
|
|
_expect(
|
|
SaveService.save_autosave(path, valid_autosave, {}, 3)[&"ok"],
|
|
"valid autosave is written behind both invalid formal generations"
|
|
)
|
|
var package_guard := {
|
|
&"formal": _package_fingerprint(path),
|
|
&"backup": _package_fingerprint(path + ".bak"),
|
|
&"autosave": _package_fingerprint(SaveService.get_autosave_path(path, 0)),
|
|
}
|
|
_state.add_resource(&"faith", 23)
|
|
var recovered: Dictionary = _runtime.load_with_recovery(path)
|
|
_expect(
|
|
recovered[&"ok"] and recovered[&"selected"][&"id"] == &"autosave_0",
|
|
"Runtime walks past two domain-invalid candidates to the newest valid autosave"
|
|
)
|
|
_expect(
|
|
recovered[&"candidates"][0][&"domain_status"] == &"REJECTED"
|
|
and recovered[&"candidates"][1][&"domain_status"] == &"REJECTED"
|
|
and recovered[&"candidates"][0][&"failed_attempt_live_state_preserved"]
|
|
and recovered[&"candidates"][1][&"failed_attempt_live_state_preserved"]
|
|
and recovered[&"candidates"][4][&"domain_status"] == &"VALID",
|
|
"candidate attempts retain both ordered domain rejections and the autosave success"
|
|
)
|
|
_expect(
|
|
recovered[&"recovery_report"][&"domain_rejections"].size() == 2
|
|
and recovered[&"recovery_report"][&"selected_candidate_id"] == &"autosave_0",
|
|
"combined recovery report records every rejected domain candidate and final selection"
|
|
)
|
|
_expect(_runtime.latest_digest == valid_digest, "autosave fallback restores the exact valid snapshot")
|
|
_expect(
|
|
package_guard == {
|
|
&"formal": _package_fingerprint(path),
|
|
&"backup": _package_fingerprint(path + ".bak"),
|
|
&"autosave": _package_fingerprint(SaveService.get_autosave_path(path, 0)),
|
|
},
|
|
"domain fallback remains read-only and never promotes or rewrites any package"
|
|
)
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_all_domain_invalid_candidates_preserve_complete_live_state() -> void:
|
|
_reset_runtime(60_011, 2, 10, 0)
|
|
var path := TEST_PREFIX + "_all_domain_invalid"
|
|
SaveService.delete_save(path)
|
|
var base: Dictionary = _runtime.create_snapshot()
|
|
var invalid_backup: Dictionary = base.duplicate(true)
|
|
invalid_backup[&"game_state"][&"resources"][&"gold"] = int(
|
|
invalid_backup[&"game_state"][&"resources"][&"gold"]
|
|
) + 1
|
|
var invalid_formal: Dictionary = base.duplicate(true)
|
|
invalid_formal[&"game_state"][&"resources"][&"gold"] = int(
|
|
invalid_formal[&"game_state"][&"resources"][&"gold"]
|
|
) + 2
|
|
var invalid_autosave: Dictionary = base.duplicate(true)
|
|
invalid_autosave[&"game_state"][&"resources"][&"gold"] = int(
|
|
invalid_autosave[&"game_state"][&"resources"][&"gold"]
|
|
) + 3
|
|
_expect(
|
|
SaveService.save_snapshot(path, invalid_backup)[&"ok"]
|
|
and SaveService.save_snapshot(path, invalid_formal)[&"ok"]
|
|
and SaveService.save_autosave(path, invalid_autosave, {}, 3)[&"ok"],
|
|
"all-invalid fixture exposes three checksum-valid but domain-invalid candidates"
|
|
)
|
|
_state.add_resource(&"faith", 31)
|
|
_runtime.event_service.configure_content_filter(
|
|
EventDefinition.FilterBehavior.DISABLE,
|
|
EventDefinition.ContentRating.GENERAL,
|
|
[&"local_atomic_filter"]
|
|
)
|
|
_clock.set_speed(3)
|
|
_clock.set_paused(true)
|
|
_expect(
|
|
_runtime.queue_manual_checkpoint(TEST_PREFIX + "_never_written")[&"ok"],
|
|
"all-invalid atomicity fixture includes a pending Runtime checkpoint"
|
|
)
|
|
var live_guard := _complete_live_guard()
|
|
var rejected: Dictionary = _runtime.load_with_recovery(path)
|
|
_expect(
|
|
not rejected[&"ok"]
|
|
and rejected[&"code"] == &"RESTORE_FAILED"
|
|
and rejected[&"recovery_report"][&"rejection_reason"] == &"NO_DOMAIN_VALID_RECOVERY_CANDIDATE",
|
|
"all domain-invalid candidates return one explicit terminal recovery failure"
|
|
)
|
|
_expect(
|
|
rejected[&"recovery_report"][&"domain_rejections"].size() == 3
|
|
and rejected[&"selected"].is_empty(),
|
|
"terminal failure reports all three domain rejections and no selected candidate"
|
|
)
|
|
_expect(
|
|
_complete_live_guard() == live_guard and rejected[&"live_state_preserved"],
|
|
"all-candidate failure preserves the complete live snapshot, filter, pause, policy, and pending checkpoint"
|
|
)
|
|
_expect(
|
|
rejected[&"restore_safety_report"] == rejected[&"recovery_report"]
|
|
and rejected[&"package_candidate_report"].has(&"candidates"),
|
|
"terminal failure preserves both package inventory and Runtime restore-safety reports"
|
|
)
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_content_filter_cannot_be_weakened() -> void:
|
|
_reset_runtime(60_006)
|
|
var weak_path := TEST_PREFIX + "_weak_filter"
|
|
var exact_path := TEST_PREFIX + "_exact_filter"
|
|
SaveService.delete_save(weak_path)
|
|
SaveService.delete_save(exact_path)
|
|
_runtime.event_service.configure_content_filter(
|
|
EventDefinition.FilterBehavior.NONE,
|
|
EventDefinition.ContentRating.RESTRICTED,
|
|
[&"saved_tag"]
|
|
)
|
|
_expect(_runtime.save_to_path(weak_path)[&"ok"], "weaker saved content profile fixture is valid")
|
|
_runtime.event_service.configure_content_filter(
|
|
EventDefinition.FilterBehavior.DISABLE,
|
|
EventDefinition.ContentRating.GENERAL,
|
|
[&"local_tag"]
|
|
)
|
|
var loaded = _runtime.load_from_path(weak_path)
|
|
var profile = _runtime.event_service.get_content_filter_profile()
|
|
_expect(
|
|
loaded[&"ok"]
|
|
and profile[&"filter_mode"] == EventDefinition.FilterBehavior.DISABLE
|
|
and profile[&"maximum_content_rating"] == EventDefinition.ContentRating.GENERAL,
|
|
"loading a weaker save cannot reduce behavior severity or raise the rating ceiling"
|
|
)
|
|
_expect(
|
|
profile[&"disabled_content_tags"] == [&"local_tag", &"saved_tag"],
|
|
"load safety unions local and saved disabled content tags"
|
|
)
|
|
var filter_report: Dictionary = loaded[&"restore_safety_report"][&"content_filter"]
|
|
_expect(filter_report[&"saved_profile_was_weaker"] and filter_report[&"strict_profile_preserved"], "load result explains that the stricter local filter won")
|
|
|
|
var exact_snapshot = _runtime.create_snapshot()
|
|
var exact_digest := SimulationDigest.compute(exact_snapshot)
|
|
_expect(_runtime.save_to_path(exact_path)[&"ok"], "effective strict profile saves normally")
|
|
_runtime.event_service.configure_content_filter(
|
|
EventDefinition.FilterBehavior.NONE,
|
|
EventDefinition.ContentRating.RESTRICTED,
|
|
[]
|
|
)
|
|
var exact_loaded = _runtime.load_from_path(exact_path)
|
|
_expect(exact_loaded[&"ok"] and _runtime.latest_digest == exact_digest, "ordinary content-profile round trip remains snapshot-exact")
|
|
SaveService.delete_save(weak_path)
|
|
SaveService.delete_save(exact_path)
|
|
|
|
|
|
func _test_whitelisted_recovery_repairs() -> void:
|
|
_reset_runtime(60_007)
|
|
var path := TEST_PREFIX + "_allowed_repairs"
|
|
SaveService.delete_save(path)
|
|
var snapshot = _runtime.create_snapshot()
|
|
var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"]
|
|
var staff := StaffRuntime.new().configure(&"repair_staff", &"worker", {
|
|
&"state": StaffRuntime.State.MOVING_TO_TASK,
|
|
&"current_task_id": &"repair_task",
|
|
})
|
|
var task := StaffTask.new().configure(&"repair_task", &"cleanup", 50, _clock.absolute_minute, {
|
|
&"state": StaffTask.State.RESERVED,
|
|
&"assigned_staff_id": &"missing_staff",
|
|
})
|
|
runtime_data[&"staff"][&"staff"].append(staff.create_snapshot())
|
|
runtime_data[&"staff"][&"tasks"].append(task.create_snapshot())
|
|
_append_staff_path_crosslink(runtime_data, &"repair_staff", Vector3i.ZERO)
|
|
var terminal := EventInstance.new().configure(
|
|
&"terminal_foreground",
|
|
&"relationship.threshold",
|
|
&"relationship.threshold",
|
|
[&"player"],
|
|
100,
|
|
_clock.absolute_minute,
|
|
-1,
|
|
true,
|
|
&""
|
|
)
|
|
terminal.state = EventInstance.State.COMPLETED
|
|
runtime_data[&"events"][&"instances"].append(terminal.create_snapshot())
|
|
runtime_data[&"events"][&"foreground_instance_id"] = "terminal_foreground"
|
|
runtime_data[&"events"][&"actor_locks"] = {"player": "terminal_foreground"}
|
|
_expect(SaveService.save_snapshot(path, snapshot)[&"ok"], "checksum-valid orphan repair fixture writes as a package")
|
|
var recovered = _runtime.load_with_recovery(path)
|
|
_expect(recovered[&"ok"], "recovery accepts only the whitelisted Task and Event Lock damage")
|
|
var repaired_task = _runtime.staff_service.get_task(&"repair_task")
|
|
var repaired_staff = _runtime.staff_service.get_staff(&"repair_staff")
|
|
_expect(
|
|
repaired_task != null and repaired_task.state == StaffTask.State.OPEN and repaired_task.assigned_staff_id.is_empty()
|
|
and repaired_staff != null and repaired_staff.current_task_id.is_empty(),
|
|
"StaffService audit reopens the orphan Task and releases its stale Staff pointer"
|
|
)
|
|
_expect(_runtime.event_service.get_actor_locks().is_empty() and _runtime.event_service.get_foreground_instance() == null, "terminal foreground recovery releases only its stale Event Locks")
|
|
var report: Dictionary = recovered[&"recovery_report"]
|
|
_expect(
|
|
report[&"repairs"][&"orphan_tasks"][&"reopened_task_ids"] == [&"repair_task"]
|
|
and report[&"repairs"][&"orphan_tasks"][&"released_staff_ids"] == [&"repair_staff"],
|
|
"repair report names every repaired Task and Staff exactly"
|
|
)
|
|
_expect(
|
|
report[&"repairs"][&"orphan_event_locks"][&"released_actor_ids"] == [&"player"]
|
|
and report[&"repairs"][&"orphan_event_locks"][&"released_foreground_instance_id"] == &"terminal_foreground",
|
|
"repair report names the released Actor Lock and terminal foreground exactly"
|
|
)
|
|
_expect(report[&"automatic_repairs_applied"] and not report[&"forbidden_repairs_applied"], "recovery report confirms its authoritative whitelist boundary")
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _test_non_whitelisted_corruption_is_rejected_atomically() -> void:
|
|
_reset_runtime(60_008)
|
|
var built = _runtime.submit_command(
|
|
&"build_facility",
|
|
{&"definition_id": &"reception", &"origin": Vector3i(-6, 0, 0), &"rotation": 0},
|
|
&"player", &"", 100, -1, &"s6.invalid.fixture.facility"
|
|
)
|
|
_expect(built.is_success(), "invalid recovery fixtures start from a valid constructed facility")
|
|
_clock.advance_minutes(120)
|
|
var base = _runtime.create_snapshot()
|
|
|
|
var missing_facility = base.duplicate(true)
|
|
missing_facility[&"systems"][&"runtime"][&"facilities"][&"instances"] = []
|
|
_test_rejected_candidate(
|
|
TEST_PREFIX + "_reject_missing_facility",
|
|
missing_facility,
|
|
"recovery never deletes or invents a missing operational facility"
|
|
)
|
|
|
|
var ledger_mutation = base.duplicate(true)
|
|
ledger_mutation[&"game_state"][&"resources"][&"gold"] = int(
|
|
ledger_mutation[&"game_state"][&"resources"][&"gold"]
|
|
) + 1
|
|
_test_rejected_candidate(
|
|
TEST_PREFIX + "_reject_ledger",
|
|
ledger_mutation,
|
|
"recovery never synthesizes Gold or rewrites the ledger projection"
|
|
)
|
|
|
|
var route_mismatch = base.duplicate(true)
|
|
var invalid_relationship := RelationshipState.new().configure(
|
|
&"invalid_route",
|
|
&"actor_a",
|
|
&"actor_b",
|
|
{
|
|
&"route_lock": RelationshipState.RouteLock.EXCLUSIVE_LOCKED,
|
|
&"route_id": &"",
|
|
}
|
|
)
|
|
route_mismatch[&"systems"][&"runtime"][&"relationships"][&"relationships"].append(
|
|
invalid_relationship.create_snapshot()
|
|
)
|
|
_test_rejected_candidate(
|
|
TEST_PREFIX + "_reject_route",
|
|
route_mismatch,
|
|
"recovery never guesses an exclusive Relationship route lock"
|
|
)
|
|
|
|
var unreleasable = base.duplicate(true)
|
|
var bad_staff := StaffRuntime.new().configure(&"blocked_staff", &"worker", {
|
|
&"state": StaffRuntime.State.MOVING_TO_TASK,
|
|
&"current_task_id": &"blocked_task",
|
|
})
|
|
var bad_task := StaffTask.new().configure(&"blocked_task", &"cleanup", 50, _clock.absolute_minute, {
|
|
&"state": StaffTask.State.RESERVED,
|
|
&"assigned_staff_id": &"missing_staff",
|
|
&"reservation_ids": [&"missing_reservation"],
|
|
})
|
|
unreleasable[&"systems"][&"runtime"][&"staff"][&"staff"].append(bad_staff.create_snapshot())
|
|
unreleasable[&"systems"][&"runtime"][&"staff"][&"tasks"].append(bad_task.create_snapshot())
|
|
_append_staff_path_crosslink(
|
|
unreleasable[&"systems"][&"runtime"],
|
|
&"blocked_staff",
|
|
Vector3i.ZERO
|
|
)
|
|
_test_rejected_candidate(
|
|
TEST_PREFIX + "_reject_reservation",
|
|
unreleasable,
|
|
"recovery rejects an orphan Task whose inventory Reservation cannot be released"
|
|
)
|
|
|
|
|
|
func _test_rejected_candidate(path: String, snapshot: Dictionary, label: String) -> void:
|
|
SaveService.delete_save(path)
|
|
_expect(SaveService.save_snapshot(path, snapshot)[&"ok"], "%s fixture is checksum-valid" % label)
|
|
var guard := _authoritative_live_guard()
|
|
var result = _runtime.load_with_recovery(path)
|
|
_expect(not result[&"ok"] and result[&"code"] == &"RESTORE_FAILED", label)
|
|
_expect(_authoritative_live_guard() == guard, "%s and leaves authoritative live state unchanged" % label)
|
|
_expect(
|
|
result[&"recovery_report"][&"restore_committed"] == false
|
|
and not result[&"recovery_report"][&"forbidden_repairs_applied"],
|
|
"%s reports atomic rejection without forbidden repair" % label
|
|
)
|
|
SaveService.delete_save(path)
|
|
|
|
|
|
func _append_staff_path_crosslink(
|
|
runtime_data: Dictionary,
|
|
staff_id: StringName,
|
|
cell: Vector3i
|
|
) -> void:
|
|
var actor_id := StringName("staff:%s" % staff_id)
|
|
runtime_data[&"path"][&"actor_cells"].append({
|
|
&"actor_id": actor_id,
|
|
&"cell": cell,
|
|
})
|
|
runtime_data[&"path"][&"actor_progress"].append({
|
|
&"actor_id": actor_id,
|
|
&"last_cell": cell,
|
|
&"last_progress_at": _clock.absolute_minute,
|
|
&"last_observed_at": _clock.absolute_minute,
|
|
&"stuck_reported_at": -1,
|
|
})
|
|
runtime_data[&"movement_credits"].append({
|
|
&"actor_id": String(actor_id),
|
|
&"credit": 0,
|
|
&"updated_at": _clock.absolute_minute,
|
|
})
|
|
for traffic: Dictionary in runtime_data[&"path"][&"cell_traffic"]:
|
|
if traffic[&"cell"] == cell:
|
|
traffic[&"current_density"] = int(traffic[&"current_density"]) + 1
|
|
break
|
|
|
|
|
|
func _authoritative_live_guard() -> Dictionary:
|
|
return {
|
|
&"clock": _clock.clock_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"economy": _runtime.economy_service.create_snapshot(),
|
|
&"inventory": _runtime.inventory_service.create_snapshot(),
|
|
&"facilities": _runtime.facility_service.create_snapshot(),
|
|
&"construction": _runtime.construction_service.create_snapshot(),
|
|
&"staff": _runtime.staff_service.create_snapshot(),
|
|
&"relationships": _runtime.relationship_service.create_snapshot(),
|
|
&"events": _runtime.event_service.create_snapshot(),
|
|
}
|
|
|
|
|
|
func _complete_live_guard() -> Dictionary:
|
|
return {
|
|
&"snapshot": _runtime.create_snapshot(),
|
|
&"clock_paused": _clock.paused,
|
|
&"save_policy": _runtime.get_save_policy(),
|
|
&"pending_checkpoint_count": _runtime.get_pending_checkpoint_count(),
|
|
&"last_daily_checkpoint_day": _runtime.get_last_daily_checkpoint_day(),
|
|
&"content_filter": _runtime.event_service.get_content_filter_profile(),
|
|
}
|
|
|
|
|
|
func _reset_runtime(seed: int, day := 1, hour := 6, minute := 0) -> void:
|
|
_cleanup_runtime()
|
|
_clock = load("res://autoload/game_clock.gd").new()
|
|
_state = load("res://autoload/game_state.gd").new()
|
|
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
|
_clock.reset(day, hour, minute)
|
|
_state.reset()
|
|
var report = _runtime.initialize(_clock, _state, seed)
|
|
_expect(report[&"valid"], "Runtime fixture initializes deterministic definitions")
|
|
|
|
|
|
func _cleanup_runtime() -> void:
|
|
if _runtime != null:
|
|
_runtime.free()
|
|
if _clock != null:
|
|
_clock.free()
|
|
if _state != null:
|
|
_state.free()
|
|
_runtime = null
|
|
_clock = null
|
|
_state = null
|
|
|
|
|
|
func _cleanup_test_saves() -> void:
|
|
for suffix: String in [
|
|
"_disabled", "_queued", "_daily", "_round_trip", "_weak_filter",
|
|
"_exact_filter", "_allowed_repairs", "_reject_missing_facility",
|
|
"_reject_ledger", "_reject_route", "_reject_reservation",
|
|
"_domain_backup", "_domain_autosave", "_all_domain_invalid",
|
|
"_never_written",
|
|
]:
|
|
SaveService.delete_save(TEST_PREFIX + suffix)
|
|
|
|
|
|
func _has_domain_event(event_type: StringName) -> bool:
|
|
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
|
if event.event_type == event_type:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _package_fingerprint(save_path: String) -> Dictionary:
|
|
var absolute_path := ProjectSettings.globalize_path(save_path)
|
|
var result := {&"directory_exists": DirAccess.dir_exists_absolute(absolute_path)}
|
|
for file_name: String in [SaveService.MANIFEST_FILE, SaveService.WORLD_FILE, SaveService.THUMBNAIL_FILE]:
|
|
var file_path := absolute_path.path_join(file_name)
|
|
result[file_name] = FileAccess.get_md5(file_path) if FileAccess.file_exists(file_path) else ""
|
|
return result
|
|
|
|
|
|
func _corrupt_world(save_path: String) -> void:
|
|
var world_path := ProjectSettings.globalize_path(save_path).path_join(SaveService.WORLD_FILE)
|
|
var bytes := FileAccess.get_file_as_bytes(world_path)
|
|
if bytes.is_empty():
|
|
return
|
|
bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0x5a
|
|
var file := FileAccess.open(world_path, FileAccess.WRITE)
|
|
file.store_buffer(bytes)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|