Files
gamedev-the-steward/simulation/SimulationManager.gd
T
admin b8752f8835 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
2026-07-04 19:24:30 +02:00

237 lines
6.1 KiB
GDScript

extends Node
signal npc_task_changed(npc: SimNPC, old_task: String, new_task: String)
signal village_changed(village: SimVillage)
signal npc_died(npc: SimNPC)
var village := SimVillage.new()
const DEBUG_LOGS := true
var npcs: Array[SimNPC] = []
var tick_interval := 1.2
var tick_timer := 0.0
var tick_count := 0
var names := [
"Amina", "Tarik", "Jasmin"
]
var professions := [
"farmer",
"woodcutter",
"guard",
"scholar",
"wanderer"
]
func _ready() -> void:
randomize()
village.update_modifiers()
village.update_priorities()
generate_npcs()
print("--- Simulation started ---")
func _process(delta: float) -> void:
tick_timer += delta
if tick_timer >= tick_interval:
tick_timer -= tick_interval
simulate_tick()
func generate_npcs() -> void:
for i in range(names.size()):
var npc := SimNPC.new(
i,
names[i],
professions.pick_random(),
randf_range(1.0, 10.0),
randf_range(1.0, 10.0)
)
npcs.append(npc)
func simulate_tick() -> void:
tick_count += 1
print("--- Tick ", tick_count, " ---")
var village_was_changed := false
for npc in npcs:
var old_task := npc.current_task
var old_target := npc.target_id
var old_state := npc.task_state
var was_complete := npc.task_complete
var was_dead := npc.is_dead
npc.simulate_tick(village)
if old_task != npc.current_task and old_target != &"":
release_npc_reservation(npc.id)
if not was_dead and npc.is_dead:
if old_target != &"":
release_npc_reservation(npc.id)
npc_died.emit(npc)
npc_task_changed.emit(npc, old_task, npc.current_task)
if DEBUG_LOGS:
print("[SimulationManager] NPC died: ", npc.npc_name)
if old_task != npc.current_task and not npc.is_dead:
npc_task_changed.emit(npc, old_task, npc.current_task)
if not was_complete and npc.task_complete and not npc.is_dead:
var completed_task := npc.current_task
var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task()
if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id)
if node != null and node.reserved_by == npc.id:
var extracted := node.extract()
if extracted > 0:
village.apply_resource_delta(node.resource_id, extracted)
if DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " extracted ", extracted, " ", node.resource_id, " from ", node.node_id)
elif DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid")
release_npc_reservation(npc.id)
elif completed_task not in ["gather_food", "gather_wood"]:
village.apply_npc_task(npc)
elif DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": no resource target")
village_was_changed = true
npc.task_complete = true
npc.task_state = SimNPC.TASK_STATE_IDLE
npc.last_task = completed_task
npc.current_task = "idle"
if needs_immediate_food_after_gather and village.food > 0:
npc.set_task("eat", 1.0)
npc_task_changed.emit(npc, completed_task, npc.current_task)
if DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.")
if DEBUG_LOGS:
print(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if old_state != npc.task_state:
print("[SimulationManager] State changed: ", npc.npc_name, " ", old_state, " -> ", npc.task_state)
if village_was_changed:
village_changed.emit(village)
if DEBUG_LOGS:
print(village.get_summary())
func set_npc_target_id(npc_id: int, target_id: StringName) -> void:
for npc in npcs:
if npc.id == npc_id:
npc.target_id = target_id
return
func release_npc_reservation(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id)
if node != null:
node.release(npc.id)
npc.target_id = &""
return
func notify_npc_arrived(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
if npc.is_dead:
return
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id)
if node == null or not node.can_extract():
if DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning")
notify_npc_navigation_failed(npc.id)
return
npc.start_working()
if DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " arrived and started working on: ", npc.current_task)
return
func notify_npc_navigation_failed(npc_id: int) -> void:
for npc in npcs:
if npc.id != npc_id:
continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return
var failed_task := npc.current_task
release_npc_reservation(npc.id)
npc.last_task = failed_task
npc.set_task("wander", 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task)
if DEBUG_LOGS:
print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target")
return
func notify_npc_target_unavailable(npc_id: int) -> void:
notify_npc_navigation_failed(npc_id)
func eat_food(amount: float) -> void:
village.food -= amount
village_changed.emit(village)
func add_food(amount: float) -> void:
village.food += amount
village_changed.emit(village)
func add_wood(amount: float) -> void:
village.wood += amount
village_changed.emit(village)
func add_safety(amount: float) -> void:
village.safety = clamp(village.safety + amount, 0.0, 100.0)
village_changed.emit(village)
func add_knowledge(amount: float) -> void:
village.knowledge += amount
village_changed.emit(village)
func get_starving_count() -> int:
var count := 0
for npc in npcs:
if npc.is_starving:
count += 1
return count