diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 886b5eb..89fdb74 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -309,4 +309,91 @@ func get_state_snapshot() -> Dictionary: } func get_state_checksum() -> String: - return JSON.stringify(get_state_snapshot()).sha256_text() + return create_state_record().to_json().sha256_text() + +func create_state_record() -> SimulationStateRecord: + var record := SimulationStateRecord.new() + var wander_streams: Array[Dictionary] = [] + var sorted_npc_ids: Array = wander_random_sources.keys() + sorted_npc_ids.sort() + for npc_id in sorted_npc_ids: + var source: RandomNumberGenerator = wander_random_sources[npc_id] + wander_streams.append({ + "npc_id": int(npc_id), + "seed": str(source.seed), + "state": str(source.state) + }) + + record.simulation = { + "seed": simulation_seed, + "tick_interval": tick_interval, + "tick_count": tick_count, + "clock_accumulator": clock.accumulator, + "clock_elapsed_ticks": clock.elapsed_ticks, + "wander_random_streams": wander_streams + } + record.village = VillageStateRecord.capture(village) + for npc in npcs: + record.npcs.append(NPCStateRecord.capture(npc)) + + var resource_nodes := ResourceNode.get_all() + resource_nodes.sort_custom( + func(a: ResourceNode, b: ResourceNode) -> bool: + return String(a.node_id) < String(b.node_id) + ) + var captured_resource_ids := {} + for node in resource_nodes: + if captured_resource_ids.has(node.node_id): + continue + captured_resource_ids[node.node_id] = true + record.resources.append(ResourceStateRecord.capture(node)) + return record + +func serialize_state() -> String: + return create_state_record().to_json() + +func restore_state_from_json(json_text: String) -> bool: + var record := SimulationStateRecord.from_json(json_text) + if record == null: + push_error("SimulationManager: refused invalid or unsupported simulation state") + return false + return restore_state(record) + +func restore_state(record: SimulationStateRecord) -> bool: + if record == null: + return false + + simulation_seed = int(record.simulation["seed"]) + tick_interval = float(record.simulation["tick_interval"]) + tick_count = int(record.simulation["tick_count"]) + clock = SimulationClock.new(tick_interval) + clock.accumulator = float(record.simulation["clock_accumulator"]) + clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"]) + village = record.village.restore(debug_logs) + + npcs.clear() + for npc_record in record.npcs: + npcs.append(npc_record.restore(debug_logs)) + + wander_random_sources.clear() + var wander_streams: Array = record.simulation["wander_random_streams"] + for stream_data in wander_streams: + var source := RandomNumberGenerator.new() + source.seed = String(stream_data["seed"]).to_int() + source.state = String(stream_data["state"]).to_int() + wander_random_sources[int(stream_data["npc_id"])] = source + + for resource_record in record.resources: + var node_id := StringName(resource_record.data["node_id"]) + var node := ResourceNode.get_by_id(node_id) + if node == null: + push_warning( + "SimulationManager: resource state '%s' has no loaded presentation node" + % node_id + ) + continue + if not resource_record.restore(node): + return false + + village_changed.emit(village) + return true diff --git a/simulation/state/NPCStateRecord.gd b/simulation/state/NPCStateRecord.gd new file mode 100644 index 0000000..dc991e6 --- /dev/null +++ b/simulation/state/NPCStateRecord.gd @@ -0,0 +1,88 @@ +class_name NPCStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + +static func capture(npc: SimNPC) -> NPCStateRecord: + return NPCStateRecord.new({ + "schema_version": SCHEMA_VERSION, + "id": npc.id, + "name": npc.npc_name, + "profession": npc.profession, + "hunger": npc.hunger, + "energy": npc.energy, + "strength": npc.strength, + "intelligence": npc.intelligence, + "current_task": npc.current_task, + "task_state": npc.task_state, + "position": [npc.position.x, npc.position.y, npc.position.z], + "is_starving": npc.is_starving, + "starvation_ticks": npc.starvation_ticks, + "starvation_death_threshold": npc.starvation_death_threshold, + "is_dead": npc.is_dead, + "task_duration": npc.task_duration, + "task_progress": npc.task_progress, + "task_complete": npc.task_complete, + "target_id": String(npc.target_id), + "last_task": npc.last_task, + "random_seed": str(npc.random_source.seed), + "random_state": str(npc.random_source.state) + }) + +static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all([ + "id", "name", "profession", "hunger", "energy", "strength", + "intelligence", "current_task", "task_state", "position", + "is_starving", "starvation_ticks", "starvation_death_threshold", + "is_dead", "task_duration", "task_progress", "task_complete", + "target_id", "last_task", "random_seed", "random_state" + ]): + return null + var saved_position = record_data["position"] + if not saved_position is Array or saved_position.size() != 3: + return null + return NPCStateRecord.new(record_data) + +func restore(debug_logs: bool) -> SimNPC: + var random_source := RandomNumberGenerator.new() + random_source.seed = String(data["random_seed"]).to_int() + var npc := SimNPC.new( + int(data["id"]), + String(data["name"]), + String(data["profession"]), + float(data["strength"]), + float(data["intelligence"]), + random_source + ) + var saved_position: Array = data["position"] + npc.hunger = float(data["hunger"]) + npc.energy = float(data["energy"]) + npc.current_task = String(data["current_task"]) + npc.task_state = String(data["task_state"]) + npc.position = Vector3( + float(saved_position[0]), + float(saved_position[1]), + float(saved_position[2]) + ) + npc.is_starving = bool(data["is_starving"]) + npc.starvation_ticks = int(data["starvation_ticks"]) + npc.starvation_death_threshold = int(data["starvation_death_threshold"]) + npc.is_dead = bool(data["is_dead"]) + npc.task_duration = float(data["task_duration"]) + npc.task_progress = float(data["task_progress"]) + npc.task_complete = bool(data["task_complete"]) + npc.target_id = StringName(data["target_id"]) + npc.last_task = String(data["last_task"]) + npc.random_source.state = String(data["random_state"]).to_int() + npc.debug_logs = debug_logs + return npc + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/NPCStateRecord.gd.uid b/simulation/state/NPCStateRecord.gd.uid new file mode 100644 index 0000000..2c1a033 --- /dev/null +++ b/simulation/state/NPCStateRecord.gd.uid @@ -0,0 +1 @@ +uid://wf605vpwv8vn diff --git a/simulation/state/ResourceStateRecord.gd b/simulation/state/ResourceStateRecord.gd new file mode 100644 index 0000000..eba911c --- /dev/null +++ b/simulation/state/ResourceStateRecord.gd @@ -0,0 +1,42 @@ +class_name ResourceStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + +static func capture(node: ResourceNode) -> ResourceStateRecord: + return ResourceStateRecord.new({ + "schema_version": SCHEMA_VERSION, + "node_id": String(node.node_id), + "amount_remaining": node.amount_remaining, + "reserved_by": node.reserved_by, + "enabled": node.enabled + }) + +static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all([ + "node_id", "amount_remaining", "reserved_by", "enabled" + ]): + return null + if String(record_data["node_id"]).is_empty(): + return null + return ResourceStateRecord.new(record_data) + +func restore(node: ResourceNode) -> bool: + if node == null or node.node_id != StringName(data["node_id"]): + return false + node.restore_persistent_state( + float(data["amount_remaining"]), + int(data["reserved_by"]), + bool(data["enabled"]) + ) + return true + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/ResourceStateRecord.gd.uid b/simulation/state/ResourceStateRecord.gd.uid new file mode 100644 index 0000000..b16fde1 --- /dev/null +++ b/simulation/state/ResourceStateRecord.gd.uid @@ -0,0 +1 @@ +uid://pflq8apn87d6 diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd new file mode 100644 index 0000000..b03ba09 --- /dev/null +++ b/simulation/state/SimulationStateRecord.gd @@ -0,0 +1,108 @@ +class_name SimulationStateRecord +extends RefCounted + +const SCHEMA_NAME := "the_steward.simulation" +const SCHEMA_VERSION := 1 + +var simulation: Dictionary +var village: VillageStateRecord +var npcs: Array[NPCStateRecord] = [] +var resources: Array[ResourceStateRecord] = [] + +func to_dictionary() -> Dictionary: + var npc_data: Array[Dictionary] = [] + for npc_record in npcs: + npc_data.append(npc_record.to_dictionary()) + + var resource_data: Array[Dictionary] = [] + for resource_record in resources: + resource_data.append(resource_record.to_dictionary()) + + return { + "schema": SCHEMA_NAME, + "schema_version": SCHEMA_VERSION, + "simulation": simulation.duplicate(true), + "village": village.to_dictionary(), + "npcs": npc_data, + "resources": resource_data + } + +func to_json() -> String: + return JSON.stringify(to_dictionary()) + +static func from_json(json_text: String) -> SimulationStateRecord: + var parsed = JSON.parse_string(json_text) + if not parsed is Dictionary: + return null + return from_dictionary(parsed) + +static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: + if record_data.get("schema", "") != SCHEMA_NAME: + return null + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all([ + "simulation", "village", "npcs", "resources" + ]): + return null + + var simulation_data = record_data["simulation"] + if not simulation_data is Dictionary: + return null + if not simulation_data.has_all([ + "seed", "tick_interval", "tick_count", "clock_accumulator", + "clock_elapsed_ticks", "wander_random_streams" + ]): + return null + var wander_streams = simulation_data["wander_random_streams"] + if not wander_streams is Array: + return null + for stream_data in wander_streams: + if not stream_data is Dictionary: + return null + if not stream_data.has_all(["npc_id", "seed", "state"]): + return null + + var village_data = record_data["village"] + if not village_data is Dictionary: + return null + var village_record := VillageStateRecord.from_dictionary(village_data) + if village_record == null: + return null + + var npc_data = record_data["npcs"] + var resource_data = record_data["resources"] + if not npc_data is Array or not resource_data is Array: + return null + + var record := SimulationStateRecord.new() + record.simulation = simulation_data.duplicate(true) + record.village = village_record + var npc_ids := {} + + for item in npc_data: + if not item is Dictionary: + return null + var npc_record := NPCStateRecord.from_dictionary(item) + if npc_record == null: + return null + var npc_id := int(npc_record.data["id"]) + if npc_ids.has(npc_id): + return null + npc_ids[npc_id] = true + record.npcs.append(npc_record) + + var resource_ids := {} + for item in resource_data: + if not item is Dictionary: + return null + var resource_record := ResourceStateRecord.from_dictionary(item) + if resource_record == null: + return null + var resource_id := String(resource_record.data["node_id"]) + if resource_ids.has(resource_id): + return null + resource_ids[resource_id] = true + record.resources.append(resource_record) + + return record diff --git a/simulation/state/SimulationStateRecord.gd.uid b/simulation/state/SimulationStateRecord.gd.uid new file mode 100644 index 0000000..f9b78e5 --- /dev/null +++ b/simulation/state/SimulationStateRecord.gd.uid @@ -0,0 +1 @@ +uid://nmke245eadkk diff --git a/simulation/state/VillageStateRecord.gd b/simulation/state/VillageStateRecord.gd new file mode 100644 index 0000000..b98de3b --- /dev/null +++ b/simulation/state/VillageStateRecord.gd @@ -0,0 +1,39 @@ +class_name VillageStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + +static func capture(village: SimVillage) -> VillageStateRecord: + return VillageStateRecord.new({ + "schema_version": SCHEMA_VERSION, + "food": village.food, + "wood": village.wood, + "safety": village.safety, + "knowledge": village.knowledge + }) + +static func from_dictionary(record_data: Dictionary) -> VillageStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all(["food", "wood", "safety", "knowledge"]): + return null + return VillageStateRecord.new(record_data) + +func restore(debug_logs: bool) -> SimVillage: + var village := SimVillage.new() + village.debug_logs = debug_logs + village.food = float(data["food"]) + village.wood = float(data["wood"]) + village.safety = float(data["safety"]) + village.knowledge = float(data["knowledge"]) + village.update_modifiers() + village.update_priorities() + return village + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/VillageStateRecord.gd.uid b/simulation/state/VillageStateRecord.gd.uid new file mode 100644 index 0000000..95d8276 --- /dev/null +++ b/simulation/state/VillageStateRecord.gd.uid @@ -0,0 +1 @@ +uid://cipncpfigveuh diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd new file mode 100644 index 0000000..2531e6b --- /dev/null +++ b/tests/simulation_state_serialization_test.gd @@ -0,0 +1,127 @@ +extends SceneTree + +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + await _test_deterministic_continuation() + await _test_resource_state_round_trip() + _test_schema_rejection() + + if failures.is_empty(): + print("[TEST] Simulation state serialization passed") + quit(0) + return + + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _test_deterministic_continuation() -> void: + var uninterrupted := _create_manager(91234) + _advance_scenario(uninterrupted, 24) + uninterrupted.clock.advance(0.5) + _advance_scenario(uninterrupted, 24) + var uninterrupted_checksum: String = uninterrupted.get_state_checksum() + + var before_save := _create_manager(91234) + _advance_scenario(before_save, 24) + before_save.clock.advance(0.5) + var saved_json: String = before_save.serialize_state() + + var restored := _create_manager(1) + _check( + restored.restore_state_from_json(saved_json), + "A valid versioned state should restore into a fresh manager" + ) + _check(restored.tick_count == 24, "Tick count should survive restoration") + _check( + is_equal_approx(restored.clock.accumulator, 0.5), + "Clock remainder should survive restoration" + ) + _advance_scenario(restored, 24) + var restored_checksum: String = restored.get_state_checksum() + if restored_checksum != uninterrupted_checksum: + print( + "[TEST] uninterrupted=", + uninterrupted.serialize_state(), + "\n[TEST] restored=", + restored.serialize_state() + ) + _check( + restored_checksum == uninterrupted_checksum, + "Restored simulation should match uninterrupted deterministic continuation" + ) + + uninterrupted.free() + before_save.free() + restored.free() + +func _test_resource_state_round_trip() -> void: + var resource: ResourceNode = load( + "res://world/resource_nodes/ResourceNode.tscn" + ).instantiate() + resource.node_id = &"SerializationTestBerry" + resource.amount_remaining = 9.0 + resource.yield_per_action = 2.0 + root.add_child(resource) + await process_frame + + _check(resource.reserve(42), "Resource should accept the test reservation") + resource.extract() + + var manager := _create_manager(77) + var saved_json: String = manager.serialize_state() + resource.extract(5.0) + resource.release(42) + resource.enabled = false + + _check( + manager.restore_state_from_json(saved_json), + "Resource state should restore with the simulation record" + ) + _check( + is_equal_approx(resource.amount_remaining, 7.0), + "Resource amount should round-trip" + ) + _check(resource.reserved_by == 42, "Resource reservation should round-trip") + _check(resource.enabled, "Resource enabled state should round-trip") + + manager.free() + resource.free() + +func _test_schema_rejection() -> void: + var unsupported := JSON.stringify({ + "schema": SimulationStateRecord.SCHEMA_NAME, + "schema_version": 999 + }) + _check( + SimulationStateRecord.from_json(unsupported) == null, + "Unsupported schema versions should be rejected" + ) + _check( + SimulationStateRecord.from_json("[]") == null, + "Non-record JSON should be rejected" + ) + +func _create_manager(seed_value: int) -> Node: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.simulation_seed = seed_value + manager.debug_logs = false + root.add_child(manager) + manager.set_process(false) + return manager + +func _advance_scenario(manager: Node, steps: int) -> void: + for step in steps: + manager.simulate_tick() + for npc in manager.npcs: + manager.get_wander_offset(npc.id) + if npc.task_state == SimNPC.TASK_STATE_TRAVELING: + manager.notify_npc_arrived(npc.id) + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/simulation_state_serialization_test.gd.uid b/tests/simulation_state_serialization_test.gd.uid new file mode 100644 index 0000000..7741640 --- /dev/null +++ b/tests/simulation_state_serialization_test.gd.uid @@ -0,0 +1 @@ +uid://5c7luv7p1odh diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 1dc3dcc..feca3bb 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -75,6 +75,20 @@ func extract(requested_amount: float = yield_per_action) -> float: $Visual.visible = false return extracted +func restore_persistent_state( + saved_amount: float, + saved_reserved_by: int, + saved_enabled: bool +) -> void: + amount_remaining = maxf(saved_amount, 0.0) + reserved_by = saved_reserved_by if amount_remaining > 0.0 else -1 + enabled = saved_enabled + amount_changed.emit(node_id, amount_remaining) + reservation_changed.emit(node_id, reserved_by) + if has_node("Visual"): + $Visual.visible = not (hide_visual_when_depleted and is_depleted()) + _update_debug_label() + func _update_debug_label(_a = null, _b = null) -> void: if not has_node("DebugLabel"): return @@ -92,6 +106,9 @@ static func get_by_id(node_id: StringName) -> ResourceNode: return node return null +static func get_all() -> Array[ResourceNode]: + return _all.duplicate() + static func find_available(action: StringName, agent_id: int, from_position: Vector3) -> ResourceNode: var best: ResourceNode var best_dist := INF