mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Fix agent navigation for partial paths and stuck recovery
This commit is contained in:
2 files changed
+514
-135
No files matched your search
@@ -3,6 +3,9 @@
|
||||
//! like a player (walk, look, break, place, use, click buttons). Actions that
|
||||
//! take time run as a task advanced every frame and reply when they finish or
|
||||
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
|
||||
#[path = "agent_navigation.rs"]
|
||||
mod navigation;
|
||||
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
@@ -17,6 +20,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use navigation::{Cell, Node, Plan, Progress};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
@@ -206,9 +210,6 @@ fn delta(before: &BTreeMap<String, i64>, after: &BTreeMap<String, i64>) -> BTree
|
||||
out
|
||||
}
|
||||
|
||||
/// A feet position the A* path walks through.
|
||||
type Node = [i32; 3];
|
||||
|
||||
enum TaskKind {
|
||||
/// Hold keys until a deadline.
|
||||
Move {
|
||||
@@ -251,9 +252,11 @@ struct Goto {
|
||||
target: [f64; 3],
|
||||
near: f64,
|
||||
sprint: bool,
|
||||
path: Vec<Node>,
|
||||
path: Plan,
|
||||
blocked: Vec<(Node, Node)>,
|
||||
progress: Progress,
|
||||
next: usize,
|
||||
/// Tick of the last waypoint reached, and how many replans so far.
|
||||
/// Tick of the last waypoint progress, and stuck recoveries so far.
|
||||
progress_tick: u64,
|
||||
replans: u32,
|
||||
jump_until: u64,
|
||||
@@ -817,105 +820,24 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a block stops movement, for the path finder.
|
||||
fn blocks_motion(&self, pos: [i32; 3]) -> bool {
|
||||
let f = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]));
|
||||
f.full_collision || f.legacy_solid
|
||||
}
|
||||
fn walkable(&self, n: Node) -> bool {
|
||||
let below = self.world.block(n[0], n[1] - 1, n[2]);
|
||||
let lava = |p: [i32; 3]| {
|
||||
&*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
== "minecraft:lava"
|
||||
};
|
||||
self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4))
|
||||
&& (self.blocks_motion([n[0], n[1] - 1, n[2]])
|
||||
|| self.res.facts().get(below).fluid != horizon_client::blocks::Fluid::None)
|
||||
&& !self.blocks_motion(n)
|
||||
&& !self.blocks_motion([n[0], n[1] + 1, n[2]])
|
||||
&& !lava(n)
|
||||
&& !lava([n[0], n[1] - 1, n[2]])
|
||||
}
|
||||
/// A* over standable feet positions: steps up one block, drops up to three.
|
||||
fn find_path(&self, from: Node, goal: [f64; 3], near: f64) -> Option<Vec<Node>> {
|
||||
use std::{cmp::Reverse, collections::BinaryHeap};
|
||||
let reached = |n: Node| {
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
|
||||
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
|
||||
};
|
||||
let h = |n: Node| {
|
||||
((f64::from(n[0]) + 0.5 - goal[0]).abs()
|
||||
+ (f64::from(n[1]) - goal[1]).abs()
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
|
||||
* 10.0
|
||||
};
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut cost: HashMap<Node, u32> = HashMap::new();
|
||||
let mut came: HashMap<Node, Node> = HashMap::new();
|
||||
cost.insert(from, 0);
|
||||
open.push(Reverse((h(from) as u32, from)));
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, n))) = open.pop() {
|
||||
if reached(n) {
|
||||
let mut path = vec![n];
|
||||
let mut at = n;
|
||||
while let Some(&p) = came.get(&at) {
|
||||
path.push(p);
|
||||
at = p;
|
||||
}
|
||||
path.reverse();
|
||||
return Some(path);
|
||||
}
|
||||
expanded += 1;
|
||||
if expanded > 30_000 {
|
||||
fn find_path(
|
||||
&self,
|
||||
from: Node,
|
||||
goal: [f64; 3],
|
||||
near: f64,
|
||||
blocked: &[(Node, Node)],
|
||||
) -> Option<Plan> {
|
||||
navigation::find_path(from, goal, near, blocked, |n| {
|
||||
if !self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4)) {
|
||||
return None;
|
||||
}
|
||||
let base = cost[&n];
|
||||
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
|
||||
for dy in [0, 1, -1, -2, -3] {
|
||||
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
|
||||
// A step up needs head room above the start; a drop needs
|
||||
// the column in front clear down to the landing.
|
||||
let clear = match dy {
|
||||
1 => !self.blocks_motion([n[0], n[1] + 2, n[2]]),
|
||||
d if d < 0 => {
|
||||
(m[1]..=n[1] + 1).all(|y| !self.blocks_motion([m[0], y, m[2]]))
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if !clear || !self.walkable(m) {
|
||||
continue;
|
||||
}
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(m[0], m[1], m[2]))
|
||||
.fluid
|
||||
!= horizon_client::blocks::Fluid::None;
|
||||
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
|
||||
let c = base + step;
|
||||
if cost.get(&m).is_none_or(|&old| c < old) {
|
||||
cost.insert(m, c);
|
||||
came.insert(m, n);
|
||||
open.push(Reverse((c + h(m) as u32, m)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
let f = self.res.facts().get(self.world.block(n[0], n[1], n[2]));
|
||||
Some(Cell {
|
||||
solid: f.full_collision || f.legacy_solid,
|
||||
fluid: f.fluid != horizon_client::blocks::Fluid::None,
|
||||
lava: &*f.name == "minecraft:lava",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Point the view at `at` from the eye.
|
||||
@@ -1259,10 +1181,13 @@ impl Game<'_> {
|
||||
let timeout = a.num("timeout", 60.0)?;
|
||||
let p = self.player.as_ref().ok_or("no player")?.position;
|
||||
let from = [p.x.floor() as i32, p.y.round() as i32, p.z.floor() as i32];
|
||||
let (path, warning) = match self.find_path(from, target, near) {
|
||||
let (path, warning) = match self.find_path(from, target, near, &[]) {
|
||||
Some(path) => (path, None),
|
||||
None if a.has("direct") => (
|
||||
Vec::new(),
|
||||
Plan {
|
||||
nodes: Vec::new(),
|
||||
complete: true,
|
||||
},
|
||||
Some("no path found through loaded terrain; walking straight".to_owned()),
|
||||
),
|
||||
None => {
|
||||
@@ -1272,12 +1197,17 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let position = [p.x, p.y, p.z];
|
||||
let mut next = 1;
|
||||
let aim = path.aim(position, &mut next, target).unwrap_or(target);
|
||||
let goto = Goto {
|
||||
target,
|
||||
near,
|
||||
sprint: a.has("sprint"),
|
||||
path,
|
||||
next: 1,
|
||||
blocked: Vec::new(),
|
||||
progress: Progress::new(position, aim),
|
||||
next,
|
||||
progress_tick: self.ticks,
|
||||
replans: 0,
|
||||
jump_until: 0,
|
||||
@@ -2077,9 +2007,7 @@ impl Game<'_> {
|
||||
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
|
||||
let horizontal =
|
||||
|a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
|
||||
let arrived = horizontal(p, g.target) <= g.near
|
||||
&& (p[1] - g.target[1]).abs() <= g.near.max(1.0) + 1.0;
|
||||
if arrived {
|
||||
if navigation::arrived(p, g.target, g.near) {
|
||||
return Some(
|
||||
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
@@ -2091,48 +2019,60 @@ impl Game<'_> {
|
||||
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
}
|
||||
// The next waypoint: path nodes, then the target itself.
|
||||
let mut aim = g.target;
|
||||
while g.next < g.path.len() {
|
||||
let n = g.path[g.next];
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
let last = g.next + 1 == g.path.len();
|
||||
if horizontal(p, c) < if last { 0.3 } else { 0.45 } && (p[1] - c[1]).abs() < 1.1 {
|
||||
g.next += 1;
|
||||
let previous_next = g.next;
|
||||
let mut aim = g.path.aim(p, &mut g.next, g.target);
|
||||
if let Some(aim) = aim {
|
||||
if g.next != previous_next {
|
||||
g.progress = Progress::new(p, aim);
|
||||
g.progress_tick = self.ticks;
|
||||
} else if g.progress.advance(p, aim) {
|
||||
g.progress_tick = self.ticks;
|
||||
continue;
|
||||
}
|
||||
aim = c;
|
||||
break;
|
||||
}
|
||||
// Stuck: jump, then replan from here.
|
||||
let stalled = self.ticks.saturating_sub(g.progress_tick);
|
||||
if stalled > 60 && on_ground {
|
||||
g.jump_until = self.ticks + 4;
|
||||
}
|
||||
if stalled > 120 {
|
||||
g.replans += 1;
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false, "position": p,
|
||||
"target": g.target, "distance": dist(p, g.target) }),
|
||||
);
|
||||
if aim.is_none() || stalled > 120 {
|
||||
if stalled > 120 {
|
||||
g.replans += 1;
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false,
|
||||
"position": p, "target": g.target, "distance": dist(p, g.target) }),
|
||||
);
|
||||
}
|
||||
if g.next > 0 && g.next < g.path.nodes.len() {
|
||||
let edge = (g.path.nodes[g.next - 1], g.path.nodes[g.next]);
|
||||
if edge.0 != edge.1 && !g.blocked.contains(&edge) {
|
||||
g.blocked.push(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = [
|
||||
p[0].floor() as i32,
|
||||
p[1].round() as i32,
|
||||
p[2].floor() as i32,
|
||||
];
|
||||
if let Some(path) = self.find_path(from, g.target, g.near) {
|
||||
g.path = path;
|
||||
g.next = 1;
|
||||
}
|
||||
let Some(path) = self.find_path(from, g.target, g.near, &g.blocked) else {
|
||||
return Some(
|
||||
json!({ "error": "no further walking progress through loaded terrain",
|
||||
"arrived": false, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
};
|
||||
g.path = path;
|
||||
g.next = 1;
|
||||
g.progress_tick = self.ticks;
|
||||
aim = g.path.aim(p, &mut g.next, g.target);
|
||||
g.progress = Progress::new(p, aim.unwrap_or(g.target));
|
||||
}
|
||||
let Some(aim) = aim else {
|
||||
return Some(
|
||||
json!({ "error": "path did not provide a new walking waypoint",
|
||||
"arrived": false, "position": p, "target": g.target }),
|
||||
);
|
||||
};
|
||||
let d = [aim[0] - p[0], aim[2] - p[2]];
|
||||
if d[0].abs() + d[1].abs() > 1e-3 {
|
||||
self.yaw = (-d[0]).atan2(d[1]).to_degrees() as f32;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BinaryHeap, HashMap},
|
||||
};
|
||||
|
||||
pub(crate) type Node = [i32; 3];
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct Cell {
|
||||
pub solid: bool,
|
||||
pub fluid: bool,
|
||||
pub lava: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub nodes: Vec<Node>,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
fn solid(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
|
||||
query(n).is_none_or(|b| b.solid)
|
||||
}
|
||||
|
||||
fn walkable(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
|
||||
let Some(feet) = query(n) else {
|
||||
return false;
|
||||
};
|
||||
let Some(below) = query([n[0], n[1] - 1, n[2]]) else {
|
||||
return false;
|
||||
};
|
||||
let Some(head) = query([n[0], n[1] + 1, n[2]]) else {
|
||||
return false;
|
||||
};
|
||||
(below.solid || below.fluid) && !feet.solid && !head.solid && !feet.lava && !below.lava
|
||||
}
|
||||
|
||||
pub(crate) fn find_path(
|
||||
from: Node,
|
||||
goal: [f64; 3],
|
||||
near: f64,
|
||||
blocked: &[(Node, Node)],
|
||||
query: impl Fn(Node) -> Option<Cell>,
|
||||
) -> Option<Plan> {
|
||||
let reached = |n: Node| {
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
|
||||
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
|
||||
};
|
||||
let h = |n: Node| {
|
||||
((f64::from(n[0]) + 0.5 - goal[0]).abs()
|
||||
+ (f64::from(n[1]) - goal[1]).abs()
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
|
||||
* 10.0
|
||||
};
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut cost: HashMap<Node, u32> = HashMap::new();
|
||||
let mut came: HashMap<Node, Node> = HashMap::new();
|
||||
cost.insert(from, 0);
|
||||
open.push(Reverse((h(from) as u32, from)));
|
||||
let distance = |n: Node| {
|
||||
(f64::from(n[0]) + 0.5 - goal[0]).powi(2)
|
||||
+ (f64::from(n[1]) - goal[1]).powi(2)
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).powi(2)
|
||||
};
|
||||
let mut best = from;
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, n))) = open.pop() {
|
||||
if reached(n) {
|
||||
return Some(reconstruct(n, &came, true));
|
||||
}
|
||||
if distance(n) < distance(best) {
|
||||
best = n;
|
||||
}
|
||||
expanded += 1;
|
||||
if expanded >= 30_000 {
|
||||
break;
|
||||
}
|
||||
let base = cost[&n];
|
||||
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
|
||||
for dy in [0, 1, -1, -2, -3] {
|
||||
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
|
||||
// A step up needs head room above the start; a drop needs
|
||||
// the column in front clear down to the landing.
|
||||
let clear = match dy {
|
||||
1 => !solid(&query, [n[0], n[1] + 2, n[2]]),
|
||||
d if d < 0 => (m[1]..=n[1] + 1).all(|y| !solid(&query, [m[0], y, m[2]])),
|
||||
_ => true,
|
||||
};
|
||||
if !clear || !walkable(&query, m) {
|
||||
continue;
|
||||
}
|
||||
if blocked
|
||||
.iter()
|
||||
.any(|&(a, b)| (a == n && b == m) || (a == m && b == n))
|
||||
{
|
||||
break;
|
||||
}
|
||||
let water = query(m).is_some_and(|b| b.fluid);
|
||||
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
|
||||
let c = base + step;
|
||||
if cost.get(&m).is_none_or(|&old| c < old) {
|
||||
cost.insert(m, c);
|
||||
came.insert(m, n);
|
||||
open.push(Reverse((c + h(m) as u32, m)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(best != from && distance(best) + 1e-6 < distance(from))
|
||||
.then(|| reconstruct(best, &came, false))
|
||||
}
|
||||
|
||||
fn reconstruct(end: Node, came: &HashMap<Node, Node>, complete: bool) -> Plan {
|
||||
let mut nodes = vec![end];
|
||||
let mut at = end;
|
||||
while let Some(&previous) = came.get(&at) {
|
||||
nodes.push(previous);
|
||||
at = previous;
|
||||
}
|
||||
nodes.reverse();
|
||||
Plan { nodes, complete }
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub(crate) fn aim(&self, p: [f64; 3], next: &mut usize, target: [f64; 3]) -> Option<[f64; 3]> {
|
||||
while *next < self.nodes.len() {
|
||||
let n = self.nodes[*next];
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
let last = *next + 1 == self.nodes.len();
|
||||
if ((p[0] - c[0]).powi(2) + (p[2] - c[2]).powi(2)).sqrt()
|
||||
< if last { 0.3 } else { 0.45 }
|
||||
&& (p[1] - c[1]).abs() < 1.1
|
||||
{
|
||||
*next += 1;
|
||||
} else {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
self.complete.then_some(target)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn arrived(p: [f64; 3], target: [f64; 3], near: f64) -> bool {
|
||||
((p[0] - target[0]).powi(2) + (p[2] - target[2]).powi(2)).sqrt() <= near
|
||||
&& (p[1] - target[1]).abs() <= near.max(1.0) + 1.0
|
||||
}
|
||||
|
||||
fn horizontal_distance(p: [f64; 3], aim: [f64; 3]) -> f64 {
|
||||
((p[0] - aim[0]).powi(2) + (p[2] - aim[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
pub(crate) struct Progress {
|
||||
best_distance: f64,
|
||||
}
|
||||
impl Progress {
|
||||
pub(crate) fn new(position: [f64; 3], aim: [f64; 3]) -> Self {
|
||||
Self {
|
||||
best_distance: horizontal_distance(position, aim),
|
||||
}
|
||||
}
|
||||
pub(crate) fn advance(&mut self, p: [f64; 3], aim: [f64; 3]) -> bool {
|
||||
let distance = horizontal_distance(p, aim);
|
||||
if self.best_distance - distance >= 0.25 {
|
||||
self.best_distance = distance;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Terrain {
|
||||
floors: HashMap<(i32, i32), i32>,
|
||||
cells: HashMap<Node, Cell>,
|
||||
}
|
||||
impl Terrain {
|
||||
fn flat(x: std::ops::RangeInclusive<i32>, z: std::ops::RangeInclusive<i32>) -> Self {
|
||||
let mut t = Self::default();
|
||||
for x in x {
|
||||
for z in z.clone() {
|
||||
t.floors.insert((x, z), 63);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
fn query(&self, n: Node) -> Option<Cell> {
|
||||
let floor = self.floors.get(&(n[0], n[2]))?;
|
||||
Some(self.cells.get(&n).copied().unwrap_or(Cell {
|
||||
solid: n[1] <= *floor,
|
||||
..Cell::default()
|
||||
}))
|
||||
}
|
||||
fn path(&self, from: Node, goal: [f64; 3], blocked: &[(Node, Node)]) -> Option<Plan> {
|
||||
find_path(from, goal, 0.6, blocked, |n| self.query(n))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_path_is_complete() {
|
||||
let p = Terrain::flat(0..=5, 0..=0)
|
||||
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
|
||||
.unwrap();
|
||||
assert!(p.complete);
|
||||
assert_eq!(p.nodes.last(), Some(&[5, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_frontier_continues_after_chunks_extend() {
|
||||
let mut t = Terrain::flat(0..=5, 0..=0);
|
||||
let goal = [10.5, 64.0, 0.5];
|
||||
let partial = t
|
||||
.path([0, 64, 0], goal, &[])
|
||||
.expect("loaded frontier should be usable");
|
||||
assert!(!partial.complete);
|
||||
assert_eq!(partial.nodes.last(), Some(&[5, 64, 0]));
|
||||
let endpoint = *partial.nodes.last().unwrap();
|
||||
assert!(
|
||||
t.path(endpoint, goal, &[]).is_none(),
|
||||
"unchanged frontier must stop"
|
||||
);
|
||||
for x in 6..=10 {
|
||||
t.floors.insert((x, 0), 63);
|
||||
}
|
||||
let complete = t.path(endpoint, goal, &[]).unwrap();
|
||||
assert!(complete.complete);
|
||||
assert_eq!(complete.nodes.last(), Some(&[10, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closer_endpoint_fails() {
|
||||
assert!(
|
||||
Terrain::flat(-4..=0, 0..=0)
|
||||
.path([0, 64, 0], [10.5, 64.0, 0.5], &[])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_path_can_initially_move_away() {
|
||||
let mut t = Terrain::flat(-2..=0, 0..=0);
|
||||
for z in 0..=2 {
|
||||
t.floors.insert((-2, z), 63);
|
||||
}
|
||||
for x in -2..=5 {
|
||||
t.floors.insert((x, 2), 63);
|
||||
}
|
||||
for z in 0..=2 {
|
||||
t.floors.insert((5, z), 63);
|
||||
}
|
||||
let p = t.path([0, 64, 0], [5.5, 64.0, 0.5], &[]).unwrap();
|
||||
assert!(p.complete);
|
||||
assert_eq!(p.nodes[1], [-1, 64, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edge_uses_bypass() {
|
||||
let blocked = [([0, 64, 0], [1, 64, 0])];
|
||||
let p = Terrain::flat(0..=3, 0..=1)
|
||||
.path([0, 64, 0], [3.5, 64.0, 0.5], &blocked)
|
||||
.unwrap();
|
||||
assert!(p.complete);
|
||||
assert!(
|
||||
!p.nodes
|
||||
.windows(2)
|
||||
.any(|w| w == [blocked[0].0, blocked[0].1])
|
||||
);
|
||||
assert_eq!(p.nodes[1], [0, 64, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edge_without_bypass_stops() {
|
||||
assert!(
|
||||
Terrain::flat(0..=1, 0..=0)
|
||||
.path([0, 64, 0], [1.5, 64.0, 0.5], &[([0, 64, 0], [1, 64, 0])])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_and_unknown_terrain_are_not_walked() {
|
||||
let mut t = Terrain::flat(0..=1, 0..=0);
|
||||
t.cells.insert(
|
||||
[1, 63, 0],
|
||||
Cell {
|
||||
lava: true,
|
||||
fluid: true,
|
||||
solid: false,
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
|
||||
t.cells.remove(&[1, 63, 0]);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
lava: true,
|
||||
fluid: true,
|
||||
solid: false,
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
|
||||
assert!(
|
||||
Terrain::flat(0..=0, 0..=0)
|
||||
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_step_up_and_three_step_drop_only_with_clearance() {
|
||||
let mut t = Terrain::flat(0..=1, 0..=0);
|
||||
t.floors.insert((1, 0), 64);
|
||||
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).unwrap().complete);
|
||||
t.cells.insert(
|
||||
[0, 66, 0],
|
||||
Cell {
|
||||
solid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).is_none());
|
||||
t.cells.clear();
|
||||
t.floors.insert((1, 0), 65);
|
||||
assert!(t.path([0, 64, 0], [1.5, 66.0, 0.5], &[]).is_none());
|
||||
t.floors.insert((1, 0), 60);
|
||||
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).unwrap().complete);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
solid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).is_none());
|
||||
t.cells.clear();
|
||||
t.floors.insert((1, 0), 59);
|
||||
assert!(t.path([0, 64, 0], [1.5, 60.0, 0.5], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_bypass_costs_less_than_water() {
|
||||
let mut t = Terrain::flat(0..=2, 0..=1);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
fluid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
let p = t.path([0, 64, 0], [2.5, 64.0, 0.5], &[]).unwrap();
|
||||
assert!(p.complete);
|
||||
assert!(!p.nodes.contains(&[1, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_budget_returns_progress_instead_of_discarding_it() {
|
||||
let p = Terrain::flat(0..=30_010, 0..=0)
|
||||
.path([0, 64, 0], [40_000.5, 64.0, 0.5], &[])
|
||||
.unwrap();
|
||||
assert!(!p.complete);
|
||||
assert!(p.nodes.len() > 2 && p.nodes.len() <= 30_001);
|
||||
}
|
||||
#[test]
|
||||
fn consumed_partial_never_aims_at_unknown_goal_or_arrives() {
|
||||
let plan = Plan {
|
||||
nodes: vec![[0, 64, 0], [1, 64, 0]],
|
||||
complete: false,
|
||||
};
|
||||
let p = [1.5, 64.0, 0.5];
|
||||
let goal = [10.5, 64.0, 0.5];
|
||||
let mut next = 1;
|
||||
assert_eq!(plan.aim(p, &mut next, goal), None);
|
||||
assert!(!arrived(p, goal, 0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_path_uses_its_new_waypoint() {
|
||||
let plan = Plan {
|
||||
nodes: vec![[0, 64, 0], [0, 64, 1], [1, 64, 1]],
|
||||
complete: false,
|
||||
};
|
||||
assert_eq!(
|
||||
plan.aim([0.5, 64.0, 0.5], &mut 1, [5.5, 64.0, 0.5]),
|
||||
Some([0.5, 64.0, 1.5])
|
||||
);
|
||||
assert!(arrived([5.5, 64.0, 0.5], [5.5, 64.0, 0.5], 0.6));
|
||||
}
|
||||
#[test]
|
||||
fn jumping_in_place_does_not_reset_stuck_timer() {
|
||||
let start = [0.5, 64.0, 0.5];
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new(start, aim);
|
||||
assert!(!progress.advance([0.5, 65.0, 0.5], aim));
|
||||
assert!(!progress.advance([0.6, 64.0, 0.6], aim));
|
||||
assert!(progress.advance([0.8, 64.0, 0.5], aim));
|
||||
}
|
||||
#[test]
|
||||
fn oscillation_and_vertical_drift_do_not_count_as_progress() {
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new([0.5, 64.0, 0.5], aim);
|
||||
for tick in 0..400 {
|
||||
let x = if tick % 2 == 0 { 0.2 } else { 0.5 };
|
||||
assert!(
|
||||
!progress.advance([x, 64.0, 0.5], aim),
|
||||
"oscillation reset at {tick}"
|
||||
);
|
||||
}
|
||||
assert!(!progress.advance([0.5, 66.0, 0.5], aim));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_new_best_distance_counts_and_waypoint_switch_resets_baseline() {
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let start = [0.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new(start, aim);
|
||||
assert!(!progress.advance([0.6, 64.0, 0.5], aim));
|
||||
assert!(progress.advance([0.8, 64.0, 0.5], aim));
|
||||
assert!(!progress.advance(start, aim));
|
||||
assert!(!progress.advance([0.8, 64.0, 0.5], aim));
|
||||
assert!(progress.advance([1.1, 64.0, 0.5], aim));
|
||||
let detour_aim = [1.1, 64.0, 2.5];
|
||||
let mut progress = Progress::new([1.1, 64.0, 0.5], detour_aim);
|
||||
assert!(progress.advance([1.1, 64.0, 0.8], detour_aim));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user