diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index ad404ba..9747562 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -2,6 +2,7 @@ extends CharacterBody3D signal arrived_at_target(sim_id: int) signal navigation_failed(sim_id: int) +signal position_changed(sim_id: int, active_position: Vector3) const DEBUG_LOGS := true @@ -123,6 +124,8 @@ func _physics_process(delta: float) -> void: velocity.y = 0.0 move_and_slide() + if not global_position.is_equal_approx(prev_pos): + position_changed.emit(sim_id, global_position) var minimum_progress := move_speed * delta * 0.1 if global_position.distance_squared_to(prev_pos) < minimum_progress * minimum_progress: diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 6fff6ae..2f1559c 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -26,6 +26,8 @@ var task_duration := 0.0 var task_progress := 0.0 var task_complete := true var target_id: StringName = &"" +var travel_target_position: Vector3 +var has_travel_target := false var last_task: StringName var random_source: RandomNumberGenerator var debug_logs := true @@ -62,6 +64,7 @@ func die_from_starvation() -> void: task_progress = 0.0 task_duration = 0.0 task_complete = true + has_travel_target = false if debug_logs: print("[SimNPC] ", npc_name, " died from starvation.") @@ -85,6 +88,7 @@ func set_task(action_id: StringName, duration: float = -1.0) -> void: task_progress = 0.0 task_complete = false task_state = TASK_STATE_TRAVELING + has_travel_target = false func start_working() -> void: if task_state != TASK_STATE_TRAVELING: diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 3f5b74d..db28b1f 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -182,6 +182,7 @@ func simulate_tick() -> void: npc.task_state = SimNPC.TASK_STATE_IDLE npc.last_task = completed_task npc.current_task = SimulationIds.ACTION_IDLE + npc.has_travel_target = false if needs_immediate_food_after_gather and village.food > 0: npc.set_task(SimulationIds.ACTION_EAT, 1.0) @@ -262,6 +263,7 @@ func notify_npc_arrived(npc_id: int) -> void: notify_npc_navigation_failed(npc.id) return + npc.has_travel_target = false npc.start_working() if debug_logs: @@ -308,10 +310,32 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool: notify_npc_target_unavailable(npc.id) return false npc.target_id = StringName(result.get("target_id", "")) - npc_travel_requested.emit(npc, result["position"]) + npc.travel_target_position = result["position"] + npc.has_travel_target = true + npc_travel_requested.emit(npc, npc.travel_target_position) return true return false +func request_current_travel(npc_id: int) -> bool: + for npc in npcs: + if npc.id != npc_id: + continue + if npc.task_state != SimNPC.TASK_STATE_TRAVELING: + return false + if npc.has_travel_target: + npc_travel_requested.emit(npc, npc.travel_target_position) + else: + npc_target_requested.emit(npc) + return true + return false + +func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool: + for npc in npcs: + if npc.id == npc_id: + npc.position = active_position + return true + return false + func register_loaded_resource_nodes() -> void: for node in ResourceNode.get_all(): register_resource_node(node) @@ -444,6 +468,12 @@ func get_state_snapshot() -> Dictionary: "task_progress": npc.task_progress, "target_id": String(npc.target_id), "position": [npc.position.x, npc.position.y, npc.position.z], + "travel_target_position": [ + npc.travel_target_position.x, + npc.travel_target_position.y, + npc.travel_target_position.z + ], + "has_travel_target": npc.has_travel_target, "starvation_ticks": npc.starvation_ticks, "is_dead": npc.is_dead }) diff --git a/simulation/state/NPCStateRecord.gd b/simulation/state/NPCStateRecord.gd index 8cf7019..dcb6059 100644 --- a/simulation/state/NPCStateRecord.gd +++ b/simulation/state/NPCStateRecord.gd @@ -1,7 +1,8 @@ class_name NPCStateRecord extends RefCounted -const SCHEMA_VERSION := 1 +const SCHEMA_VERSION := 2 +const LEGACY_SCHEMA_VERSION := 1 var data: Dictionary @@ -29,25 +30,41 @@ static func capture(npc: SimNPC) -> NPCStateRecord: "task_progress": npc.task_progress, "task_complete": npc.task_complete, "target_id": String(npc.target_id), + "travel_target_position": [ + npc.travel_target_position.x, + npc.travel_target_position.y, + npc.travel_target_position.z + ], + "has_travel_target": npc.has_travel_target, "last_task": String(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: + var version := int(record_data.get("schema_version", -1)) + if version == LEGACY_SCHEMA_VERSION: + record_data = _migrate_v1(record_data) + elif version != 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" + "target_id", "travel_target_position", "has_travel_target", + "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 + var saved_target_position = record_data["travel_target_position"] + if ( + not saved_target_position is Array + or saved_target_position.size() != 3 + ): + return null var profession_id := StringName(record_data["profession"]) if SimulationDefinitions.get_profession(profession_id) == null: return null @@ -68,6 +85,16 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: return null return NPCStateRecord.new(record_data) +static func _migrate_v1(legacy_data: Dictionary) -> Dictionary: + var migrated := legacy_data.duplicate(true) + migrated["schema_version"] = SCHEMA_VERSION + migrated["travel_target_position"] = legacy_data.get( + "position", + [0.0, 0.0, 0.0] + ) + migrated["has_travel_target"] = false + return migrated + func restore(debug_logs: bool) -> SimNPC: var random_source := RandomNumberGenerator.new() random_source.seed = String(data["random_seed"]).to_int() @@ -80,6 +107,7 @@ func restore(debug_logs: bool) -> SimNPC: random_source ) var saved_position: Array = data["position"] + var saved_target_position: Array = data["travel_target_position"] npc.hunger = float(data["hunger"]) npc.energy = float(data["energy"]) npc.current_task = StringName(data["current_task"]) @@ -97,6 +125,12 @@ func restore(debug_logs: bool) -> SimNPC: npc.task_progress = float(data["task_progress"]) npc.task_complete = bool(data["task_complete"]) npc.target_id = StringName(data["target_id"]) + npc.travel_target_position = Vector3( + float(saved_target_position[0]), + float(saved_target_position[1]), + float(saved_target_position[2]) + ) + npc.has_travel_target = bool(data["has_travel_target"]) npc.last_task = StringName(data["last_task"]) npc.random_source.state = String(data["random_state"]).to_int() npc.debug_logs = debug_logs diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd new file mode 100644 index 0000000..eafed1e --- /dev/null +++ b/tests/npc_visual_lifecycle_test.gd @@ -0,0 +1,94 @@ +extends SceneTree + +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + await physics_frame + await physics_frame + + var manager: Node = main_scene.get_node("SimulationManager") + var view: Node = main_scene.get_node("WorldViewManager") + manager.set_process(false) + var npc: SimNPC = manager.npcs[0] + var visual: Node3D = view.active_npc_visuals[npc.id] + visual.set_physics_process(false) + + var resource_state: ResourceStateRecord = manager.get_resource_state( + &"berry_bush_01" + ) + npc.set_task(SimulationIds.ACTION_GATHER_FOOD) + npc.target_id = resource_state.get_node_id() + npc.travel_target_position = Vector3(-6.0, 0.0, -8.0) + npc.has_travel_target = true + resource_state.reserve(npc.id) + manager.request_current_travel(npc.id) + + var saved_action := npc.current_task + var saved_target_id := npc.target_id + var saved_target_position := npc.travel_target_position + var authoritative_position := Vector3(-2.5, 0.5, 1.75) + visual.global_position = authoritative_position + + _check(view.despawn_npc_visual(npc.id), "Visual should unload") + await process_frame + _check( + npc.position.is_equal_approx(authoritative_position), + "Unload should synchronize the last active position" + ) + _check( + npc.current_task == saved_action + and npc.target_id == saved_target_id + and npc.travel_target_position.is_equal_approx(saved_target_position), + "Unload should preserve action and target state" + ) + _check( + resource_state.get_reserved_by() == npc.id, + "Unload should preserve the resource reservation" + ) + var unloaded_checksum: String = manager.get_state_checksum() + + _check(view.reload_npc_visual(npc.id), "Visual should reload") + var reloaded: Node3D = view.active_npc_visuals[npc.id] + reloaded.set_physics_process(false) + _check( + reloaded.global_position.is_equal_approx(authoritative_position), + "Reloaded visual should use authoritative position" + ) + var resumed_target: Vector3 = reloaded.get("current_target") + _check( + resumed_target.is_equal_approx(saved_target_position), + "Reloaded visual should resume the persisted travel target" + ) + _check( + manager.get_state_checksum() == unloaded_checksum, + "Loading a visual must not mutate simulation state" + ) + _check( + resource_state.get_reserved_by() == npc.id, + "Reload should preserve the reservation" + ) + + var moved_position := authoritative_position + Vector3(0.5, 0.0, 0.25) + reloaded.emit_signal("position_changed", npc.id, moved_position) + _check( + npc.position.is_equal_approx(moved_position), + "Active position callback should synchronize simulation state" + ) + + if failures.is_empty(): + print("[TEST] NPC visual lifecycle passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/npc_visual_lifecycle_test.gd.uid b/tests/npc_visual_lifecycle_test.gd.uid new file mode 100644 index 0000000..7170a9b --- /dev/null +++ b/tests/npc_visual_lifecycle_test.gd.uid @@ -0,0 +1 @@ +uid://eji34itd1s6k diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index 094a611..ca59366 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -8,6 +8,7 @@ func _initialize() -> void: func _run() -> void: await _test_deterministic_continuation() await _test_resource_state_round_trip() + _test_legacy_npc_migration() _test_legacy_resource_migration() _test_schema_rejection() @@ -160,6 +161,27 @@ func _test_legacy_resource_migration() -> void: "Resource migration should preserve mutable authority" ) +func _test_legacy_npc_migration() -> void: + var npc := SimNPC.new( + 88, + "LegacyNPC", + SimulationIds.PROFESSION_FARMER, + 5.0, + 5.0 + ) + var legacy_data := NPCStateRecord.capture(npc).to_dictionary() + legacy_data["schema_version"] = NPCStateRecord.LEGACY_SCHEMA_VERSION + legacy_data.erase("travel_target_position") + legacy_data.erase("has_travel_target") + var migrated := NPCStateRecord.from_dictionary(legacy_data) + _check(migrated != null, "NPCStateRecord v1 should migrate explicitly") + if migrated == null: + return + _check( + not bool(migrated.data["has_travel_target"]), + "Legacy NPC migration should not invent an active travel target" + ) + func _create_manager(seed_value: int) -> Node: var manager: Node = load("res://simulation/SimulationManager.gd").new() manager.simulation_seed = seed_value diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index f3abaa4..e57bc97 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -17,7 +17,6 @@ func _ready() -> void: func initialize_world_view() -> void: await get_tree().physics_frame - spawn_initial_npcs() if simulation_manager.has_signal("npc_target_requested"): simulation_manager.npc_target_requested.connect( _on_npc_target_requested @@ -34,7 +33,7 @@ func initialize_world_view() -> void: simulation_manager.npc_died.connect(_on_npc_died) else: push_error("WorldViewManager: SimulationManager has no npc_died signal") - request_current_targets() + spawn_initial_npcs() func spawn_initial_npcs() -> void: if simulation_manager == null: @@ -65,14 +64,37 @@ func spawn_npc_visual(npc: SimNPC) -> void: visual.navigation_failed.connect(_on_npc_visual_navigation_failed) else: push_error("WorldViewManager: NPCVisual has no navigation_failed signal") + if visual.has_signal("position_changed"): + visual.position_changed.connect(_on_npc_visual_position_changed) + else: + push_error("WorldViewManager: NPCVisual has no position_changed signal") active_npc_visuals[npc.id] = visual debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position]) + if npc.is_dead: + visual.apply_dead_visual_state() + elif npc.task_state == SimNPC.TASK_STATE_TRAVELING: + simulation_manager.request_current_travel(npc.id) -func request_current_targets() -> void: +func despawn_npc_visual(npc_id: int) -> bool: + var visual = active_npc_visuals.get(npc_id) as Node3D + if visual == null: + return false + simulation_manager.synchronize_npc_position( + npc_id, + visual.global_position + ) + active_npc_visuals.erase(npc_id) + visual.queue_free() + return true + +func reload_npc_visual(npc_id: int) -> bool: + if active_npc_visuals.has(npc_id): + return false for npc in simulation_manager.npcs: - if npc.task_state != SimNPC.TASK_STATE_TRAVELING: - continue - _on_npc_target_requested(npc) + if npc.id == npc_id: + spawn_npc_visual(npc) + return true + return false func _on_npc_target_requested(npc: SimNPC) -> void: var visual = active_npc_visuals.get(npc.id) as Node3D @@ -98,6 +120,12 @@ func _on_npc_visual_arrived(sim_id: int) -> void: if simulation_manager == null: push_error("WorldViewManager: simulation_manager missing during arrival") return + var visual = active_npc_visuals.get(sim_id) as Node3D + if visual != null: + simulation_manager.synchronize_npc_position( + sim_id, + visual.global_position + ) simulation_manager.notify_npc_arrived(sim_id) func _on_npc_visual_navigation_failed(sim_id: int) -> void: @@ -105,8 +133,20 @@ func _on_npc_visual_navigation_failed(sim_id: int) -> void: if simulation_manager == null: push_error("WorldViewManager: simulation_manager missing during failure") return + var visual = active_npc_visuals.get(sim_id) as Node3D + if visual != null: + simulation_manager.synchronize_npc_position( + sim_id, + visual.global_position + ) simulation_manager.notify_npc_navigation_failed(sim_id) +func _on_npc_visual_position_changed( + sim_id: int, + active_position: Vector3 +) -> void: + simulation_manager.synchronize_npc_position(sim_id, active_position) + func _on_npc_died(npc: SimNPC) -> void: if not active_npc_visuals.has(npc.id): return