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