Skip to content

Commit

Permalink
patch pathfinder obstructions instead of just truncating the path
Browse files Browse the repository at this point in the history
  • Loading branch information
mat-1 committed Dec 25, 2024
1 parent 2f1fe5f commit e11a902
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 46 deletions.
1 change: 0 additions & 1 deletion azalea-protocol/src/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ fn parse_frame(buffer: &mut Cursor<Vec<u8>>) -> Result<Box<[u8]>, FrameSplitterE

// the length of the varint that says the length of the whole packet
let varint_length = buffer.remaining() - buffer_copy.remaining();
drop(buffer_copy);

buffer.advance(varint_length);
let data =
Expand Down
6 changes: 1 addition & 5 deletions azalea/examples/testbot/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,7 @@ async fn handle(bot: Client, event: azalea::Event, state: State) -> anyhow::Resu

Ok(())
}
async fn swarm_handle(
mut swarm: Swarm,
event: SwarmEvent,
_state: SwarmState,
) -> anyhow::Result<()> {
async fn swarm_handle(swarm: Swarm, event: SwarmEvent, _state: SwarmState) -> anyhow::Result<()> {
match &event {
SwarmEvent::Disconnect(account, join_opts) => {
println!("bot got kicked! {}", account.username);
Expand Down
1 change: 1 addition & 0 deletions azalea/src/pathfinder/astar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const COEFFICIENTS: [f32; 7] = [1.5, 2., 2.5, 3., 4., 5., 10.];

const MIN_IMPROVEMENT: f32 = 0.01;

#[derive(Debug, Clone, Copy, PartialEq)]
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
Expand Down
187 changes: 147 additions & 40 deletions azalea/src/pathfinder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use std::collections::VecDeque;
use std::sync::atomic::{self, AtomicUsize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::{cmp, thread};

use astar::PathfinderTimeout;
use azalea_client::inventory::{Inventory, InventorySet, SetSelectedHotbarSlotEvent};
Expand All @@ -35,6 +36,7 @@ use bevy_ecs::query::Changed;
use bevy_ecs::schedule::IntoSystemConfigs;
use bevy_tasks::{AsyncComputeTaskPool, Task};
use futures_lite::future;
use goals::BlockPosGoal;
use parking_lot::RwLock;
use rel_block_pos::RelBlockPos;
use tracing::{debug, error, info, trace, warn};
Expand Down Expand Up @@ -105,7 +107,8 @@ pub struct Pathfinder {
pub is_calculating: bool,
pub allow_mining: bool,

pub deterministic_timeout: bool,
pub default_timeout: Option<PathfinderTimeout>,
pub max_timeout: Option<PathfinderTimeout>,

pub goto_id: Arc<AtomicUsize>,
}
Expand Down Expand Up @@ -138,13 +141,11 @@ pub struct GotoEvent {
/// Whether the bot is allowed to break blocks while pathfinding.
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,
/// Also see [`PathfinderTimeout::Nodes`]
pub default_timeout: PathfinderTimeout,
pub max_timeout: PathfinderTimeout,
}
#[derive(Event, Clone)]
#[derive(Event, Clone, Debug)]
pub struct PathFoundEvent {
pub entity: Entity,
pub start: BlockPos,
Expand Down Expand Up @@ -186,7 +187,8 @@ impl PathfinderClientExt for azalea_client::Client {
goal: Arc::new(goal),
successors_fn: moves::default_move,
allow_mining: true,
deterministic_timeout: false,
default_timeout: PathfinderTimeout::Time(Duration::from_secs(1)),
max_timeout: PathfinderTimeout::Time(Duration::from_secs(5)),
});
}

Expand All @@ -198,7 +200,8 @@ impl PathfinderClientExt for azalea_client::Client {
goal: Arc::new(goal),
successors_fn: moves::default_move,
allow_mining: false,
deterministic_timeout: false,
default_timeout: PathfinderTimeout::Time(Duration::from_secs(1)),
max_timeout: PathfinderTimeout::Time(Duration::from_secs(5)),
});
}

Expand Down Expand Up @@ -247,6 +250,8 @@ pub fn goto_listener(
pathfinder.successors_fn = Some(event.successors_fn);
pathfinder.is_calculating = true;
pathfinder.allow_mining = event.allow_mining;
pathfinder.default_timeout = Some(event.default_timeout);
pathfinder.max_timeout = Some(event.max_timeout);

let start = if let Some(executing_path) = executing_path
&& let Some(final_node) = executing_path.path.back()
Expand Down Expand Up @@ -279,19 +284,23 @@ pub fn goto_listener(
None
});

let deterministic_timeout = event.deterministic_timeout;
let default_timeout = event.default_timeout;
let max_timeout = event.max_timeout;

let task = thread_pool.spawn(calculate_path(CalculatePathOpts {
entity,
start,
goal,
successors_fn,
world_lock,
goto_id_atomic,
allow_mining,
mining_cache,
deterministic_timeout,
}));
let task = thread_pool.spawn(async move {
calculate_path(CalculatePathOpts {
entity,
start,
goal,
successors_fn,
world_lock,
goto_id_atomic,
allow_mining,
mining_cache,
default_timeout,
max_timeout,
})
});

commands.entity(event.entity).insert(ComputePath(task));
}
Expand All @@ -306,8 +315,9 @@ pub struct CalculatePathOpts {
pub goto_id_atomic: Arc<AtomicUsize>,
pub allow_mining: bool,
pub mining_cache: MiningCache,
/// See [`GotoEvent::deterministic_timeout`]
pub deterministic_timeout: bool,
/// Also see [`GotoEvent::deterministic_timeout`]
pub default_timeout: PathfinderTimeout,
pub max_timeout: PathfinderTimeout,
}

/// Calculate the [`PathFoundEvent`] for the given pathfinder options.
Expand All @@ -318,7 +328,7 @@ pub struct CalculatePathOpts {
/// 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> {
pub 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;
Expand All @@ -337,14 +347,10 @@ pub async fn calculate_path(opts: CalculatePathOpts) -> Option<PathFoundEvent> {
'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
})
let timeout = if attempt_number == 0 {
opts.default_timeout
} else {
PathfinderTimeout::Time(Duration::from_secs(if attempt_number == 0 { 1 } else { 5 }))
opts.max_timeout
};

let astar::Path { movements, partial } = a_star(
Expand All @@ -364,7 +370,7 @@ pub async fn calculate_path(opts: CalculatePathOpts) -> Option<PathFoundEvent> {
info!("Pathfinder took {duration:?} (incomplete path)");
}
// wait a bit so it's not a busy loop
std::thread::sleep(Duration::from_millis(100));
thread::sleep(Duration::from_millis(100));
} else {
info!("Pathfinder took {duration:?}");
}
Expand All @@ -385,7 +391,7 @@ pub async fn calculate_path(opts: CalculatePathOpts) -> Option<PathFoundEvent> {
}

if path.is_empty() && partial {
if attempt_number == 0 {
if attempt_number == 0 && opts.default_timeout != opts.max_timeout {
debug!("this path is empty, retrying with a higher timeout");
attempt_number += 1;
continue 'calculate;
Expand Down Expand Up @@ -660,10 +666,16 @@ pub fn check_node_reached(
}

pub fn check_for_path_obstruction(
mut query: Query<(&Pathfinder, &mut ExecutingPath, &InstanceName, &Inventory)>,
mut query: Query<(
Entity,
&Pathfinder,
&mut ExecutingPath,
&InstanceName,
&Inventory,
)>,
instance_container: Res<InstanceContainer>,
) {
for (pathfinder, mut executing_path, instance_name, inventory) in &mut query {
for (entity, pathfinder, mut executing_path, instance_name, inventory) in &mut query {
let Some(successors_fn) = pathfinder.successors_fn else {
continue;
};
Expand Down Expand Up @@ -693,8 +705,95 @@ pub fn check_for_path_obstruction(
"path obstructed at index {obstructed_index} (starting at {:?}, path: {:?})",
executing_path.last_reached_node, executing_path.path
);
executing_path.path.truncate(obstructed_index);
executing_path.is_path_partial = true;
// if it's near the end, don't bother recalculating a patch, just truncate and
// mark it as partial
if obstructed_index + 5 > executing_path.path.len() {
debug!(
"obstruction is near the end of the path, truncating and marking path as partial"
);
executing_path.path.truncate(obstructed_index);
executing_path.is_path_partial = true;
continue;
}

let Some(successors_fn) = pathfinder.successors_fn else {
error!("got PatchExecutingPathEvent but the bot has no successors_fn");
continue;
};

let world_lock = instance_container
.get(instance_name)
.expect("Entity tried to pathfind but the entity isn't in a valid world");

let patch_start = if obstructed_index == 0 {
executing_path.last_reached_node
} else {
executing_path.path[obstructed_index - 1].target
};

// patch up to 20 nodes
let patch_end_index = cmp::min(obstructed_index + 20, executing_path.path.len() - 1);
let patch_end = executing_path.path[patch_end_index].target;

// this doesn't override the main goal, it's just the goal for this A*
// calculation
let goal = Arc::new(BlockPosGoal(patch_end));

let goto_id_atomic = pathfinder.goto_id.clone();

let allow_mining = pathfinder.allow_mining;
let mining_cache = MiningCache::new(if allow_mining {
Some(inventory.inventory_menu.clone())
} else {
None
});

// the timeout is small enough that this doesn't need to be async
let path_found_event = calculate_path(CalculatePathOpts {
entity,
start: patch_start,
goal,
successors_fn,
world_lock,
goto_id_atomic,
allow_mining,
mining_cache,
default_timeout: PathfinderTimeout::Nodes(10_000),
max_timeout: PathfinderTimeout::Nodes(10_000),
});
debug!("obstruction patch: {path_found_event:?}");

let mut new_path = VecDeque::new();
if obstructed_index > 0 {
new_path.extend(executing_path.path.iter().take(obstructed_index).cloned());
}

let mut is_patch_complete = false;
if let Some(path_found_event) = path_found_event {
if let Some(found_path_patch) = path_found_event.path {
if !found_path_patch.is_empty() {
new_path.extend(found_path_patch);

if !path_found_event.is_partial {
new_path
.extend(executing_path.path.iter().skip(patch_end_index).cloned());
is_patch_complete = true;
debug!("the obstruction patch is not partial");
} else {
debug!(
"the obstruction patch is partial, throwing away rest of path :("
);
}
}
}
} else {
// no path found, rip
}

executing_path.path = new_path;
if !is_patch_complete {
executing_path.is_path_partial = true;
}
}
}
}
Expand Down Expand Up @@ -726,7 +825,10 @@ pub fn recalculate_near_end_of_path(
goal,
successors_fn,
allow_mining: pathfinder.allow_mining,
deterministic_timeout: pathfinder.deterministic_timeout,
default_timeout: pathfinder
.default_timeout
.expect("default_timeout should be set"),
max_timeout: pathfinder.max_timeout.expect("max_timeout should be set"),
});
pathfinder.is_calculating = true;

Expand Down Expand Up @@ -823,7 +925,10 @@ pub fn recalculate_if_has_goal_but_no_path(
goal,
successors_fn: pathfinder.successors_fn.unwrap(),
allow_mining: pathfinder.allow_mining,
deterministic_timeout: pathfinder.deterministic_timeout,
default_timeout: pathfinder
.default_timeout
.expect("default_timeout should be set"),
max_timeout: pathfinder.max_timeout.expect("max_timeout should be set"),
});
pathfinder.is_calculating = true;
}
Expand Down Expand Up @@ -950,6 +1055,7 @@ mod tests {
use azalea_world::{Chunk, ChunkStorage, PartialChunkStorage};

use super::{
astar::PathfinderTimeout,
goals::BlockPosGoal,
moves,
simulation::{SimulatedPlayerBundle, Simulation},
Expand All @@ -976,7 +1082,8 @@ mod tests {
goal: Arc::new(BlockPosGoal(end_pos)),
successors_fn: moves::default_move,
allow_mining: false,
deterministic_timeout: true,
default_timeout: PathfinderTimeout::Nodes(1_000_000),
max_timeout: PathfinderTimeout::Nodes(5_000_000),
});
simulation
}
Expand Down

0 comments on commit e11a902

Please sign in to comment.