Skip to main content

flatland_pathfinding/
session.rs

1//! Path session — follow waypoints toward a goal (client auto-nav + worker travel steps).
2
3use crate::grid::NavWorld;
4use crate::path::find_path_with_goal_z;
5use crate::z_nav::{goal_z_for, transition_vertical, Z_LEVEL_TOLERANCE};
6
7pub const WAYPOINT_RADIUS_M: f32 = 0.85;
8pub const ARRIVE_RADIUS_M: f32 = 0.75;
9const SPRINT_LEG_M: f32 = 4.0;
10const STUCK_TICKS_BEFORE_REPLAN: u32 = 6;
11const MAX_REPLAN_ATTEMPTS: u32 = 5;
12
13/// Dexterity contribution to move speed (display DEX 1–100 scale).
14pub const DEX_MOVE_COEFF: f32 = 0.003;
15
16/// Effective horizontal speed (m/s) for travel ETA and worker movement.
17pub fn effective_move_speed_mps(base_speed: f32, dex_display: f32, encumbrance_mult: f32) -> f32 {
18    base_speed * encumbrance_mult * (1.0 + DEX_MOVE_COEFF * dex_display.max(0.0))
19}
20
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct PathSteer {
23    pub forward: f32,
24    pub strafe: f32,
25    pub vertical: f32,
26    pub sprint: bool,
27}
28
29#[derive(Debug, Clone)]
30pub struct PathSession {
31    waypoints: Vec<(f32, f32)>,
32    waypoint_index: usize,
33    pub goal_x: f32,
34    pub goal_y: f32,
35    pub goal_z: f32,
36    last_progress_x: f32,
37    last_progress_y: f32,
38    stuck_ticks: u32,
39    replan_attempts: u32,
40}
41
42impl PathSession {
43    pub fn plan(
44        world: &NavWorld,
45        from_x: f32,
46        from_y: f32,
47        from_z: f32,
48        goal_x: f32,
49        goal_y: f32,
50    ) -> Option<Self> {
51        let goal_z = goal_z_for(world, goal_x, goal_y, from_z);
52        Self::plan_with_goal_z(world, from_x, from_y, from_z, goal_x, goal_y, goal_z)
53    }
54
55    pub fn plan_with_goal_z(
56        world: &NavWorld,
57        from_x: f32,
58        from_y: f32,
59        from_z: f32,
60        goal_x: f32,
61        goal_y: f32,
62        goal_z: f32,
63    ) -> Option<Self> {
64        let path = find_path_with_goal_z(world, from_x, from_y, from_z, goal_x, goal_y, goal_z)?;
65        if path.is_empty() {
66            return None;
67        }
68        Some(Self {
69            waypoints: path,
70            waypoint_index: 0,
71            goal_x,
72            goal_y,
73            goal_z,
74            last_progress_x: from_x,
75            last_progress_y: from_y,
76            stuck_ticks: 0,
77            replan_attempts: 0,
78        })
79    }
80
81    pub fn active(&self) -> bool {
82        self.waypoint_index < self.waypoints.len()
83    }
84
85    pub fn waypoints(&self) -> &[(f32, f32)] {
86        &self.waypoints
87    }
88
89    pub fn replan(&mut self, world: &NavWorld, px: f32, py: f32, pz: f32) -> bool {
90        if let Some(path) =
91            find_path_with_goal_z(world, px, py, pz, self.goal_x, self.goal_y, self.goal_z)
92        {
93            if !path.is_empty() {
94                let changed = path != self.waypoints;
95                self.waypoints = path;
96                self.waypoint_index = 0;
97                self.stuck_ticks = 0;
98                self.last_progress_x = px;
99                self.last_progress_y = py;
100                if changed {
101                    self.replan_attempts = 0;
102                } else {
103                    self.replan_attempts = self.replan_attempts.saturating_add(1);
104                }
105                return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
106            }
107        }
108        self.replan_attempts = self.replan_attempts.saturating_add(1);
109        self.replan_attempts < MAX_REPLAN_ATTEMPTS
110    }
111
112    pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
113        if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
114            self.last_progress_x = px;
115            self.last_progress_y = py;
116            self.stuck_ticks = 0;
117            self.replan_attempts = 0;
118            return false;
119        }
120        self.stuck_ticks = self.stuck_ticks.saturating_add(1);
121        self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
122    }
123
124    pub fn steer(&mut self, px: f32, py: f32, pz: f32, world: &NavWorld) -> Option<PathSteer> {
125        let vertical_climb = transition_vertical(world, px, py, pz, self.goal_z);
126        if vertical_climb.abs() > f32::EPSILON {
127            return Some(PathSteer {
128                forward: 0.0,
129                strafe: 0.0,
130                vertical: vertical_climb,
131                sprint: false,
132            });
133        }
134        while self.waypoint_index < self.waypoints.len() {
135            let (wx, wy) = self.waypoints[self.waypoint_index];
136            let dx = wx - px;
137            let dy = wy - py;
138            let dist = (dx * dx + dy * dy).sqrt();
139            if dist < WAYPOINT_RADIUS_M {
140                self.waypoint_index += 1;
141                continue;
142            }
143            let forward = (dy / dist).clamp(-1.0, 1.0);
144            let strafe = (dx / dist).clamp(-1.0, 1.0);
145            let sprint = dist > SPRINT_LEG_M;
146            return Some(PathSteer {
147                forward,
148                strafe,
149                vertical: 0.0,
150                sprint,
151            });
152        }
153        let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
154        if goal_dist > ARRIVE_RADIUS_M || (pz - self.goal_z).abs() > Z_LEVEL_TOLERANCE {
155            if goal_dist > ARRIVE_RADIUS_M {
156                let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
157                let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
158                return Some(PathSteer {
159                    forward,
160                    strafe,
161                    vertical: 0.0,
162                    sprint: goal_dist > SPRINT_LEG_M,
163                });
164            }
165            let vz = transition_vertical(world, px, py, pz, self.goal_z);
166            if vz.abs() > f32::EPSILON {
167                return Some(PathSteer {
168                    forward: 0.0,
169                    strafe: 0.0,
170                    vertical: vz,
171                    sprint: false,
172                });
173            }
174        }
175        None
176    }
177
178    /// Remaining path length in meters (rough estimate).
179    pub fn remaining_distance_m(&self, px: f32, py: f32) -> f32 {
180        let mut total = 0.0;
181        let mut last = (px, py);
182        if self.waypoint_index < self.waypoints.len() {
183            for &(wx, wy) in &self.waypoints[self.waypoint_index..] {
184                total += (wx - last.0).hypot(wy - last.1);
185                last = (wx, wy);
186            }
187        }
188        total + (self.goal_x - last.0).hypot(self.goal_y - last.1)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::grid::NavWorld;
196
197    fn open_world() -> NavWorld {
198        NavWorld {
199            world_width_m: 64.0,
200            world_height_m: 64.0,
201            terrain_zones: vec![],
202            z_platforms: vec![],
203            z_transitions: vec![],
204            buildings: vec![],
205            doors: vec![],
206            circles: vec![],
207        }
208    }
209
210    #[test]
211    fn replan_gives_up_when_goal_becomes_unreachable() {
212        let world = open_world();
213        let mut session =
214            PathSession::plan_with_goal_z(&world, 10.0, 10.0, 0.0, 20.0, 10.0, 0.0).expect("path");
215        // Wall off the goal after the first plan.
216        let mut blocked = open_world();
217        blocked.terrain_zones.push(flatland_protocol::TerrainZoneView {
218            id: "wall".into(),
219            x0: 14.0,
220            y0: 0.0,
221            x1: 18.0,
222            y1: 64.0,
223            kind: flatland_protocol::TerrainKindView::DeepWater,
224            elevation: 0.0,
225            glyph: None,
226            color: None,
227            tile_id: None,
228            z_order: 0,
229        });
230        let mut gave_up = false;
231        for _ in 0..MAX_REPLAN_ATTEMPTS + 2 {
232            if !session.replan(&blocked, 10.0, 10.0, 0.0) {
233                gave_up = true;
234                break;
235            }
236        }
237        assert!(gave_up, "replan must eventually return false for unreachable goals");
238    }
239}