Add supply auto-purchase and preserve save recovery state
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
extends SceneTree
|
||||
|
||||
# Genuine Runtime command fixture. No direct raid, RNG, ID or event mutation.
|
||||
# JSON/old untyped-wave compatibility is a separate Defense input contract.
|
||||
var checks := 0
|
||||
var failures := 0
|
||||
var runtime: Variant
|
||||
var clock: Variant
|
||||
var state: Variant
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func expect(ok: bool, label: String) -> void:
|
||||
checks += 1
|
||||
if not ok:
|
||||
failures += 1
|
||||
push_error("Runtime snapshot serialization: " + label)
|
||||
|
||||
func run() -> void:
|
||||
runtime = load("res://autoload/simulation_runtime.gd").new()
|
||||
clock = load("res://autoload/game_clock.gd").new()
|
||||
state = load("res://autoload/game_state.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
var initialized: Dictionary = runtime.initialize(clock, state, 4260214)
|
||||
clock.set_paused(true)
|
||||
expect(initialized.get(&"valid", false), "current Runtime initializes")
|
||||
if not initialized.get(&"valid", false):
|
||||
finish()
|
||||
return
|
||||
var created: CommandResult = runtime.submit_command(&"create_defense_raid", {
|
||||
&"raid_id": &"serialization.raid",
|
||||
&"definition": {
|
||||
&"source_id": &"serialization.disclosed_fixture",
|
||||
&"entry_points": [&"gate.north"],
|
||||
&"objective_ids": [&"serialization.objective"],
|
||||
&"round_minutes": 5,
|
||||
&"max_duration_minutes": 30,
|
||||
&"retreat_policy": &"HOLD",
|
||||
&"defender_retreat_threshold": 0,
|
||||
&"terrain_bonus": 0,
|
||||
&"command_bonus": 0,
|
||||
&"surprise_penalty": 0,
|
||||
&"multiple_entry_penalty": 0,
|
||||
&"waves": [{&"wave_id": &"serialization.wave", &"threat_strength": 10, &"max_rounds": 6, &"retreat_threshold": 0}],
|
||||
},
|
||||
})
|
||||
expect(created.is_success(), "ordinary Runtime command creates genuine raid and wave")
|
||||
if not created.is_success():
|
||||
finish()
|
||||
return
|
||||
var expected: Dictionary = runtime.create_snapshot()
|
||||
var data := runtime_data(expected)
|
||||
expect(data[&"runtime_schema_version"] == runtime.RUNTIME_SNAPSHOT_VERSION, "snapshot is the actual current schema")
|
||||
var waves: Array = saved_waves(expected)
|
||||
expect(waves.is_typed() and waves.get_typed_builtin() == TYPE_DICTIONARY, "genuine created waves are Array[Dictionary]")
|
||||
expect(waves.size() == 1 and waves[0][&"wave_id"] == &"serialization.wave", "ordinary source wave identity and order are explicit")
|
||||
var rng_keys: Array[String] = []
|
||||
var all_rng_keys_are_strings := true
|
||||
for key: Variant in (data[&"rng_streams"] as Dictionary).keys():
|
||||
all_rng_keys_are_strings = all_rng_keys_are_strings and typeof(key) == TYPE_STRING
|
||||
rng_keys.append(String(key))
|
||||
var lexical_keys := rng_keys.duplicate()
|
||||
lexical_keys.sort()
|
||||
expect(all_rng_keys_are_strings and rng_keys == lexical_keys, "RNG snapshot String keys are lexically ordered")
|
||||
var expected_bytes := var_to_bytes(expected)
|
||||
for iteration: int in 3:
|
||||
expect(runtime.restore_snapshot(expected), "same genuine checkpoint restore%d succeeds" % iteration)
|
||||
var after: Dictionary = runtime.create_snapshot()
|
||||
var after_data := runtime_data(after)
|
||||
var after_waves: Array = saved_waves(after)
|
||||
expect(after_waves.is_typed() and after_waves.get_typed_builtin() == TYPE_DICTIONARY, "restore%d retains wave container declaration" % iteration)
|
||||
expect(var_to_bytes(after_waves) == var_to_bytes(waves), "restore%d retains all wave values and element order" % iteration)
|
||||
expect(var_to_bytes(after_data[&"rng_streams"]) == var_to_bytes(data[&"rng_streams"]), "restore%d retains every RNG value and lexical key order" % iteration)
|
||||
expect(var_to_bytes(after_data[&"id_generator"]) == var_to_bytes(data[&"id_generator"]) and var_to_bytes(after_data[&"domain_events"]) == var_to_bytes(data[&"domain_events"]), "restore%d retains all IDs and domain event sequence" % iteration)
|
||||
expect(var_to_bytes(after) == expected_bytes, "restore%d is exact whole typed binary state" % iteration)
|
||||
# Older Defense restore paths exported identical wave values with an untyped
|
||||
# container. Such input stays loadable and re-exports the normal canonical type.
|
||||
var legacy := expected.duplicate(true)
|
||||
var untyped_waves: Array = []
|
||||
for wave: Dictionary in waves:
|
||||
untyped_waves.append(wave.duplicate(true))
|
||||
runtime_data(legacy)[&"defense"][&"raids"][0][&"waves"] = untyped_waves
|
||||
expect(not untyped_waves.is_typed() and var_to_bytes(legacy) != expected_bytes, "old untyped-wave input is an actual typed-byte difference")
|
||||
expect(runtime.restore_snapshot(legacy), "old untyped-wave Runtime snapshot remains accepted")
|
||||
var canonical: Dictionary = runtime.create_snapshot()
|
||||
var canonical_waves: Array = saved_waves(canonical)
|
||||
expect(canonical_waves.is_typed() and canonical_waves.get_typed_builtin() == TYPE_DICTIONARY, "old input exports canonical Dictionary wave type")
|
||||
expect(var_to_bytes(canonical_waves) == var_to_bytes(waves), "old input preserves wave values and element order")
|
||||
expect(var_to_bytes(canonical) == expected_bytes, "old input changes only missing wave declaration and reproduces genuine whole canonical state")
|
||||
var defense_snapshot: Dictionary = runtime.defense_service.create_snapshot()
|
||||
var json_input: Variant = JSON.parse_string(JSON.stringify(defense_snapshot))
|
||||
var json_restored := DefenseService.new()
|
||||
expect(json_input is Dictionary and json_restored.restore_snapshot(json_input), "existing JSON Defense transport remains accepted")
|
||||
var json_output: Dictionary = json_restored.create_snapshot()
|
||||
var json_waves: Array = json_output[&"raids"][0][&"waves"]
|
||||
expect(json_waves.is_typed() and json_waves.get_typed_builtin() == TYPE_DICTIONARY, "JSON waves export canonical Dictionary container type")
|
||||
expect(JSON.stringify(json_output) == JSON.stringify(defense_snapshot), "JSON value/order transport contract is retained separately from typed binary transport")
|
||||
var malformed: Dictionary = defense_snapshot.duplicate(true)
|
||||
malformed[&"raids"][0][&"waves"] = [42]
|
||||
var json_before := var_to_bytes(json_output)
|
||||
expect(not json_restored.restore_snapshot(malformed) and var_to_bytes(json_restored.create_snapshot()) == json_before, "non-Dictionary wave input rejects and preserves every live typed byte")
|
||||
var string_waves: Array[String] = ["malformed wave"]
|
||||
malformed = defense_snapshot.duplicate(true)
|
||||
malformed[&"raids"][0][&"waves"] = string_waves
|
||||
expect(not json_restored.restore_snapshot(malformed) and var_to_bytes(json_restored.create_snapshot()) == json_before, "typed String waves reject without container mutation or type error")
|
||||
finish()
|
||||
|
||||
func runtime_data(snapshot: Dictionary) -> Dictionary:
|
||||
return snapshot[&"systems"][&"runtime"]
|
||||
|
||||
func saved_waves(snapshot: Dictionary) -> Array:
|
||||
return runtime_data(snapshot)[&"defense"][&"raids"][0][&"waves"]
|
||||
|
||||
func finish() -> void:
|
||||
if runtime != null: runtime.free()
|
||||
if clock != null: clock.free()
|
||||
if state != null: state.free()
|
||||
print("Runtime snapshot serialization: %d checks, %d failures" % [checks, failures])
|
||||
quit(failures)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl6brsqwn5lr8
|
||||
@@ -0,0 +1,222 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _prefix := ""
|
||||
var _runtime: Variant
|
||||
var _source: Dictionary
|
||||
|
||||
func _initialize() -> void: call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
_prefix = "user://tests/save_preflight_%d" % OS.get_process_id()
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
clock.reset(1,6,0); state.reset(); _runtime.initialize(clock,state,42602); clock.set_paused(true)
|
||||
_source = _runtime.create_snapshot()
|
||||
_test_read_only_preview_and_recovery()
|
||||
_test_manifest_types_and_versions()
|
||||
_test_resource_limits_and_corruption()
|
||||
_test_runtime_schema_rejection()
|
||||
_test_envelope_rejection_and_legacy_defaults()
|
||||
_measure_fixture_sizes()
|
||||
print("Save preflight: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(_failures)
|
||||
|
||||
func _test_read_only_preview_and_recovery() -> void:
|
||||
var path := _prefix + "/read_only"
|
||||
_expect(SaveService.save_snapshot(path,_source)[&"ok"],"write valid disposable Runtime save")
|
||||
var before := _slot_fingerprint(path)
|
||||
var inspected := SaveService.inspect_snapshot(path)
|
||||
_expect(inspected[&"ok"] and inspected[&"read_only"] and inspected[&"snapshot"] == _source,"inspection returns exact formal snapshot")
|
||||
_expect(_slot_fingerprint(path) == before,"valid inspection leaves every package byte unchanged")
|
||||
_write_marker(path); before = _slot_fingerprint(path)
|
||||
inspected = SaveService.inspect_snapshot(path)
|
||||
_expect(inspected[&"ok"] and _slot_fingerprint(path) == before,"inspection preserves stale marker and formal bytes")
|
||||
var live: Dictionary = _runtime.create_snapshot()
|
||||
var preview: Dictionary = _runtime.preview_facility_dimensions_at_path(path)
|
||||
_expect(preview[&"ok"] and _runtime.create_snapshot() == live,"dimension preview preserves live Runtime")
|
||||
_expect(_slot_fingerprint(path) == before,"dimension preview does not reconcile transaction artifacts")
|
||||
_expect(SaveService.load_snapshot(path)[&"ok"] and not FileAccess.file_exists(ProjectSettings.globalize_path(path) + SaveService.SAVE_TRANSACTION_SUFFIX),"ordinary load retains interrupted-save reconciliation")
|
||||
var absolute := ProjectSettings.globalize_path(path)
|
||||
_expect(SaveService.save_snapshot(path+".tmp",_source)[&"ok"],"write verified interrupted candidate")
|
||||
_expect(DirAccess.rename_absolute(absolute,absolute+".bak") == OK,"hold formal source at crash boundary")
|
||||
_write_marker(path); before = _slot_fingerprint(path)
|
||||
inspected = SaveService.inspect_snapshot(path)
|
||||
_expect(not inspected[&"ok"] and inspected[&"code"] == &"SAVE_NOT_FOUND" and inspected[&"read_only"],"inspection refuses to promote missing formal")
|
||||
preview = _runtime.preview_facility_dimensions_at_path(path)
|
||||
_expect(not preview[&"ok"] and _runtime.create_snapshot() == live,"missing-formal preview refuses without live mutation")
|
||||
_expect(_slot_fingerprint(path) == before,"rejected preview preserves candidate, backup and marker SHA")
|
||||
var recovered := SaveService.load_snapshot(path)
|
||||
_expect(recovered[&"ok"] and recovered[&"interrupted_save_reconciled"] and recovered[&"snapshot"] == _source,"ordinary load still promotes verified candidate")
|
||||
SaveService.delete_save(path)
|
||||
|
||||
func _test_manifest_types_and_versions() -> void:
|
||||
var path := _prefix+"/manifest"
|
||||
_expect(SaveService.save_snapshot(path,_source)[&"ok"],"write manifest fixture")
|
||||
var manifest_path := ProjectSettings.globalize_path(path).path_join(SaveService.MANIFEST_FILE)
|
||||
var original := FileAccess.get_file_as_string(manifest_path)
|
||||
for key: String in ["format_version","uncompressed_size","checksum_world"]:
|
||||
var values: Array = [true,"1",1.5,null]
|
||||
if key == "format_version": values.append_array([-1,4294967296.0])
|
||||
if key == "uncompressed_size": values.append_array([0,-1])
|
||||
if key == "checksum_world": values = [true,123,null,"bad_hash"]
|
||||
for value: Variant in values:
|
||||
var manifest: Dictionary = JSON.parse_string(original)
|
||||
manifest[key] = value
|
||||
_write_text(manifest_path,JSON.stringify(manifest))
|
||||
_assert_read_rejection(path,&"INVALID_MANIFEST","malformed %s %s" % [key,str(value)])
|
||||
var missing: Dictionary = JSON.parse_string(original)
|
||||
missing.erase("format_version"); _write_text(manifest_path,JSON.stringify(missing))
|
||||
_assert_read_rejection(path,&"INVALID_MANIFEST","missing format version")
|
||||
missing = JSON.parse_string(original)
|
||||
missing.erase("uncompressed_size"); _write_text(manifest_path,JSON.stringify(missing))
|
||||
_expect(SaveService.inspect_snapshot(path)[&"ok"],"legacy missing duplicate size retains bounded binary compatibility")
|
||||
_write_text(manifest_path,original)
|
||||
for version: int in [0,2]:
|
||||
var manifest: Dictionary = JSON.parse_string(original)
|
||||
manifest["format_version"] = version; _write_text(manifest_path,JSON.stringify(manifest))
|
||||
var world := FileAccess.open(ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE),FileAccess.READ_WRITE)
|
||||
world.seek(4); world.store_32(version); world = null
|
||||
_assert_read_rejection(path,&"UNSUPPORTED_FORMAT","old or unknown package version %d" % version)
|
||||
var before := _slot_fingerprint(path)
|
||||
var migration := SaveService.migrate_save(path,SaveMigrationRegistry.new())
|
||||
_expect(not migration[&"ok"] and _slot_fingerprint(path) == before,"unregistered old migration or future downgrade preserves source")
|
||||
SaveService.delete_save(path)
|
||||
|
||||
func _test_resource_limits_and_corruption() -> void:
|
||||
var path := _prefix+"/limits"
|
||||
_expect(SaveService.save_snapshot(path,_source)[&"ok"],"write resource boundary fixture")
|
||||
var absolute := ProjectSettings.globalize_path(path)
|
||||
var world_path := absolute.path_join(SaveService.WORLD_FILE)
|
||||
var manifest_path := absolute.path_join(SaveService.MANIFEST_FILE)
|
||||
var original_world := FileAccess.get_file_as_bytes(world_path)
|
||||
var original_manifest := FileAccess.get_file_as_string(manifest_path)
|
||||
for size: int in [0,-1,SaveService.MAX_WORLD_BYTES,SaveService.MAX_WORLD_BYTES+1]:
|
||||
_write_bytes(world_path,original_world)
|
||||
var world := FileAccess.open(world_path,FileAccess.READ_WRITE)
|
||||
world.seek(8); world.store_64(size); world = null
|
||||
# The inclusive limit reaches the independent manifest mismatch before
|
||||
# decompression; over-limit values reject before any expansion allocation.
|
||||
var code: StringName = &"SIZE_MISMATCH" if size == SaveService.MAX_WORLD_BYTES else &"SAVE_SIZE_LIMIT_EXCEEDED"
|
||||
_assert_read_rejection(path,code,"advertised expansion boundary %d" % size)
|
||||
_write_bytes(world_path,original_world)
|
||||
var world := FileAccess.open(world_path,FileAccess.READ_WRITE)
|
||||
world.seek(SaveService.MAX_WORLD_BYTES+16); world.store_8(0); world = null
|
||||
_assert_read_rejection(path,&"SAVE_SIZE_LIMIT_EXCEEDED","oversized compressed file rejected before bulk read")
|
||||
_write_bytes(world_path,original_world)
|
||||
_write_text(manifest_path," ".repeat(SaveService.MAX_MANIFEST_BYTES+1))
|
||||
_assert_read_rejection(path,&"SAVE_SIZE_LIMIT_EXCEEDED","oversized manifest rejected before text read")
|
||||
_write_text(manifest_path,"{invalid JSON")
|
||||
_assert_read_rejection(path,&"INVALID_MANIFEST","malformed JSON")
|
||||
_write_text(manifest_path,original_manifest)
|
||||
var corrupt := original_world.duplicate()
|
||||
corrupt[corrupt.size()-1] = corrupt[corrupt.size()-1] ^ 0xff
|
||||
_write_bytes(world_path,corrupt); _assert_read_rejection(path,&"CHECKSUM_MISMATCH","corrupt world checksum")
|
||||
_write_bytes(world_path,original_world.slice(0,15)); _assert_read_rejection(path,&"INVALID_WORLD_HEADER","truncated binary header")
|
||||
_write_bytes(world_path,original_world)
|
||||
var before := _slot_fingerprint(path)
|
||||
var too_large := SaveService.save_snapshot(path,_source,{&"custom_note":"x".repeat(SaveService.MAX_MANIFEST_BYTES)})
|
||||
_expect(not too_large[&"ok"] and too_large[&"code"] == &"SAVE_SIZE_LIMIT_EXCEEDED","writer refuses manifest beyond reader resource limit")
|
||||
_expect(_slot_fingerprint(path) == before,"oversized save preserves formal and backup SHA")
|
||||
SaveService.delete_save(path)
|
||||
|
||||
func _test_runtime_schema_rejection() -> void:
|
||||
for mutation: String in ["unknown","missing","string","extra"]:
|
||||
var candidate := _source.duplicate(true)
|
||||
var data: Dictionary = candidate[&"systems"][&"runtime"]
|
||||
match mutation:
|
||||
"unknown": data[&"runtime_schema_version"] = 999
|
||||
"missing": data.erase(&"facilities")
|
||||
"string": data[&"runtime_schema_version"] = "13"
|
||||
"extra": data[&"unknown_feature"] = {}
|
||||
var original := candidate.duplicate(true)
|
||||
var live: Dictionary = _runtime.create_snapshot()
|
||||
_expect(not _runtime.restore_snapshot(candidate),"Runtime rejects %s current-schema payload" % mutation)
|
||||
_expect(candidate == original and _runtime.create_snapshot() == live,"Runtime rejection preserves source and live state for %s" % mutation)
|
||||
|
||||
func _measure_fixture_sizes() -> void:
|
||||
var raw := var_to_bytes(_source)
|
||||
var world := WorldService.new()
|
||||
var harness := preload("res://tests/performance/perf_mvp_001_harness.gd").new()
|
||||
var cells: Array[Vector3i] = harness._populate_world(world,Vector2i(200,50))
|
||||
var world_raw := var_to_bytes(world.create_snapshot())
|
||||
_expect(cells.size() == 20000,"resource sizing uses existing PERF 20000-cell world fixture")
|
||||
print("SAVE_RESOURCE_SIZE_REPORT=%s" % JSON.stringify({
|
||||
"starter_runtime_raw_bytes":raw.size(),"starter_runtime_compressed_bytes":raw.compress(FileAccess.COMPRESSION_ZSTD).size(),
|
||||
"world_cells":cells.size(),"world_only_raw_bytes":world_raw.size(),"world_only_compressed_bytes":world_raw.compress(FileAccess.COMPRESSION_ZSTD).size(),
|
||||
"world_limit_bytes":SaveService.MAX_WORLD_BYTES,"manifest_limit_bytes":SaveService.MAX_MANIFEST_BYTES,"full_runtime_20000_cell_acceptance":false,
|
||||
}))
|
||||
|
||||
func _test_envelope_rejection_and_legacy_defaults() -> void:
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
var source := SimulationSnapshot.capture(clock,state)
|
||||
for row: Array in [
|
||||
[&"schema_version","1"],[&"schema_version",true],[&"schema_version",1.5],
|
||||
[&"absolute_minute","360"],[&"absolute_minute",-1],[&"absolute_minute",360.5],
|
||||
[&"speed",true],[&"speed",1.5],[&"speed",0],[&"paused","false"],
|
||||
[&"snapshot_version",-1],[&"snapshot_version","0"],[&"snapshot_version",1.5],
|
||||
]:
|
||||
var candidate := source.duplicate(true)
|
||||
if row[0] == &"schema_version": candidate[row[0]] = row[1]
|
||||
else: candidate[&"clock"][row[0]] = row[1]
|
||||
var original := candidate.duplicate(true)
|
||||
var live: Dictionary = _runtime.create_snapshot()
|
||||
_expect(not SimulationSnapshot.validate(candidate)[&"ok"],"envelope rejects %s = %s" % [row[0],str(row[1])])
|
||||
_expect(not SimulationSnapshot.restore(candidate,clock,state) and not _runtime.restore_snapshot(candidate),"invalid envelope refuses both restore entrypoints")
|
||||
_expect(candidate == original and _runtime.create_snapshot() == live,"envelope rejection preserves source and complete live Runtime")
|
||||
var json_numeric := source.duplicate(true)
|
||||
json_numeric[&"schema_version"] = 1.0
|
||||
json_numeric[&"clock"][&"absolute_minute"] = 360.0
|
||||
json_numeric[&"clock"][&"speed"] = 1.0
|
||||
json_numeric[&"clock"][&"snapshot_version"] = 0.0
|
||||
_expect(SimulationSnapshot.validate(json_numeric)[&"ok"],"exact integral JSON numbers remain valid generic envelopes")
|
||||
var legacy := source.duplicate(true)
|
||||
legacy[&"clock"].erase(&"paused"); legacy[&"clock"].erase(&"snapshot_version")
|
||||
_expect(SimulationSnapshot.validate(legacy)[&"ok"],"absent legacy pause/version retain compatible defaults")
|
||||
legacy[&"clock"][&"speed"] = 4
|
||||
_expect(SimulationSnapshot.restore(legacy,clock,state) and clock.speed == 3,"legacy x4 still resumes at x3 through validated envelope")
|
||||
_expect(_runtime.restore_snapshot(_source),"restore original live state after legacy compatibility probe")
|
||||
|
||||
func _assert_read_rejection(path: String,code: StringName,label: String) -> void:
|
||||
var before := _slot_fingerprint(path)
|
||||
var inspected := SaveService.inspect_snapshot(path)
|
||||
_expect(not inspected[&"ok"] and inspected[&"code"] == code,label+": correct rejection")
|
||||
_expect(_slot_fingerprint(path) == before,label+": all disk SHA unchanged")
|
||||
|
||||
func _slot_fingerprint(path: String) -> Dictionary:
|
||||
var absolute := ProjectSettings.globalize_path(path)
|
||||
var parent := DirAccess.open(absolute.get_base_dir())
|
||||
var result := {}
|
||||
if parent == null: return result
|
||||
for file: String in parent.get_files():
|
||||
if file.begins_with(absolute.get_file()): result[file] = FileAccess.get_sha256(absolute.get_base_dir().path_join(file))
|
||||
for directory: String in parent.get_directories():
|
||||
if directory.begins_with(absolute.get_file()): result[directory+"/"] = _tree_fingerprint(absolute.get_base_dir().path_join(directory))
|
||||
return result
|
||||
|
||||
func _tree_fingerprint(path: String) -> Dictionary:
|
||||
var dir := DirAccess.open(path)
|
||||
var result := {}
|
||||
for file: String in dir.get_files(): result[file] = FileAccess.get_sha256(path.path_join(file))
|
||||
for child: String in dir.get_directories(): result[child+"/"] = _tree_fingerprint(path.path_join(child))
|
||||
return result
|
||||
|
||||
func _write_marker(path: String) -> void:
|
||||
_write_text(ProjectSettings.globalize_path(path)+SaveService.SAVE_TRANSACTION_SUFFIX,SaveService.SAVE_TRANSACTION_MARKER)
|
||||
|
||||
func _write_text(path: String,value: String) -> void:
|
||||
var file := FileAccess.open(path,FileAccess.WRITE)
|
||||
file.store_string(value)
|
||||
|
||||
func _write_bytes(path: String,value: PackedByteArray) -> void:
|
||||
var file := FileAccess.open(path,FileAccess.WRITE)
|
||||
file.store_buffer(value)
|
||||
|
||||
func _expect(ok: bool,label: String) -> void:
|
||||
_checks += 1
|
||||
if not ok:
|
||||
_failures += 1
|
||||
push_error(label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn6w8j53gr8ql
|
||||
@@ -0,0 +1,145 @@
|
||||
extends SceneTree
|
||||
# Candidate acceptance/reproduction driver. Disposable user:// fixtures only.
|
||||
# On the unpatched base, EXPECT failures expose the two preservation defects.
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _source: Dictionary
|
||||
var _prefix := ""
|
||||
func _initialize() -> void: call_deferred("_run")
|
||||
func _run() -> void:
|
||||
_prefix = "user://tests/recovery_readonly_%d" % OS.get_process_id()
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
clock.reset(1,6,0); clock.set_paused(true); state.reset()
|
||||
_source = SimulationSnapshot.capture(clock,state)
|
||||
_test_explicit_read_only()
|
||||
_test_identified_unknown_preservation()
|
||||
_test_current_corruption_recovery()
|
||||
_test_registered_legacy_migration()
|
||||
print("Recovery read-only candidate: %d checks, %d failures" % [_checks,_failures])
|
||||
quit(_failures)
|
||||
func _test_explicit_read_only() -> void:
|
||||
for formal_present: bool in [true,false]:
|
||||
var path := _prefix + "/explicit_%s" % str(formal_present)
|
||||
_setup(path)
|
||||
if not formal_present: _remove_tree(ProjectSettings.globalize_path(path))
|
||||
var before := _fingerprint(path)
|
||||
var result := SaveService.load_recovery_candidate(path,&"backup")
|
||||
_expect(result[&"ok"] and result[&"read_only"] and result[&"snapshot"] == _source,"explicit backup is readable with/without formal")
|
||||
_expect(_fingerprint(path)==before,"explicit backup selection never promotes, removes or migrates artifacts")
|
||||
var normal := SaveService.load_snapshot(path)
|
||||
_expect(normal[&"ok"],"ordinary load still reconciles stale marker or missing formal")
|
||||
_expect(not FileAccess.file_exists(ProjectSettings.globalize_path(path)+SaveService.SAVE_TRANSACTION_SUFFIX),"ordinary load owns marker cleanup")
|
||||
_cleanup(path)
|
||||
func _test_identified_unknown_preservation() -> void:
|
||||
var operations := ["load_snapshot","load_with_recovery","inspect_recovery_candidates","migrate_save","load_recovery_candidate","save_snapshot","replace_snapshot_with_backup","inspect_snapshot"]
|
||||
for variant: String in ["matching_future","corrupt_future","header_only_future","manifest_only_future","unsupported_legacy"]:
|
||||
for operation: String in operations:
|
||||
var path := _prefix + "/%s_%s" % [variant,operation]
|
||||
_setup(path)
|
||||
var version := 0 if variant=="unsupported_legacy" else 2
|
||||
_set_version(path,version,variant!="header_only_future",variant!="manifest_only_future")
|
||||
if variant=="header_only_future": _write_text(ProjectSettings.globalize_path(path).path_join(SaveService.MANIFEST_FILE),"{bad json")
|
||||
if variant=="corrupt_future":
|
||||
var world_path := ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE)
|
||||
var bytes := FileAccess.get_file_as_bytes(world_path)
|
||||
bytes[bytes.size()-1] = bytes[bytes.size()-1] ^ 0xff
|
||||
_write_bytes(world_path,bytes)
|
||||
var before := _fingerprint(path)
|
||||
var result: Dictionary
|
||||
match operation:
|
||||
"load_snapshot": result=SaveService.load_snapshot(path)
|
||||
"load_with_recovery": result=SaveService.load_with_recovery(path)
|
||||
"inspect_recovery_candidates": result=SaveService.inspect_recovery_candidates(path)
|
||||
"migrate_save": result=SaveService.migrate_save(path,SaveMigrationRegistry.new())
|
||||
"load_recovery_candidate": result=SaveService.load_recovery_candidate(path,&"backup")
|
||||
"save_snapshot": result=SaveService.save_snapshot(path,_source)
|
||||
"replace_snapshot_with_backup":
|
||||
var candidate := _source.duplicate(true)
|
||||
candidate[&"clock"][&"absolute_minute"] += 1
|
||||
result=SaveService.replace_snapshot_with_backup(path,_source,candidate)
|
||||
"inspect_snapshot": result=SaveService.inspect_snapshot(path)
|
||||
_expect(not result[&"ok"],"%s rejects %s formal instead of silently loading old backup" % [operation,variant])
|
||||
_expect(_fingerprint(path)==before,"%s preserves formal and every related artifact for %s" % [operation,variant])
|
||||
_cleanup(path)
|
||||
func _test_current_corruption_recovery() -> void:
|
||||
var path := _prefix + "/current_corruption"
|
||||
_setup(path)
|
||||
var world_path := ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE)
|
||||
var bytes := FileAccess.get_file_as_bytes(world_path);bytes[bytes.size()-1] = bytes[bytes.size()-1] ^ 0xff
|
||||
_write_bytes(world_path,bytes)
|
||||
var result := SaveService.load_snapshot(path)
|
||||
_expect(result[&"ok"] and result[&"interrupted_save_reconciled"],"damaged recognized current formal retains authorized transaction recovery")
|
||||
_expect(result[&"snapshot"] == _source,"recognized current transaction recovers exact verified snapshot")
|
||||
_cleanup(path)
|
||||
func _test_registered_legacy_migration() -> void:
|
||||
var path := _prefix + "/registered_legacy"
|
||||
_setup(path);_set_version(path,0,true,true)
|
||||
var before_world := FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE))
|
||||
var before_artifacts := _fingerprint(path)
|
||||
var registry := SaveMigrationRegistry.new()
|
||||
_expect(registry.register_step(0,&"registered_zero_to_one",Callable(self,"_migrate_legacy"))[&"ok"],"register explicit known legacy migration")
|
||||
var result := SaveService.migrate_save(path,registry)
|
||||
_expect(result[&"ok"] and result[&"backup_created"],"explicit known legacy chain remains supported")
|
||||
_expect(FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(path+SaveService.MIGRATION_BACKUP_SUFFIX).path_join(SaveService.WORLD_FILE))==before_world,"legacy migration preserves exact original binary backup")
|
||||
_expect(SaveService.inspect_snapshot(path)[&"ok"],"migrated formal is current-loadable")
|
||||
var after_artifacts := _fingerprint(path)
|
||||
var owned_name := ProjectSettings.globalize_path(path).get_file()
|
||||
for suffix: String in [".tmp/",".bak/",SaveService.SAVE_TRANSACTION_SUFFIX]:
|
||||
_expect(after_artifacts.get(owned_name+suffix)==before_artifacts.get(owned_name+suffix),"registered legacy migration does not pre-clean stale normal artifact "+suffix)
|
||||
_cleanup(path)
|
||||
path = _prefix + "/registered_legacy_failure"
|
||||
_setup(path);_set_version(path,0,true,true)
|
||||
before_artifacts = _fingerprint(path)
|
||||
registry = SaveMigrationRegistry.new()
|
||||
registry.register_step(0,&"explicit_reject",Callable(self,"_reject_legacy"))
|
||||
result = SaveService.migrate_save(path,registry)
|
||||
_expect(not result[&"ok"] and result[&"code"]==&"FIXTURE_REJECTED","registered legacy callback failure is returned")
|
||||
_expect(_fingerprint(path)==before_artifacts,"failed legacy migration preserves source and stale normal artifacts")
|
||||
_cleanup(path)
|
||||
func _migrate_legacy(payload: Dictionary,_from: int,_to: int) -> Dictionary:return payload
|
||||
func _reject_legacy(_payload: Dictionary,_from: int,_to: int) -> Dictionary:return {&"ok":false,&"code":&"FIXTURE_REJECTED"}
|
||||
func _setup(path: String) -> void:
|
||||
_expect(SaveService.save_snapshot(path,_source)[&"ok"],"write disposable formal")
|
||||
_expect(SaveService.save_snapshot(path+".bak",_source)[&"ok"],"write disposable verified backup")
|
||||
_expect(SaveService.save_snapshot(path+".tmp",_source)[&"ok"],"write disposable verified candidate")
|
||||
_write_text(ProjectSettings.globalize_path(path)+SaveService.SAVE_TRANSACTION_SUFFIX,SaveService.SAVE_TRANSACTION_MARKER)
|
||||
func _set_version(path: String,version: int,manifest: bool,header: bool) -> void:
|
||||
var absolute := ProjectSettings.globalize_path(path)
|
||||
if manifest:
|
||||
var value: Dictionary=JSON.parse_string(FileAccess.get_file_as_string(absolute.path_join(SaveService.MANIFEST_FILE)))
|
||||
value["format_version"]=version;_write_text(absolute.path_join(SaveService.MANIFEST_FILE),JSON.stringify(value))
|
||||
if header:
|
||||
var file:=FileAccess.open(absolute.path_join(SaveService.WORLD_FILE),FileAccess.READ_WRITE)
|
||||
file.seek(4);file.store_32(version)
|
||||
func _fingerprint(path: String) -> Dictionary:
|
||||
var absolute:=ProjectSettings.globalize_path(path);var parent:=DirAccess.open(absolute.get_base_dir());var result:={}
|
||||
for file: String in parent.get_files():
|
||||
if file.begins_with(absolute.get_file()):result[file]=FileAccess.get_sha256(absolute.get_base_dir().path_join(file))
|
||||
for directory: String in parent.get_directories():
|
||||
if directory.begins_with(absolute.get_file()):result[directory+"/"]=_tree(absolute.get_base_dir().path_join(directory))
|
||||
return result
|
||||
func _tree(path: String) -> Dictionary:
|
||||
var dir:=DirAccess.open(path);var result:={}
|
||||
for file: String in dir.get_files():result[file]=FileAccess.get_sha256(path.path_join(file))
|
||||
for child: String in dir.get_directories():result[child+"/"]=_tree(path.path_join(child))
|
||||
return result
|
||||
func _cleanup(path: String) -> void:
|
||||
var absolute:=ProjectSettings.globalize_path(path);var parent:=DirAccess.open(absolute.get_base_dir())
|
||||
for file: String in parent.get_files():
|
||||
if file.begins_with(absolute.get_file()):DirAccess.remove_absolute(absolute.get_base_dir().path_join(file))
|
||||
for directory: String in parent.get_directories():
|
||||
if directory.begins_with(absolute.get_file()):_remove_tree(absolute.get_base_dir().path_join(directory))
|
||||
func _remove_tree(path: String) -> void:
|
||||
var dir:=DirAccess.open(path)
|
||||
if dir==null:return
|
||||
for file: String in dir.get_files():DirAccess.remove_absolute(path.path_join(file))
|
||||
for child: String in dir.get_directories():_remove_tree(path.path_join(child))
|
||||
DirAccess.remove_absolute(path)
|
||||
func _write_text(path: String,value: String) -> void:
|
||||
var file:=FileAccess.open(path,FileAccess.WRITE);file.store_string(value)
|
||||
func _write_bytes(path: String,value: PackedByteArray) -> void:
|
||||
var file:=FileAccess.open(path,FileAccess.WRITE);file.store_buffer(value)
|
||||
func _expect(ok: bool,label: String) -> void:
|
||||
_checks+=1
|
||||
if not ok:_failures+=1;push_error(label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn6i1dsht6xtp
|
||||
Reference in New Issue
Block a user