Skip to main content

flatland_client_lib/
navigation.rs

1//! Client-side pathfinding and auto-navigation toward a map target.
2
3use std::cmp::Ordering;
4use std::collections::{BinaryHeap, HashMap};
5
6use flatland_protocol::{
7    BuildingView, DoorView, LifeState, ResourceNodeState, TerrainKindView, TerrainZoneView,
8    ZTransitionView,
9};
10
11use crate::game::GameState;
12
13#[derive(Debug, Clone, Copy)]
14struct BlockingCircle {
15    x: f32,
16    y: f32,
17    radius_m: f32,
18}
19
20#[derive(Debug, Clone)]
21struct NavObstacles {
22    circles: Vec<BlockingCircle>,
23    buildings: Vec<BuildingView>,
24}
25
26impl NavObstacles {
27    fn from_state(state: &GameState) -> Self {
28        let mut circles = Vec::new();
29        for node in &state.resource_nodes {
30            if node.blocking
31                && matches!(
32                    node.state,
33                    ResourceNodeState::Available | ResourceNodeState::Harvesting
34                )
35            {
36                let radius = if node.blocking_radius_m > 0.0 {
37                    node.blocking_radius_m
38                } else {
39                    0.8
40                };
41                circles.push(BlockingCircle {
42                    x: node.x,
43                    y: node.y,
44                    radius_m: radius,
45                });
46            }
47        }
48        for npc in &state.npcs {
49            let alive =
50                npc.life_state != Some(LifeState::Dead) && npc.hp_pct.is_none_or(|h| h > 0.0);
51            if alive {
52                circles.push(BlockingCircle {
53                    x: npc.x,
54                    y: npc.y,
55                    radius_m: 0.55,
56                });
57            }
58        }
59        Self {
60            circles,
61            buildings: state.buildings.clone(),
62        }
63    }
64}
65
66fn collides_player_at(x: f32, y: f32, obstacles: &NavObstacles) -> bool {
67    if obstacles
68        .circles
69        .iter()
70        .any(|o| circle_overlap(x, y, PLAYER_RADIUS_M, o.x, o.y, o.radius_m))
71    {
72        return true;
73    }
74    let pad = PLAYER_RADIUS_M;
75    obstacles.buildings.iter().any(|b| {
76        let hw = b.width_m / 2.0 + pad;
77        let hd = b.depth_m / 2.0 + pad;
78        x >= b.x - hw && x <= b.x + hw && y >= b.y - hd && y <= b.y + hd
79    })
80}
81
82fn circle_overlap(ax: f32, ay: f32, ar: f32, bx: f32, by: f32, br: f32) -> bool {
83    let dx = ax - bx;
84    let dy = ay - by;
85    let min_dist = ar + br;
86    dx * dx + dy * dy < min_dist * min_dist
87}
88
89fn segment_clear_world(
90    grid: &NavGrid,
91    obstacles: &NavObstacles,
92    from: (f32, f32),
93    to: (f32, f32),
94) -> bool {
95    let (fx, fy) = from;
96    let (tx, ty) = to;
97    let dist = (tx - fx).hypot(ty - fy);
98    let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
99    for step in 0..=steps {
100        let t = step as f32 / steps as f32;
101        let x = fx + (tx - fx) * t;
102        let y = fy + (ty - fy) * t;
103        if collides_player_at(x, y, obstacles) {
104            return false;
105        }
106    }
107    let (cx0, cy0) = world_to_cell(fx, fy);
108    let (cx1, cy1) = world_to_cell(tx, ty);
109    line_clear(grid, (cx0, cy0), (cx1, cy1))
110}
111
112const PLAYER_RADIUS_M: f32 = 0.45;
113/// Extra clearance so paths don't hug walls (server collision uses player radius).
114const PATH_CLEARANCE_M: f32 = 0.35;
115const WAYPOINT_RADIUS_M: f32 = 0.85;
116const ARRIVE_RADIUS_M: f32 = 0.75;
117const SPRINT_LEG_M: f32 = 4.0;
118/// Sample spacing when testing straight segments against circle obstacles.
119const SEGMENT_SAMPLE_M: f32 = 0.3;
120/// Intent ticks with no progress before attempting a replan.
121const STUCK_TICKS_BEFORE_REPLAN: u32 = 6;
122/// Consecutive failed/useless replans before giving up.
123const MAX_REPLAN_ATTEMPTS: u32 = 5;
124const Z_LEVEL_TOLERANCE: f32 = 0.35;
125
126fn nearest_level(levels: &[f32], current: f32) -> f32 {
127    levels
128        .iter()
129        .min_by(|a, b| {
130            (*a - current)
131                .abs()
132                .partial_cmp(&(*b - current).abs())
133                .unwrap_or(std::cmp::Ordering::Equal)
134        })
135        .copied()
136        .unwrap_or(current)
137}
138
139fn walkable_levels(state: &GameState, x: f32, y: f32) -> Vec<f32> {
140    state.walkable_levels_at(x, y)
141}
142
143fn goal_z_for(state: &GameState, goal_x: f32, goal_y: f32, player_z: f32) -> f32 {
144    nearest_level(&walkable_levels(state, goal_x, goal_y), player_z)
145}
146
147fn transition_at<'a>(state: &'a GameState, x: f32, y: f32) -> Option<&'a ZTransitionView> {
148    state
149        .z_transitions
150        .iter()
151        .find(|t| x >= t.x0 && x <= t.x1 && y >= t.y0 && y <= t.y1)
152}
153
154fn transition_vertical(state: &GameState, px: f32, py: f32, pz: f32, goal_z: f32) -> f32 {
155    let Some(tr) = transition_at(state, px, py) else {
156        return 0.0;
157    };
158    if (pz - goal_z).abs() <= Z_LEVEL_TOLERANCE {
159        return 0.0;
160    }
161    if goal_z > pz + Z_LEVEL_TOLERANCE
162        && (pz - tr.z_from).abs() <= Z_LEVEL_TOLERANCE
163        && tr.z_to > tr.z_from
164    {
165        return 1.0;
166    }
167    if goal_z < pz - Z_LEVEL_TOLERANCE
168        && (pz - tr.z_to).abs() <= Z_LEVEL_TOLERANCE
169        && tr.z_to > tr.z_from
170    {
171        return -1.0;
172    }
173    if goal_z > pz + Z_LEVEL_TOLERANCE && pz <= tr.z_min() + Z_LEVEL_TOLERANCE {
174        return 1.0;
175    }
176    if goal_z < pz - Z_LEVEL_TOLERANCE && pz >= tr.z_max() - Z_LEVEL_TOLERANCE {
177        return -1.0;
178    }
179    0.0
180}
181
182trait ZTransitionExt {
183    fn z_min(&self) -> f32;
184    fn z_max(&self) -> f32;
185}
186
187impl ZTransitionExt for ZTransitionView {
188    fn z_min(&self) -> f32 {
189        self.z_from.min(self.z_to)
190    }
191
192    fn z_max(&self) -> f32 {
193        self.z_from.max(self.z_to)
194    }
195}
196
197#[derive(Debug, Clone)]
198pub struct AutoNavigator {
199    waypoints: Vec<(f32, f32)>,
200    waypoint_index: usize,
201    pub goal_x: f32,
202    pub goal_y: f32,
203    pub goal_z: f32,
204    last_progress_x: f32,
205    last_progress_y: f32,
206    stuck_ticks: u32,
207    replan_attempts: u32,
208}
209
210impl AutoNavigator {
211    pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
212        let (px, py, pz) = state.player_position_with_z();
213        let goal_z = goal_z_for(state, goal_x, goal_y, pz);
214        let path = find_path(state, px, py, pz, goal_x, goal_y, goal_z)?;
215        if path.is_empty() {
216            return None;
217        }
218        Some(Self {
219            waypoints: path,
220            waypoint_index: 0,
221            goal_x,
222            goal_y,
223            goal_z,
224            last_progress_x: px,
225            last_progress_y: py,
226            stuck_ticks: 0,
227            replan_attempts: 0,
228        })
229    }
230
231    pub fn active(&self) -> bool {
232        self.waypoint_index < self.waypoints.len()
233    }
234
235    /// Recompute path from current position (e.g. after getting stuck on an obstacle).
236    /// Returns `false` only after several failed attempts — keeps trying around buildings.
237    pub fn replan(&mut self, state: &GameState) -> bool {
238        let (px, py, pz) = state.player_position_with_z();
239        if let Some(path) = find_path(state, px, py, pz, self.goal_x, self.goal_y, self.goal_z) {
240            if !path.is_empty() {
241                let changed = path != self.waypoints;
242                self.waypoints = path;
243                self.waypoint_index = 0;
244                self.stuck_ticks = 0;
245                self.last_progress_x = px;
246                self.last_progress_y = py;
247                if changed {
248                    self.replan_attempts = 0;
249                } else {
250                    self.replan_attempts = self.replan_attempts.saturating_add(1);
251                }
252                return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
253            }
254        }
255        self.replan_attempts = self.replan_attempts.saturating_add(1);
256        self.replan_attempts < MAX_REPLAN_ATTEMPTS
257    }
258
259    /// Returns true when movement has not progressed and a replan is advised.
260    pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
261        if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
262            self.last_progress_x = px;
263            self.last_progress_y = py;
264            self.stuck_ticks = 0;
265            self.replan_attempts = 0;
266            return false;
267        }
268        self.stuck_ticks = self.stuck_ticks.saturating_add(1);
269        self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
270    }
271
272    /// Movement intent toward the next waypoint. `None` when finished.
273    pub fn steer(
274        &mut self,
275        px: f32,
276        py: f32,
277        pz: f32,
278        state: &GameState,
279    ) -> Option<(f32, f32, f32, bool)> {
280        let vertical_climb = transition_vertical(state, px, py, pz, self.goal_z);
281        if vertical_climb.abs() > f32::EPSILON {
282            return Some((0.0, 0.0, vertical_climb, false));
283        }
284        while self.waypoint_index < self.waypoints.len() {
285            let (wx, wy) = self.waypoints[self.waypoint_index];
286            let dx = wx - px;
287            let dy = wy - py;
288            let dist = (dx * dx + dy * dy).sqrt();
289            if dist < WAYPOINT_RADIUS_M {
290                self.waypoint_index += 1;
291                continue;
292            }
293            let forward = (dy / dist).clamp(-1.0, 1.0);
294            let strafe = (dx / dist).clamp(-1.0, 1.0);
295            let sprint = dist > SPRINT_LEG_M;
296            return Some((forward, strafe, 0.0, sprint));
297        }
298        let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
299        if goal_dist > ARRIVE_RADIUS_M || (pz - self.goal_z).abs() > Z_LEVEL_TOLERANCE {
300            if goal_dist > ARRIVE_RADIUS_M {
301                let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
302                let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
303                return Some((forward, strafe, 0.0, goal_dist > SPRINT_LEG_M));
304            }
305            let vz = transition_vertical(state, px, py, pz, self.goal_z);
306            if vz.abs() > f32::EPSILON {
307                return Some((0.0, 0.0, vz, false));
308            }
309        }
310        None
311    }
312}
313
314#[derive(Clone, Copy, Eq, PartialEq)]
315struct OpenNode {
316    f: u32,
317    g: u32,
318    x: i16,
319    y: i16,
320}
321
322impl Ord for OpenNode {
323    fn cmp(&self, other: &Self) -> Ordering {
324        other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
325    }
326}
327
328impl PartialOrd for OpenNode {
329    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
330        Some(self.cmp(other))
331    }
332}
333
334struct NavGrid {
335    width: i16,
336    height: i16,
337    blocked: Vec<bool>,
338    cost: Vec<u16>,
339}
340
341impl NavGrid {
342    fn idx(&self, x: i16, y: i16) -> usize {
343        (y as usize) * (self.width as usize) + (x as usize)
344    }
345
346    fn in_bounds(&self, x: i16, y: i16) -> bool {
347        x >= 0 && y >= 0 && x < self.width && y < self.height
348    }
349
350    fn is_walkable(&self, x: i16, y: i16) -> bool {
351        self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
352    }
353
354    fn move_cost(&self, x: i16, y: i16) -> u32 {
355        self.cost[self.idx(x, y)] as u32
356    }
357
358    fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
359        if self.in_bounds(x, y) {
360            let idx = self.idx(x, y);
361            self.blocked[idx] = blocked;
362        }
363    }
364}
365
366fn terrain_at(zones: &[TerrainZoneView], x: f32, y: f32) -> TerrainKindView {
367    for zone in zones {
368        if x >= zone.x0 && x < zone.x1 && y >= zone.y0 && y < zone.y1 {
369            return zone.kind;
370        }
371    }
372    TerrainKindView::Grass
373}
374
375fn terrain_cost(kind: TerrainKindView) -> u16 {
376    match kind {
377        TerrainKindView::Grass => 10,
378        TerrainKindView::Hill => 14,
379        TerrainKindView::Bog => 25,
380        TerrainKindView::ShallowWater => 40,
381        TerrainKindView::DeepWater => u16::MAX,
382        TerrainKindView::Trail => 8,
383        TerrainKindView::Rock => u16::MAX,
384    }
385}
386
387fn block_circle(grid: &mut NavGrid, cx: f32, cy: f32, radius_m: f32) {
388    let block_r = radius_m + PLAYER_RADIUS_M + PATH_CLEARANCE_M;
389    let r = block_r.ceil() as i16;
390    let ix = cx.floor() as i16;
391    let iy = cy.floor() as i16;
392    for dy in -r..=r {
393        for dx in -r..=r {
394            let x = ix + dx;
395            let y = iy + dy;
396            if !grid.in_bounds(x, y) {
397                continue;
398            }
399            let cell_cx = x as f32 + 0.5;
400            let cell_cy = y as f32 + 0.5;
401            if (cell_cx - cx).hypot(cell_cy - cy) <= block_r {
402                grid.set_blocked(x, y, true);
403            }
404        }
405    }
406}
407
408/// Block building AABB inflated by player radius so paths match server collision.
409fn mark_building_footprint(grid: &mut NavGrid, building: &BuildingView) {
410    let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
411    let hw = building.width_m / 2.0;
412    let hd = building.depth_m / 2.0;
413    let x0 = (building.x - hw - pad).floor() as i16;
414    let y0 = (building.y - hd - pad).floor() as i16;
415    let x1 = (building.x + hw + pad).ceil() as i16 - 1;
416    let y1 = (building.y + hd + pad).ceil() as i16 - 1;
417    if x1 < x0 || y1 < y0 {
418        return;
419    }
420    for x in x0..=x1 {
421        for y in y0..=y1 {
422            grid.set_blocked(x, y, true);
423        }
424    }
425}
426
427fn clear_door_cells(grid: &mut NavGrid, doors: &[DoorView]) {
428    // Outdoor pathfinding treats buildings as solid. Only clear a small doorway
429    // corridor when the door is open so paths can enter if the goal is inside.
430    for door in doors {
431        if door.open {
432            let (x, y) = world_to_cell(door.x, door.y);
433            for dx in -1i16..=1 {
434                for dy in -1i16..=1 {
435                    if dx.abs() + dy.abs() <= 1 {
436                        grid.set_blocked(x + dx, y + dy, false);
437                    }
438                }
439            }
440        }
441    }
442}
443
444fn build_grid(state: &GameState, obstacles: &NavObstacles, player_z: f32, goal_z: f32) -> NavGrid {
445    let width = state.world_width_m.max(1.0).ceil() as i16;
446    let height = state.world_height_m.max(1.0).ceil() as i16;
447    let len = (width as usize) * (height as usize);
448    let mut blocked = vec![false; len];
449    let mut cost = vec![10u16; len];
450
451    for y in 0..height {
452        for x in 0..width {
453            let cx = x as f32 + 0.5;
454            let cy = y as f32 + 0.5;
455            let kind = terrain_at(&state.terrain_zones, cx, cy);
456            let tc = terrain_cost(kind);
457            let idx = (y as usize) * (width as usize) + (x as usize);
458            cost[idx] = tc;
459            if tc == u16::MAX {
460                blocked[idx] = true;
461            }
462        }
463    }
464
465    let mut grid = NavGrid {
466        width,
467        height,
468        blocked,
469        cost,
470    };
471
472    for circle in &obstacles.circles {
473        block_circle(&mut grid, circle.x, circle.y, circle.radius_m);
474    }
475
476    for building in &state.buildings {
477        mark_building_footprint(&mut grid, building);
478    }
479
480    clear_door_cells(&mut grid, &state.doors);
481
482    for y in 0..height {
483        for x in 0..width {
484            let cx = x as f32 + 0.5;
485            let cy = y as f32 + 0.5;
486            if !cell_walkable_for_path(state, cx, cy, player_z, goal_z) {
487                grid.set_blocked(x, y, true);
488            }
489        }
490    }
491
492    grid
493}
494
495fn cell_walkable_for_path(state: &GameState, x: f32, y: f32, player_z: f32, goal_z: f32) -> bool {
496    if state.is_walkable_at_z(x, y, player_z) {
497        return true;
498    }
499    if let Some(tr) = transition_at(state, x, y) {
500        let on_from = (player_z - tr.z_from).abs() <= Z_LEVEL_TOLERANCE;
501        let on_to = (player_z - tr.z_to).abs() <= Z_LEVEL_TOLERANCE;
502        let goal_on_from = (goal_z - tr.z_from).abs() <= Z_LEVEL_TOLERANCE;
503        let goal_on_to = (goal_z - tr.z_to).abs() <= Z_LEVEL_TOLERANCE;
504        if (on_from && goal_on_to) || (on_to && goal_on_from) {
505            return true;
506        }
507    }
508    false
509}
510
511fn world_to_cell(x: f32, y: f32) -> (i16, i16) {
512    (x.floor() as i16, y.floor() as i16)
513}
514
515fn cell_center(x: i16, y: i16) -> (f32, f32) {
516    (x as f32 + 0.5, y as f32 + 0.5)
517}
518
519fn heuristic(ax: i16, ay: i16, bx: i16, by: i16) -> u32 {
520    let dx = (ax - bx).unsigned_abs() as u32;
521    let dy = (ay - by).unsigned_abs() as u32;
522    let diag = dx.min(dy);
523    let straight = dx.max(dy) - diag;
524    diag * 14 + straight * 10
525}
526
527fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
528    let (mut x0, mut y0) = from;
529    let (x1, y1) = to;
530    let dx = (x1 - x0).abs();
531    let dy = (y1 - y0).abs();
532    let sx = if x0 < x1 { 1 } else { -1 };
533    let sy = if y0 < y1 { 1 } else { -1 };
534    let mut err = dx - dy;
535    loop {
536        if !grid.is_walkable(x0, y0) {
537            return false;
538        }
539        if x0 == x1 && y0 == y1 {
540            break;
541        }
542        let e2 = err * 2;
543        if e2 > -dy {
544            err -= dy;
545            x0 += sx;
546        }
547        if e2 < dx {
548            err += dx;
549            y0 += sy;
550        }
551    }
552    true
553}
554
555fn find_path(
556    state: &GameState,
557    from_x: f32,
558    from_y: f32,
559    from_z: f32,
560    to_x: f32,
561    to_y: f32,
562    to_z: f32,
563) -> Option<Vec<(f32, f32)>> {
564    let obstacles = NavObstacles::from_state(state);
565    let grid = build_grid(state, &obstacles, from_z, to_z);
566    let (sx, sy) = world_to_cell(from_x, from_y);
567    let (gx, gy) = world_to_cell(to_x, to_y);
568
569    if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
570        return None;
571    }
572
573    let mut goal_x = gx;
574    let mut goal_y = gy;
575    if !grid.is_walkable(goal_x, goal_y) {
576        let mut found = None;
577        'search: for radius in 1..=16i16 {
578            for dy in -radius..=radius {
579                for dx in -radius..=radius {
580                    if dx.abs() != radius && dy.abs() != radius {
581                        continue;
582                    }
583                    let x = gx + dx;
584                    let y = gy + dy;
585                    if grid.is_walkable(x, y) {
586                        found = Some((x, y));
587                        break 'search;
588                    }
589                }
590            }
591        }
592        let (x, y) = found?;
593        goal_x = x;
594        goal_y = y;
595    }
596
597    let goal_key = (goal_x, goal_y);
598
599    // If start is inside a blocked cell (snapped), find nearest walkable.
600    let mut start_x = sx;
601    let mut start_y = sy;
602    if !grid.is_walkable(start_x, start_y) {
603        let mut found = None;
604        'start: for radius in 1..=16i16 {
605            for dy in -radius..=radius {
606                for dx in -radius..=radius {
607                    if dx.abs() != radius && dy.abs() != radius {
608                        continue;
609                    }
610                    let x = sx + dx;
611                    let y = sy + dy;
612                    if grid.is_walkable(x, y) {
613                        found = Some((x, y));
614                        break 'start;
615                    }
616                }
617            }
618        }
619        let (x, y) = found?;
620        start_x = x;
621        start_y = y;
622    }
623    let start_key = (start_x, start_y);
624
625    if start_key == goal_key {
626        return Some(vec![cell_center(goal_x, goal_y)]);
627    }
628
629    let mut open = BinaryHeap::new();
630    let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
631    let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
632
633    g_score.insert(start_key, 0);
634    open.push(OpenNode {
635        f: heuristic(start_x, start_y, goal_x, goal_y),
636        g: 0,
637        x: start_x,
638        y: start_y,
639    });
640
641    const NEIGHBORS: [(i16, i16, u32); 8] = [
642        (1, 0, 10),
643        (-1, 0, 10),
644        (0, 1, 10),
645        (0, -1, 10),
646        (1, 1, 14),
647        (1, -1, 14),
648        (-1, 1, 14),
649        (-1, -1, 14),
650    ];
651
652    while let Some(current) = open.pop() {
653        if (current.x, current.y) == goal_key {
654            return Some(simplify_path(
655                &grid,
656                &obstacles,
657                &came_from,
658                start_key,
659                goal_key,
660                cell_center(goal_x, goal_y),
661            ));
662        }
663        let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
664            continue;
665        };
666        if current.g > best_g {
667            continue;
668        }
669
670        for (dx, dy, step_base) in NEIGHBORS {
671            let nx = current.x + dx;
672            let ny = current.y + dy;
673            if !grid.is_walkable(nx, ny) {
674                continue;
675            }
676            if dx != 0 && dy != 0 {
677                if !grid.is_walkable(current.x + dx, current.y)
678                    || !grid.is_walkable(current.x, current.y + dy)
679                {
680                    continue;
681                }
682            }
683            let from = cell_center(current.x, current.y);
684            let to = cell_center(nx, ny);
685            if !segment_clear_world(&grid, &obstacles, from, to) {
686                continue;
687            }
688            let step = step_base * grid.move_cost(nx, ny) / 10;
689            let tentative = best_g + step;
690            let key = (nx, ny);
691            if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
692                continue;
693            }
694            came_from.insert(key, (current.x, current.y));
695            g_score.insert(key, tentative);
696            open.push(OpenNode {
697                f: tentative + heuristic(nx, ny, goal_x, goal_y),
698                g: tentative,
699                x: nx,
700                y: ny,
701            });
702        }
703    }
704
705    None
706}
707
708fn simplify_path(
709    grid: &NavGrid,
710    obstacles: &NavObstacles,
711    came_from: &HashMap<(i16, i16), (i16, i16)>,
712    start: (i16, i16),
713    goal: (i16, i16),
714    goal_center: (f32, f32),
715) -> Vec<(f32, f32)> {
716    let mut cells = vec![goal];
717    let mut current = goal;
718    while current != start {
719        let Some(&prev) = came_from.get(&current) else {
720            break;
721        };
722        cells.push(prev);
723        current = prev;
724    }
725    cells.reverse();
726
727    if cells.is_empty() {
728        return vec![goal_center];
729    }
730
731    let mut waypoints: Vec<(i16, i16)> = Vec::new();
732    let mut anchor = 0usize;
733    waypoints.push(cells[0]);
734    for i in 1..cells.len() {
735        if i + 1 < cells.len() {
736            let from = cell_center(cells[anchor].0, cells[anchor].1);
737            let to = if cells[i + 1] == goal {
738                goal_center
739            } else {
740                cell_center(cells[i + 1].0, cells[i + 1].1)
741            };
742            if line_clear(grid, cells[anchor], cells[i + 1])
743                && segment_clear_world(grid, obstacles, from, to)
744            {
745                continue;
746            }
747        }
748        waypoints.push(cells[i]);
749        anchor = i;
750    }
751
752    let mut out: Vec<(f32, f32)> = waypoints.iter().map(|&(x, y)| cell_center(x, y)).collect();
753    if let Some(last) = out.last_mut() {
754        *last = goal_center;
755    }
756
757    if path_segments_clear(&grid, &obstacles, &out) {
758        return out;
759    }
760
761    // Simplification shortcut would cut through a circle obstacle — keep full cell path.
762    let mut fallback: Vec<(f32, f32)> = cells.iter().map(|&(x, y)| cell_center(x, y)).collect();
763    if let Some(last) = fallback.last_mut() {
764        *last = goal_center;
765    }
766    fallback
767}
768
769fn path_segments_clear(grid: &NavGrid, obstacles: &NavObstacles, path: &[(f32, f32)]) -> bool {
770    path.windows(2)
771        .all(|w| segment_clear_world(grid, obstacles, w[0], w[1]))
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use flatland_protocol::{EntityState, Transform, Velocity2D, WorldCoord};
778
779    fn empty_state() -> GameState {
780        GameState {
781            session_id: Default::default(),
782            entity_id: 1,
783            character_id: None,
784            tick: 0,
785            chunk_rev: 0,
786            content_rev: 0,
787            publish_rev: 0,
788            entities: vec![],
789            player: Some(EntityState {
790                id: 1,
791                transform: Transform {
792                    position: WorldCoord::surface(10.0, 10.0),
793                    yaw: 0.0,
794                    velocity: Velocity2D { vx: 0.0, vy: 0.0 },
795                },
796                label: "p".into(),
797                vitals: None,
798                attributes: None,
799                skills: None,
800                inside_building: None,
801                tile_id: None,
802                presentation_state: None,
803                sprite_mode: None,
804                progression_xp: None,
805            }),
806            resource_nodes: vec![],
807            ground_drops: vec![],
808            placed_containers: vec![],
809            buildings: vec![],
810            doors: vec![],
811            interior_map: None,
812            npcs: vec![],
813            blueprints: vec![],
814            world_width_m: 64.0,
815            world_height_m: 64.0,
816            terrain_zones: vec![],
817            z_platforms: vec![],
818            z_transitions: vec![],
819            world_clock: Default::default(),
820            inventory: Default::default(),
821            inventory_hints: Default::default(),
822            logs: Default::default(),
823            intents_sent: 0,
824            ticks_received: 0,
825            connected: true,
826            disconnect_reason: None,
827            show_stats: false,
828            show_craft_menu: false,
829            craft_menu_index: 0,
830            craft_batch_quantity: 1,
831            show_shop_menu: false,
832            shop_catalog: None,
833            shop_tab: crate::game::ShopTab::default(),
834            shop_menu_index: 0,
835            shop_quantity: 1,
836            shop_trade_log: std::collections::VecDeque::new(),
837            show_npc_verb_menu: false,
838            npc_verb_target: None,
839            npc_verb_index: 0,
840            show_npc_chat: false,
841            npc_chat: None,
842            show_inventory_menu: false,
843            inventory_menu_index: 0,
844            show_move_picker: false,
845            show_rename_prompt: false,
846            rename_buffer: String::new(),
847            move_picker_index: 0,
848            move_picker: None,
849            show_destroy_picker: false,
850            destroy_confirm_pending: false,
851            destroy_picker: None,
852            combat_target: None,
853            combat_target_label: None,
854            in_combat: false,
855            auto_attack: false,
856            combat_has_los: false,
857            attack_cd_ticks: 0,
858            gcd_ticks: 0,
859            weapon_ability_id: String::new(),
860            mainhand_template_id: None,
861            mainhand_label: None,
862            worn: std::collections::BTreeMap::new(),
863            carry_mass: 0.0,
864            carry_mass_max: 0.0,
865            encumbrance: flatland_protocol::EncumbranceState::Light,
866            inventory_stacks: Vec::new(),
867            keychain_stacks: Vec::new(),
868            combat_target_detail: None,
869            cast_progress: None,
870            ability_cooldowns: vec![],
871            blocking_active: false,
872            max_target_slots: 1,
873            combat_slots: vec![],
874            rotation_presets: vec![],
875            show_loadout_menu: false,
876            show_keychain_menu: false,
877            keychain_menu_index: 0,
878            show_rotation_editor: false,
879            loadout_menu_index: 0,
880            rotation_editor: Default::default(),
881            harvest_in_progress: false,
882            harvest_started_at: None,
883            pending_craft_ack: None,
884            quest_log: Vec::new(),
885            interactables: Vec::new(),
886            show_quest_offer: false,
887            pending_quest_offer: None,
888            show_quest_menu: false,
889            quest_menu_index: 0,
890            quest_withdraw_confirm: false,
891            progression_curve: None,
892        }
893    }
894
895    #[test]
896    fn path_on_open_field() {
897        let state = empty_state();
898        let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
899        assert!(!path.is_empty());
900        let last = *path.last().unwrap();
901        assert!((last.0 - 20.5).abs() < 1.0);
902        assert!((last.1 - 15.5).abs() < 1.0);
903    }
904
905    #[test]
906    fn path_routes_around_blocking_tree() {
907        let mut state = empty_state();
908        state
909            .resource_nodes
910            .push(flatland_protocol::ResourceNodeView {
911                id: "oak".into(),
912                label: "Oak".into(),
913                x: 15.0,
914                y: 12.0,
915                z: 0.0,
916                item_template: "oak_log".into(),
917                state: ResourceNodeState::Available,
918                blocking: true,
919                blocking_radius_m: 0.8,
920                tile_id: None,
921                sprite_mode: None,
922                presentation_state: None,
923            });
924        let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
925        for (x, y) in &path {
926            let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
927            assert!(!near_tree, "path should not cut through tree at ({x},{y})");
928        }
929    }
930
931    #[test]
932    fn path_segments_avoid_tree_corner_cut() {
933        let mut state = empty_state();
934        state
935            .resource_nodes
936            .push(flatland_protocol::ResourceNodeView {
937                id: "oak".into(),
938                label: "Oak".into(),
939                x: 15.0,
940                y: 12.0,
941                z: 0.0,
942                item_template: "oak_log".into(),
943                state: ResourceNodeState::Available,
944                blocking: true,
945                blocking_radius_m: 0.8,
946                tile_id: None,
947                sprite_mode: None,
948                presentation_state: None,
949            });
950        let path = find_path(&state, 10.0, 11.0, 0.0, 20.0, 13.0, 0.0).expect("diagonal path");
951        let obstacles = NavObstacles::from_state(&state);
952        let grid = build_grid(&state, &obstacles, 0.0, 0.0);
953        assert!(
954            path_segments_clear(&grid, &obstacles, &path),
955            "every leg must clear tree collision: {path:?}"
956        );
957    }
958
959    #[test]
960    fn path_routes_around_building_with_clearance() {
961        let mut state = empty_state();
962        // 6x6 building centered at (20, 20) — blocks [17,23] x [17,23] before pad.
963        state.buildings.push(flatland_protocol::BuildingView {
964            id: "hut".into(),
965            label: "Hut".into(),
966            x: 20.0,
967            y: 20.0,
968            width_m: 6.0,
969            depth_m: 6.0,
970            interior_blueprint: None,
971            tags: vec![],
972        });
973        let path =
974            find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
975        assert!(!path.is_empty());
976        let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
977        let x0 = 20.0 - 3.0 - pad;
978        let x1 = 20.0 + 3.0 + pad;
979        let y0 = 20.0 - 3.0 - pad;
980        let y1 = 20.0 + 3.0 + pad;
981        for (x, y) in &path {
982            let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
983            assert!(
984                !inside,
985                "path waypoint ({x},{y}) intersects inflated building footprint"
986            );
987        }
988        let last = *path.last().unwrap();
989        assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
990    }
991
992    #[test]
993    fn auto_navigator_finishes_near_goal() {
994        let state = empty_state();
995        let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
996        let (fx, fy, fz) = state.player_position_with_z();
997        let steer = nav.steer(fx, fy, fz, &state).expect("steer");
998        assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
999    }
1000}