From 79e0efa4099ede9972897d887ad9c9a173c89aaf Mon Sep 17 00:00:00 2001 From: popertots Date: Fri, 27 Mar 2026 20:28:58 +0000 Subject: [PATCH] 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) --- src/entities/item/inventory/container.rs | 2 +- src/entities/sentient/dorf.rs | 2 ++ src/entities/shared_systems/pathfinding.rs | 20 +----------- src/entities/tasks/components.rs | 38 ++++++++++++++++++++++ src/entities/tasks/events.rs | 2 +- src/entities/tasks/executor.rs | 25 +++++++++++--- src/entities/tasks/job_assignment.rs | 12 ++++--- src/entities/tasks/job_queue.rs | 34 +++++++++++++++++-- src/entities/tasks/jobs.rs | 10 ++++-- src/entities/tasks/tasks/idle.rs | 1 + src/world/generation/forestry.rs | 2 +- src/world/tiles/chunk_data.rs | 13 +++++--- 12 files changed, 119 insertions(+), 42 deletions(-) diff --git a/src/entities/item/inventory/container.rs b/src/entities/item/inventory/container.rs index 8404201..7677597 100644 --- a/src/entities/item/inventory/container.rs +++ b/src/entities/item/inventory/container.rs @@ -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, } diff --git a/src/entities/sentient/dorf.rs b/src/entities/sentient/dorf.rs index a7a87a6..7f1340d 100644 --- a/src/entities/sentient/dorf.rs +++ b/src/entities/sentient/dorf.rs @@ -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 { diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs index 1883003..e1d0899 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -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 diff --git a/src/entities/tasks/components.rs b/src/entities/tasks/components.rs index 0442eac..6a5b5c9 100644 --- a/src/entities/tasks/components.rs +++ b/src/entities/tasks/components.rs @@ -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 { + 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 { + 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. diff --git a/src/entities/tasks/events.rs b/src/entities/tasks/events.rs index 315ae71..6bc2039 100644 --- a/src/entities/tasks/events.rs +++ b/src/entities/tasks/events.rs @@ -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, } diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs index f276b3b..695f12e 100644 --- a/src/entities/tasks/executor.rs +++ b/src/entities/tasks/executor.rs @@ -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 = 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 { diff --git a/src/entities/tasks/job_assignment.rs b/src/entities/tasks/job_assignment.rs index 7285f7e..ca88742 100644 --- a/src/entities/tasks/job_assignment.rs +++ b/src/entities/tasks/job_assignment.rs @@ -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; } } diff --git a/src/entities/tasks/job_queue.rs b/src/entities/tasks/job_queue.rs index bb5115c..a2f177e 100644 --- a/src/entities/tasks/job_queue.rs +++ b/src/entities/tasks/job_queue.rs @@ -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, + 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 { + ) -> 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())); } } diff --git a/src/entities/tasks/jobs.rs b/src/entities/tasks/jobs.rs index 4d6c285..c2ddaf4 100644 --- a/src/entities/tasks/jobs.rs +++ b/src/entities/tasks/jobs.rs @@ -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 { diff --git a/src/entities/tasks/tasks/idle.rs b/src/entities/tasks/tasks/idle.rs index 1c7d09c..1cf50d7 100644 --- a/src/entities/tasks/tasks/idle.rs +++ b/src/entities/tasks/tasks/idle.rs @@ -22,6 +22,7 @@ pub fn execute_idle( current_tick: u32, ) -> TaskResult { let Task::Idle { + job_id, origin, sigma_world, state, diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs index c177c83..8471c6b 100644 --- a/src/world/generation/forestry.rs +++ b/src/world/generation/forestry.rs @@ -311,7 +311,7 @@ pub fn fell_tree( let mut dirty_columns: FxHashSet = 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 { diff --git a/src/world/tiles/chunk_data.rs b/src/world/tiles/chunk_data.rs index 18baab9..4b0b208 100644 --- a/src/world/tiles/chunk_data.rs +++ b/src/world/tiles/chunk_data.rs @@ -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;