diff --git a/src/constants.rs b/src/constants.rs index 336d73a..09ee644 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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* diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs index e1d0899..9b5dcb6 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -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); } diff --git a/src/entities/tasks/demo.rs b/src/entities/tasks/demo.rs index 99b8c2b..da2c429 100644 --- a/src/entities/tasks/demo.rs +++ b/src/entities/tasks/demo.rs @@ -16,7 +16,10 @@ pub fn demo_system( dorf_query: Query<(&TaskQueue, &TaskState), With>, ) { 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( diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs index 695f12e..b6ac09e 100644 --- a/src/entities/tasks/executor.rs +++ b/src/entities/tasks/executor.rs @@ -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; } diff --git a/src/entities/tasks/job_assignment.rs b/src/entities/tasks/job_assignment.rs index ca88742..9a9643e 100644 --- a/src/entities/tasks/job_assignment.rs +++ b/src/entities/tasks/job_assignment.rs @@ -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, tilemap: Res, mut dorf_query: Query< ( + Entity, &mut TaskQueue, &mut TaskState, &Transform, @@ -21,59 +23,117 @@ pub fn job_assignment_system( With, >, ) { - 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 = 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; + } + } } diff --git a/src/entities/tasks/job_queue.rs b/src/entities/tasks/job_queue.rs index a2f177e..c16f5bd 100644 --- a/src/entities/tasks/job_queue.rs +++ b/src/entities/tasks/job_queue.rs @@ -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 { + 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() diff --git a/src/entities/tasks/queue_debug.rs b/src/entities/tasks/queue_debug.rs index 4a4a76f..ebfb491 100644 --- a/src/entities/tasks/queue_debug.rs +++ b/src/entities/tasks/queue_debug.rs @@ -6,7 +6,13 @@ use bevy::prelude::*; pub fn queue_debug_system( job_queue: Res, dorf_query: Query<(&TaskQueue, &TaskState, &Transform), With>, + mut tick: Local, ) { + *tick = tick.wrapping_add(1); + if *tick % 120 != 0 { + return; + } + let (fell_count, haul_count) = job_queue.debug_counts(); let total = job_queue.len(); diff --git a/src/entities/tasks/tasks/idle.rs b/src/entities/tasks/tasks/idle.rs index 1cf50d7..cecf62b 100644 --- a/src/entities/tasks/tasks/idle.rs +++ b/src/entities/tasks/tasks/idle.rs @@ -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 ); diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs index 4e8459b..7e9edda 100644 --- a/src/world/generation/terrain.rs +++ b/src/world/generation/terrain.rs @@ -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 { diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs index 649bf44..a2fe3c3 100644 --- a/src/world/tiles/tilemap.rs +++ b/src/world/tiles/tilemap.rs @@ -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, + /// Chunks whose surface positions have been committed to surface_positions. + /// Prevents duplicates when chunks are regenerated during world expansion. + pub applied_surface_chunks: FxHashSet, } impl TileMap {