Files
2026-08-23 17:20:41 +02:00

326 lines
10 KiB
GDScript

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