From bd1b45e4d7b37499433ce639f11e1b47f33396d8 Mon Sep 17 00:00:00 2001 From: popertots Date: Fri, 27 Mar 2026 10:29:33 +0000 Subject: [PATCH] attempt 3 --- build.rs | 2 +- config.toml | 2 +- src/entities/cargo/log.rs | 60 ++- src/entities/sentient/dorf.rs | 4 +- src/entities/shared_systems/pathfinding.rs | 206 +++++---- src/entities/tasks/demo.rs | 483 +++------------------ src/entities/tasks/executor.rs | 378 ++++++++++++---- src/entities/tasks/idle.rs | 210 --------- src/entities/tasks/job_assignment.rs | 77 ++++ src/entities/tasks/job_queue.rs | 205 +++++++++ src/entities/tasks/jobs.rs | 43 ++ src/entities/tasks/mod.rs | 13 +- src/entities/tasks/queue_debug.rs | 54 +++ src/entities/tasks/tasks/idle.rs | 234 ++++++++++ src/entities/tasks/tasks/mod.rs | 7 + src/plugins/tasks.rs | 30 +- src/world/generation/forestry.rs | 19 +- src/world/generation/terrain.rs | 4 + src/world/tiles/chunk_data.rs | 20 +- src/world/tiles/tilemap.rs | 6 + 20 files changed, 1192 insertions(+), 865 deletions(-) delete mode 100644 src/entities/tasks/idle.rs create mode 100644 src/entities/tasks/job_assignment.rs create mode 100644 src/entities/tasks/job_queue.rs create mode 100644 src/entities/tasks/jobs.rs create mode 100644 src/entities/tasks/queue_debug.rs create mode 100644 src/entities/tasks/tasks/idle.rs create mode 100644 src/entities/tasks/tasks/mod.rs diff --git a/build.rs b/build.rs index b8352da..24f8bc7 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -use image::{GenericImageView, ImageBuffer, Rgba}; +use image::{ImageBuffer, Rgba}; const TILE_PX: u32 = 16; diff --git a/config.toml b/config.toml index f3b905a..6f36a60 100644 --- a/config.toml +++ b/config.toml @@ -4,6 +4,6 @@ initial_chunk_radius = 6 vsync = "mailbox" [spawn_counts] -dorfs = 2 +dorfs = 5 pigs = 0 rabbits = 0 \ No newline at end of file diff --git a/src/entities/cargo/log.rs b/src/entities/cargo/log.rs index e88d338..f4b2d9f 100644 --- a/src/entities/cargo/log.rs +++ b/src/entities/cargo/log.rs @@ -20,20 +20,6 @@ use crate::world::VisibleGameEntity; /// Weight of a single log in kg. Enough to encumber a dorf carrying one. pub const LOG_WEIGHT_KG: u32 = 15; -/// Find the standable surface tile at world XY. Returns the floor tile -/// where an entity can stand, or None if not found. -/// The standable position IS the floor tile itself (has can_stand_in=true), -/// not the air above it. -fn find_surface_at(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option { - for z in -3i32..=4i32 { - let floor_pos = IVec3::new(world_x, world_y, z * ITILE_SIZE); - if tilemap.floor_tiles.contains_key(&floor_pos) && tilemap.is_standable(floor_pos) { - return Some(floor_pos); - } - } - None -} - /// Spawn a Cargo log entity with a directional fall bias. /// /// `tile_pos` — world position of the trunk tile this log came from. @@ -56,7 +42,7 @@ pub fn spawn_log_cargo( fall_direction: Vec2, log_sprite: Handle, rng: &mut WyRand, -) -> Option { +) -> Option<(Entity, IVec3)> { // Distance along fall direction: 0–3 tiles let fall_tiles = rng.random_range(0u32..=3) as f32; @@ -65,26 +51,34 @@ pub fn spawn_log_cargo( let jitter_tiles = rng.random_range(0u32..=2) as f32 - 1.0; // -1, 0, or +1 let offset = fall_direction * fall_tiles + perp * jitter_tiles; - let biased_xy_x = tile_pos.x + (offset.x * ITILE_SIZE as f32).round() as i32; - let biased_xy_y = tile_pos.y + (offset.y * ITILE_SIZE as f32).round() as i32; - // Find the actual standable surface at this XY — logs land on the floor - let biased_pos = find_surface_at(biased_xy_x, biased_xy_y, tilemap).unwrap_or(IVec3::new( - biased_xy_x, - biased_xy_y, - tile_pos.z, - )); + // Snap to grid + let offset_tiles_x = offset.x.round() as i32; + let offset_tiles_y = offset.y.round() as i32; + let biased_xy_x = tile_pos.x + offset_tiles_x * ITILE_SIZE; + let biased_xy_y = tile_pos.y + offset_tiles_y * ITILE_SIZE; - // Find nearest free tile from biased position (increased radius for scattered logs) + // Find the actual standable surface at this XY — start search from tree's Z + let mut biased_pos = IVec3::new(biased_xy_x, biased_xy_y, tile_pos.z); + + // Search up and down for a standable tile (air above a solid floor) + let start_z_idx = tile_pos.z / ITILE_SIZE; + let mut found_surface = false; + + // Search nearest Z first + let z_search_order = [0, -1, 1, -2, 2, -3, 3, -4, 4]; + for dz in z_search_order { + let check_pos = IVec3::new(biased_xy_x, biased_xy_y, (start_z_idx + dz) * ITILE_SIZE); + if tilemap.is_standable(check_pos) { + biased_pos = check_pos; + found_surface = true; + break; + } + } + + // Find nearest free tile from biased position let drop_pos = tilemap.find_nearest_free_cargo_tile(biased_pos, 8, &[])?; - // TODO: OuchEvent — if a living entity occupies drop_pos, they take impact damage. - // Check tilemap occupancy here when the combat/injury system exists. - // For now: debug log so we know when it would have fired. - // if occupancy.count_at_ivec3(drop_pos) > 0 { - // debug!("Log landed on occupied tile {:?} — ouch! (TODO: damage)", drop_pos); - // } - let entity = commands .spawn(( Cargo { @@ -115,12 +109,12 @@ pub fn spawn_log_cargo( "spawn_log_cargo: tile_pos={:?} drop_pos={:?} entity_z={}", tile_pos, drop_pos, - drop_pos.z as f32 / 16.0 - 1.0 + drop_pos.z as f32 / 16.0 ); tilemap .place_cargo(drop_pos, entity) .expect("place_cargo failed after find_nearest_free_cargo_tile succeeded"); - Some(entity) + Some((entity, drop_pos)) } diff --git a/src/entities/sentient/dorf.rs b/src/entities/sentient/dorf.rs index 39efb53..a7a87a6 100644 --- a/src/entities/sentient/dorf.rs +++ b/src/entities/sentient/dorf.rs @@ -98,8 +98,8 @@ pub fn spawn_dorfs( let mut rng = WyRand::seed_from_u64(seed); for _ in 0..config.spawn_counts.dorfs { - let raw_x = rng.random_range(-8.0f32..8.0f32); - let raw_y = rng.random_range(-8.0f32..8.0f32); + let raw_x = rng.random_range(-512.0f32..512.0f32); + let raw_y = rng.random_range(-512.0f32..512.0f32); let grid_x = (raw_x / TILE_SIZE).round() * TILE_SIZE; let grid_y = (raw_y / TILE_SIZE).round() * TILE_SIZE; let grid_z = 35.0 * TILE_SIZE; diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs index e54a145..1883003 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -314,14 +314,10 @@ pub fn prepare_paths( tilemap: Res, chunk_map: Res, ) { - let mut paths_needed = 0; - let mut paths_computed = 0; - for (entity, mut ambulatory, transform) in query.iter_mut() { if ambulatory.current_path.is_some() || ambulatory.target.is_none() { continue; } - paths_needed += 1; let Some(target) = ambulatory.target else { continue; @@ -338,7 +334,6 @@ pub fn prepare_paths( let path = calculate_path_benchmarked(&tilemap, start, goal); ambulatory.current_path = Some(path); ambulatory.path_index = 0; - paths_computed += 1; } else if chunk_distance > PATHFINDER_HIERARCHICAL_THRESHOLD_CHUNKS { let chunk_path = calculate_chunk_path(&chunk_map, start_chunk, goal_chunk); let provisional_goal = goal; @@ -624,6 +619,10 @@ pub fn movement( query .par_iter_mut() .for_each(|(mut ambulatory, mut transform)| { + info!( + "[PATH] Moving: target={:?} current={:?}", + ambulatory.target, transform.translation + ); let current_pos = transform.translation; if !is_standable_tile(&tilemap, current_pos.as_ivec3()) { // Entities can spawn above the loaded world range (z > Z_ABOVE*TILE_SIZE). @@ -714,45 +713,24 @@ pub fn movement( let forward_2d = our_dir; let left_2d = Vec2::new(-forward_2d.y, forward_2d.x); let right_2d = Vec2::new(forward_2d.y, -forward_2d.x); - let cur_z = transform.translation.z; let candidates = [ - snap_to_grid( - transform.translation - + Vec3::new( - (left_2d.x + forward_2d.x).signum() * TILE_SIZE, - (left_2d.y + forward_2d.y).signum() * TILE_SIZE, - 0.0, - ), - cur_z, - TILE_SIZE, - ), - snap_to_grid( - transform.translation - + Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0), - cur_z, - TILE_SIZE, - ), - snap_to_grid( - transform.translation - + Vec3::new( - (right_2d.x + forward_2d.x).signum() * TILE_SIZE, - (right_2d.y + forward_2d.y).signum() * TILE_SIZE, - 0.0, - ), - cur_z, - TILE_SIZE, - ), - snap_to_grid( - transform.translation - + Vec3::new( - right_2d.x * TILE_SIZE, - right_2d.y * TILE_SIZE, - 0.0, - ), - cur_z, - TILE_SIZE, - ), + transform.translation + + Vec3::new( + (left_2d.x + forward_2d.x).signum() * TILE_SIZE, + (left_2d.y + forward_2d.y).signum() * TILE_SIZE, + 0.0, + ), + transform.translation + + Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0), + transform.translation + + Vec3::new( + (right_2d.x + forward_2d.x).signum() * TILE_SIZE, + (right_2d.y + forward_2d.y).signum() * TILE_SIZE, + 0.0, + ), + transform.translation + + Vec3::new(right_2d.x * TILE_SIZE, right_2d.y * TILE_SIZE, 0.0), ]; let sidestep = candidates.iter().copied().find(|&c| { @@ -783,45 +761,24 @@ pub fn movement( let forward_2d = our_dir; let left_2d = Vec2::new(-forward_2d.y, forward_2d.x); let right_2d = Vec2::new(forward_2d.y, -forward_2d.x); - let cur_z = transform.translation.z; let candidates = [ - snap_to_grid( - transform.translation - + Vec3::new( - (left_2d.x + forward_2d.x).signum() * TILE_SIZE, - (left_2d.y + forward_2d.y).signum() * TILE_SIZE, - 0.0, - ), - cur_z, - TILE_SIZE, - ), - snap_to_grid( - transform.translation - + Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0), - cur_z, - TILE_SIZE, - ), - snap_to_grid( - transform.translation - + Vec3::new( - (right_2d.x + forward_2d.x).signum() * TILE_SIZE, - (right_2d.y + forward_2d.y).signum() * TILE_SIZE, - 0.0, - ), - cur_z, - TILE_SIZE, - ), - snap_to_grid( - transform.translation - + Vec3::new( - right_2d.x * TILE_SIZE, - right_2d.y * TILE_SIZE, - 0.0, - ), - cur_z, - TILE_SIZE, - ), + transform.translation + + Vec3::new( + (left_2d.x + forward_2d.x).signum() * TILE_SIZE, + (left_2d.y + forward_2d.y).signum() * TILE_SIZE, + 0.0, + ), + transform.translation + + Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0), + transform.translation + + Vec3::new( + (right_2d.x + forward_2d.x).signum() * TILE_SIZE, + (right_2d.y + forward_2d.y).signum() * TILE_SIZE, + 0.0, + ), + transform.translation + + Vec3::new(right_2d.x * TILE_SIZE, right_2d.y * TILE_SIZE, 0.0), ]; let sidestep = candidates.iter().copied().find(|&c| { @@ -945,16 +902,25 @@ fn validate_next_steps(tilemap: &TileMap, path: &[Vec3], start_index: usize, ste } fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool { - tilemap.is_standable(pos) -} + 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) + }; -#[inline] -fn snap_to_grid(raw: Vec3, preserve_z: f32, tile_size: f32) -> Vec3 { - Vec3::new( - (raw.x / tile_size).round() * tile_size, - (raw.y / tile_size).round() * tile_size, - preserve_z, - ) + 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 } /// Sample a random standable tile on an edge of `next_chunk`, picking the one @@ -1103,7 +1069,20 @@ fn directional_chunk_waypoint( #[inline] fn get_tile_weight(tilemap: &TileMap, pos: IVec3) -> u8 { let floor_pos = IVec3::new(pos.x, pos.y, pos.z - ITILE_SIZE); - tilemap.get_astar_weight(floor_pos) + let weight = tilemap.get_astar_weight(floor_pos); + if weight == 100 { + let floor_tile = tilemap.floor_tiles.get(&floor_pos); + let fixture_tile = tilemap.fixture_tiles.get(&floor_pos); + let chunk_pos = crate::world::chunks::world_to_chunk(floor_pos); + let chunk_exists = tilemap.chunks.contains_key(&chunk_pos); + info!( + "[PATH] get_tile_weight=100 (default): pos={:?} floor_pos={:?} chunk_loaded={} floor={:?} fixture={:?}", + pos, floor_pos, chunk_exists, + floor_tile.map(|f| f.id.clone()), + fixture_tile.map(|f| f.id.clone()) + ); + } + weight } /// Calculate movement cost including tile weight. @@ -1119,7 +1098,10 @@ fn calculate_movement_cost(move_dir: IVec3, tile_weight: u8) -> i32 { (1, 1, 0) => 14, // Diagonal (1, 0, 1) | (0, 1, 1) => 42, // Vertical + cardinal (1, 1, 1) => 56, // Vertical + diagonal - _ => 0, + _ => { + info!("[PATH] calculate_movement_cost=0: move_dir={:?}", move_dir); + return 0; + } }; if base_cost == 0 { @@ -1274,13 +1256,40 @@ pub fn calculate_provisional_path( ) -> Vec { let timer = Instant::now(); - if !is_standable_tile(tilemap, start) { + let start_standable = is_standable_tile(tilemap, start); + if !start_standable { LOCAL_FAILED_PATHS.with(|f| { *f.borrow_mut() += 1; }); + info!( + "[PATH] FAIL: start not standable start={:?} z_level={} floor={:?} fixture={:?}", + start, + start.z, + tilemap.floor_tiles.get(&start).map(|f| f.id.clone()), + tilemap.fixture_tiles.get(&start).map(|f| f.id.clone()) + ); return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)]; } + let goal_standable = is_standable_tile(tilemap, goal); + if !goal_standable { + info!( + "[PATH] WARN: goal not standable goal={:?} z_level={} floor={:?} fixture={:?}", + goal, + goal.z, + tilemap.floor_tiles.get(&goal).map(|f| f.id.clone()), + tilemap.fixture_tiles.get(&goal).map(|f| f.id.clone()) + ); + } + + if !is_standable_tile(tilemap, goal) { + info!( + "[PATH] WARN: is_standable check false: goal={:?}, is_standable={}", + goal, + tilemap.is_standable(goal) + ); + } + let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; let result = SCRATCHPAD.with(|s| { @@ -1318,6 +1327,10 @@ pub fn calculate_provisional_path( } if nodes_expanded >= node_limit { + info!( + "[PATH] FAIL: node_limit hit limit={} start={:?} goal={:?} expanded={}", + node_limit, start, goal, nodes_expanded + ); return (Vec::new(), nodes_expanded); } @@ -1326,9 +1339,10 @@ pub fn calculate_provisional_path( for &move_dir in &ALLOWED_MOVES { let neighbor_pos = current + move_dir; - if !is_standable_tile(tilemap, neighbor_pos) - || scratch.closed_set.contains(&neighbor_pos) - { + if !is_standable_tile(tilemap, neighbor_pos) { + continue; + } + if scratch.closed_set.contains(&neighbor_pos) { continue; } @@ -1353,6 +1367,10 @@ pub fn calculate_provisional_path( } } + info!( + "[PATH] FAIL: no_path_found start={:?} goal={:?} best_node={:?} best_h={}", + start, goal, best_node, best_h + ); ( reconstruct_path(&scratch.came_from, best_node), nodes_expanded, diff --git a/src/entities/tasks/demo.rs b/src/entities/tasks/demo.rs index 3c9f546..99b8c2b 100644 --- a/src/entities/tasks/demo.rs +++ b/src/entities/tasks/demo.rs @@ -1,439 +1,94 @@ -//! Demo loop — one tree at a time, chop and haul to origin. -//! -//! # Behaviour -//! 1. Find the nearest standing tree trunk to (0,0) in the loaded world. -//! 2. Assign a ChopTree task to one idle dorf. -//! 3. When the tree is felled (trunk fixtures gone, Cargo logs exist): -//! - All logs enter the haul queue ordered by proximity to origin. -//! - Each tick: assign idle dorfs to the nearest unassigned log. -//! - Dorfs that finish hauling become available for the next log. -//! 4. When the haul queue is empty AND no in-progress hauls remain: -//! - Find the next tree. -//! 5. Dorfs with no task remain on Task::Idle (wander). -//! -//! # State machine -//! Tracked in DemoState resource. -//! -//! # Limitations (acceptable for demo) -//! - Only one tree targeted at a time. -//! - Does not use the JobQueue — tasks pushed directly. -//! - Does not handle dorf death mid-chop. -//! - Haul destination is a fixed search near IVec3::ZERO — not a stockpile. - use bevy::prelude::*; -use rustc_hash::FxHashSet; -use smallvec::SmallVec; -use std::collections::VecDeque; use crate::constants::ITILE_SIZE; -use crate::entities::cargo::{Cargo, HaulSlot, Haulable}; -use crate::entities::tasks::components::{ - ChopStep, HaulStep, Task, TaskQueue, TaskState, CHOP_TICKS_DEFAULT, -}; -use crate::entities::tasks::events::{LogsSpawned, TaskFailed}; +use crate::entities::behaviour::EntityType; +use crate::entities::tasks::components::{Task, TaskQueue, TaskState}; +use crate::entities::tasks::job_queue::{JobKind, JobQueue}; use crate::world::chunks::ChunkMap; use crate::world::generation::forestry::TreePart; use crate::world::tiles::TileMap; -/// Radius (in tiles, Chebyshev) to search for unclaimed logs after felling. -/// Logs scatter within ~3 tiles of trunk positions. -const LOG_SEARCH_RADIUS_TILES: i32 = 12; - -/// Radius to search for a haul drop destination near origin. -const HAUL_DEST_SEARCH_RADIUS: i32 = 32; - -/// Tracks what the demo loop is currently doing. -#[derive(Resource, Debug, Default)] -pub enum DemoState { - /// No active tree — searching for one. - #[default] - Idle, - /// A ChopTree task has been assigned to `chopper`. - /// `trunk_pos` is the lowest trunk tile of the target tree. - Chopping { trunk_pos: IVec3, chopper: Entity }, - /// Tree has been felled. Working through the haul queue. - /// - /// `unassigned` — logs not yet claimed, ordered nearest-to-origin first. - /// `in_progress` — dorf entities currently executing a HaulCargo task for this tree. - /// A dorf is removed when they return to idle (log dropped, task complete). - Hauling { - felled_trunk_pos: IVec3, - /// Queue of (log_entity, log_tile_pos) not yet assigned. - /// Front = highest priority (nearest to origin). - unassigned: VecDeque<(Entity, IVec3)>, - /// Dorf entities currently hauling a log for this tree. - in_progress: FxHashSet, - }, -} - -/// The demo loop system. Runs in FixedUpdate after task_executor_system. -/// -/// State transitions: -/// Idle → Chopping: found a tree, assigned ChopTree to one idle dorf -/// Chopping → Hauling: trunk no longer in fixture_tiles (tree felled) -/// Hauling → Idle: both unassigned and in_progress are empty -/// -/// Performance: scans fixture_tiles once per state transition (infrequent), -/// not per tick. During Chopping and Hauling states the system does O(1) checks. pub fn demo_system( - mut demo_state: ResMut, + mut job_queue: ResMut, tilemap: Res, chunk_map: Res, tree_parts: Query<(Entity, &TreePart)>, - cargo_query: Query<(Entity, &Cargo), With>, - haul_slot_query: Query<&HaulSlot>, - mut dorf_query: Query<(Entity, &mut TaskQueue, &mut TaskState, &Transform)>, - mut task_failed: MessageReader, - mut logs_spawned: MessageReader, + dorf_query: Query<(&TaskQueue, &TaskState), With>, ) { - // Handle task failures that should reset the demo state for retry - for event in task_failed.read() { - if event.reason == "no adjacent standable tile to approach tree" { - if let DemoState::Chopping { trunk_pos, chopper } = *demo_state { - warn!( - "[DEMO] ChopTree failed for tree at {:?} (chopper={:?}): {}, resetting to Idle", - trunk_pos, chopper, event.reason - ); - *demo_state = DemoState::Idle; + let any_chopping = dorf_query.iter().any(|(queue, state)| { + let is_active_or_completing = *state == TaskState::Active || *state == TaskState::Completed; + if is_active_or_completing { + if let Some(current) = queue.current() { + return matches!(current, Task::ChopTree { .. }); } } + false + }); + + if !job_queue.has_fell_tree() && !any_chopping { + if let Some(trunk_pos) = find_tree_nearest_origin(&tilemap, &chunk_map, &tree_parts) { + job_queue.push(JobKind::FellTree { trunk_pos }); + } } - // Handle logs spawned from felling — transition to Hauling - // Collect events first to avoid double-mutable borrow conflict with demo_state - let pending_logs: Vec<_> = logs_spawned.read().collect(); - for event in pending_logs { - if let DemoState::Chopping { - trunk_pos, - chopper: _, - } = *demo_state - { - // Look up cargo positions from the newly spawned entities - let mut unassigned: VecDeque<(Entity, IVec3)> = VecDeque::new(); - for &log_entity in event.log_entities.iter() { - if let Ok((_, cargo)) = cargo_query.get(log_entity) { - unassigned.push_back((log_entity, cargo.tile_pos)); - } - } - info!( - "[DEMO] → Hauling: {} logs spawned from tree at {:?}", - unassigned.len(), - trunk_pos - ); - *demo_state = DemoState::Hauling { - felled_trunk_pos: trunk_pos, - unassigned, - in_progress: Default::default(), - }; - } - } - - match &mut *demo_state { - DemoState::Idle => { - // Find the nearest standing tree to (0,0). - // A "tree" is identified by a TreePart with is_trunk=true whose - // tile_pos is still in fixture_tiles (not yet felled). - let origin = IVec3::ZERO; - - let mut best: Option<(IVec3, i32)> = None; // (trunk_pos, chebyshev_dist) - - for (_, part) in tree_parts.iter() { - if !part.is_trunk { - continue; - } - if !tilemap.fixture_tiles.contains_key(&part.tile_pos) { - continue; // already felled - } - // Only consider trees in fully-loaded chunks - let chunk = crate::world::chunks::world_to_chunk(part.tile_pos); - let loaded = chunk_map.loaded_chunks.contains_key(&(chunk + IVec2::X)) - && chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::X)) - && chunk_map.loaded_chunks.contains_key(&(chunk + IVec2::Y)) - && chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::Y)); - if !loaded { - continue; - } - - let dx = (part.tile_pos.x - origin.x).abs() / ITILE_SIZE; - let dy = (part.tile_pos.y - origin.y).abs() / ITILE_SIZE; - let dist = dx.max(dy); - - if best.map_or(true, |(_, best_dist)| dist < best_dist) { - best = Some((part.tile_pos, dist)); - } - } - - let Some((trunk_pos, _)) = best else { - // No trees found — nothing to do - return; - }; - - // Find the lowest trunk tile (minimum z) for this tree's XY column. - let lowest_trunk = tree_parts - .iter() - .filter(|(_, p)| { - p.is_trunk - && p.tile_pos.x == trunk_pos.x - && p.tile_pos.y == trunk_pos.y - && tilemap.fixture_tiles.contains_key(&p.tile_pos) - }) - .map(|(_, p)| p.tile_pos) - .min_by_key(|pos| pos.z) - .unwrap_or(trunk_pos); - - // Find one idle dorf — prefer closest to the tree. - // Don't interrupt a dorf still carrying cargo. - let mut best_dorf: Option<(Entity, i32)> = None; - let mut considered = 0u32; - let mut rejected_busy = 0u32; - let mut rejected_hauling = 0u32; - - for (entity, queue, state, transform) in dorf_query.iter() { - considered += 1; - if !is_idle_dorf(&queue, &state) { - rejected_busy += 1; - continue; - } - // Skip dorfs still carrying cargo - if haul_slot_query - .get(entity) - .map(|h| h.is_occupied()) - .unwrap_or(false) - { - rejected_hauling += 1; - continue; - } - let pos = transform.translation.as_ivec3(); - let dx = (pos.x - lowest_trunk.x).abs() / ITILE_SIZE; - let dy = (pos.y - lowest_trunk.y).abs() / ITILE_SIZE; - let dist = dx.max(dy); - if best_dorf.map_or(true, |(_, d)| dist < d) { - best_dorf = Some((entity, dist)); - } - } - - let Some((chopper, _)) = best_dorf else { - info!( - "[DEMO] No idle dorf found for ChopTree (considered={} busy={} hauling={})", - considered, rejected_busy, rejected_hauling - ); - return; // no idle dorfs available - }; - - // Assign ChopTree task - if let Ok((_, mut queue, mut state, _)) = dorf_query.get_mut(chopper) { - queue.clear(); - queue.push(Task::ChopTree { - trunk_pos: lowest_trunk, - chop_ticks: CHOP_TICKS_DEFAULT, - step: ChopStep::MovingToTree { approach: None }, - }); - *state = TaskState::Pending; - } - - *demo_state = DemoState::Chopping { - trunk_pos: lowest_trunk, - chopper, - }; - - info!( - "[DEMO] → Chopping: chopper={:?} trunk={:?}", - chopper, lowest_trunk - ); - } - - DemoState::Chopping { trunk_pos, chopper } => { - let trunk_pos = *trunk_pos; - let chopper = *chopper; - - // Check if the tree has been felled (fixture gone from tilemap) - if tilemap.fixture_tiles.contains_key(&trunk_pos) { - // Still standing — check chopper hasn't abandoned the task - if let Ok((_, queue, state, _)) = dorf_query.get(chopper) { - let still_chopping = queue.current().map_or( - false, - |t| matches!(t, Task::ChopTree { trunk_pos: tp, .. } if *tp == trunk_pos), - ); - if !still_chopping && queue.is_empty() { - *demo_state = DemoState::Idle; - warn!("[DEMO] chopper {:?} abandoned ChopTree at {:?} — queue={:?} state={:?}", - chopper, trunk_pos, - queue.current().map(|t| t.name()), - state); - } - } - return; - } - - // Tree is felled — transition to Hauling - info!("[DEMO] → Hauling: tree at {:?} felled", trunk_pos); - - // Find all Cargo logs near the trunk position - let search_world = LOG_SEARCH_RADIUS_TILES * ITILE_SIZE; - let mut logs: SmallVec<[(Entity, IVec3, i32); 8]> = cargo_query - .iter() - .filter(|(_, cargo)| { - cargo.name == "log" - && (cargo.tile_pos.x - trunk_pos.x).abs() <= search_world - && (cargo.tile_pos.y - trunk_pos.y).abs() <= search_world - }) - .map(|(e, cargo)| { - // Sort key: Chebyshev distance from origin - let dx = cargo.tile_pos.x.abs() / ITILE_SIZE; - let dy = cargo.tile_pos.y.abs() / ITILE_SIZE; - (e, cargo.tile_pos, dx.max(dy)) - }) - .collect(); - - // Nearest to origin first — dorfs haul the closest logs first - logs.sort_by_key(|(_, _, dist)| *dist); - - if logs.is_empty() { - warn!("[DEMO] no logs found after felling {:?}", trunk_pos); - *demo_state = DemoState::Idle; - return; - } - - // Convert to VecDeque, dropping sort key - let unassigned: VecDeque<(Entity, IVec3)> = - logs.into_iter().map(|(e, pos, _)| (e, pos)).collect(); - - info!("[DEMO] → Hauling: {} logs queued", unassigned.len()); - - *demo_state = DemoState::Hauling { - felled_trunk_pos: trunk_pos, - unassigned, - in_progress: FxHashSet::default(), - }; - } - - DemoState::Hauling { - felled_trunk_pos, - unassigned, - in_progress, - } => { - let felled_trunk_pos = *felled_trunk_pos; - - // Remove dorfs that have returned to idle — their haul is complete - in_progress.retain(|&dorf_entity| { - dorf_query - .get(dorf_entity) - .map(|(_, queue, state, _)| !is_idle_dorf(queue, state)) - .unwrap_or(false) // entity gone = treat as done - }); - - // Assign idle dorfs to unassigned logs - if !unassigned.is_empty() { - // Collect idle dorfs sorted by proximity to front of log queue - let next_log_pos = unassigned.front().map(|(_, p)| *p).unwrap_or(IVec3::ZERO); - - let mut idle_dorfs: SmallVec<[(Entity, i32); 8]> = dorf_query - .iter() - .filter(|(_, queue, state, _)| is_idle_dorf(queue, state)) - .map(|(e, _, _, transform)| { - let pos = transform.translation.as_ivec3(); - let dx = (pos.x - next_log_pos.x).abs() / ITILE_SIZE; - let dy = (pos.y - next_log_pos.y).abs() / ITILE_SIZE; - (e, dx.max(dy)) - }) - .collect(); - // Sort by distance — nearest dorf gets nearest log - idle_dorfs.sort_by_key(|(_, d)| *d); - // Drop distance, keep entity - let idle_dorfs: SmallVec<[Entity; 8]> = - idle_dorfs.into_iter().map(|(e, _)| e).collect(); - - // Track destinations reserved this tick to avoid assigning the same tile - // to multiple dorfs before any have physically dropped their cargo. - let mut reserved: SmallVec<[IVec3; 8]> = SmallVec::new(); - - for dorf_entity in idle_dorfs { - // Compute haul destination fresh for each assignment, - // excluding tiles already reserved this tick. - let dest = tilemap - .find_nearest_free_cargo_tile( - IVec3::ZERO, - HAUL_DEST_SEARCH_RADIUS, - &reserved, - ) - .unwrap_or(IVec3::ZERO); - reserved.push(dest); - - let Some((log_entity, log_pos)) = unassigned.pop_front() else { - break; - }; - - // Verify log still exists and is in cargo_tiles before assigning - if !tilemap.cargo_tiles.contains_key(&log_pos) { - // Log already picked up by someone else — skip it - continue; - } - - if let Ok((_, mut queue, mut state, _)) = dorf_query.get_mut(dorf_entity) { - queue.clear(); - queue.push(Task::HaulCargo { - cargo_entity: log_entity, - cargo_pos: log_pos, - dest, - step: HaulStep::MovingToCargo { approach: None }, - }); - *state = TaskState::Pending; - in_progress.insert(dorf_entity); - info!( - "[DEMO] assigned HaulCargo log={:?} → dorf={:?} dest={:?}", - log_entity, dorf_entity, dest - ); - } else { - // Couldn't assign — put log back at front of queue - unassigned.push_front((log_entity, log_pos)); - break; - } - } - } - - // Check if all work is done - if unassigned.is_empty() && in_progress.is_empty() { - info!("[DEMO] → Idle: all hauled, seeking next tree"); - *demo_state = DemoState::Idle; - } - } + if !job_queue.is_empty() { + let (fell, haul) = job_queue.debug_counts(); + info!("[QUEUE] FellTree: {}, HaulCargo: {}", fell, haul); } } -/// Returns true if a dorf has no active task or is purely wandering idle. -/// Used to find dorfs available for task assignment. -#[inline] -fn is_idle_dorf(queue: &TaskQueue, state: &TaskState) -> bool { - // Dorf is available if: queue is empty, OR current task is Idle (wandering) - queue.is_empty() - || matches!(queue.current(), Some(Task::Idle { .. })) - || *state == TaskState::Pending - && queue - .current() - .map_or(true, |t| matches!(t, Task::Idle { .. })) -} +fn find_tree_nearest_origin( + tilemap: &TileMap, + chunk_map: &ChunkMap, + tree_parts: &Query<(Entity, &TreePart)>, +) -> Option { + let origin = IVec3::ZERO; + let mut best_xy: Option<(IVec2, i32)> = None; -/// Debug system — prints full task queue state whenever any TaskQueue changes. -/// Only compiles in debug builds. -#[cfg(debug_assertions)] -pub fn debug_task_queues(query: Query<(Entity, &TaskQueue, &TaskState), Changed>) { - for (entity, queue, state) in query.iter() { - let current = queue - .current() - .map(|t| format!("{}[{:?}]", t.name(), state)) - .unwrap_or_else(|| format!("EMPTY[{:?}]", state)); + for (_, part) in tree_parts.iter() { + if !part.is_trunk { + continue; + } + if !tilemap.fixture_tiles.contains_key(&part.tile_pos) { + continue; + } - let pending: Vec<&str> = queue.tasks.iter().skip(1).map(|t| t.name()).collect(); + let chunk = crate::world::chunks::world_to_chunk(part.tile_pos); + let loaded = chunk_map.loaded_chunks.contains_key(&(chunk + IVec2::X)) + && chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::X)) + && chunk_map.loaded_chunks.contains_key(&(chunk + IVec2::Y)) + && chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::Y)); + if !loaded { + continue; + } - if pending.is_empty() { - info!("[TASK] {:?} → {}", entity, current); - } else { - info!( - "[TASK] {:?} → {} pending:[{}]", - entity, - current, - pending.join(",") - ); + let dx = (part.tile_pos.x - origin.x).abs() / ITILE_SIZE; + let dy = (part.tile_pos.y - origin.y).abs() / ITILE_SIZE; + let dist = dx.max(dy); + + if best_xy.map_or(true, |(_, best_dist)| dist < best_dist) { + best_xy = Some((part.tile_pos.xy(), dist)); } } + + let Some((nearest_xy, _)) = best_xy else { + return None; + }; + + let lowest_trunk = tree_parts + .iter() + .filter_map(|(_, p)| { + if p.is_trunk + && p.tile_pos.x == nearest_xy.x + && p.tile_pos.y == nearest_xy.y + && tilemap.fixture_tiles.contains_key(&p.tile_pos) + { + Some(p.tile_pos) + } else { + None + } + }) + .min_by_key(|pos| pos.z); + + lowest_trunk } diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs index a64e153..f276b3b 100644 --- a/src/entities/tasks/executor.rs +++ b/src/entities/tasks/executor.rs @@ -8,6 +8,7 @@ //! //! Uses Changed + Changed to minimise queries. +use crate::constants::ITILE_SIZE; use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType}; use crate::entities::cargo::{Cargo, HaulSlot}; use crate::entities::shared_components::Ambulatory; @@ -15,7 +16,8 @@ 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::idle::execute_idle; +use crate::entities::tasks::job_queue::{JobKind, JobQueue}; +use crate::entities::tasks::tasks::idle::execute_idle; use crate::world::chunks::ChunkMap; use crate::world::generation::forestry::{fell_tree, TreePart}; use crate::world::tiles::tile_changed::TileChangedEvent; @@ -24,6 +26,7 @@ use crate::world::tiles::TileMap; use bevy::prelude::*; use bevy_rand::prelude::*; use smallvec::SmallVec; +use std::collections::VecDeque; /// Main task executor. Runs in FixedUpdate. pub fn task_executor_system( @@ -45,6 +48,7 @@ pub fn task_executor_system( &EntityType, Option<&mut HaulSlot>, )>, + mut job_queue: ResMut, mut claimed_writer: MessageWriter, mut completed_writer: MessageWriter, mut failed_writer: MessageWriter, @@ -98,6 +102,8 @@ 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( @@ -139,6 +145,10 @@ pub fn task_executor_system( } => match step { ChopStep::MovingToTree { ref mut approach } => { if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) { + info!( + "TASK FAILED: {:?} for {:?} - {}", + current_task, entity, "tree already gone" + ); failed_writer.write(TaskFailed { entity, task: current_task.clone(), @@ -187,10 +197,15 @@ pub fn task_executor_system( ambulatory.current_path = None; } None => { + let reason = "no adjacent standable tile to approach tree"; + info!( + "TASK FAILED: {:?} for {:?} - {}", + current_task, entity, reason + ); failed_writer.write(TaskFailed { entity, task: current_task.clone(), - reason: "no adjacent standable tile to approach tree", + reason, }); *state = TaskState::Failed; continue; @@ -224,8 +239,9 @@ pub fn task_executor_system( // If target is Some, pathfinding is handling movement — nothing to do } ChopStep::Chopping { ticks_remaining } => { + info!("[EXECUTOR] Chop tick: {:?}", ticks_remaining); if *ticks_remaining == 0 { - let trunk_positions = fell_tree( + let (trunk_position, trunk_count) = fell_tree( *trunk_pos, &tree_parts, &mut commands, @@ -243,9 +259,14 @@ 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; 8]> = SmallVec::new(); - for &pos in trunk_positions.iter() { - if let Some(log_entity) = + let mut log_entities: SmallVec<[(Entity, IVec3); 8]> = + SmallVec::new(); + // Loop from 0 up to the number of trunk segments found + for i in 0..trunk_count { + // Calculate the position for this specific log by offseting Z + let pos = trunk_position + IVec3::new(0, 0, i as i32); + + if let Some((log_entity, drop_pos)) = crate::entities::cargo::spawn_log_cargo( &mut commands, &mut tilemap_mut, @@ -255,14 +276,42 @@ pub fn task_executor_system( &mut rng, ) { - log_entities.push(log_entity); + log_entities.push((log_entity, drop_pos)); } } - // Emit event so demo can queue HaulCargo tasks for these logs if !log_entities.is_empty() { + // Find surface Z at (0,0) - search for floor tile at different Z levels + use crate::constants::ITILE_SIZE; + let dest_z = (0..=4) + .find_map(|z_idx| { + let check_pos = IVec3::new(0, 0, z_idx * ITILE_SIZE); + if tilemap_mut.floor_tiles.contains_key(&check_pos) { + Some((z_idx + 1) * ITILE_SIZE) + } else { + None + } + }) + .unwrap_or(16); // Default to z=16 if no floor found + + let dest = IVec3::new(0, 0, dest_z); + + for (cargo_entity, actual_cargo_pos) in log_entities.iter() { + job_queue.push(JobKind::HaulCargo { + cargo_entity: *cargo_entity, + cargo_pos: *actual_cargo_pos, + dest, + }); + info!( + "[EXECUTOR] Added HaulCargo for cargo at {:?} -> {:?}", + actual_cargo_pos, dest + ); + } logs_spawned_writer.write(LogsSpawned { - log_entities, - dest: IVec3::ZERO, + log_entities: log_entities + .iter() + .map(|(e, _)| *e) + .collect(), + dest, }); } *step = ChopStep::Done; @@ -294,7 +343,14 @@ pub fn task_executor_system( HaulStep::MovingToCargo { approach } => { use crate::constants::ITILE_SIZE; + info!("[HAUL] {:?} MovingToCargo: cargo_pos={:?}, approach={:?}, target={:?}", + entity, cargo_pos, approach, ambulatory.target); + if !tilemap_mut.cargo_tiles.contains_key(cargo_pos) { + info!( + "TASK FAILED: {:?} for {:?} - {}", + current_task, entity, "cargo no longer exists" + ); failed_writer.write(TaskFailed { entity, task: current_task.clone(), @@ -305,40 +361,107 @@ pub fn task_executor_system( } if approach.is_none() { - // Search for nearest standable tile to approach cargo. - // Radius 0 = cargo tile itself (can stand in same tile as cargo). - // Radius 1-2 = adjacent tiles if cargo tile is blocked. + const NODE_CAP: usize = 1024; + let mut frontier: VecDeque = VecDeque::new(); + let mut visited: std::collections::HashSet = + std::collections::HashSet::new(); + let cargo_z = cargo_pos.z; - let approach_tile = (0..=2i32).find_map(|radius: i32| { - for dx in -radius..=radius { - for dy in -radius..=radius { - if radius > 0 - && dx.abs() != radius - && dy.abs() != radius - { - continue; - } - let candidate = IVec3::new( - cargo_pos.x + dx * ITILE_SIZE, - cargo_pos.y + dy * ITILE_SIZE, - cargo_z, - ); - if tilemap_mut.is_standable(candidate) { - return Some(candidate); - } + + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let neighbor = IVec3::new( + cargo_pos.x + dx * ITILE_SIZE, + cargo_pos.y + dy * ITILE_SIZE, + cargo_z, + ); + if visited.insert(neighbor) { + frontier.push_back(neighbor); } } - None - }); + } + + for dz in -1i32..=1 { + if dz == 0 { + continue; + } + let above = IVec3::new( + cargo_pos.x, + cargo_pos.y, + cargo_z + dz * ITILE_SIZE, + ); + if visited.insert(above) { + frontier.push_back(above); + } + } + + let mut approach_tile: Option = None; + + while let Some(tile) = frontier.pop_front() { + if visited.len() > NODE_CAP { + break; + } + + if !tilemap_mut.is_standable(tile) { + continue; + } + + if tilemap_mut.cargo_tiles.contains_key(&tile) { + for dz in -1i32..=1 { + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 && dz == 0 { + continue; + } + let neighbor = IVec3::new( + tile.x + dx * ITILE_SIZE, + tile.y + dy * ITILE_SIZE, + tile.z + dz * ITILE_SIZE, + ); + if visited.insert(neighbor) { + frontier.push_back(neighbor); + } + } + } + } + continue; + } + + if tilemap_mut.claimed_tiles.contains_key(&tile) { + for dz in -1i32..=1 { + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 && dz == 0 { + continue; + } + let neighbor = IVec3::new( + tile.x + dx * ITILE_SIZE, + tile.y + dy * ITILE_SIZE, + tile.z + dz * ITILE_SIZE, + ); + if visited.insert(neighbor) { + frontier.push_back(neighbor); + } + } + } + } + continue; + } + + approach_tile = Some(tile); + break; + } match approach_tile { Some(tile) => { *approach = Some(tile); info!( - "[HAUL] {:?} target set: cargo={:?} approach={:?}", - entity, cargo_pos, tile + "[HAUL] {:?} setting target: cargo={:?} approach={:?} target={:?}", + entity, cargo_pos, tile, ambulatory.target ); - // +1.0 z-offset for entity standing height (same as trees) ambulatory.target = Some(Vec3::new( tile.x as f32, tile.y as f32, @@ -359,22 +482,26 @@ pub fn task_executor_system( } // Check arrival at cargo tile - if approach.is_some() && ambulatory.target.is_none() { - let dx = transform.translation.x - cargo_pos.x as f32; - let dy = transform.translation.y - cargo_pos.y as f32; + // Use approach tile position for distance check, not target None + if let Some(approach_tile) = *approach { + let dx = transform.translation.x - approach_tile.x as f32; + let dy = transform.translation.y - approach_tile.y as f32; let dist_sq = dx * dx + dy * dy; - let pickup_range_sq = + let arrive_sq = (ITILE_SIZE as f32 * 1.5) * (ITILE_SIZE as f32 * 1.5); - if dist_sq <= pickup_range_sq { + info!("[HAUL] {:?} arrival check: approach={:?} dist_sq={:.1} arrive_sq={:.1} transform={:?}", + entity, approach_tile, dist_sq, arrive_sq, transform.translation.truncate()); + if dist_sq <= arrive_sq { info!( - "[HAUL] {:?} arrived at cargo {:?}, picking up", - entity, cargo_entity + "[HAUL] {:?} arrived at approach {:?}, picking up cargo at {:?}", + entity, approach_tile, cargo_pos ); *step = HaulStep::PickingUp; } } } HaulStep::PickingUp => { + info!("[HAUL] {:?} PickingUp: cargo_pos={:?}", entity, cargo_pos); if haul.is_occupied() { failed_writer.write(TaskFailed { entity, @@ -390,7 +517,14 @@ pub fn task_executor_system( "[HAUL] {:?} picked up {:?} → hauling to {:?}", entity, cargo_entity, dest ); - *step = HaulStep::MovingToDest { chosen_drop: None }; + + let drop_target = + find_drop_tile(&mut *tilemap_mut, *dest, entity); + tilemap_mut.claimed_tiles.insert(drop_target, entity); + + *step = HaulStep::MovingToDest { + chosen_drop: Some(drop_target), + }; } else { failed_writer.write(TaskFailed { entity, @@ -401,38 +535,43 @@ pub fn task_executor_system( } } HaulStep::MovingToDest { chosen_drop } => { - if chosen_drop.is_none() { - *chosen_drop = Some( - tilemap_mut - .find_nearest_free_cargo_tile(*dest, 8, &[]) - .unwrap_or(*dest), - ); - } - let drop_pos = chosen_drop.unwrap(); - if ambulatory.target.is_none() { - info!("[HAUL] {:?} target set: drop at {:?}", entity, drop_pos); - // +1.0 z-offset for entity standing height (same as approach) - ambulatory.target = Some(Vec3::new( - drop_pos.x as f32, - drop_pos.y as f32, - drop_pos.z as f32 + 1.0, - )); - ambulatory.current_path = None; - } + let drop_pos = *chosen_drop; + if let Some(drop) = drop_pos { + if ambulatory.target.is_none() { + info!( + "[HAUL] {:?} setting target to drop at {:?}", + entity, drop + ); + ambulatory.target = Some(Vec3::new( + drop.x as f32, + drop.y as f32, + drop.z as f32 + 1.0, + )); + ambulatory.current_path = None; + } - // Always check arrival distance, not gated by target status - let dx = transform.translation.x - drop_pos.x as f32; - let dy = transform.translation.y - drop_pos.y as f32; - let dist_sq = dx * dx + dy * dy; - let arrive_sq = (crate::constants::TILE_SIZE as f32 * 1.5) - * (crate::constants::TILE_SIZE as f32 * 1.5); - if dist_sq <= arrive_sq { - ambulatory.target = None; - info!( - "[HAUL] {:?} arrived at drop point {:?}", - entity, drop_pos - ); - *step = HaulStep::Dropping { drop_pos }; + let dx = transform.translation.x - drop.x as f32; + let dy = transform.translation.y - drop.y as f32; + let dist_sq = dx * dx + dy * dy; + let arrive_sq = (crate::constants::TILE_SIZE as f32 * 1.5) + * (crate::constants::TILE_SIZE as f32 * 1.5); + if dist_sq <= arrive_sq { + ambulatory.target = None; + tilemap_mut.claimed_tiles.remove(&drop); + info!( + "[HAUL] {:?} arrived at drop point {:?}", + entity, drop + ); + *step = HaulStep::Dropping { drop_pos: drop }; + } + } else { + failed_writer.write(TaskFailed { + entity, + task: current_task.clone(), + reason: "drop target not set", + }); + *state = TaskState::Failed; + continue; } } HaulStep::Dropping { drop_pos } => { @@ -551,3 +690,94 @@ pub fn task_executor_system( } } } + +fn find_drop_tile(tilemap: &mut TileMap, dest: IVec3, exclude_entity: Entity) -> IVec3 { + const SEARCH_RADIUS: i32 = 8; + const NODE_CAP: usize = 1024; + + let mut frontier: VecDeque = VecDeque::new(); + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + + let dest_z = dest.z; + + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let neighbor = IVec3::new(dest.x + dx * ITILE_SIZE, dest.y + dy * ITILE_SIZE, dest_z); + if visited.insert(neighbor) { + frontier.push_back(neighbor); + } + } + } + + for dz in -1i32..=1 { + if dz == 0 { + continue; + } + let above = IVec3::new(dest.x, dest.y, dest_z + dz * ITILE_SIZE); + if visited.insert(above) { + frontier.push_back(above); + } + } + + while let Some(tile) = frontier.pop_front() { + if visited.len() > NODE_CAP { + break; + } + + if !tilemap.is_standable(tile) { + continue; + } + + if tilemap.cargo_tiles.contains_key(&tile) { + for dz in -1i32..=1 { + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 && dz == 0 { + continue; + } + let neighbor = IVec3::new( + tile.x + dx * ITILE_SIZE, + tile.y + dy * ITILE_SIZE, + tile.z + dz * ITILE_SIZE, + ); + if visited.insert(neighbor) { + frontier.push_back(neighbor); + } + } + } + } + continue; + } + + if tilemap.claimed_tiles.contains_key(&tile) { + let claimant = tilemap.claimed_tiles.get(&tile).copied(); + if claimant != Some(exclude_entity) { + for dz in -1i32..=1 { + for dx in -1i32..=1 { + for dy in -1i32..=1 { + if dx == 0 && dy == 0 && dz == 0 { + continue; + } + let neighbor = IVec3::new( + tile.x + dx * ITILE_SIZE, + tile.y + dy * ITILE_SIZE, + tile.z + dz * ITILE_SIZE, + ); + if visited.insert(neighbor) { + frontier.push_back(neighbor); + } + } + } + } + continue; + } + } + + return tile; + } + + dest +} diff --git a/src/entities/tasks/idle.rs b/src/entities/tasks/idle.rs deleted file mode 100644 index daf1147..0000000 --- a/src/entities/tasks/idle.rs +++ /dev/null @@ -1,210 +0,0 @@ -use crate::constants::{ITILE_SIZE, TILE_SIZE}; -use crate::entities::behaviour::IdleBehaviour; -use crate::entities::shared_components::Ambulatory; -use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES}; -use crate::world::chunks::ChunkMap; -use crate::world::chunks::CHUNK_SIZE; -use crate::world::tiles::TileMap; -use bevy::prelude::*; -use bevy_rand::prelude::*; -use rand::RngExt; - -pub(super) fn execute_idle( - task: &mut Task, - transform: &Transform, - ambulatory: &mut Ambulatory, - sprite: &mut Sprite, - tilemap: &TileMap, - chunk_map: &ChunkMap, - behaviour: &IdleBehaviour, - rng: &mut WyRand, - current_tick: u32, -) { - let Task::Idle { - origin, - sigma_world, - state, - } = task - else { - return; - }; - - match state { - IdleState::Picking { - retry_after_tick, - retry_count, - } => { - if current_tick < *retry_after_tick { - return; - } - - match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) { - Some(target) => { - ambulatory.target = Some(Vec3::new( - target.x as f32, - target.y as f32, - target.z as f32 + 1.0, - )); - ambulatory.current_path = None; - ambulatory.path_index = 0; - *state = IdleState::Moving { target }; - } - None => { - *retry_count += 1; - if *retry_count >= IDLE_MAX_RETRIES { - panic!( - "Entity stuck: pick_gaussian_target returned None {} times \ - consecutively. origin={:?} sigma_world={:.1} \ - loaded_chunks={} \ - — no standable tile found. Check tilemap state.", - retry_count, - origin, - sigma_world, - chunk_map.loaded_chunks.len(), - ); - } - *retry_after_tick = current_tick.saturating_add(30); - } - } - } - - IdleState::Moving { target } => { - let dx = transform.translation.x - target.x as f32; - let dy = transform.translation.y - target.y as f32; - let dist_sq = dx * dx + dy * dy; - let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5); - - let arrived = dist_sq < arrive_threshold_sq; - let no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none(); - - if arrived || no_nav { - ambulatory.target = None; - ambulatory.current_path = None; - - let loiter_roll: f32 = rng.random(); - if loiter_roll < behaviour.loiter_chance { - let duration_range = behaviour.loiter_max_ticks - behaviour.loiter_min_ticks; - let duration = - behaviour.loiter_min_ticks + rng.random_range(0..=duration_range); - - let flip1: f32 = rng.random(); - let flip2: f32 = rng.random(); - let flips_remaining = (flip1 < behaviour.flip_chance) as u8 - + (flip2 < behaviour.flip_chance) as u8; - - let next_flip_at = if flips_remaining > 0 { - current_tick.saturating_add(rng.random_range(1..=duration / 2)) - } else { - u32::MAX - }; - - *state = IdleState::Loitering { - ticks_remaining: duration, - flips_remaining, - next_flip_at, - }; - } else { - *state = IdleState::Picking { - retry_after_tick: 0, - retry_count: 0, - }; - } - } - } - - IdleState::Loitering { - ticks_remaining, - flips_remaining, - next_flip_at, - } => { - if *flips_remaining > 0 && current_tick >= *next_flip_at { - sprite.flip_x = !sprite.flip_x; - *flips_remaining -= 1; - - if *flips_remaining > 0 && *ticks_remaining > 2 { - *next_flip_at = - current_tick.saturating_add(rng.random_range(1..=*ticks_remaining / 2)); - } - } - - if *ticks_remaining == 0 { - sprite.flip_x = false; - *state = IdleState::Picking { - retry_after_tick: 0, - retry_count: 0, - }; - } else { - *ticks_remaining -= 1; - } - } - } -} - -fn pick_gaussian_target( - origin: &IVec3, - sigma_world: f32, - tilemap: &TileMap, - chunk_map: &ChunkMap, - rng: &mut WyRand, -) -> Option { - let two_sigma_sq = 2.0 * sigma_world * sigma_world; - let mut chosen: Option = None; - let mut weight_sum = 0.0f32; - - for &chunk_pos in chunk_map.loaded_chunks.keys() { - let has_all_neighbours = chunk_map - .loaded_chunks - .contains_key(&(chunk_pos + IVec2::X)) - && chunk_map - .loaded_chunks - .contains_key(&(chunk_pos - IVec2::X)) - && chunk_map - .loaded_chunks - .contains_key(&(chunk_pos + IVec2::Y)) - && chunk_map - .loaded_chunks - .contains_key(&(chunk_pos - IVec2::Y)); - if !has_all_neighbours { - continue; - } - - const SAMPLES_PER_CHUNK: usize = 4; - for _ in 0..SAMPLES_PER_CHUNK { - let local_x = rng.random_range(0..CHUNK_SIZE); - let local_y = rng.random_range(0..CHUNK_SIZE); - let world_x = (chunk_pos.x * CHUNK_SIZE + local_x) * ITILE_SIZE; - let world_y = (chunk_pos.y * CHUNK_SIZE + local_y) * ITILE_SIZE; - - let Some(candidate) = find_surface(world_x, world_y, tilemap) else { - continue; - }; - - let dx = (candidate.x - origin.x) as f32; - let dy = (candidate.y - origin.y) as f32; - let dist_sq = dx * dx + dy * dy; - let weight = (-dist_sq / two_sigma_sq).exp(); - - weight_sum += weight; - let accept: f32 = rng.random(); - if accept < weight / weight_sum { - chosen = Some(candidate); - } - } - } - - chosen -} - -#[inline] -fn find_surface(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option { - for z in -3i32..=4i32 { - let floor_pos = IVec3::new(world_x, world_y, z * ITILE_SIZE); - if tilemap.floor_tiles.contains_key(&floor_pos) { - let above = IVec3::new(world_x, world_y, floor_pos.z + ITILE_SIZE); - if tilemap.is_standable(above) { - return Some(above); - } - } - } - None -} diff --git a/src/entities/tasks/job_assignment.rs b/src/entities/tasks/job_assignment.rs new file mode 100644 index 0000000..7285f7e --- /dev/null +++ b/src/entities/tasks/job_assignment.rs @@ -0,0 +1,77 @@ +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::jobs::{FellTreeJob, HaulCargoJob, IdleJob}; +use crate::world::tiles::TileMap; +use bevy::prelude::*; + +pub fn job_assignment_system( + mut job_queue: ResMut, + tilemap: Res, + mut dorf_query: Query< + ( + &mut TaskQueue, + &mut TaskState, + &Transform, + &HaulSlot, + &mut Ambulatory, + ), + 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) = 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(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(cargo_entity, cargo_pos, dest)); + *state = TaskState::Pending; + } + } + } 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(origin, &behaviour.idle)); + *state = TaskState::Pending; + } + } + } +} diff --git a/src/entities/tasks/job_queue.rs b/src/entities/tasks/job_queue.rs new file mode 100644 index 0000000..bb5115c --- /dev/null +++ b/src/entities/tasks/job_queue.rs @@ -0,0 +1,205 @@ +use crate::constants::ITILE_SIZE; +use crate::entities::shared_systems::pathfinding::calculate_provisional_path; +use bevy::prelude::*; +use smallvec::SmallVec; +use std::cmp::Ordering; +use std::collections::VecDeque; + +#[derive(Clone, Debug)] +pub enum JobKind { + FellTree { + trunk_pos: IVec3, + }, + HaulCargo { + cargo_entity: Entity, + cargo_pos: IVec3, + dest: IVec3, + }, +} + +impl JobKind { + #[inline] + pub fn priority(&self) -> u8 { + match self { + JobKind::FellTree { .. } => 2, + JobKind::HaulCargo { .. } => 1, + } + } + + #[inline] + pub fn target(&self) -> IVec3 { + match self { + JobKind::FellTree { trunk_pos, .. } => *trunk_pos, + JobKind::HaulCargo { cargo_pos, .. } => *cargo_pos, + } + } + + #[inline] + pub fn is_fell_tree(&self) -> bool { + matches!(self, JobKind::FellTree { .. }) + } + #[inline] + pub fn is_haul_cargo(&self) -> bool { + matches!(self, JobKind::HaulCargo { .. }) + } +} + +struct Entry { + kind: JobKind, + claimed: bool, +} + +#[derive(Resource, Default)] +pub struct JobQueue { + jobs: VecDeque, +} + +impl JobQueue { + #[inline] + pub fn push(&mut self, kind: JobKind) { + self.jobs.push_back(Entry { + kind, + claimed: false, + }); + } + + pub fn pop_best_pathfinding( + &mut self, + tilemap: &crate::world::tiles::TileMap, + dorf_pos: IVec3, + _dorf_pos_2d: Vec2, + ) -> Option { + 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) + .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(); + (i, entry.kind.priority(), dist) + }) + .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 + }); + + let max_path_dist = 1024; + + // 3. Evaluate the sorted jobs with actual pathfinding + for (idx, _pri, _dist) in candidates { + let entry = &self.jobs[idx]; + + let best_path_target = match &entry.kind { + JobKind::FellTree { trunk_pos } => { + let standable_tiles = Self::find_all_standable_adjacent(trunk_pos, tilemap); + + 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 { + shortest_path_len = path.len(); + best_target = Some(tile); + } + } + best_target + } + JobKind::HaulCargo { cargo_pos, .. } => { + let path = + calculate_provisional_path(tilemap, dorf_pos, *cargo_pos, max_path_dist); + if !path.is_empty() { + Some(*cargo_pos) + } else { + None + } + } + }; + + // 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 + return Some(self.jobs[idx].kind.clone()); + } + } + + info!("[QUEUE] No reachable jobs found for dorf at {:?}", dorf_pos); + None + } + + /// Returns ALL standable tiles immediately adjacent to the tree on the same Z level. + // job_queue.rs + + fn find_all_standable_adjacent( + trunk_pos: &IVec3, + tilemap: &crate::world::tiles::TileMap, + ) -> SmallVec<[IVec3; 8]> { + let mut tiles = SmallVec::new(); + + // The standing position is the SAME Z as the trunk. + // If trunk is at 16, dorf stands at 16 (in the air). + 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 + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.jobs.is_empty() + } + #[inline] + pub fn len(&self) -> usize { + self.jobs.len() + } + #[inline] + pub fn has_fell_tree(&self) -> bool { + self.jobs.iter().any(|e| e.kind.is_fell_tree()) + } + + pub fn debug_counts(&self) -> (usize, usize) { + let fell = self.jobs.iter().filter(|e| e.kind.is_fell_tree()).count(); + let haul = self.jobs.iter().filter(|e| e.kind.is_haul_cargo()).count(); + (fell, haul) + } + + pub fn iter(&self) -> impl Iterator { + self.jobs.iter().map(|e| &e.kind) + } +} diff --git a/src/entities/tasks/jobs.rs b/src/entities/tasks/jobs.rs new file mode 100644 index 0000000..4d6c285 --- /dev/null +++ b/src/entities/tasks/jobs.rs @@ -0,0 +1,43 @@ +use crate::entities::behaviour::IdleBehaviour; +use crate::entities::tasks::components::{ChopStep, HaulStep, IdleState, Task, CHOP_TICKS_DEFAULT}; +use bevy::prelude::{Entity, IVec3}; + +pub struct FellTreeJob; + +impl FellTreeJob { + pub fn start(trunk_pos: IVec3) -> Task { + Task::ChopTree { + trunk_pos, + chop_ticks: CHOP_TICKS_DEFAULT, + step: ChopStep::MovingToTree { approach: None }, + } + } +} + +pub struct HaulCargoJob; + +impl HaulCargoJob { + pub fn start(cargo_entity: Entity, cargo_pos: IVec3, dest: IVec3) -> Task { + Task::HaulCargo { + cargo_entity, + cargo_pos, + dest, + step: HaulStep::MovingToCargo { approach: None }, + } + } +} + +pub struct IdleJob; + +impl IdleJob { + pub fn start(origin: IVec3, behaviour: &IdleBehaviour) -> Task { + Task::Idle { + origin, + sigma_world: behaviour.sigma_world, + state: IdleState::Picking { + retry_after_tick: 0, + retry_count: 0, + }, + } + } +} diff --git a/src/entities/tasks/mod.rs b/src/entities/tasks/mod.rs index 9f6e1d2..1ec29ba 100644 --- a/src/entities/tasks/mod.rs +++ b/src/entities/tasks/mod.rs @@ -2,13 +2,18 @@ pub mod components; pub mod demo; pub mod events; pub mod executor; -pub mod idle; +pub mod job_assignment; +pub mod job_queue; +pub mod jobs; +pub mod queue_debug; +pub mod tasks; pub use components::{IdleState, Task, TaskQueue, TaskState}; -#[cfg(debug_assertions)] -pub use demo::debug_task_queues; -pub use demo::{demo_system, DemoState}; +pub use demo::demo_system; pub use events::{LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed}; pub use executor::task_executor_system; +pub use job_assignment::job_assignment_system; +pub use job_queue::{JobKind, JobQueue}; +pub use queue_debug::queue_debug_system; pub use crate::plugins::tasks::TasksPlugin; diff --git a/src/entities/tasks/queue_debug.rs b/src/entities/tasks/queue_debug.rs new file mode 100644 index 0000000..4a4a76f --- /dev/null +++ b/src/entities/tasks/queue_debug.rs @@ -0,0 +1,54 @@ +use crate::entities::behaviour::EntityType; +use crate::entities::tasks::components::{TaskQueue, TaskState}; +use crate::entities::tasks::job_queue::{JobKind, JobQueue}; +use bevy::prelude::*; + +pub fn queue_debug_system( + job_queue: Res, + dorf_query: Query<(&TaskQueue, &TaskState, &Transform), With>, +) { + let (fell_count, haul_count) = job_queue.debug_counts(); + let total = job_queue.len(); + + info!( + "=== QUEUE === fell:{}, haul:{}, total:{}", + fell_count, haul_count, total + ); + + for (i, kind) in job_queue.iter().enumerate() { + match kind { + JobKind::FellTree { trunk_pos } => { + info!(" [{}] FellTree at {:?}", i, trunk_pos); + } + JobKind::HaulCargo { + cargo_entity, + cargo_pos, + dest, + } => { + info!( + " [{}] HaulCargo entity:{:?} from {:?} to {:?}", + i, cargo_entity, cargo_pos, dest + ); + } + } + } + + info!("=== DORFS ==="); + for (queue, state, transform) in dorf_query.iter() { + let pos = transform.translation.truncate(); + let current_task = queue.current().map(|t| t.name()).unwrap_or("EMPTY"); + let state_str = match *state { + TaskState::Pending => "PENDING", + TaskState::Active => "ACTIVE", + TaskState::Completed => "COMPLETED", + TaskState::Failed => "FAILED", + }; + info!( + " Dorf at {:?}: task={} state={} queue_len={}", + pos, + current_task, + state_str, + queue.tasks.len() + ); + } +} diff --git a/src/entities/tasks/tasks/idle.rs b/src/entities/tasks/tasks/idle.rs new file mode 100644 index 0000000..1c7d09c --- /dev/null +++ b/src/entities/tasks/tasks/idle.rs @@ -0,0 +1,234 @@ +use super::TaskResult; +use crate::constants::{ITILE_SIZE, TILE_SIZE}; +use crate::entities::behaviour::IdleBehaviour; +use crate::entities::shared_components::Ambulatory; +use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES}; +use crate::world::chunks::ChunkMap; +use crate::world::chunks::CHUNK_SIZE; +use crate::world::tiles::TileMap; +use bevy::prelude::*; +use bevy_rand::prelude::*; +use rand::RngExt; + +pub fn execute_idle( + task: &mut Task, + transform: &Transform, + ambulatory: &mut Ambulatory, + sprite: &mut Sprite, + tilemap: &TileMap, + chunk_map: &ChunkMap, + behaviour: &IdleBehaviour, + rng: &mut WyRand, + current_tick: u32, +) -> TaskResult { + let Task::Idle { + origin, + sigma_world, + state, + } = task + else { + return TaskResult::Failed("not an idle task"); + }; + + match state.clone() { + IdleState::Picking { + retry_after_tick, + retry_count, + } => { + if current_tick < retry_after_tick { + return TaskResult::Continue(0); + } + + match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) { + Some(floor_pos) => { + // 1. The PATHFINDER needs to target the air ABOVE the floor + let standable_target = floor_pos + IVec3::new(0, 0, ITILE_SIZE); + + // 2. The AMBULATORY system (floats) needs the target + 1.0 offset + ambulatory.target = Some(Vec3::new( + standable_target.x as f32, + standable_target.y as f32, + standable_target.z as f32 + 1.0, + )); + + // 3. Reset movement state + ambulatory.current_path = None; + ambulatory.path_index = 0; + ambulatory.move_direction = Vec2::ZERO; + + // 4. Update the Task State with the STANDABLE target + if let Task::Idle { + state: ref mut s, .. + } = task + { + *s = IdleState::Moving { + target: standable_target, + }; + } + + // Return the required TaskResult + TaskResult::Continue(0) + } + None => { + // If no target found, retry later + if let Task::Idle { + state: ref mut s, .. + } = task + { + *s = IdleState::Picking { + retry_after_tick: current_tick + 30, + retry_count: retry_count + 1, + }; + } + TaskResult::Continue(0) + } + } + } + + IdleState::Moving { target } => { + info!( + "[IDLE] Moving: target={:?} current={:?}", + target, transform.translation + ); + let dx = transform.translation.x - target.x as f32; + let dy = transform.translation.y - target.y as f32; + let dist_sq = dx * dx + dy * dy; + let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5); + + 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); + if arrived || no_nav { + ambulatory.target = None; + ambulatory.current_path = None; + + let loiter_roll: f32 = rng.random(); + if loiter_roll < behaviour.loiter_chance { + let duration_range = behaviour.loiter_max_ticks - behaviour.loiter_min_ticks; + let duration = + behaviour.loiter_min_ticks + rng.random_range(0..=duration_range); + + let flip1: f32 = rng.random(); + let flip2: f32 = rng.random(); + let flips_remaining = (flip1 < behaviour.flip_chance) as u8 + + (flip2 < behaviour.flip_chance) as u8; + + let next_flip_at = if flips_remaining > 0 { + current_tick.saturating_add(rng.random_range(1..=duration / 2)) + } else { + u32::MAX + }; + + if let Task::Idle { + state: ref mut s, .. + } = task + { + *s = IdleState::Loitering { + ticks_remaining: duration, + flips_remaining, + next_flip_at, + }; + } + } else { + if let Task::Idle { + state: ref mut s, .. + } = task + { + *s = IdleState::Picking { + retry_after_tick: 0, + retry_count: 0, + }; + } + } + } + TaskResult::Continue(0) + } + + IdleState::Loitering { + ticks_remaining, + flips_remaining, + next_flip_at, + } => { + if flips_remaining > 0 && current_tick >= next_flip_at { + sprite.flip_x = !sprite.flip_x; + } + + if ticks_remaining == 0 { + sprite.flip_x = false; + if let Task::Idle { + state: ref mut s, .. + } = task + { + *s = IdleState::Picking { + retry_after_tick: 0, + retry_count: 0, + }; + } + } else { + if let Task::Idle { + state: + IdleState::Loitering { + ref mut ticks_remaining, + .. + }, + .. + } = task + { + *ticks_remaining -= 1; + } + } + TaskResult::Continue(0) + } + } +} + +fn pick_gaussian_target( + origin: &IVec3, + sigma_world: f32, + tilemap: &TileMap, + _chunk_map: &ChunkMap, // No longer needed for O(1) lookups + rng: &mut WyRand, +) -> Option { + if tilemap.surface_positions.is_empty() { + return None; + } + + let two_sigma_sq = 2.0 * sigma_world * sigma_world; + let mut chosen: Option = None; + let mut weight_sum = 0.0f32; + + // We sample a fixed number of times globally from known surface tiles. + // This is O(SAMPLES) instead of O(CHUNKS * SAMPLES * TOTAL_TILES). + const TOTAL_SAMPLES: usize = 32; + + for _ in 0..TOTAL_SAMPLES { + // 1. Pick a random surface tile directly from the pre-populated list + let idx = rng.random_range(0..tilemap.surface_positions.len()); + let candidate = tilemap.surface_positions[idx]; + + // 2. Calculate Gaussian weight based on 2D distance + // (Allows dorfs to choose targets on different Z-levels if they are surface tiles) + let dx = (candidate.x - origin.x) as f32; + let dy = (candidate.y - origin.y) as f32; + let dist_sq = dx * dx + dy * dy; + + // Use a small epsilon to prevent exp(0) issues + let weight = (-(dist_sq / two_sigma_sq)).exp().max(0.0001); + + // 3. Reservoir Sampling: Update the chosen target based on relative weight + weight_sum += weight; + if rng.random_range(0.0..1.0) < (weight / weight_sum) { + chosen = Some(candidate); + } + } + + if let Some(pos) = chosen { + info!( + "[IDLE] Target picked at {:?} (Weight Sum: {:.4})", + pos, weight_sum + ); + } + + chosen +} diff --git a/src/entities/tasks/tasks/mod.rs b/src/entities/tasks/tasks/mod.rs new file mode 100644 index 0000000..0e0e148 --- /dev/null +++ b/src/entities/tasks/tasks/mod.rs @@ -0,0 +1,7 @@ +pub mod idle; + +pub enum TaskResult { + Complete, + Continue(u32), + Failed(&'static str), +} diff --git a/src/plugins/tasks.rs b/src/plugins/tasks.rs index 2d8eaf6..883fdc3 100644 --- a/src/plugins/tasks.rs +++ b/src/plugins/tasks.rs @@ -1,13 +1,7 @@ -//! TasksPlugin — registers task infrastructure: executor, events, idle logic, demo loop. -//! -//! Systems: -//! - task_executor_system (FixedUpdate) -//! - demo_system (FixedUpdate, after task_executor_system) -//! - debug_task_queues (FixedUpdate, after demo_system, debug only) - use crate::entities::tasks::{ - demo_system, task_executor_system, DemoState, LogsSpawned, TaskBlocked, TaskClaimed, - TaskCompleted, TaskDropped, TaskFailed, + demo_system, job_assignment_system, job_queue::JobQueue, queue_debug_system, + task_executor_system, LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, + TaskFailed, }; use bevy::prelude::*; @@ -21,17 +15,15 @@ impl Plugin for TasksPlugin { .add_message::() .add_message::() .add_message::() - .init_resource::() + .init_resource::() .add_systems( - bevy::app::FixedUpdate, - demo_system.after(task_executor_system), + FixedUpdate, + ( + demo_system, + job_assignment_system, + task_executor_system, + queue_debug_system, + ), ); - - // Debug only — compiles away in release - #[cfg(debug_assertions)] - { - use crate::entities::tasks::debug_task_queues; - app.add_systems(bevy::app::FixedUpdate, debug_task_queues.after(demo_system)); - } } } diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs index 872b36d..c177c83 100644 --- a/src/world/generation/forestry.rs +++ b/src/world/generation/forestry.rs @@ -292,7 +292,8 @@ pub fn fell_tree( tilemap: &mut TileMap, tile_changed: &mut MessageWriter, occlusion: &mut MessageWriter, -) -> SmallVec<[IVec3; 8]> { +) -> (IVec3, usize) { + // Changed return type let target_chunk = world_to_chunk(trunk_pos); let trunk_x = trunk_pos.x; let trunk_y = trunk_pos.y; @@ -333,5 +334,19 @@ pub fn fell_tree( for pos in dirty_columns { occlusion.write(TileOcclusionEvent { tile_position: pos }); } - trunk_positions + + let trunk_count = trunk_positions.len(); + // Default to the input trunk_pos if for some reason no trunk parts were found + let lowest_trunk = trunk_positions + .iter() + .min_by_key(|p| p.z) + .cloned() + .unwrap_or(trunk_pos); + + info!( + "[FORESTRY] fell_tree: trunk_pos={:?} lowest={:?} count={}", + trunk_pos, lowest_trunk, trunk_count + ); + + (lowest_trunk, trunk_count) } diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs index d8fa794..4e8459b 100644 --- a/src/world/generation/terrain.rs +++ b/src/world/generation/terrain.rs @@ -329,6 +329,10 @@ 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()); + } + forrestry_event_writer.write(ChunkForrestryEvent { chunk_position: blob.chunk_pos, floor_tiles: blob.surface_positions, diff --git a/src/world/tiles/chunk_data.rs b/src/world/tiles/chunk_data.rs index e7118fd..18baab9 100644 --- a/src/world/tiles/chunk_data.rs +++ b/src/world/tiles/chunk_data.rs @@ -96,14 +96,11 @@ impl ChunkData { /// - (can_stand_in_floor OR can_stand_in_fixture) at (x, y, z) /// - AND (can_stand_on_floor OR can_stand_on_fixture) at (x, y, z-1) #[inline] - pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool { - // Bounds check: z must be within -Z_BELOW..Z_ABOVE - if z < -(Z_BELOW as i32) || z > (Z_ABOVE as i32) { - return false; - } + // chunkdata.rs - // Bounds check: local coords must be within chunk - if local_x < 0 || local_x >= CHUNK_SIZE || local_y < 0 || local_y >= CHUNK_SIZE { + 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 { return false; } @@ -112,15 +109,15 @@ impl ChunkData { let bit = idx % 32; let mask = 1u32 << bit; + // Am I in Air? let in_floor = (self.stand_in_floor[word] & mask) != 0; let in_fixture = (self.stand_in_fixture[word] & mask) != 0; - // Can't stand at the very bottom of the world - if z <= -(Z_BELOW as i32) { + // 2. Check the tile immediately below (z - 1) + if z <= 0 { return false; - } + } // Bottom of the world - // Check tile below for "stand on" let below_idx = Self::pos_to_index(local_x, local_y, z - 1); let below_word = below_idx / 32; let below_bit = below_idx % 32; @@ -129,6 +126,7 @@ impl ChunkData { let on_floor = (self.stand_on_floor[below_word] & below_mask) != 0; let on_fixture = (self.stand_on_fixture[below_word] & below_mask) != 0; + // Logic: Current tile is passable AND tile below is solid (in_floor || in_fixture) && (on_floor || on_fixture) } diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs index ee86728..649bf44 100644 --- a/src/world/tiles/tilemap.rs +++ b/src/world/tiles/tilemap.rs @@ -208,6 +208,12 @@ pub struct TileMap { /// One Cargo entity per tile position. Enforces single-occupancy. /// Cargo does not affect standability — purely for lookup and placement validation. pub cargo_tiles: FxHashMap, + /// Tiles claimed by dorfs for drop destinations. Prevents multiple dorfs + /// from targeting the same drop tile. Maps tile position -> entity doing the claim. + pub claimed_tiles: FxHashMap, + /// Surface tile positions for idle pathfinding and valid spawn targets. + /// Populated from TerrainBlob during terrain processing. + pub surface_positions: Vec, } impl TileMap {