Files
dorf/src/entities/tasks/job_pathfinding.rs
T
popertots 1d78c211ad fix: cargo standability and clear old task target
- Make is_standable return true for cargo tiles (logs, rocks can be stood on)
- Clear ambulatory.target when assigning new task to prevent old targets persisting

These fixes address the 'Lost Signal' bug where dorfs would freeze:
1. Cargo tiles were incorrectly flagged as non-standable, blocking pathfinding
2. Old Idle task targets persisted when new jobs assigned, causing dorfs to path to wrong locations
2026-04-06 00:23:09 +01:00

354 lines
13 KiB
Rust

use bevy::prelude::*;
use smallvec::SmallVec;
use crate::config::GameConfig;
use crate::entities::behaviour::EntityType;
use crate::entities::shared_components::Ambulatory;
use crate::entities::shared_systems::pathfinding::calculate_traversal_distance;
use crate::entities::tasks::components::{ChopStep, HaulStep, Task, TaskQueue, TaskState};
use crate::entities::tasks::job_queue::{JobId, JobKind, JobQueue, JobState};
use crate::world::tiles::TileMap;
const MAX_JOBS_PER_TICK: usize = 5;
pub fn job_pathfinding_system(
mut job_queue: ResMut<JobQueue>,
config: Res<GameConfig>,
tilemap: Res<TileMap>,
mut dorf_query: Query<
(
Entity,
&mut TaskQueue,
&mut TaskState,
&Transform,
&mut Ambulatory,
),
With<EntityType>,
>,
) {
let mut unclaimed: Vec<usize> = job_queue.iter_unclaimed().map(|(idx, _)| idx).collect();
if unclaimed.is_empty() {
return;
}
unclaimed.sort_by(|&a, &b| {
let pri_a = job_queue.get_job_priority(a).unwrap_or(0);
let pri_b = job_queue.get_job_priority(b).unwrap_or(0);
pri_b.cmp(&pri_a)
});
info!("[PATHFIND] Processing {} unclaimed jobs", unclaimed.len());
let locked_dorfs = job_queue.get_locked_dorfs();
if !locked_dorfs.is_empty() {
info!("[PATHFIND] Locked dorfs: {:?}", locked_dorfs.len());
}
// Track dorfs assigned jobs in this frame to prevent multiple assignments
let mut assigned_this_frame: std::collections::HashSet<Entity> =
std::collections::HashSet::new();
for job_idx in unclaimed.into_iter().take(MAX_JOBS_PER_TICK) {
let (kind, state, _) = match job_queue.get_job_at(job_idx) {
Some(k) => k,
None => continue,
};
if !matches!(state, JobState::Unclaimed) {
info!(
"[PATHFIND] Job idx={} state={:?} - skipping",
job_idx, state
);
continue;
}
let kind = kind.clone();
let target_pos = kind.target();
let scope = job_queue.get_scope_for_job(job_idx);
let retry_count = job_queue.get_job_retry_count(job_idx).unwrap_or(0);
info!(
"[PATHFIND] Job idx={} kind={:?} target={:?} scope={} retry_count={}",
job_idx, kind, target_pos, scope, retry_count
);
// Collect all dorfs and their current task status for debugging
let mut total_dorfs = 0;
let mut locked_count = 0;
let mut not_standable_count = 0;
let mut busy_count = 0; // Truly busy: Active AND non-Idle
let candidate_dorfs: Vec<(Entity, IVec3)> = dorf_query
.iter_mut()
.filter_map(|(entity, queue, state, transform, _)| {
total_dorfs += 1;
if locked_dorfs.contains(&entity) || assigned_this_frame.contains(&entity) {
locked_count += 1;
return None;
}
let pos = transform.translation.as_ivec3();
if !tilemap.is_standable(pos) {
not_standable_count += 1;
info!(
"[PATHFIND] Dorf {:?} NOT STANDABLE at pos={:?} z_level={}",
entity,
pos,
pos.z / crate::constants::ITILE_SIZE
);
return None;
}
// A dorf is available if:
// 1. Queue is empty, OR
// 2. Current task is Idle (we can replace it)
let current_task = queue.current();
let is_idle = queue.is_empty()
|| current_task
.map(|t| matches!(t, Task::Idle { .. }))
.unwrap_or(false);
// A dorf is "busy" if they're Active AND doing non-Idle work
// We can interrupt Idle tasks but not other tasks
let state_val: &TaskState = &*state;
let is_busy = matches!(state_val, TaskState::Active) && !is_idle;
if is_busy {
busy_count += 1;
return None;
}
Some((entity, pos))
})
.collect();
info!(
"[PATHFIND] Dorfs: total={} locked={} not_standable={} busy={} candidates={}",
total_dorfs,
locked_count,
not_standable_count,
busy_count,
candidate_dorfs.len()
);
if candidate_dorfs.is_empty() {
info!(
"[PATHFIND] No candidate dorfs for job idx={} - incrementing retry",
job_idx
);
job_queue.increment_retry(job_idx);
continue;
}
let mut candidate_dorfs = candidate_dorfs;
candidate_dorfs.sort_by_key(|(_, pos)| {
(pos.x - target_pos.x).abs()
+ (pos.y - target_pos.y).abs()
+ (pos.z - target_pos.z).abs()
});
let dorfs_to_try: Vec<_> = candidate_dorfs.into_iter().take(scope as usize).collect();
info!(
"[PATHFIND] Trying {} dorfs for job idx={}",
dorfs_to_try.len(),
job_idx
);
if dorfs_to_try.is_empty() {
job_queue.increment_retry(job_idx);
continue;
}
let dorf_entities: Vec<Entity> = dorfs_to_try.iter().map(|(e, _)| *e).collect();
if !job_queue.set_job_calculating(job_idx, dorf_entities.clone()) {
info!(
"[PATHFIND] Failed to set jobCalculating for idx={}",
job_idx
);
continue;
}
let mut best_result: Option<(Entity, IVec3, i32)> = None;
match kind {
JobKind::FellTree { trunk_pos } => {
let approach_tiles = find_all_standable_adjacent(&trunk_pos, &tilemap);
info!(
"[PATHFIND] FellTree at {:?}: found {} approach tiles",
trunk_pos,
approach_tiles.len()
);
if approach_tiles.is_empty() {
info!(
"[PATHFIND] No approach tiles for tree at {:?} - suspending job",
trunk_pos
);
job_queue.suspend_job(job_idx);
continue;
}
for (dorf_entity, dorf_pos) in &dorfs_to_try {
let mut best_for_dorf: Option<(IVec3, i32)> = None;
for approach_tile in &approach_tiles {
match calculate_traversal_distance(&tilemap, *dorf_pos, *approach_tile) {
Ok(distance) => match best_for_dorf {
None => best_for_dorf = Some((*approach_tile, distance)),
Some((_, best_dist)) if distance < best_dist => {
best_for_dorf = Some((*approach_tile, distance));
}
_ => {}
},
Err(()) => {
// Path not found for this tile
}
}
}
if let Some((approach, dist)) = best_for_dorf {
info!(
"[PATHFIND] Dorf at {:?} can reach tree via {:?} (dist={})",
dorf_pos, approach, dist
);
match best_result {
None => best_result = Some((*dorf_entity, approach, dist)),
Some((_, _, best_dist)) if dist < best_dist => {
best_result = Some((*dorf_entity, approach, dist));
}
_ => {}
}
} else {
info!(
"[PATHFIND] Dorf at {:?} CANNOT reach tree at {:?}",
dorf_pos, trunk_pos
);
}
}
}
JobKind::HaulCargo { cargo_pos, .. } => {
info!("[PATHFIND] HaulCargo at {:?}", cargo_pos);
for (dorf_entity, dorf_pos) in &dorfs_to_try {
match calculate_traversal_distance(&tilemap, *dorf_pos, cargo_pos) {
Ok(distance) => {
info!(
"[PATHFIND] Dorf at {:?} can reach cargo (dist={})",
dorf_pos, distance
);
match best_result {
None => best_result = Some((*dorf_entity, cargo_pos, distance)),
Some((_, _, best_dist)) if distance < best_dist => {
best_result = Some((*dorf_entity, cargo_pos, distance));
}
_ => {}
}
}
Err(()) => {
info!(
"[PATHFIND] Dorf at {:?} CANNOT reach cargo at {:?}",
dorf_pos, cargo_pos
);
}
}
}
}
}
if let Some((dorf_entity, approach_target, dist)) = best_result {
info!(
"[PATHFIND] Best dorf {:?} with approach {:?} (dist={})",
dorf_entity, approach_target, dist
);
if let Some(job_id) = job_queue.assign_job(job_idx, dorf_entity, approach_target) {
if let Ok((_, mut queue, mut state, _, mut ambulatory)) =
dorf_query.get_mut(dorf_entity)
{
let job_kind = match job_queue.get_job_kind_at(job_idx) {
Some(k) => k,
None => continue,
};
let task = match job_kind {
JobKind::FellTree { trunk_pos } => Task::ChopTree {
job_id,
trunk_pos,
chop_ticks: 120,
step: ChopStep::MovingToTree {
approach: Some(approach_target),
},
},
JobKind::HaulCargo {
cargo_entity,
cargo_pos,
dest,
} => Task::HaulCargo {
job_id,
cargo_entity,
cargo_pos,
dest,
step: HaulStep::MovingToCargo {
approach: Some(approach_target),
},
},
};
queue.clear();
queue.push(task);
*state = TaskState::Pending;
ambulatory.current_path = None;
ambulatory.path_index = 0;
ambulatory.target = None; // Clear old target from previous task
info!(
"[PATHFIND] SUCCESS: Assigned job {:?} to dorf {:?} with approach {:?}",
job_id, dorf_entity, approach_target
);
assigned_this_frame.insert(dorf_entity);
}
}
} else {
info!("[PATHFIND] NO PATH FOUND for any dorf - retry_count will increment");
job_queue.increment_retry(job_idx);
for entity in &dorf_entities {
job_queue.unclaim_jobs_for_entity(*entity);
}
let retry_count = job_queue.get_job_retry_count(job_idx).unwrap_or(0);
if retry_count >= 10 {
info!(
"[PATHFIND] Job idx={} suspended after {} retries",
job_idx, retry_count
);
job_queue.suspend_job(job_idx);
}
}
}
}
fn find_all_standable_adjacent(trunk_pos: &IVec3, tilemap: &TileMap) -> SmallVec<[IVec3; 8]> {
use crate::constants::ITILE_SIZE;
let mut tiles = SmallVec::new();
let standing_z = trunk_pos.z;
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let candidate = IVec3::new(
trunk_pos.x + dx * ITILE_SIZE,
trunk_pos.y + dy * ITILE_SIZE,
standing_z,
);
if tilemap.is_standable(candidate) {
tiles.push(candidate);
}
}
}
tiles
}