feat: add navigation_failed signal with time-based stuck detection and simulation pipeline

NpcVisual:
- Add navigation_failed(sim_id) signal distinct from arrived_at_target
  so the simulation layer can distinguish genuine arrival from failure
- Replace integer stuck_counter with delta-based stuck_time and
  exported stuck_timeout (1.5s) for frame-rate-independent detection
- Add path_request_id / path_pending mechanism to reject stale
  async path results when a new target is set or NPC dies
- On empty path or stuck movement, emit navigation_failed instead
  of arriving; only emit arrived_at_target when truly close to target
- Reduce arrival_distance back to 0.6 now that navigation_failure
  is properly signalled (no longer needed as a workaround)
- clamp arrival_distance reference in apply_dead_visual_state

SimNPC:
- Remove retry_count (replaced by navigation_failed signal pathway)
- Move last_task assignment from set_task() to task completion in
  SimulationManager so it reflects the task that actually finished

SimulationManager:
- Add notify_npc_navigation_failed(): release reservation, record
  last_task, send NPC to wander for 2s, emit task_changed signal
- Add notify_npc_target_unavailable() as public alias of above
- Guard extraction with node.reserved_by == npc.id to prevent
  stale/illegitimate extraction when reservation is lost
- Guard fallback zone task completion (apply_npc_task) so it only
  fires for non-resource tasks (gather_food/wood with no target
  silently skip instead of applying zone values)
- Remove retry_count safety net (fully replaced by signal pipeline)

WorldViewManager:
- Connect navigation_failed signal in spawn_npc_visual()
- Skip target assignment for empty/idle/dead task states
- When no resource node available for gather_food/wood, call
  notify_npc_target_unavailable() so NPC wanders instead of idling
- Add _on_npc_visual_navigation_failed handler delegating to
  SimulationManager.notify_npc_navigation_failed()
- Remove retry_count references
This commit is contained in:
2026-07-04 19:24:30 +02:00
parent 1837bc0d2d
commit b8752f8835
4 changed files with 106 additions and 37 deletions
+32 -5
View File
@@ -71,6 +71,11 @@ func spawn_npc_visual(npc: SimNPC) -> void:
else:
push_error("WorldViewManager: NPCVisual has no arrived_at_target signal")
if visual.has_signal("navigation_failed"):
visual.navigation_failed.connect(_on_npc_visual_navigation_failed)
else:
push_error("WorldViewManager: NPCVisual has no navigation_failed signal")
active_npc_visuals[npc.id] = visual
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
@@ -83,18 +88,23 @@ func update_npc_targets() -> void:
continue
var visual = active_npc_visuals[npc.id]
var task = npc.current_task
if task in ["", "idle", "dead"]:
continue
var resource_pos = _try_get_resource_target(npc, npc.current_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
continue
var task = npc.current_task
if task == "wander":
var offset = Vector3(randf_range(-6.0, 6.0), 0.0, randf_range(-6.0, 6.0))
visual.set_target_position(visual.global_position + offset)
continue
if task in ["gather_food", "gather_wood"]:
if simulation_manager.has_method("notify_npc_target_unavailable"):
simulation_manager.notify_npc_target_unavailable(npc.id)
continue
var target = get_target_for_task(task)
@@ -137,7 +147,7 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant:
var node := ResourceNode.find_available(action_id, npc.id, visual.global_position)
if node == null:
debug_log("No available resource node for %s, using zone fallback" % task)
debug_log("No available resource node for %s" % task)
return null
if not node.reserve(npc.id):
@@ -145,7 +155,6 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant:
return null
npc.target_id = node.node_id
npc.retry_count = 0
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
return node.interaction_point.global_position
@@ -155,6 +164,9 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo
if not active_npc_visuals.has(npc.id):
return
if new_task in ["", "idle", "dead"]:
return
var visual = active_npc_visuals[npc.id]
var resource_pos = _try_get_resource_target(npc, new_task)
@@ -168,6 +180,10 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo
return
if new_task in ["gather_food", "gather_wood"]:
if simulation_manager.has_method("notify_npc_target_unavailable"):
simulation_manager.notify_npc_target_unavailable(npc.id)
else:
push_error("WorldViewManager: SimulationManager cannot handle unavailable targets")
return
var target := get_target_for_task(new_task)
@@ -186,8 +202,19 @@ func _on_npc_visual_arrived(sim_id: int) -> void:
simulation_manager.notify_npc_arrived(sim_id)
else:
push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method")
func _on_npc_visual_navigation_failed(sim_id: int) -> void:
debug_log("NPC visual navigation failed. sim_id=%s" % sim_id)
if simulation_manager == null:
push_error("WorldViewManager: simulation_manager missing during navigation failure")
return
if simulation_manager.has_method("notify_npc_navigation_failed"):
simulation_manager.notify_npc_navigation_failed(sim_id)
else:
push_error("WorldViewManager: SimulationManager has no notify_npc_navigation_failed method")
func _on_npc_died(npc: SimNPC) -> void:
if not active_npc_visuals.has(npc.id):
return