Refactor job queue: add JobState enum, improve pathfinding fallback

- Add JobState enum (Unclaimed, Claimed, Complete) replacing boolean claimed flag
- Increase provisional node limit from 256 to 4096
- Add iter_unclaimed() and claim_job_at() methods to JobQueue
- Update job_assignment to filter idle dorfs, match by distance
- Add unclaim_jobs_for_entity() for handling dorf death/failure
- Add JobId collision detection in debug builds
- Fix demo system to check Pending state for chopping dorfs
- Throttle queue_debug to every 120 ticks
- Add surface position deduplication for terrain chunks
This commit is contained in:
2026-03-29 13:02:05 +01:00
parent 79e0efa409
commit 3c74a68d4d
10 changed files with 278 additions and 90 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ pub const SEED: u32 = 420;
pub const PATHFINDER_SHORT_PATH_MAX_TILES: i32 = 64;
pub const PATHFINDER_MAX_NODES: usize = 15000;
pub const PATHFINDER_PROVISIONAL_NODE_LIMIT: usize = 256;
pub const PATHFINDER_PROVISIONAL_NODE_LIMIT: usize = 4096;
// Tier 1: Same/adjacent chunk -> sync A* (fast, ~87µs)
// Tier 2: 2-4 chunks away -> Provisional + full path via queue
// Tier 3: >4 chunks away -> Hierarchical chunk-path + async segmented A*
+10 -4
View File
@@ -366,9 +366,14 @@ pub fn prepare_paths(
request_id: 0,
});
} else {
info!("[PATH] Hierarchical provisional failed for {:?}: start={:?} goal={:?} chunks={}", entity, start, goal, chunk_distance);
let path = calculate_path_benchmarked(&tilemap, start, goal);
if path.len() <= 1 {
// Path failed — clear target so entity picks a new reachable one
info!(
"[PATH] Short path failed for {:?}, clearing target (path_len={})",
entity,
path.len()
);
ambulatory.target = None;
ambulatory.current_path = None;
} else {
@@ -619,7 +624,7 @@ pub fn movement(
query
.par_iter_mut()
.for_each(|(mut ambulatory, mut transform)| {
info!(
debug!(
"[PATH] Moving: target={:?} current={:?}",
ambulatory.target, transform.translation
);
@@ -1309,9 +1314,10 @@ pub fn calculate_provisional_path(
}
if nodes_expanded >= node_limit {
let goal_standable = is_standable_tile(tilemap, goal);
info!(
"[PATH] FAIL: node_limit hit limit={} start={:?} goal={:?} expanded={}",
node_limit, start, goal, nodes_expanded
"[PATH] FAIL: node_limit hit limit={} start={:?} goal={:?} goal_standable={} expanded={}",
node_limit, start, goal, goal_standable, nodes_expanded
);
return (Vec::new(), nodes_expanded);
}
+8 -5
View File
@@ -16,7 +16,10 @@ pub fn demo_system(
dorf_query: Query<(&TaskQueue, &TaskState), With<EntityType>>,
) {
let any_chopping = dorf_query.iter().any(|(queue, state)| {
let is_active_or_completing = *state == TaskState::Active || *state == TaskState::Completed;
let is_active_or_completing = matches!(
*state,
TaskState::Active | TaskState::Completed | TaskState::Pending
);
if is_active_or_completing {
if let Some(current) = queue.current() {
return matches!(current, Task::ChopTree { .. });
@@ -31,10 +34,10 @@ pub fn demo_system(
}
}
if !job_queue.is_empty() {
let (fell, haul) = job_queue.debug_counts();
info!("[QUEUE] FellTree: {}, HaulCargo: {}", fell, haul);
}
// if !job_queue.is_empty() {
// let (fell, haul) = job_queue.debug_counts();
// info!("[QUEUE] FellTree: {}, HaulCargo: {}", fell, haul);
// }
}
fn find_tree_nearest_origin(
+9
View File
@@ -146,6 +146,10 @@ pub fn task_executor_system(
} => match step {
ChopStep::MovingToTree { ref mut approach } => {
let job_id_val = *job_id;
info!(
"[EXECUTOR] ChopTree {:?}: trunk_pos={:?} approach={:?} target={:?}",
entity, trunk_pos, approach, ambulatory.target
);
if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) {
info!(
"TASK FAILED: {:?} for {:?} - {}",
@@ -168,6 +172,10 @@ pub fn task_executor_system(
// Neighbours at the same z as the trunk base — this IS the floor level.
let trunk_z = trunk_pos.z;
info!(
"[EXECUTOR] Searching for approach tile around trunk at z={}",
trunk_z
);
// Search expanding outward from the trunk for a standable tile
let approach_target = (1..=4).find_map(|radius: i32| {
for dx in -radius..=radius {
@@ -196,6 +204,7 @@ pub fn task_executor_system(
match approach_target {
Some(target) => {
info!("[EXECUTOR] ChopTree found approach tile: {:?}, setting target", target);
ambulatory.target = Some(target);
ambulatory.current_path = None;
}
+111 -51
View File
@@ -6,12 +6,14 @@ use crate::entities::tasks::job_queue::{JobId, JobKind, JobQueue};
use crate::entities::tasks::jobs::{FellTreeJob, HaulCargoJob, IdleJob};
use crate::world::tiles::TileMap;
use bevy::prelude::*;
use std::collections::HashSet;
pub fn job_assignment_system(
mut job_queue: ResMut<JobQueue>,
tilemap: Res<TileMap>,
mut dorf_query: Query<
(
Entity,
&mut TaskQueue,
&mut TaskState,
&Transform,
@@ -21,59 +23,117 @@ pub fn job_assignment_system(
With<EntityType>,
>,
) {
for (mut queue, mut state, transform, haul_slot, mut ambulatory) in dorf_query.iter_mut() {
let is_idle = queue.is_empty() || matches!(queue.current(), Some(Task::Idle { .. }));
if !is_idle {
continue;
}
if haul_slot.is_occupied() {
continue;
}
let dorf_pos_ivec = transform.translation.as_ivec3();
let dorf_pos_2d = Vec2::new(transform.translation.x, transform.translation.y);
if let Some((job_id, job)) =
job_queue.pop_best_pathfinding(&tilemap, dorf_pos_ivec, dorf_pos_2d)
{
info!(
"[ASSIGN] Job assigned to dorf at {:?}: {:?}",
dorf_pos_ivec.xy(),
job
);
// Stop the dorf in its tracks so the new Task can take over movement.
ambulatory.current_path = None;
ambulatory.target = None;
ambulatory.path_index = 0;
match job {
JobKind::FellTree { trunk_pos } => {
info!("[ASSIGN] Dorf assigned to FellTree at {:?}", trunk_pos);
queue.clear();
queue.push(FellTreeJob::start(job_id, trunk_pos));
*state = TaskState::Pending;
}
JobKind::HaulCargo {
cargo_entity,
cargo_pos,
dest,
} => {
info!("[ASSIGN] Dorf assigned to HaulCargo at {:?}", cargo_pos);
queue.clear();
queue.push(HaulCargoJob::start(job_id, cargo_entity, cargo_pos, dest));
*state = TaskState::Pending;
}
let idle_dorfs: Vec<(Entity, IVec3)> = dorf_query
.iter_mut()
.filter(|(entity, queue, _state, transform, haul_slot, _)| {
let pos = transform.translation.as_ivec3();
let pos_standable = tilemap.is_standable(pos);
let current_task = queue.current().map(|t| t.name()).unwrap_or("EMPTY");
let is_idle = queue.is_empty() || matches!(queue.current(), Some(Task::Idle { .. }));
let no_cargo = haul_slot.is_empty();
let should_assign = is_idle && no_cargo;
if !should_assign {
info!("[ASSIGN] Filter out dorf {:?}: pos_standable={} current={} is_idle={} no_cargo={}",
entity, pos_standable, current_task, is_idle, no_cargo);
}
} else if queue.is_empty() {
let behaviour = EntityBehaviourRegistry::global_get("dorf");
let origin = transform.translation.as_ivec3();
if queue.is_empty() {
queue.push(IdleJob::start(JobId::default(), origin, &behaviour.idle));
*state = TaskState::Pending;
if !pos_standable {
return false;
}
is_idle && no_cargo
})
.map(|(entity, _, _, transform, _, _)| (entity, transform.translation.as_ivec3()))
.collect();
if idle_dorfs.is_empty() {
return;
}
let mut sorted_jobs: Vec<(usize, JobKind)> = job_queue
.iter_unclaimed()
.map(|(idx, kind)| (idx, kind.clone()))
.collect();
sorted_jobs.sort_by(|a, b| b.1.priority().cmp(&a.1.priority()));
info!(
"[ASSIGN] idle_dorfs={} unclaimed_jobs={}",
idle_dorfs.len(),
sorted_jobs.len()
);
if sorted_jobs.is_empty() {
return;
}
let mut assigned_dorfs: HashSet<Entity> = HashSet::new();
for (job_idx, job_kind) in sorted_jobs {
let target_pos = job_kind.target();
let mut remaining: Vec<_> = idle_dorfs
.iter()
.filter(|(e, _)| !assigned_dorfs.contains(e))
.collect();
remaining.sort_by_key(|(_, pos)| {
(pos.x - target_pos.x).abs()
+ (pos.y - target_pos.y).abs()
+ (pos.z - target_pos.z).abs()
});
for (dorf_entity, dorf_pos) in remaining {
if let Some((job_id, _)) =
job_queue.claim_job_at(job_idx, *dorf_entity, &tilemap, *dorf_pos)
{
if let Ok((_, mut queue, mut state, _, _, mut ambulatory)) =
dorf_query.get_mut(*dorf_entity)
{
let task = match &job_kind {
JobKind::FellTree { trunk_pos } => Task::ChopTree {
job_id,
trunk_pos: *trunk_pos,
chop_ticks: 120,
step: crate::entities::tasks::components::ChopStep::MovingToTree {
approach: None,
},
},
JobKind::HaulCargo {
cargo_entity,
cargo_pos,
dest,
} => Task::HaulCargo {
job_id,
cargo_entity: *cargo_entity,
cargo_pos: *cargo_pos,
dest: *dest,
step: crate::entities::tasks::components::HaulStep::MovingToCargo {
approach: None,
},
},
};
let task_name = task.name();
queue.clear();
queue.push(task);
*state = TaskState::Pending;
info!(
"[ASSIGN] Assigned {:?} to dorf {:?}: {} -> Pending",
*dorf_entity, dorf_pos, task_name
);
ambulatory.current_path = None;
ambulatory.target = None;
ambulatory.path_index = 0;
assigned_dorfs.insert(*dorf_entity);
break;
}
}
}
}
for (dorf_entity, mut queue, mut state, transform, _, _) in dorf_query.iter_mut() {
if queue.is_empty() && !matches!(*state, TaskState::Active) {
let behaviour = EntityBehaviourRegistry::global_get("dorf");
let origin = transform.translation.as_ivec3();
queue.push(IdleJob::start(JobId::default(), origin, &behaviour.idle));
*state = TaskState::Pending;
}
}
}
+121 -23
View File
@@ -5,6 +5,18 @@ use smallvec::SmallVec;
use std::cmp::Ordering;
use std::collections::VecDeque;
/// Job state tracking - replaces simple boolean claimed flag.
#[derive(Debug, Clone, PartialEq)]
pub enum JobState {
/// No dorf has taken this job.
Unclaimed,
/// A dorf has claimed this job and is executing it.
/// Stores the entity so we can unclaim if the dorf dies/fails.
Claimed(Entity),
/// All tasks for this job are done. Pending removal.
Complete,
}
#[derive(Clone, Debug, PartialEq)]
pub enum JobKind {
FellTree {
@@ -45,7 +57,7 @@ impl JobKind {
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JobId(u16);
pub struct JobId(u32);
impl JobId {
#[inline]
@@ -57,7 +69,7 @@ impl JobId {
struct Entry {
kind: JobKind,
claimed: bool,
state: JobState,
id: JobId,
}
@@ -70,10 +82,18 @@ pub struct JobQueue {
impl JobQueue {
#[inline]
pub fn push(&mut self, kind: JobKind) {
let new_id = self.next_id.next();
#[cfg(debug_assertions)]
if self.jobs.iter().any(|e| e.id == new_id) {
panic!(
"JobId collision detected: {:?} already exists in queue",
new_id
);
}
self.jobs.push_back(Entry {
kind,
claimed: false,
id: self.next_id.next(),
state: JobState::Unclaimed,
id: new_id,
});
}
@@ -86,31 +106,38 @@ impl JobQueue {
pub fn unclaim_job(&mut self, id: JobId) {
if let Some(entry) = self.jobs.iter_mut().find(|j| j.id == id) {
entry.claimed = false;
entry.state = JobState::Unclaimed;
info!("[QUEUE] Job ID {:?} marked unclaimed for reassignment", id);
}
}
pub fn unclaim_jobs_for_entity(&mut self, entity: Entity) {
for entry in self.jobs.iter_mut() {
if let JobState::Claimed(e) = entry.state {
if e == entity {
entry.state = JobState::Unclaimed;
}
}
}
}
pub fn pop_best_pathfinding(
&mut self,
tilemap: &crate::world::tiles::TileMap,
dorf_entity: Entity,
dorf_pos: IVec3,
_dorf_pos_2d: Vec2,
) -> Option<(JobId, JobKind)> {
if self.jobs.is_empty() {
return None;
}
// 1. Gather all unclaimed jobs and score them for sorting.
// We store: (Queue Index, Priority, Rough Distance)
let mut candidates: Vec<(usize, u8, i32)> = self
.jobs
.iter()
.enumerate()
.filter(|(_, entry)| !entry.claimed)
.filter(|(_, entry)| entry.state == JobState::Unclaimed)
.map(|(i, entry)| {
let target = entry.kind.target();
// Manhattan distance is a cheap heuristic for sorting
let dist = (target.x - dorf_pos.x).abs()
+ (target.y - dorf_pos.y).abs()
+ (target.z - dorf_pos.z).abs();
@@ -118,15 +145,10 @@ impl JobQueue {
})
.collect();
// 2. Sort: Highest Priority first. If tied, Shortest Distance first.
candidates.sort_by(|a, b| {
b.1.cmp(&a.1) // Descending priority
.then(a.2.cmp(&b.2)) // Ascending distance
});
candidates.sort_by(|a, b| b.1.cmp(&a.1).then(a.2.cmp(&b.2)));
let max_path_dist = 1024;
let max_path_dist = 4096;
// 3. Evaluate the sorted jobs with actual pathfinding
for (idx, _pri, _dist) in candidates {
let entry = &self.jobs[idx];
@@ -137,11 +159,10 @@ impl JobQueue {
let mut best_target = None;
let mut shortest_path_len = usize::MAX;
// Pathfind to EVERY standable adjacent tile to find the absolute closest one
for tile in standable_tiles {
let path =
calculate_provisional_path(tilemap, dorf_pos, tile, max_path_dist);
if !path.is_empty() && path.len() < shortest_path_len {
if path.len() > 1 && path.len() < shortest_path_len {
shortest_path_len = path.len();
best_target = Some(tile);
}
@@ -151,7 +172,7 @@ impl JobQueue {
JobKind::HaulCargo { cargo_pos, .. } => {
let path =
calculate_provisional_path(tilemap, dorf_pos, *cargo_pos, max_path_dist);
if !path.is_empty() {
if path.len() > 1 {
Some(*cargo_pos)
} else {
None
@@ -159,15 +180,13 @@ impl JobQueue {
}
};
// 4. If we found a valid path to this job, claim it and return it
if let Some(target_pos) = best_path_target {
info!(
"[QUEUE] Job Claimed: kind={:?} target={:?} dorf={:?}",
entry.kind, target_pos, dorf_pos
);
self.jobs[idx].claimed = true;
// Note: JobKind needs `#[derive(Clone)]` if it doesn't have it already
self.jobs[idx].state = JobState::Claimed(dorf_entity);
return Some((self.jobs[idx].id, self.jobs[idx].kind.clone()));
}
}
@@ -208,6 +227,85 @@ impl JobQueue {
tiles
}
pub fn iter_unclaimed(&self) -> impl Iterator<Item = (usize, &JobKind)> {
self.jobs
.iter()
.enumerate()
.filter(|(_, entry)| entry.state == JobState::Unclaimed)
.map(|(idx, entry)| (idx, &entry.kind))
}
pub fn claim_job_at(
&mut self,
idx: usize,
dorf_entity: Entity,
tilemap: &crate::world::tiles::TileMap,
dorf_pos: IVec3,
) -> Option<(JobId, JobKind)> {
const MAX_PATH_DIST: usize = 4096;
let entry = self.jobs.get(idx)?;
if entry.state != JobState::Unclaimed {
info!(
"[CLAIM] idx={} state={:?} - not unclaimed",
idx, entry.state
);
return None;
}
let job_id = entry.id;
let job_kind = entry.kind.clone();
info!(
"[CLAIM] idx={} dorf={:?} dorf_pos={:?} kind={:?}",
idx, dorf_entity, dorf_pos, job_kind
);
let best_path_target = match &job_kind {
JobKind::FellTree { trunk_pos } => {
let mut standable_tiles = Self::find_all_standable_adjacent(trunk_pos, tilemap);
standable_tiles.sort_by_key(|tile| {
let dx = tile.x - dorf_pos.x;
let dy = tile.y - dorf_pos.y;
let dz = tile.z - dorf_pos.z;
dx * dx + dy * dy + dz * dz
});
standable_tiles.iter().find_map(|tile| {
let path = calculate_provisional_path(tilemap, dorf_pos, *tile, MAX_PATH_DIST);
if path.len() > 1 {
Some(*tile)
} else {
None
}
})
}
JobKind::HaulCargo { cargo_pos, .. } => {
let path = calculate_provisional_path(tilemap, dorf_pos, *cargo_pos, MAX_PATH_DIST);
if path.len() > 1 {
Some(*cargo_pos)
} else {
None
}
}
};
if let Some(target_pos) = best_path_target {
info!(
"[QUEUE] Job Claimed: kind={:?} target={:?} dorf={:?}",
job_kind, target_pos, dorf_pos
);
self.jobs[idx].state = JobState::Claimed(dorf_entity);
Some((job_id, job_kind))
} else {
info!(
"[QUEUE] Job NOT Claimed: kind={:?} dorf={:?} - no path found",
job_kind, dorf_pos
);
None
}
}
#[inline]
pub fn is_empty(&self) -> bool {
self.jobs.is_empty()
+6
View File
@@ -6,7 +6,13 @@ use bevy::prelude::*;
pub fn queue_debug_system(
job_queue: Res<JobQueue>,
dorf_query: Query<(&TaskQueue, &TaskState, &Transform), With<EntityType>>,
mut tick: Local<u32>,
) {
*tick = tick.wrapping_add(1);
if *tick % 120 != 0 {
return;
}
let (fell_count, haul_count) = job_queue.debug_counts();
let total = job_queue.len();
+3 -3
View File
@@ -87,7 +87,7 @@ pub fn execute_idle(
}
IdleState::Moving { target } => {
info!(
debug!(
"[IDLE] Moving: target={:?} current={:?}",
target, transform.translation
);
@@ -99,7 +99,7 @@ pub fn execute_idle(
let arrived = dist_sq < arrive_threshold_sq;
let no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
info!("[IDLE] Moving: arrived={} no_nav={}", arrived, no_nav);
debug!("[IDLE] Moving: arrived={} no_nav={}", arrived, no_nav);
if arrived || no_nav {
ambulatory.target = None;
ambulatory.current_path = None;
@@ -225,7 +225,7 @@ fn pick_gaussian_target(
}
if let Some(pos) = chosen {
info!(
debug!(
"[IDLE] Target picked at {:?} (Weight Sum: {:.4})",
pos, weight_sum
);
+5 -2
View File
@@ -329,8 +329,11 @@ pub fn apply_terrain_blobs(
occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos });
}
for surface in blob.surface_positions.iter() {
tilemap.surface_positions.push(surface.0.as_ivec3());
if !tilemap.applied_surface_chunks.contains(&blob.chunk_pos) {
tilemap.applied_surface_chunks.insert(blob.chunk_pos);
for surface in blob.surface_positions.iter() {
tilemap.surface_positions.push(surface.0.as_ivec3());
}
}
forrestry_event_writer.write(ChunkForrestryEvent {
+4 -1
View File
@@ -29,7 +29,7 @@
//! - item_tiles: Entity references per tile position
use bevy::prelude::*;
use rustc_hash::FxHashMap;
use rustc_hash::{FxHashMap, FxHashSet};
use super::chunk_data::ChunkData;
use crate::constants::ITILE_SIZE;
@@ -214,6 +214,9 @@ pub struct TileMap {
/// Surface tile positions for idle pathfinding and valid spawn targets.
/// Populated from TerrainBlob during terrain processing.
pub surface_positions: Vec<IVec3>,
/// Chunks whose surface positions have been committed to surface_positions.
/// Prevents duplicates when chunks are regenerated during world expansion.
pub applied_surface_chunks: FxHashSet<IVec2>,
}
impl TileMap {