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        // Final steer must not chase the raw click/container coord through walls —
69        // A* may snap the walkable cell; follow the path terminus instead.
70        let (approach_x, approach_y) = path.last().copied().unwrap_or((goal_x, goal_y));
71        Some(Self {
72            waypoints: path,
73            waypoint_index: 0,
74            goal_x: approach_x,
75            goal_y: approach_y,
76            goal_z,
77            last_progress_x: from_x,
78            last_progress_y: from_y,
79            stuck_ticks: 0,
80            replan_attempts: 0,
81        })
82    }
83
84    pub fn active(&self) -> bool {
85        self.waypoint_index < self.waypoints.len()
86    }
87
88    pub fn waypoints(&self) -> &[(f32, f32)] {
89        &self.waypoints
90    }
91
92    pub fn replan(&mut self, world: &NavWorld, px: f32, py: f32, pz: f32) -> bool {
93        if let Some(path) =
94            find_path_with_goal_z(world, px, py, pz, self.goal_x, self.goal_y, self.goal_z)
95        {
96            if !path.is_empty() {
97                let changed = path != self.waypoints;
98                self.waypoints = path;
99                self.waypoint_index = 0;
100                self.stuck_ticks = 0;
101                self.last_progress_x = px;
102                self.last_progress_y = py;
103                if changed {
104                    self.replan_attempts = 0;
105                } else {
106                    self.replan_attempts = self.replan_attempts.saturating_add(1);
107                }
108                return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
109            }
110        }
111        self.replan_attempts = self.replan_attempts.saturating_add(1);
112        self.replan_attempts < MAX_REPLAN_ATTEMPTS
113    }
114
115    pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
116        if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
117            self.last_progress_x = px;
118            self.last_progress_y = py;
119            self.stuck_ticks = 0;
120            self.replan_attempts = 0;
121            return false;
122        }
123        self.stuck_ticks = self.stuck_ticks.saturating_add(1);
124        self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
125    }
126
127    pub fn steer(&mut self, px: f32, py: f32, pz: f32, world: &NavWorld) -> Option<PathSteer> {
128        let vertical_climb = transition_vertical(world, px, py, pz, self.goal_z);
129        if vertical_climb.abs() > f32::EPSILON {
130            return Some(PathSteer {
131                forward: 0.0,
132                strafe: 0.0,
133                vertical: vertical_climb,
134                sprint: false,
135            });
136        }
137        while self.waypoint_index < self.waypoints.len() {
138            let (wx, wy) = self.waypoints[self.waypoint_index];
139            let dx = wx - px;
140            let dy = wy - py;
141            let dist = (dx * dx + dy * dy).sqrt();
142            if dist < WAYPOINT_RADIUS_M {
143                self.waypoint_index += 1;
144                continue;
145            }
146            let forward = (dy / dist).clamp(-1.0, 1.0);
147            let strafe = (dx / dist).clamp(-1.0, 1.0);
148            let sprint = dist > SPRINT_LEG_M;
149            return Some(PathSteer {
150                forward,
151                strafe,
152                vertical: 0.0,
153                sprint,
154            });
155        }
156        let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
157        if goal_dist > ARRIVE_RADIUS_M || (pz - self.goal_z).abs() > Z_LEVEL_TOLERANCE {
158            if goal_dist > ARRIVE_RADIUS_M {
159                let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
160                let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
161                return Some(PathSteer {
162                    forward,
163                    strafe,
164                    vertical: 0.0,
165                    sprint: goal_dist > SPRINT_LEG_M,
166                });
167            }
168            let vz = transition_vertical(world, px, py, pz, self.goal_z);
169            if vz.abs() > f32::EPSILON {
170                return Some(PathSteer {
171                    forward: 0.0,
172                    strafe: 0.0,
173                    vertical: vz,
174                    sprint: false,
175                });
176            }
177        }
178        None
179    }
180
181    /// Remaining path length in meters (rough estimate).
182    pub fn remaining_distance_m(&self, px: f32, py: f32) -> f32 {
183        let mut total = 0.0;
184        let mut last = (px, py);
185        if self.waypoint_index < self.waypoints.len() {
186            for &(wx, wy) in &self.waypoints[self.waypoint_index..] {
187                total += (wx - last.0).hypot(wy - last.1);
188                last = (wx, wy);
189            }
190        }
191        total + (self.goal_x - last.0).hypot(self.goal_y - last.1)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::grid::NavWorld;
199
200    fn open_world() -> NavWorld {
201        NavWorld {
202            world_width_m: 64.0,
203            world_height_m: 64.0,
204            terrain_zones: vec![],
205            z_platforms: vec![],
206            z_transitions: vec![],
207            buildings: vec![],
208            doors: vec![],
209            circles: vec![],
210        }
211    }
212
213    #[test]
214    fn replan_gives_up_when_goal_becomes_unreachable() {
215        let world = open_world();
216        let mut session =
217            PathSession::plan_with_goal_z(&world, 10.0, 10.0, 0.0, 20.0, 10.0, 0.0).expect("path");
218        // Wall off the goal after the first plan.
219        let mut blocked = open_world();
220        blocked.terrain_zones.push(flatland_protocol::TerrainZoneView {
221            id: "wall".into(),
222            x0: 14.0,
223            y0: 0.0,
224            x1: 18.0,
225            y1: 64.0,
226            kind: flatland_protocol::TerrainKindView::DeepWater,
227            elevation: 0.0,
228            glyph: None,
229            color: None,
230            tile_id: None,
231            z_order: 0,
232            channel_start_tick: None,
233            channel_end_tick: None,
234        });
235        let mut gave_up = false;
236        for _ in 0..MAX_REPLAN_ATTEMPTS + 2 {
237            if !session.replan(&blocked, 10.0, 10.0, 0.0) {
238                gave_up = true;
239                break;
240            }
241        }
242        assert!(gave_up, "replan must eventually return false for unreachable goals");
243    }
244}