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