1
2
Fork 0
mirror of https://github.com/mat-1/azalea.git synced 2025-08-02 06:16:04 +00:00

improve some docs and apis related to pathfinder

This commit is contained in:
mat 2024-12-24 04:37:55 +00:00
parent 30cbeecdfe
commit 958848e8ed
7 changed files with 240 additions and 85 deletions

View file

@ -7,7 +7,6 @@ license = { workspace = true }
repository = { workspace = true } repository = { workspace = true }
[dependencies] [dependencies]
anyhow = { workspace = true }
azalea-auth = { path = "../azalea-auth", version = "0.11.0" } azalea-auth = { path = "../azalea-auth", version = "0.11.0" }
azalea-block = { path = "../azalea-block", version = "0.11.0" } azalea-block = { path = "../azalea-block", version = "0.11.0" }
azalea-buf = { path = "../azalea-buf", version = "0.11.0" } azalea-buf = { path = "../azalea-buf", version = "0.11.0" }
@ -36,6 +35,9 @@ tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true } tracing = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
[features] [features]
default = ["log"] default = ["log"]
# enables bevy_log::LogPlugin by default # enables bevy_log::LogPlugin by default

View file

@ -518,6 +518,14 @@ impl Client {
/// Get a component from this client. This will clone the component and /// Get a component from this client. This will clone the component and
/// return it. /// return it.
/// ///
///
/// If the component can't be cloned, try [`Self::map_component`] instead.
/// If it isn't guaranteed to be present, use [`Self::get_component`] or
/// [`Self::map_get_component`].
///
/// You may also use [`Self::ecs`] and [`Self::query`] directly if you need
/// more control over when the ECS is locked.
///
/// # Panics /// # Panics
/// ///
/// This will panic if the component doesn't exist on the client. /// This will panic if the component doesn't exist on the client.
@ -534,10 +542,56 @@ impl Client {
} }
/// Get a component from this client, or `None` if it doesn't exist. /// Get a component from this client, or `None` if it doesn't exist.
///
/// If the component can't be cloned, try [`Self::map_component`] instead.
/// You may also have to use [`Self::ecs`] and [`Self::query`] directly.
pub fn get_component<T: Component + Clone>(&self) -> Option<T> { pub fn get_component<T: Component + Clone>(&self) -> Option<T> {
self.query::<Option<&T>>(&mut self.ecs.lock()).cloned() self.query::<Option<&T>>(&mut self.ecs.lock()).cloned()
} }
/// Get a required component for this client and call the given function.
///
/// Similar to [`Self::component`], but doesn't clone the component since
/// it's passed as a reference. [`Self::ecs`] will remain locked while the
/// callback is being run.
///
/// If the component is not guaranteed to be present, use
/// [`Self::get_component`] instead.
///
/// # Panics
///
/// This will panic if the component doesn't exist on the client.
///
/// ```
/// # use azalea_client::{Client, Hunger};
/// # fn example(bot: &Client) {
/// let hunger = bot.map_component::<Hunger, _>(|h| h.food);
/// # }
/// ```
pub fn map_component<T: Component, R>(&self, f: impl FnOnce(&T) -> R) -> R {
let mut ecs = self.ecs.lock();
let value = self.query::<&T>(&mut ecs);
f(value)
}
/// Optionally get a component for this client and call the given function.
///
/// Similar to [`Self::get_component`], but doesn't clone the component
/// since it's passed as a reference. [`Self::ecs`] will remain locked
/// while the callback is being run.
///
/// ```
/// # use azalea_client::{Client, mining::Mining};
/// # fn example(bot: &Client) {
/// let is_mining = bot.map_get_component::<Mining, _>(|m| m.is_some());
/// # }
/// ```
pub fn map_get_component<T: Component, R>(&self, f: impl FnOnce(Option<&T>) -> R) -> R {
let mut ecs = self.ecs.lock();
let value = self.query::<Option<&T>>(&mut ecs);
f(value)
}
/// Get an `RwLock` with a reference to our (potentially shared) world. /// Get an `RwLock` with a reference to our (potentially shared) world.
/// ///
/// This gets the [`Instance`] from the client's [`InstanceHolder`] /// This gets the [`Instance`] from the client's [`InstanceHolder`]

