Fix job queueing system: add JobId tracking for failed job reassignment

- Add JobId (rolling u16 counter) to track jobs through their lifecycle
- Add job_id field to all Task variants (Idle, GoTo, ChopTree, HaulCargo, DropHauled)
- Add unclaim_job() method to requeue failed jobs for reassignment
- Change task failure paths to use unclaim_job() instead of complete_job_by_id()
  - This allows failed jobs to be picked up by another dorf instead of being stuck
- Add to_job_kind() and get_job_id() helper methods to Task
- Fix pathfinding is_standable_tile debug logging spam
- Fix chunk bounds check in ChunkData::is_standable (was checking wrong z range)
This commit is contained in:
2026-03-27 20:28:58 +00:00
parent bd1b45e4d7
commit 79e0efa409
12 changed files with 119 additions and 42 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ pub struct Container {
pub is_open: bool,
/// Items held. May include entities that themselves have Container components.
/// TODO: migrate away from Entity when stable ID scheme exists.
pub contents: SmallVec<[Entity; 4]>,
pub contents: SmallVec<[Entity; 8]>,
/// Cached total weight of contents (updated on add/remove, not per-tick).
pub current_weight: u32,
}
+2
View File
@@ -4,6 +4,7 @@ use crate::constants::*;
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
use crate::entities::cargo::{CarryVisualState, HaulSlot};
use crate::entities::shared_components::Ambulatory;
use crate::entities::tasks::job_queue::JobId;
use crate::entities::tasks::{IdleState, Task, TaskQueue, TaskState};
use crate::game::SpawnDelay;
use crate::world::VisibleGameEntity;
@@ -57,6 +58,7 @@ impl Dorf {
carry_visual: CarryVisualState::new(normal_sprite, carry_sprite),
task_queue: TaskQueue {
tasks: VecDeque::from([Task::Idle {
job_id: JobId::default(),
origin,
sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking {
+1 -19
View File
@@ -902,25 +902,7 @@ fn validate_next_steps(tilemap: &TileMap, path: &[Vec3], start_index: usize, ste
}
fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool {
let result = tilemap.is_standable(pos);
if !result {
let chunk_pos = crate::world::chunks::world_to_chunk(pos);
let chunk_exists = tilemap.chunks.contains_key(&chunk_pos);
let (local_x, local_y, z) = if let Some(c) = tilemap.chunks.get(&chunk_pos) {
crate::world::tiles::chunk_data::ChunkData::world_to_local(pos)
} else {
(0, 0, 0)
};
let floor_exists = tilemap.floor_tiles.contains_key(&pos);
let fixture_exists = tilemap.fixture_tiles.contains_key(&pos);
info!(
"[PATH] is_standable_tile=false: pos={:?} chunk={:?} chunk_loaded={} local=({},{},{}) floor={} fixture={}",
pos, chunk_pos, chunk_exists, local_x, local_y, z, floor_exists, fixture_exists
);
}
result
tilemap.is_standable(pos)
}
/// Sample a random standable tile on an edge of `next_chunk`, picking the one
+38
View File
@@ -14,6 +14,8 @@
use bevy::prelude::*;
use std::collections::VecDeque;
use crate::entities::tasks::{job_queue::JobId, JobKind};
/// Maximum consecutive pick failures before panic.
/// Prevents infinite retry loops when tilemap has no standable tiles.
pub const IDLE_MAX_RETRIES: u32 = 50;
@@ -112,6 +114,7 @@ pub const CHOP_TICKS_DEFAULT: u32 = 120;
#[repr(u8)]
pub enum Task {
Idle {
job_id: JobId,
/// Home position in world units — centre of Gaussian distribution.
/// Set at spawn, does not drift.
origin: IVec3,
@@ -125,6 +128,7 @@ pub enum Task {
/// Completes when entity is within `threshold_tiles` of target.
/// Uses existing pathfinding infrastructure.
GoTo {
job_id: JobId,
target: IVec3,
/// Completion threshold in tiles (Chebyshev distance).
threshold_tiles: i32,
@@ -132,6 +136,7 @@ pub enum Task {
/// Walk to a tree trunk and fell it. Produces Cargo log entities on completion.
ChopTree {
job_id: JobId,
/// World position of the lowest trunk fixture tile.
trunk_pos: IVec3,
/// Ticks required to chop. Future: derived from skill + tool.
@@ -142,6 +147,7 @@ pub enum Task {
/// Pick up a specific Cargo entity and haul it to dest.
/// Renamed from HaulObject for consistency with the Cargo type.
HaulCargo {
job_id: JobId,
/// The Cargo entity to pick up.
cargo_entity: Entity,
/// Tile position of the cargo in the world (for pathfinding).
@@ -153,6 +159,7 @@ pub enum Task {
/// Drop whatever is in HaulSlot at or near pos.
DropHauled {
job_id: JobId,
/// Preferred drop position. Actual drop may be nearby if occupied.
pos: IVec3,
step: DropStep,
@@ -178,6 +185,37 @@ impl Task {
Task::DropHauled { .. } => "DropHauled",
}
}
pub fn to_job_kind(&self) -> Option<JobKind> {
match self {
// 1. Handle the "Global" job types
Task::ChopTree { trunk_pos, .. } => Some(JobKind::FellTree {
trunk_pos: *trunk_pos,
}),
Task::HaulCargo {
cargo_entity,
cargo_pos,
dest,
..
} => Some(JobKind::HaulCargo {
cargo_entity: *cargo_entity,
cargo_pos: *cargo_pos,
dest: *dest,
}),
// 2. Handle everything else (GoTo, DropHauled, Idle, etc.)
// We use the underscore _ to say "for any other variant, do this"
_ => None,
}
}
pub fn get_job_id(&self) -> Option<JobId> {
match self {
Task::ChopTree { job_id, .. } => Some(*job_id),
Task::HaulCargo { job_id, .. } => Some(*job_id),
_ => None,
}
}
}
/// Queue of tasks for an entity. Front of queue is currently executing task.
+1 -1
View File
@@ -45,7 +45,7 @@ pub struct TaskBlocked {
#[derive(Message, Clone)]
pub struct LogsSpawned {
/// Entities of the spawned logs.
pub log_entities: SmallVec<[Entity; 8]>,
pub log_entities: SmallVec<[Entity; 12]>,
/// Destination for hauling (currently origin, later stockpile).
pub dest: IVec3,
}
+20 -5
View File
@@ -16,7 +16,7 @@ use crate::entities::tasks::components::{
ChopStep, DropStep, HaulStep, IdleState, Task, TaskQueue, TaskState,
};
use crate::entities::tasks::events::{LogsSpawned, TaskClaimed, TaskCompleted, TaskFailed};
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
use crate::entities::tasks::job_queue::{JobId, JobKind, JobQueue};
use crate::entities::tasks::tasks::idle::execute_idle;
use crate::world::chunks::ChunkMap;
use crate::world::generation::forestry::{fell_tree, TreePart};
@@ -79,6 +79,7 @@ pub fn task_executor_system(
if queue.is_empty() {
queue.push(Task::Idle {
job_id: JobId::default(),
origin,
sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking {
@@ -102,8 +103,6 @@ pub fn task_executor_system(
// Execute current task if Active
if *state == TaskState::Active {
if let Some(current_task) = queue.current_mut() {
let mut failed_reason: Option<&'static str> = None;
match current_task {
Task::Idle { .. } => {
execute_idle(
@@ -119,6 +118,7 @@ pub fn task_executor_system(
);
}
Task::GoTo {
job_id: _,
target,
threshold_tiles,
} => {
@@ -139,11 +139,13 @@ pub fn task_executor_system(
}
}
Task::ChopTree {
job_id,
trunk_pos,
chop_ticks,
step,
} => match step {
ChopStep::MovingToTree { ref mut approach } => {
let job_id_val = *job_id;
if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) {
info!(
"TASK FAILED: {:?} for {:?} - {}",
@@ -154,6 +156,7 @@ pub fn task_executor_system(
task: current_task.clone(),
reason: "tree already gone",
});
job_queue.unclaim_job(job_id_val);
*state = TaskState::Failed;
continue;
}
@@ -207,6 +210,7 @@ pub fn task_executor_system(
task: current_task.clone(),
reason,
});
job_queue.unclaim_job(job_id_val);
*state = TaskState::Failed;
continue;
}
@@ -259,7 +263,7 @@ pub fn task_executor_system(
fall_dir = Vec2::new(1.0, 0.0); // default: fall east
}
let log_sprite: Handle<Image> = asset_server.load("log_cargo.png");
let mut log_entities: SmallVec<[(Entity, IVec3); 8]> =
let mut log_entities: SmallVec<[(Entity, IVec3); 12]> =
SmallVec::new();
// Loop from 0 up to the number of trunk segments found
for i in 0..trunk_count {
@@ -321,20 +325,25 @@ pub fn task_executor_system(
}
ChopStep::Done => {
*state = TaskState::Completed;
// Remove job from queue
job_queue.complete_job_by_id(current_task.get_job_id().unwrap());
}
},
Task::HaulCargo {
job_id,
cargo_entity,
cargo_pos,
dest,
step,
} => {
let job_id_val = *job_id;
let Some(ref mut haul) = haul_slot else {
failed_writer.write(TaskFailed {
entity,
task: current_task.clone(),
reason: "entity has no HaulSlot",
});
job_queue.unclaim_job(job_id_val);
*state = TaskState::Failed;
continue;
};
@@ -356,6 +365,7 @@ pub fn task_executor_system(
task: current_task.clone(),
reason: "cargo no longer exists",
});
job_queue.unclaim_job(job_id_val);
*state = TaskState::Failed;
continue;
}
@@ -475,6 +485,7 @@ pub fn task_executor_system(
task: current_task.clone(),
reason: "no standable tile near cargo",
});
job_queue.unclaim_job(job_id_val);
*state = TaskState::Failed;
continue;
}
@@ -508,6 +519,7 @@ pub fn task_executor_system(
task: current_task.clone(),
reason: "HaulSlot already occupied",
});
job_queue.unclaim_job(job_id_val);
*state = TaskState::Failed;
continue;
}
@@ -596,10 +608,11 @@ pub fn task_executor_system(
HaulStep::Done => {
info!("[HAUL] {:?} haul task COMPLETE", entity);
*state = TaskState::Completed;
job_queue.complete_job_by_id(current_task.get_job_id().unwrap());
}
}
}
Task::DropHauled { pos, step } => {
Task::DropHauled { job_id, pos, step } => {
let Some(ref mut haul) = haul_slot else {
*state = TaskState::Completed;
continue;
@@ -651,6 +664,7 @@ pub fn task_executor_system(
*step = DropStep::Done;
}
DropStep::Done => {
job_queue.complete_job_by_id(current_task.get_job_id().unwrap());
*state = TaskState::Completed;
}
}
@@ -677,6 +691,7 @@ pub fn task_executor_system(
if completed_task.is_terminal() && queue.is_empty() {
queue.push(Task::Idle {
job_id: JobId::default(),
origin,
sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking {
+7 -5
View File
@@ -2,7 +2,7 @@ use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
use crate::entities::cargo::HaulSlot;
use crate::entities::shared_components::Ambulatory;
use crate::entities::tasks::components::{Task, TaskQueue, TaskState};
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
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::*;
@@ -35,7 +35,9 @@ pub fn job_assignment_system(
let dorf_pos_ivec = transform.translation.as_ivec3();
let dorf_pos_2d = Vec2::new(transform.translation.x, transform.translation.y);
if let Some(job) = job_queue.pop_best_pathfinding(&tilemap, dorf_pos_ivec, dorf_pos_2d) {
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(),
@@ -51,7 +53,7 @@ pub fn job_assignment_system(
JobKind::FellTree { trunk_pos } => {
info!("[ASSIGN] Dorf assigned to FellTree at {:?}", trunk_pos);
queue.clear();
queue.push(FellTreeJob::start(trunk_pos));
queue.push(FellTreeJob::start(job_id, trunk_pos));
*state = TaskState::Pending;
}
JobKind::HaulCargo {
@@ -61,7 +63,7 @@ pub fn job_assignment_system(
} => {
info!("[ASSIGN] Dorf assigned to HaulCargo at {:?}", cargo_pos);
queue.clear();
queue.push(HaulCargoJob::start(cargo_entity, cargo_pos, dest));
queue.push(HaulCargoJob::start(job_id, cargo_entity, cargo_pos, dest));
*state = TaskState::Pending;
}
}
@@ -69,7 +71,7 @@ pub fn job_assignment_system(
let behaviour = EntityBehaviourRegistry::global_get("dorf");
let origin = transform.translation.as_ivec3();
if queue.is_empty() {
queue.push(IdleJob::start(origin, &behaviour.idle));
queue.push(IdleJob::start(JobId::default(), origin, &behaviour.idle));
*state = TaskState::Pending;
}
}
+31 -3
View File
@@ -5,7 +5,7 @@ use smallvec::SmallVec;
use std::cmp::Ordering;
use std::collections::VecDeque;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub enum JobKind {
FellTree {
trunk_pos: IVec3,
@@ -44,14 +44,27 @@ impl JobKind {
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JobId(u16);
impl JobId {
#[inline]
pub fn next(&mut self) -> JobId {
self.0 = self.0.wrapping_add(1);
*self
}
}
struct Entry {
kind: JobKind,
claimed: bool,
id: JobId,
}
#[derive(Resource, Default)]
pub struct JobQueue {
jobs: VecDeque<Entry>,
next_id: JobId,
}
impl JobQueue {
@@ -60,15 +73,30 @@ impl JobQueue {
self.jobs.push_back(Entry {
kind,
claimed: false,
id: self.next_id.next(),
});
}
pub fn complete_job_by_id(&mut self, id: JobId) {
if let Some(index) = self.jobs.iter().position(|j| j.id == id) {
self.jobs.remove(index);
info!("[QUEUE] Job ID {:?} removed from global queue", id);
}
}
pub fn unclaim_job(&mut self, id: JobId) {
if let Some(entry) = self.jobs.iter_mut().find(|j| j.id == id) {
entry.claimed = false;
info!("[QUEUE] Job ID {:?} marked unclaimed for reassignment", id);
}
}
pub fn pop_best_pathfinding(
&mut self,
tilemap: &crate::world::tiles::TileMap,
dorf_pos: IVec3,
_dorf_pos_2d: Vec2,
) -> Option<JobKind> {
) -> Option<(JobId, JobKind)> {
if self.jobs.is_empty() {
return None;
}
@@ -140,7 +168,7 @@ impl JobQueue {
self.jobs[idx].claimed = true;
// Note: JobKind needs `#[derive(Clone)]` if it doesn't have it already
return Some(self.jobs[idx].kind.clone());
return Some((self.jobs[idx].id, self.jobs[idx].kind.clone()));
}
}
+7 -3
View File
@@ -1,12 +1,14 @@
use crate::entities::behaviour::IdleBehaviour;
use crate::entities::tasks::components::{ChopStep, HaulStep, IdleState, Task, CHOP_TICKS_DEFAULT};
use crate::entities::tasks::job_queue::JobId;
use bevy::prelude::{Entity, IVec3};
pub struct FellTreeJob;
impl FellTreeJob {
pub fn start(trunk_pos: IVec3) -> Task {
pub fn start(job_id: JobId, trunk_pos: IVec3) -> Task {
Task::ChopTree {
job_id,
trunk_pos,
chop_ticks: CHOP_TICKS_DEFAULT,
step: ChopStep::MovingToTree { approach: None },
@@ -17,8 +19,9 @@ impl FellTreeJob {
pub struct HaulCargoJob;
impl HaulCargoJob {
pub fn start(cargo_entity: Entity, cargo_pos: IVec3, dest: IVec3) -> Task {
pub fn start(job_id: JobId, cargo_entity: Entity, cargo_pos: IVec3, dest: IVec3) -> Task {
Task::HaulCargo {
job_id,
cargo_entity,
cargo_pos,
dest,
@@ -30,8 +33,9 @@ impl HaulCargoJob {
pub struct IdleJob;
impl IdleJob {
pub fn start(origin: IVec3, behaviour: &IdleBehaviour) -> Task {
pub fn start(job_id: JobId, origin: IVec3, behaviour: &IdleBehaviour) -> Task {
Task::Idle {
job_id,
origin,
sigma_world: behaviour.sigma_world,
state: IdleState::Picking {
+1
View File
@@ -22,6 +22,7 @@ pub fn execute_idle(
current_tick: u32,
) -> TaskResult {
let Task::Idle {
job_id,
origin,
sigma_world,
state,
+1 -1
View File
@@ -311,7 +311,7 @@ pub fn fell_tree(
let mut dirty_columns: FxHashSet<IVec3> = FxHashSet::default();
let z_total = crate::world::chunks::Z_BELOW as i32 + crate::world::chunks::Z_ABOVE as i32 + 1;
let mut trunk_positions: SmallVec<[IVec3; 8]> = SmallVec::new();
let mut trunk_positions: SmallVec<[IVec3; 12]> = SmallVec::new();
for (entity, tile_pos, is_trunk) in to_remove.iter() {
if *is_trunk {
+9 -4
View File
@@ -99,8 +99,12 @@ impl ChunkData {
// chunkdata.rs
pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool {
// 1. Bounds check (z is an index here, e.g., 0 to 64)
if z < 0 || z >= (Z_BELOW + Z_ABOVE) as i32 {
// 1. Bounds check: z is a signed local Z index (e.g. -Z_BELOW to Z_ABOVE)
if z < -(Z_BELOW as i32) || z > (Z_ABOVE as i32) {
return false;
}
if local_x < 0 || local_x >= CHUNK_SIZE || local_y < 0 || local_y >= CHUNK_SIZE {
return false;
}
@@ -114,9 +118,10 @@ impl ChunkData {
let in_fixture = (self.stand_in_fixture[word] & mask) != 0;
// 2. Check the tile immediately below (z - 1)
if z <= 0 {
// Can't stand at the absolute bottom of the generated world
if z <= -(Z_BELOW as i32) {
return false;
} // Bottom of the world
}
let below_idx = Self::pos_to_index(local_x, local_y, z - 1);
let below_word = below_idx / 32;