//! Task executor system — the dispatcher that drives entity behaviour. //! //! Runs in FixedUpdate. For each entity with TaskQueue: //! 1. If no active task, pop front of queue → set TaskState::Active //! 2. Match on active Task variant, execute corresponding logic //! 3. On completion/failure, update TaskState, fire event, pop task //! 4. If queue empty after pop, assign default Task::Idle //! //! 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; 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::tasks::idle::execute_idle; use crate::world::chunks::ChunkMap; use crate::world::generation::forestry::{fell_tree, TreePart}; use crate::world::tiles::tile_changed::TileChangedEvent; use crate::world::tiles::visibility::TileOcclusionEvent; 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( mut commands: Commands, mut tilemap_mut: ResMut, chunk_map: Res, asset_server: Res, mut rng_q: Query<&mut WyRand, With>, mut tick: Local, tree_parts: Query<(Entity, &TreePart)>, mut cargo_query: Query<&mut Cargo>, mut query: Query<( Entity, &mut TaskQueue, &mut TaskState, &mut Ambulatory, &Transform, &mut Sprite, &EntityType, Option<&mut HaulSlot>, )>, mut job_queue: ResMut, mut claimed_writer: MessageWriter, mut completed_writer: MessageWriter, mut failed_writer: MessageWriter, mut logs_spawned_writer: MessageWriter, mut tile_changed: MessageWriter, mut occlusion: MessageWriter, ) { let Ok(mut rng) = rng_q.single_mut() else { return; }; *tick = tick.wrapping_add(1); let current_tick = *tick; for ( entity, mut queue, mut state, mut ambulatory, transform, mut sprite, entity_type, mut haul_slot, ) in query.iter_mut() { let origin = transform.translation.as_ivec3(); let behaviour = EntityBehaviourRegistry::global_get(entity_type.0); if queue.is_empty() { queue.push(Task::Idle { origin, sigma_world: behaviour.idle.sigma_world, state: IdleState::Picking { retry_after_tick: 0, retry_count: 0, }, }); } // Promote Pending → Active if needed if *state == TaskState::Pending && !queue.is_empty() { *state = TaskState::Active; if let Some(task) = queue.current() { claimed_writer.write(TaskClaimed { entity, task: task.clone(), }); } } // 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( current_task, transform, &mut ambulatory, &mut sprite, &*tilemap_mut, &chunk_map, &behaviour.idle, &mut rng, current_tick, ); } Task::GoTo { target, threshold_tiles, } => { let entity_pos = (transform.translation / 16.0f32).as_ivec3(); let distance = (entity_pos.x - target.x) .abs() .max((entity_pos.y - target.y).abs()); if distance <= *threshold_tiles || !tilemap_mut.is_standable(*target) { *state = TaskState::Completed; } else { ambulatory.target = Some(Vec3::new( target.x as f32, target.y as f32, target.z as f32 + 1.0, )); ambulatory.current_path = None; } } Task::ChopTree { trunk_pos, chop_ticks, step, } => 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(), reason: "tree already gone", }); *state = TaskState::Failed; continue; } use crate::constants::ITILE_SIZE; if approach.is_none() { // First entry: compute approach tile and set target. // Neighbours at the same z as the trunk base — this IS the floor level. let trunk_z = trunk_pos.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 { for dy in -radius..=radius { // Only check the outer ring of this radius if dx.abs() != radius && dy.abs() != radius { continue; } let candidate = IVec3::new( trunk_pos.x + dx * ITILE_SIZE, trunk_pos.y + dy * ITILE_SIZE, trunk_z, ); if tilemap_mut.is_standable(candidate) { *approach = Some(candidate); return Some(Vec3::new( candidate.x as f32, candidate.y as f32, candidate.z as f32 + 1.0, )); } } } None }); match approach_target { Some(target) => { ambulatory.target = Some(target); 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, }); *state = TaskState::Failed; continue; } } } else if ambulatory.target.is_none() { // Arrived at approach tile (or path failed and target cleared). // Check distance to trunk; if close enough, start chopping. let dx = transform.translation.x - trunk_pos.x as f32; let dy = transform.translation.y - trunk_pos.y as f32; let dist_sq = dx * dx + dy * dy; let chop_range_sq = (ITILE_SIZE as f32 * 2.5) * (ITILE_SIZE as f32 * 2.5); if dist_sq <= chop_range_sq { // Close enough — transition to chopping ambulatory.current_path = None; *step = ChopStep::Chopping { ticks_remaining: *chop_ticks, }; } else if let Some(approach_tile) = *approach { // Not in range — re-set target to approach tile ambulatory.target = Some(Vec3::new( approach_tile.x as f32, approach_tile.y as f32, approach_tile.z as f32 + 1.0, )); ambulatory.current_path = None; } } // 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_position, trunk_count) = fell_tree( *trunk_pos, &tree_parts, &mut commands, &mut tilemap_mut, &mut tile_changed, &mut occlusion, ); // Compute fall direction: from chopper (entity's position) toward trunk let chopper_pos = transform.translation.truncate(); // XY only let trunk_xy = Vec2::new(trunk_pos.x as f32, trunk_pos.y as f32); let mut fall_dir = (trunk_xy - chopper_pos).normalize_or_zero(); // If dorf is standing on the trunk (distance ~0), normalize_or_zero returns ZERO. // Safe to use == here because normalize_or_zero produces exact zero components. if fall_dir == Vec2::ZERO { 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]> = 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, pos, fall_dir, log_sprite.clone(), &mut rng, ) { log_entities.push((log_entity, drop_pos)); } } 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: log_entities .iter() .map(|(e, _)| *e) .collect(), dest, }); } *step = ChopStep::Done; } else { *ticks_remaining -= 1; } } ChopStep::Done => { *state = TaskState::Completed; } }, Task::HaulCargo { cargo_entity, cargo_pos, dest, step, } => { let Some(ref mut haul) = haul_slot else { failed_writer.write(TaskFailed { entity, task: current_task.clone(), reason: "entity has no HaulSlot", }); *state = TaskState::Failed; continue; }; match step { 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(), reason: "cargo no longer exists", }); *state = TaskState::Failed; continue; } if approach.is_none() { 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; 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); } } } 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] {:?} setting target: cargo={:?} approach={:?} target={:?}", entity, cargo_pos, tile, ambulatory.target ); ambulatory.target = Some(Vec3::new( tile.x as f32, tile.y as f32, tile.z as f32 + 1.0, )); ambulatory.current_path = None; } None => { failed_writer.write(TaskFailed { entity, task: current_task.clone(), reason: "no standable tile near cargo", }); *state = TaskState::Failed; continue; } } } // Check arrival at cargo tile // 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 arrive_sq = (ITILE_SIZE as f32 * 1.5) * (ITILE_SIZE as f32 * 1.5); 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 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, task: current_task.clone(), reason: "HaulSlot already occupied", }); *state = TaskState::Failed; continue; } if let Some(_) = tilemap_mut.remove_cargo(cargo_pos) { haul.pick_up(*cargo_entity); info!( "[HAUL] {:?} picked up {:?} → hauling to {:?}", entity, cargo_entity, dest ); 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, task: current_task.clone(), reason: "cargo vanished before pickup", }); *state = TaskState::Failed; } } HaulStep::MovingToDest { chosen_drop } => { 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; } 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 } => { if let Some(cargo_entity) = haul.release() { info!( "[HAUL] {:?} dropped {:?} at {:?}", entity, cargo_entity, drop_pos ); if tilemap_mut.place_cargo(*drop_pos, cargo_entity).is_err() { warn!( "place_cargo failed at {:?} — tile occupied. \ Cargo entity {:?} orphaned.", drop_pos, cargo_entity ); } else if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity) { cargo.tile_pos = *drop_pos; } } *step = HaulStep::Done; } HaulStep::Done => { info!("[HAUL] {:?} haul task COMPLETE", entity); *state = TaskState::Completed; } } } Task::DropHauled { pos, step } => { let Some(ref mut haul) = haul_slot else { *state = TaskState::Completed; continue; }; match step { DropStep::Moving { chosen_drop } => { if chosen_drop.is_none() { *chosen_drop = Some( tilemap_mut .find_nearest_free_cargo_tile(*pos, 8, &[]) .unwrap_or(*pos), ); } let drop_pos = chosen_drop.unwrap(); if ambulatory.target.is_none() { ambulatory.target = Some(Vec3::new( drop_pos.x as f32, drop_pos.y as f32, drop_pos.z as f32, )); 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; *step = DropStep::Dropping { drop_pos }; } } DropStep::Dropping { drop_pos } => { if let Some(cargo_entity) = haul.release() { if tilemap_mut.place_cargo(*drop_pos, cargo_entity).is_err() { warn!( "place_cargo failed at {:?} — tile occupied. \ Cargo entity {:?} orphaned.", drop_pos, cargo_entity ); } else if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity) { cargo.tile_pos = *drop_pos; } } *step = DropStep::Done; } DropStep::Done => { *state = TaskState::Completed; } } } } } } // Handle task completion/failure if *state == TaskState::Completed || *state == TaskState::Failed { if let Some(completed_task) = queue.pop() { if *state == TaskState::Completed { completed_writer.write(TaskCompleted { entity, task: completed_task.clone(), }); } else { failed_writer.write(TaskFailed { entity, task: completed_task.clone(), reason: completed_task.name(), }); } if completed_task.is_terminal() && queue.is_empty() { queue.push(Task::Idle { origin, sigma_world: behaviour.idle.sigma_world, state: IdleState::Picking { retry_after_tick: 0, retry_count: 0, }, }); } } *state = TaskState::Pending; } } } 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 }