View file

@ -58,6 +58,9 @@ pub enum Event {
/// it's actually spawned. This can be useful for setting the client /// it's actually spawned. This can be useful for setting the client
/// information with `Client::set_client_information`, so the packet /// information with `Client::set_client_information`, so the packet
/// doesn't have to be sent twice. /// doesn't have to be sent twice.
///
/// You may want to use [`Event::Login`] instead to wait for the bot to be
/// in the world.
Init, Init,
/// The client is now in the world. Fired when we receive a login packet. /// The client is now in the world. Fired when we receive a login packet.
Login, Login,

View file

@ -144,7 +144,7 @@ pub enum HandlePacketError {
#[error(transparent)] #[error(transparent)]
Io(#[from] io::Error), Io(#[from] io::Error),
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] Box<dyn std::error::Error + Send + Sync>),
#[error("{0}")] #[error("{0}")]
Send(#[from] mpsc::error::SendError<AzaleaEvent>), Send(#[from] mpsc::error::SendError<AzaleaEvent>),
} }

View file

@ -104,6 +104,11 @@ impl BotClientExt for azalea_client::Client {
}); });
} }
/// Returns a Receiver that receives a message every game tick.
///
/// This is useful if you want to efficiently loop until a certain condition
/// is met.
///
/// ``` /// ```
/// # use azalea::prelude::*; /// # use azalea::prelude::*;
/// # use azalea::container::WaitingForInventoryOpen; /// # use azalea::container::WaitingForInventoryOpen;

View file

@ -23,12 +23,24 @@ const COEFFICIENTS: [f32; 7] = [1.5, 2., 2.5, 3., 4., 5., 10.];
const MIN_IMPROVEMENT: f32 = 0.01; const MIN_IMPROVEMENT: f32 = 0.01;
pub enum PathfinderTimeout {
/// Time out after a certain duration has passed. This is a good default so
/// you don't waste too much time calculating a path if you're on a slow
/// computer.
Time(Duration),
/// Time out after this many nodes have been considered.
///
/// This is useful as an alternative to a time limit if you're doing
/// something like running tests where you want consistent results.
Nodes(usize),
}
pub fn a_star<P, M, HeuristicFn, SuccessorsFn, SuccessFn>( pub fn a_star<P, M, HeuristicFn, SuccessorsFn, SuccessFn>(
start: P, start: P,
heuristic: HeuristicFn, heuristic: HeuristicFn,
mut successors: SuccessorsFn, mut successors: SuccessorsFn,
success: SuccessFn, success: SuccessFn,
timeout: Duration, timeout: PathfinderTimeout,
) -> Path<P, M> ) -> Path<P, M>
where where
P: Eq + Hash + Copy + Debug, P: Eq + Hash + Copy + Debug,
@ -104,10 +116,16 @@ where
} }
// check for timeout every ~1ms // check for timeout every ~1ms
if num_nodes % 1000 == 0 && start_time.elapsed() > timeout { if num_nodes % 1000 == 0 {
// timeout, just return the best path we have so far let timed_out = match timeout {
trace!("A* couldn't find a path in time, returning best path"); PathfinderTimeout::Time(max_duration) => start_time.elapsed() > max_duration,
break; PathfinderTimeout::Nodes(max_nodes) => num_nodes > max_nodes,
};
if timed_out {
// timeout, just return the best path we have so far
trace!("A* couldn't find a path in time, returning best path");
break;
}
} }
} }

View file

