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
+31 -1
View File
@@ -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
})