feat: synchronize authoritative npc position

- NpcVisual emits position_changed signal during _physics_process
- SimulationManager.synchronize_npc_position() writes active position
  into SimNPC state on movement, arrival, and navigation failure
- WorldViewManager forwards position_changed and syncs before
  despawn/reload
- SimNPC stores travel_target_position and has_travel_target as
  versioned state; NPCStateRecord v2 persists them with v1 migration
- WorldViewManager.spawn_npc_visual() resumes travel for traveling NPCs
  via SimulationManager.request_current_travel()
- WorldViewManager.despawn_npc_visual() syncs position before removal
- WorldViewManager.reload_npc_visual() spawns a visual from persisted
  state without rerolling target selection
- tests/npc_visual_lifecycle_test.gd proves unload/reload preserves
  action, target, reservation, position, RNG state, and checksum
- simulation_state_serialization_test adds legacy v1 NPC migration test
  that does not invent an active travel target
This commit is contained in:
2026-07-05 17:21:17 +02:00
parent 4f9dc842fd
commit a8ae331f51
8 changed files with 238 additions and 10 deletions
+46 -6
View File
@@ -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