@ -16,6 +16,7 @@ use std::sync::atomic::{self, AtomicUsize};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use astar::PathfinderTimeout;
use azalea_client::inventory::{Inventory, InventorySet, SetSelectedHotbarSlotEvent}; use azalea_client::inventory::{Inventory, InventorySet, SetSelectedHotbarSlotEvent};
use azalea_client::mining::{Mining, StartMiningBlockEvent}; use azalea_client::mining::{Mining, StartMiningBlockEvent};
use azalea_client::movement::MoveEventsSet; use azalea_client::movement::MoveEventsSet;
@ -33,6 +34,7 @@ use bevy_ecs::query::Changed;
use bevy_ecs::schedule::IntoSystemConfigs; use bevy_ecs::schedule::IntoSystemConfigs;
use bevy_tasks::{AsyncComputeTaskPool, Task}; use bevy_tasks::{AsyncComputeTaskPool, Task};
use futures_lite::future; use futures_lite::future;
use parking_lot::RwLock;
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use self::debug::debug_render_path_with_particles; use self::debug::debug_render_path_with_particles;
@ -49,9 +51,7 @@ use crate::ecs::{
query::{With, Without}, query::{With, Without},
system::{Commands, Query, Res}, system::{Commands, Query, Res},
}; };
use crate::pathfinder::astar::a_star; use crate::pathfinder::{astar::a_star, moves::PathfinderCtx, world::CachedWorld};
use crate::pathfinder::moves::PathfinderCtx;
use crate::pathfinder::world::CachedWorld;
use crate::WalkDirection; use crate::WalkDirection;
#[derive(Clone, Default)] #[derive(Clone, Default)]
@ -103,6 +103,8 @@ pub struct Pathfinder {
pub is_calculating: bool, pub is_calculating: bool,
pub allow_mining: bool, pub allow_mining: bool,
pub deterministic_timeout: bool,
pub goto_id: Arc<AtomicUsize>, pub goto_id: Arc<AtomicUsize>,
} }
@ -117,8 +119,14 @@ pub struct ExecutingPath {
pub is_path_partial: bool, pub is_path_partial: bool,
} }
/// Send this event to start pathfinding to the given goal.
///
/// Also see [`PathfinderClientExt::goto`].
///
/// This event is read by [`goto_listener`].
#[derive(Event)] #[derive(Event)]
pub struct GotoEvent { pub struct GotoEvent {
/// The local bot entity that will do the pathfinding and execute the path.
pub entity: Entity, pub entity: Entity,
pub goal: Arc<dyn Goal + Send + Sync>, pub goal: Arc<dyn Goal + Send + Sync>,
/// The function that's used for checking what moves are possible. Usually /// The function that's used for checking what moves are possible. Usually
@ -127,6 +135,12 @@ pub struct GotoEvent {
/// Whether the bot is allowed to break blocks while pathfinding. /// Whether the bot is allowed to break blocks while pathfinding.
pub allow_mining: bool, pub allow_mining: bool,
/// Whether the timeout should be based on number of nodes considered
/// instead of the time passed.
///
/// Also see: [`PathfinderTimeout::Nodes`]
pub deterministic_timeout: bool,
} }
#[derive(Event, Clone)] #[derive(Event, Clone)]
pub struct PathFoundEvent { pub struct PathFoundEvent {
@ -155,6 +169,8 @@ pub trait PathfinderClientExt {
} }
impl PathfinderClientExt for azalea_client::Client { impl PathfinderClientExt for azalea_client::Client {
/// Start pathfinding to a given goal.
///
/// ``` /// ```
/// # use azalea::prelude::*; /// # use azalea::prelude::*;
/// # use azalea::{BlockPos, pathfinder::goals::BlockPosGoal}; /// # use azalea::{BlockPos, pathfinder::goals::BlockPosGoal};
@ -168,6 +184,7 @@ impl PathfinderClientExt for azalea_client::Client {
goal: Arc::new(goal), goal: Arc::new(goal),
successors_fn: moves::default_move, successors_fn: moves::default_move,
allow_mining: true, allow_mining: true,
deterministic_timeout: false,
}); });
} }
@ -179,6 +196,7 @@ impl PathfinderClientExt for azalea_client::Client {
goal: Arc::new(goal), goal: Arc::new(goal),
successors_fn: moves::default_move, successors_fn: moves::default_move,
allow_mining: false, allow_mining: false,
deterministic_timeout: false,
}); });
} }
@ -251,7 +269,6 @@ pub fn goto_listener(
let entity = event.entity; let entity = event.entity;
let goto_id_atomic = pathfinder.goto_id.clone(); let goto_id_atomic = pathfinder.goto_id.clone();
let goto_id = goto_id_atomic.fetch_add(1, atomic::Ordering::Relaxed) + 1;
let allow_mining = event.allow_mining; let allow_mining = event.allow_mining;
let mining_cache = MiningCache::new(if allow_mining { let mining_cache = MiningCache::new(if allow_mining {
@ -260,85 +277,133 @@ pub fn goto_listener(
None None
}); });
let task = thread_pool.spawn(async move { let deterministic_timeout = event.deterministic_timeout;
debug!("start: {start:?}");
let cached_world = CachedWorld::new(world_lock); let task = thread_pool.spawn(calculate_path(CalculatePathOpts {
let successors = |pos: BlockPos| { entity,
call_successors_fn(&cached_world, &mining_cache, successors_fn, pos) start,
}; goal,
successors_fn,
let mut attempt_number = 0; world_lock,
goto_id_atomic,
let mut path; allow_mining,
let mut is_partial: bool; mining_cache,
deterministic_timeout,
'calculate: loop { }));
let start_time = std::time::Instant::now();
let astar::Path { movements, partial } = a_star(
start,
|n| goal.heuristic(n),
successors,
|n| goal.success(n),
Duration::from_secs(if attempt_number == 0 { 1 } else { 5 }),
);
let end_time = std::time::Instant::now();
debug!("partial: {partial:?}");
let duration = end_time - start_time;
if partial {
if movements.is_empty() {
info!("Pathfinder took {duration:?} (empty path)");
} else {
info!("Pathfinder took {duration:?} (incomplete path)");
}
// wait a bit so it's not a busy loop
std::thread::sleep(Duration::from_millis(100));
} else {
info!("Pathfinder took {duration:?}");
}
debug!("Path:");
for movement in &movements {
debug!(" {:?}", movement.target);
}
path = movements.into_iter().collect::<VecDeque<_>>();
is_partial = partial;
let goto_id_now = goto_id_atomic.load(atomic::Ordering::Relaxed);
if goto_id != goto_id_now {
// we must've done another goto while calculating this path, so throw it away
warn!("finished calculating a path, but it's outdated");
return None;
}
if path.is_empty() && partial {
if attempt_number == 0 {
debug!("this path is empty, retrying with a higher timeout");
attempt_number += 1;
continue 'calculate;
} else {
debug!("this path is empty, giving up");
break 'calculate;
}
}
break;
}
Some(PathFoundEvent {
entity,
start,
path: Some(path),
is_partial,
successors_fn,
allow_mining,
})
});
commands.entity(event.entity).insert(ComputePath(task)); commands.entity(event.entity).insert(ComputePath(task));
} }
} }
pub struct CalculatePathOpts {
pub entity: Entity,
pub start: BlockPos,
pub goal: Arc<dyn Goal + Send + Sync>,
pub successors_fn: SuccessorsFn,
pub world_lock: Arc<RwLock<azalea_world::Instance>>,
pub goto_id_atomic: Arc<AtomicUsize>,
pub allow_mining: bool,
pub mining_cache: MiningCache,
/// See [`GotoEvent::deterministic_timeout`]
pub deterministic_timeout: bool,
}
/// Calculate the [`PathFoundEvent`] for the given pathfinder options.
///
/// You usually want to just use [`PathfinderClientExt::goto`] or send a
/// [`GotoEvent`] instead of calling this directly.
///
/// You are expected to immediately send the `PathFoundEvent` you received after
/// calling this function. `None` will be returned if the pathfinding was
/// interrupted by another path calculation.
pub async fn calculate_path(opts: CalculatePathOpts) -> Option<PathFoundEvent> {
debug!("start: {:?}", opts.start);
let goto_id = opts.goto_id_atomic.fetch_add(1, atomic::Ordering::SeqCst) + 1;
let cached_world = CachedWorld::new(opts.world_lock);
let successors = |pos: BlockPos| {
call_successors_fn(&cached_world, &opts.mining_cache, opts.successors_fn, pos)
};
let mut attempt_number = 0;
let mut path;
let mut is_partial: bool;
'calculate: loop {
let start_time = Instant::now();
let timeout = if opts.deterministic_timeout {
PathfinderTimeout::Nodes(if attempt_number == 0 {
1_000_000
} else {
5_000_000
})
} else {
PathfinderTimeout::Time(Duration::from_secs(if attempt_number == 0 { 1 } else { 5 }))
};
let astar::Path { movements, partial } = a_star(
opts.start,
|n| opts.goal.heuristic(n),
successors,
|n| opts.goal.success(n),
timeout,
);
let end_time = Instant::now();
debug!("partial: {partial:?}");
let duration = end_time - start_time;
if partial {
if movements.is_empty() {
info!("Pathfinder took {duration:?} (empty path)");
} else {
info!("Pathfinder took {duration:?} (incomplete path)");
}
// wait a bit so it's not a busy loop
std::thread::sleep(Duration::from_millis(100));
} else {
info!("Pathfinder took {duration:?}");
}
debug!("Path:");
for movement in &movements {
debug!(" {}", movement.target);
}
path = movements.into_iter().collect::<VecDeque<_>>();
is_partial = partial;
let goto_id_now = opts.goto_id_atomic.load(atomic::Ordering::SeqCst);
if goto_id != goto_id_now {
// we must've done another goto while calculating this path, so throw it away
warn!("finished calculating a path, but it's outdated");
return None;
}
if path.is_empty() && partial {
if attempt_number == 0 {
debug!("this path is empty, retrying with a higher timeout");
attempt_number += 1;
continue 'calculate;
} else {
debug!("this path is empty, giving up");
break 'calculate;
}
}
break;
}
Some(PathFoundEvent {
entity: opts.entity,
start: opts.start,
path: Some(path),
is_partial,
successors_fn: opts.successors_fn,
allow_mining: opts.allow_mining,
})
}
// poll the tasks and send the PathFoundEvent if they're done // poll the tasks and send the PathFoundEvent if they're done
pub fn handle_tasks( pub fn handle_tasks(
mut commands: Commands, mut commands: Commands,
@ -529,6 +594,7 @@ pub fn check_node_reached(
executing_path.path = executing_path.path.split_off(i + 1); executing_path.path = executing_path.path.split_off(i + 1);
executing_path.last_reached_node = movement.target; executing_path.last_reached_node = movement.target;
executing_path.last_node_reached_at = Instant::now(); executing_path.last_node_reached_at = Instant::now();
trace!("reached node {:?}", movement.target);
if let Some(new_path) = executing_path.queued_path.take() { if let Some(new_path) = executing_path.queued_path.take() {
debug!( debug!(
@ -640,6 +706,7 @@ pub fn recalculate_near_end_of_path(
goal, goal,
successors_fn, successors_fn,
allow_mining: pathfinder.allow_mining, allow_mining: pathfinder.allow_mining,
deterministic_timeout: pathfinder.deterministic_timeout,
}); });
pathfinder.is_calculating = true; pathfinder.is_calculating = true;
@ -713,7 +780,11 @@ pub fn tick_execute_path(
start_mining_events: &mut start_mining_events, start_mining_events: &mut start_mining_events,
set_selected_hotbar_slot_events: &mut set_selected_hotbar_slot_events, set_selected_hotbar_slot_events: &mut set_selected_hotbar_slot_events,
}; };
trace!("executing move"); trace!(
"executing move, position: {}, last_reached_node: {}",
**position,
executing_path.last_reached_node
);
(movement.data.execute)(ctx); (movement.data.execute)(ctx);
} }
} }
@ -732,6 +803,7 @@ pub fn recalculate_if_has_goal_but_no_path(
goal, goal,
successors_fn: pathfinder.successors_fn.unwrap(), successors_fn: pathfinder.successors_fn.unwrap(),
allow_mining: pathfinder.allow_mining, allow_mining: pathfinder.allow_mining,
deterministic_timeout: pathfinder.deterministic_timeout,
}); });
pathfinder.is_calculating = true; pathfinder.is_calculating = true;
} }
@ -880,6 +952,7 @@ mod tests {
goal: Arc::new(BlockPosGoal(end_pos)), goal: Arc::new(BlockPosGoal(end_pos)),
successors_fn: moves::default_move, successors_fn: moves::default_move,
allow_mining: false, allow_mining: false,
deterministic_timeout: false,
}); });
simulation simulation
} }