feat: integrate regional caravan persistence
This commit is contained in:
@@ -15,16 +15,36 @@ func _init(directory: String = DEFAULT_DIRECTORY) -> void:
|
||||
|
||||
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 (
|
||||
manager == null
|
||||
or not (
|
||||
manager.has_method("serialize_save_manifest") or manager.has_method("serialize_state")
|
||||
)
|
||||
):
|
||||
return _fail("Save source cannot serialize authoritative state")
|
||||
if not _is_valid_slot_name(slot_name):
|
||||
return _fail("Invalid save slot name")
|
||||
|
||||
var json_text: String = manager.serialize_state()
|
||||
var use_manifest := manager.has_method("serialize_save_manifest")
|
||||
if use_manifest and manager.has_method("uses_combined_save_manifest"):
|
||||
use_manifest = bool(manager.call("uses_combined_save_manifest"))
|
||||
if (
|
||||
not use_manifest
|
||||
and manager.has_method("can_serialize_local_state_only")
|
||||
and not bool(manager.call("can_serialize_local_state_only"))
|
||||
):
|
||||
return _fail("Save source has regional authority outside its active manifest scope")
|
||||
var json_text: String
|
||||
if use_manifest:
|
||||
json_text = String(manager.call("serialize_save_manifest"))
|
||||
elif manager.has_method("serialize_state"):
|
||||
json_text = String(manager.call("serialize_state"))
|
||||
else:
|
||||
return _fail("Save source cannot serialize its active authority scope")
|
||||
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 _is_valid_save_json(json_text):
|
||||
return _fail("Simulation produced an invalid save payload")
|
||||
if not _ensure_directory():
|
||||
return false
|
||||
|
||||
@@ -39,7 +59,7 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
file.store_string(json_text)
|
||||
file.flush()
|
||||
file.close()
|
||||
if _read_record(temporary_path) == null:
|
||||
if _read_payload(temporary_path).is_empty():
|
||||
_remove_if_present(temporary_path)
|
||||
return _fail("Temporary save failed validation")
|
||||
|
||||
@@ -60,19 +80,29 @@ func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
|
||||
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 manager == null:
|
||||
return _fail("Save target is missing")
|
||||
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:
|
||||
var payload := _read_payload(final_path)
|
||||
if payload.is_empty():
|
||||
payload = _read_payload(final_path + ".bak")
|
||||
if payload.is_empty():
|
||||
return _fail("Save slot is missing, invalid, or unsupported")
|
||||
if not bool(manager.restore_state(record)):
|
||||
return _fail("Simulation refused the validated save record")
|
||||
if payload.has("manifest"):
|
||||
if not manager.has_method("restore_save_manifest"):
|
||||
return _fail("Save target cannot restore a combined save manifest")
|
||||
if not bool(manager.call("restore_save_manifest", payload["manifest"])):
|
||||
return _fail("Simulation refused the validated save manifest")
|
||||
elif payload.has("local_state"):
|
||||
if not manager.has_method("restore_state"):
|
||||
return _fail("Save target cannot restore legacy local state")
|
||||
if not bool(manager.call("restore_state", payload["local_state"])):
|
||||
return _fail("Simulation refused the validated legacy save record")
|
||||
else:
|
||||
return _fail("Save slot payload kind is unsupported")
|
||||
return true
|
||||
|
||||
|
||||
@@ -80,7 +110,10 @@ 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
|
||||
return (
|
||||
not _read_payload(final_path).is_empty()
|
||||
or not _read_payload(final_path + ".bak").is_empty()
|
||||
)
|
||||
|
||||
|
||||
func delete_slot(slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
@@ -97,18 +130,29 @@ func get_slot_path(slot_name: String = DEFAULT_SLOT) -> String:
|
||||
return save_directory.path_join(slot_name + ".json")
|
||||
|
||||
|
||||
func _read_record(path: String) -> SimulationStateRecord:
|
||||
func _read_payload(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return null
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return null
|
||||
return {}
|
||||
if file.get_length() > MAX_SAVE_BYTES:
|
||||
file.close()
|
||||
return null
|
||||
return {}
|
||||
var json_text := file.get_as_text()
|
||||
file.close()
|
||||
return SimulationStateRecord.from_json(json_text)
|
||||
var manifest := SimulationSaveManifest.from_json(json_text)
|
||||
if manifest != null:
|
||||
return {"manifest": manifest}
|
||||
var local_state := SimulationStateRecord.from_json(json_text)
|
||||
return {"local_state": local_state} if local_state != null else {}
|
||||
|
||||
|
||||
func _is_valid_save_json(json_text: String) -> bool:
|
||||
return (
|
||||
SimulationSaveManifest.from_json(json_text) != null
|
||||
or SimulationStateRecord.from_json(json_text) != null
|
||||
)
|
||||
|
||||
|
||||
func _ensure_directory() -> bool:
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
class_name SimulationSaveManifest
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_NAME := "the_steward.save_manifest"
|
||||
const SCHEMA_VERSION := 1
|
||||
const MANIFEST_FIELDS := [
|
||||
"schema",
|
||||
"schema_version",
|
||||
"local_state",
|
||||
"local_checksum",
|
||||
"regional_state",
|
||||
"regional_checksum",
|
||||
]
|
||||
const MAX_SAFE_JSON_INTEGER := 9007199254740991.0
|
||||
|
||||
var _local_state: SimulationStateRecord
|
||||
var _regional_facade: RegionalSimulationFacade
|
||||
var _local_checksum := ""
|
||||
var _regional_checksum := ""
|
||||
|
||||
|
||||
static func create(
|
||||
local_state: SimulationStateRecord, regional_facade: RegionalSimulationFacade
|
||||
) -> SimulationSaveManifest:
|
||||
if local_state == null or regional_facade == null:
|
||||
return null
|
||||
var local_copy := _canonicalize_local_state(local_state)
|
||||
var regional_copy := _parse_regional_facade(regional_facade.to_dictionary())
|
||||
if local_copy == null or regional_copy == null:
|
||||
return null
|
||||
return _assemble(local_copy, regional_copy)
|
||||
|
||||
|
||||
static func from_dictionary(record_data: Dictionary) -> SimulationSaveManifest:
|
||||
return _from_dictionary(record_data, false)
|
||||
|
||||
|
||||
static func _from_dictionary(
|
||||
record_data: Dictionary, allow_json_number_types: bool
|
||||
) -> SimulationSaveManifest:
|
||||
if not _has_exact_fields(record_data, MANIFEST_FIELDS):
|
||||
return null
|
||||
if (
|
||||
not record_data["schema"] is String
|
||||
or String(record_data["schema"]) != SCHEMA_NAME
|
||||
or not record_data["schema_version"] is int
|
||||
or int(record_data["schema_version"]) != SCHEMA_VERSION
|
||||
or not record_data["local_state"] is Dictionary
|
||||
or not record_data["local_checksum"] is String
|
||||
or not record_data["regional_state"] is Dictionary
|
||||
or not record_data["regional_checksum"] is String
|
||||
):
|
||||
return null
|
||||
|
||||
var local_state := _parse_local_state(record_data["local_state"], allow_json_number_types)
|
||||
var regional_facade := _parse_regional_facade(
|
||||
record_data["regional_state"], allow_json_number_types
|
||||
)
|
||||
if local_state == null or regional_facade == null:
|
||||
return null
|
||||
var local_checksum := _checksum_dictionary(local_state.to_dictionary())
|
||||
var regional_checksum := _checksum_dictionary(regional_facade.to_dictionary())
|
||||
if (
|
||||
String(record_data["local_checksum"]) != local_checksum
|
||||
or String(record_data["regional_checksum"]) != regional_checksum
|
||||
):
|
||||
return null
|
||||
return _assemble(local_state, regional_facade)
|
||||
|
||||
|
||||
static func from_json(json_text: String) -> SimulationSaveManifest:
|
||||
var parser := JSON.new()
|
||||
if parser.parse(json_text) != OK:
|
||||
return null
|
||||
var parsed: Variant = parser.data
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var normalized: Dictionary = parsed.duplicate(true)
|
||||
if not normalized.has("schema_version"):
|
||||
return null
|
||||
var normalized_version: Variant = _restore_json_integer_types(normalized["schema_version"])
|
||||
if not normalized_version is int:
|
||||
return null
|
||||
normalized["schema_version"] = normalized_version
|
||||
if normalized.get("local_state") is Dictionary:
|
||||
var normalized_local_state: Variant = _normalize_json_local_boundary_integers(
|
||||
normalized["local_state"]
|
||||
)
|
||||
if normalized_local_state == null:
|
||||
return null
|
||||
normalized["local_state"] = normalized_local_state
|
||||
if normalized.get("regional_state") is Dictionary:
|
||||
normalized["regional_state"] = _restore_json_integer_types(normalized["regional_state"])
|
||||
return _from_dictionary(normalized, true)
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return {
|
||||
"schema": SCHEMA_NAME,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"local_state": _local_state.to_dictionary(),
|
||||
"local_checksum": _local_checksum,
|
||||
"regional_state": _regional_facade.to_dictionary(),
|
||||
"regional_checksum": _regional_checksum,
|
||||
}
|
||||
|
||||
|
||||
func to_json() -> String:
|
||||
return JSON.stringify(to_dictionary())
|
||||
|
||||
|
||||
func get_local_state() -> SimulationStateRecord:
|
||||
return _parse_local_state(_local_state.to_dictionary())
|
||||
|
||||
|
||||
func get_regional_facade() -> RegionalSimulationFacade:
|
||||
return _parse_regional_facade(_regional_facade.to_dictionary())
|
||||
|
||||
|
||||
func checksum() -> String:
|
||||
return to_json().sha256_text()
|
||||
|
||||
|
||||
static func _assemble(
|
||||
local_state: SimulationStateRecord, regional_facade: RegionalSimulationFacade
|
||||
) -> SimulationSaveManifest:
|
||||
if not _boundaries_match(local_state, regional_facade):
|
||||
return null
|
||||
var manifest := SimulationSaveManifest.new()
|
||||
manifest._local_state = local_state
|
||||
manifest._regional_facade = regional_facade
|
||||
manifest._local_checksum = _checksum_dictionary(local_state.to_dictionary())
|
||||
manifest._regional_checksum = _checksum_dictionary(regional_facade.to_dictionary())
|
||||
return manifest
|
||||
|
||||
|
||||
static func _parse_local_state(
|
||||
value: Variant, allow_json_number_types: bool = false
|
||||
) -> SimulationStateRecord:
|
||||
if not value is Dictionary:
|
||||
return null
|
||||
var source: Dictionary = value
|
||||
var parsed := SimulationStateRecord.from_dictionary(source)
|
||||
if (
|
||||
parsed == null
|
||||
or not _canonical_tree_matches(parsed.to_dictionary(), source, allow_json_number_types)
|
||||
):
|
||||
return null
|
||||
return parsed
|
||||
|
||||
|
||||
static func _canonicalize_local_state(local_state: SimulationStateRecord) -> SimulationStateRecord:
|
||||
var source := local_state.to_dictionary()
|
||||
var source_simulation: Variant = source.get("simulation")
|
||||
if not source_simulation is Dictionary:
|
||||
return null
|
||||
for field: String in ["seed", "tick_count"]:
|
||||
if not source_simulation.get(field) is int:
|
||||
return null
|
||||
var parser := JSON.new()
|
||||
if parser.parse(JSON.stringify(source)) != OK:
|
||||
return null
|
||||
if not parser.data is Dictionary:
|
||||
return null
|
||||
var canonical_data: Variant = _normalize_json_local_boundary_integers(parser.data)
|
||||
if canonical_data == null:
|
||||
return null
|
||||
var canonical := SimulationStateRecord.from_dictionary(canonical_data)
|
||||
if canonical == null:
|
||||
return null
|
||||
return _parse_local_state(canonical.to_dictionary())
|
||||
|
||||
|
||||
static func _parse_regional_facade(
|
||||
value: Variant, allow_json_number_types: bool = false
|
||||
) -> RegionalSimulationFacade:
|
||||
if not value is Dictionary:
|
||||
return null
|
||||
var source: Dictionary = value
|
||||
var parsed := RegionalSimulationFacade.from_dictionary(source)
|
||||
if (
|
||||
parsed == null
|
||||
or not _canonical_tree_matches(parsed.to_dictionary(), source, allow_json_number_types)
|
||||
):
|
||||
return null
|
||||
return parsed
|
||||
|
||||
|
||||
static func _boundaries_match(
|
||||
local_state: SimulationStateRecord, regional_facade: RegionalSimulationFacade
|
||||
) -> bool:
|
||||
var local_world_id := StringName(local_state.simulation.get("world_id", &""))
|
||||
var local_location_id := StringName(local_state.simulation.get("location_id", &""))
|
||||
var local_seed_value: Variant = local_state.simulation.get("seed")
|
||||
var local_tick_value: Variant = local_state.simulation.get("tick_count")
|
||||
if not local_seed_value is int or not local_tick_value is int:
|
||||
return false
|
||||
var local_seed := int(local_seed_value)
|
||||
var local_tick := int(local_tick_value)
|
||||
if local_world_id.is_empty() or local_location_id.is_empty() or local_tick < 0:
|
||||
return false
|
||||
if (
|
||||
local_seed != regional_facade.get_world_seed()
|
||||
or local_location_id != regional_facade.get_active_location_id()
|
||||
or local_tick != regional_facade.get_last_processed_tick()
|
||||
):
|
||||
return false
|
||||
var service: RegionalCaravanService = regional_facade.get_service()
|
||||
if service == null:
|
||||
return false
|
||||
var world_state := service.get_world_state()
|
||||
if world_state == null or world_state.get_world_id() != local_world_id:
|
||||
return false
|
||||
var location := world_state.get_location(local_location_id)
|
||||
return location != null and location.get_world_id() == local_world_id
|
||||
|
||||
|
||||
static func _checksum_dictionary(value: Dictionary) -> String:
|
||||
return JSON.stringify(_canonical_checksum_value(value)).sha256_text()
|
||||
|
||||
|
||||
static func _canonical_checksum_value(value: Variant) -> Variant:
|
||||
if _is_number(value):
|
||||
var number := float(value)
|
||||
if is_finite(number) and number == floorf(number) and absf(number) <= MAX_SAFE_JSON_INTEGER:
|
||||
return int(number)
|
||||
return number
|
||||
match typeof(value):
|
||||
TYPE_ARRAY:
|
||||
var normalized_array: Array = []
|
||||
for item: Variant in value:
|
||||
normalized_array.append(_canonical_checksum_value(item))
|
||||
return normalized_array
|
||||
TYPE_DICTIONARY:
|
||||
var normalized_dictionary: Dictionary = {}
|
||||
for key: Variant in value:
|
||||
normalized_dictionary[key] = _canonical_checksum_value(value[key])
|
||||
return normalized_dictionary
|
||||
return value
|
||||
|
||||
|
||||
static func _has_exact_fields(value: Dictionary, fields: Array) -> bool:
|
||||
return value.size() == fields.size() and value.has_all(fields)
|
||||
|
||||
|
||||
static func _canonical_tree_matches(
|
||||
canonical: Variant, source: Variant, allow_json_number_types: bool
|
||||
) -> bool:
|
||||
if allow_json_number_types and _is_number(canonical) and _is_number(source):
|
||||
return float(canonical) == float(source)
|
||||
if typeof(canonical) != typeof(source):
|
||||
return false
|
||||
match typeof(canonical):
|
||||
TYPE_ARRAY:
|
||||
if canonical.size() != source.size():
|
||||
return false
|
||||
for index in canonical.size():
|
||||
if not _canonical_tree_matches(
|
||||
canonical[index], source[index], allow_json_number_types
|
||||
):
|
||||
return false
|
||||
return true
|
||||
TYPE_DICTIONARY:
|
||||
if canonical.size() != source.size():
|
||||
return false
|
||||
for key: Variant in canonical:
|
||||
if (
|
||||
not source.has(key)
|
||||
or not _canonical_tree_matches(
|
||||
canonical[key], source[key], allow_json_number_types
|
||||
)
|
||||
):
|
||||
return false
|
||||
return true
|
||||
return canonical == source
|
||||
|
||||
|
||||
static func _is_number(value: Variant) -> bool:
|
||||
return value is int or value is float
|
||||
|
||||
|
||||
static func _normalize_json_local_boundary_integers(value: Dictionary) -> Variant:
|
||||
var normalized := value.duplicate(true)
|
||||
var simulation: Variant = normalized.get("simulation")
|
||||
if not simulation is Dictionary:
|
||||
return normalized
|
||||
for field: String in ["seed", "tick_count"]:
|
||||
if not simulation.has(field):
|
||||
continue
|
||||
var normalized_value: Variant = _restore_json_integer_types(simulation[field])
|
||||
if not normalized_value is int:
|
||||
return null
|
||||
simulation[field] = normalized_value
|
||||
normalized["simulation"] = simulation
|
||||
return normalized
|
||||
|
||||
|
||||
static func _restore_json_integer_types(value: Variant) -> Variant:
|
||||
match typeof(value):
|
||||
TYPE_FLOAT:
|
||||
var number := float(value)
|
||||
if (
|
||||
is_finite(number)
|
||||
and number == floorf(number)
|
||||
and absf(number) <= MAX_SAFE_JSON_INTEGER
|
||||
):
|
||||
return int(number)
|
||||
return number
|
||||
TYPE_ARRAY:
|
||||
var normalized_array: Array = []
|
||||
for item: Variant in value:
|
||||
normalized_array.append(_restore_json_integer_types(item))
|
||||
return normalized_array
|
||||
TYPE_DICTIONARY:
|
||||
var normalized_dictionary: Dictionary = {}
|
||||
for key: Variant in value:
|
||||
var normalized_value: Variant = _restore_json_integer_types(value[key])
|
||||
if String(key).ends_with("_ledger") and normalized_value is Dictionary:
|
||||
for ledger_key: Variant in normalized_value:
|
||||
if not _is_number(normalized_value[ledger_key]):
|
||||
return null
|
||||
normalized_value[ledger_key] = float(normalized_value[ledger_key])
|
||||
normalized_dictionary[key] = normalized_value
|
||||
return normalized_dictionary
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://cd8g03rq1kh16
|
||||
Reference in New Issue
Block a user