attempt 3

This commit is contained in:
2026-03-27 10:29:33 +00:00
parent 1f5dfe5182
commit bd1b45e4d7
20 changed files with 1192 additions and 865 deletions
+69 -414
View File
@@ -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<Entity>,
},
}
/// 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<DemoState>,
mut job_queue: ResMut<JobQueue>,
tilemap: Res<TileMap>,
chunk_map: Res<ChunkMap>,
tree_parts: Query<(Entity, &TreePart)>,
cargo_query: Query<(Entity, &Cargo), With<Haulable>>,
haul_slot_query: Query<&HaulSlot>,
mut dorf_query: Query<(Entity, &mut TaskQueue, &mut TaskState, &Transform)>,
mut task_failed: MessageReader<TaskFailed>,
mut logs_spawned: MessageReader<LogsSpawned>,
dorf_query: Query<(&TaskQueue, &TaskState), With<EntityType>>,
) {
// 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<IVec3> {
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<TaskQueue>>) {
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
}