Skip to main content

flatland_pathfinding/
path.rs

1//! A* grid pathfinding.
2
3use std::cmp::Ordering;
4use std::collections::{BinaryHeap, HashMap};
5
6use crate::grid::{
7    block_circle, cell_center, clear_door_cells, collides_player_at, mark_building_footprint,
8    min_walkable_cost, terrain_cost, terrain_is_impassable, world_to_cell, NavWorld,
9    DEFAULT_COST, PATH_CLEARANCE_M, PLAYER_RADIUS_M, SEGMENT_SAMPLE_M,
10};
11use crate::mode::PathMode;
12use crate::z_nav::{cell_walkable_for_path_with_ground, goal_z_for};
13
14/// Hard cap on A* node expansions. Unreachable goals used to flood a full 256×256
15/// map (~65k cells × world collision samples) and stall the region tick for ~1s.
16const MAX_ASTAR_EXPANSIONS: u32 = 8_000;
17
18/// When start and goal are both on Road/Trail, multiply off-path cell costs so
19/// Fastest prefers the corridor (true ETA alone still cuts grassy corners).
20const PATH_CORRIDOR_OFFROAD_MULT: u32 = 3;
21
22fn is_path_kind(kind: flatland_protocol::TerrainKindView) -> bool {
23    matches!(
24        kind,
25        flatland_protocol::TerrainKindView::Road | flatland_protocol::TerrainKindView::Trail
26    )
27}
28
29fn idx_of(x: i16, y: i16, width: i16) -> usize {
30    (y as usize) * (width as usize) + (x as usize)
31}
32
33fn grid_in_bounds(x: i16, y: i16, width: i16, height: i16) -> bool {
34    x >= 0 && y >= 0 && x < width && y < height
35}
36
37#[derive(Clone, Copy, Eq, PartialEq)]
38struct OpenNode {
39    f: u32,
40    g: u32,
41    x: i16,
42    y: i16,
43}
44
45impl Ord for OpenNode {
46    fn cmp(&self, other: &Self) -> Ordering {
47        other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
48    }
49}
50
51impl PartialOrd for OpenNode {
52    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
53        Some(self.cmp(other))
54    }
55}
56
57struct NavGrid {
58    width: i16,
59    height: i16,
60    blocked: Vec<bool>,
61    cost: Vec<u16>,
62    /// Minimum walkable cell cost — scales the octile heuristic.
63    min_cost: u16,
64}
65
66impl NavGrid {
67    fn idx(&self, x: i16, y: i16) -> usize {
68        (y as usize) * (self.width as usize) + (x as usize)
69    }
70
71    fn in_bounds(&self, x: i16, y: i16) -> bool {
72        x >= 0 && y >= 0 && x < self.width && y < self.height
73    }
74
75    fn is_walkable(&self, x: i16, y: i16) -> bool {
76        self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
77    }
78
79    fn move_cost(&self, x: i16, y: i16) -> u32 {
80        self.cost[self.idx(x, y)] as u32
81    }
82
83    fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
84        if self.in_bounds(x, y) {
85            let idx = self.idx(x, y);
86            self.blocked[idx] = blocked;
87        }
88    }
89}
90
91fn paint_static_nav(world: &NavWorld) -> crate::grid::StaticNavPaint {
92    use crate::grid::StaticNavPaint;
93
94    let width = world.world_width_m.max(1.0).ceil() as i16;
95    let height = world.world_height_m.max(1.0).ceil() as i16;
96    let len = (width as usize) * (height as usize);
97    let mut kind_at = vec![flatland_protocol::TerrainKindView::Grass; len];
98    let mut elev = vec![0.0f32; len];
99    let mut blocked_geometry = vec![false; len];
100
101    // Paint low z_order first so higher z_order wins — matches sim `terrain_zone_at`.
102    let mut zone_order: Vec<usize> = (0..world.terrain_zones.len()).collect();
103    zone_order.sort_by(|&ia, &ib| {
104        let a = &world.terrain_zones[ia];
105        let b = &world.terrain_zones[ib];
106        a.z_order.cmp(&b.z_order).then(ia.cmp(&ib))
107    });
108    for &zi in &zone_order {
109        let zone = &world.terrain_zones[zi];
110        let impassable = terrain_is_impassable(zone.kind, &world.kind_nav);
111        // Always paint non-grass kinds so empty/default tables still overwrite
112        // underlying bog/water when z_order says the road wins.
113        let paint_kind = impassable
114            || !matches!(zone.kind, flatland_protocol::TerrainKindView::Grass)
115            || zone.elevation != 0.0;
116        if !paint_kind {
117            continue;
118        }
119        let x0 = zone.x0.floor().max(0.0) as i16;
120        let y0 = zone.y0.floor().max(0.0) as i16;
121        let x1 = zone.x1.ceil().min(width as f32) as i16;
122        let y1 = zone.y1.ceil().min(height as f32) as i16;
123        for y in y0..y1 {
124            if y < 0 || y >= height {
125                continue;
126            }
127            for x in x0..x1 {
128                if x < 0 || x >= width {
129                    continue;
130                }
131                let cx = x as f32 + 0.5;
132                let cy = y as f32 + 0.5;
133                if cx < zone.x0 || cx >= zone.x1 || cy < zone.y0 || cy >= zone.y1 {
134                    continue;
135                }
136                let idx = (y as usize) * (width as usize) + (x as usize);
137                kind_at[idx] = zone.kind;
138                blocked_geometry[idx] = impassable;
139                if zone.elevation != 0.0 {
140                    elev[idx] = zone.elevation;
141                }
142            }
143        }
144    }
145
146    for building in &world.buildings {
147        mark_building_footprint(&mut blocked_geometry, width, height, building);
148    }
149    clear_door_cells(&mut blocked_geometry, width, height, &world.doors);
150
151    StaticNavPaint {
152        width,
153        height,
154        kind_at,
155        elev,
156        blocked_geometry,
157    }
158}
159
160fn build_grid(
161    world: &NavWorld,
162    player_z: f32,
163    goal_z: f32,
164    mode: PathMode,
165    from_x: f32,
166    from_y: f32,
167    to_x: f32,
168    to_y: f32,
169) -> NavGrid {
170    let t_paint = std::time::Instant::now();
171    let paint = world.ensure_static_paint(|| paint_static_nav(world));
172    let paint_ms = t_paint.elapsed().as_secs_f32() * 1000.0;
173    // Cold paint on dense maps (thousands of zones) can take tens of ms once;
174    // subsequent finds reuse the Arc cache and stay cheap.
175    if paint_ms > 30.0 {
176        eprintln!(
177            "[diag] static_nav_paint {paint_ms:.1}ms zones={} (world {:.0}x{:.0}) — cached for later finds",
178            world.terrain_zones.len(),
179            world.world_width_m,
180            world.world_height_m,
181        );
182    }
183
184    let width = paint.width;
185    let height = paint.height;
186    let min_cost = min_walkable_cost(mode, &world.terrain_zones, &world.kind_nav);
187
188    let mut cost: Vec<u16> = paint
189        .kind_at
190        .iter()
191        .map(|kind| terrain_cost(*kind, mode, &world.kind_nav))
192        .collect();
193    let mut blocked = paint.blocked_geometry.clone();
194    for (b, c) in blocked.iter_mut().zip(cost.iter()) {
195        if *c == u16::MAX {
196            *b = true;
197        }
198    }
199
200    // When both endpoints sit on Road/Trail, inflate off-path costs so Fastest
201    // stays on the corridor instead of cutting slow corners through grass/bog.
202    if mode == PathMode::Fastest {
203        let (sx, sy) = world_to_cell(from_x, from_y);
204        let (gx, gy) = world_to_cell(to_x, to_y);
205        let start_path = grid_in_bounds(sx, sy, width, height)
206            && is_path_kind(paint.kind_at[idx_of(sx, sy, width)]);
207        let goal_path = grid_in_bounds(gx, gy, width, height)
208            && is_path_kind(paint.kind_at[idx_of(gx, gy, width)]);
209        if start_path && goal_path {
210            for ((cell_cost, kind), is_blocked) in cost
211                .iter_mut()
212                .zip(paint.kind_at.iter())
213                .zip(blocked.iter())
214            {
215                if *is_blocked || is_path_kind(*kind) {
216                    continue;
217                }
218                let boosted = (*cell_cost as u32).saturating_mul(PATH_CORRIDOR_OFFROAD_MULT);
219                *cell_cost = boosted.min((u16::MAX - 1) as u32) as u16;
220            }
221        }
222    }
223
224    let mut grid = NavGrid {
225        width,
226        height,
227        blocked,
228        cost,
229        min_cost,
230    };
231
232    for circle in &world.circles {
233        block_circle(
234            &mut grid.blocked,
235            grid.width,
236            grid.height,
237            circle.x,
238            circle.y,
239            circle.radius_m,
240        );
241    }
242
243    // Z-band walkability is O(width*height × platforms); skip when no layers.
244    // Must use painted `elev` — calling elevation_at per cell re-scans all zones
245    // and stalls ~1s on large outdoor maps once any interior z_platform is set.
246    if !world.z_platforms.is_empty() || !world.z_transitions.is_empty() {
247        for y in 0..height {
248            for x in 0..width {
249                let cx = x as f32 + 0.5;
250                let cy = y as f32 + 0.5;
251                let idx = (y as usize) * (width as usize) + (x as usize);
252                if !cell_walkable_for_path_with_ground(
253                    world,
254                    cx,
255                    cy,
256                    player_z,
257                    goal_z,
258                    paint.elev[idx],
259                ) {
260                    grid.set_blocked(x, y, true);
261                }
262            }
263        }
264    }
265
266    grid
267}
268
269/// Paint the static terrain/building raster now so the first gameplay `find_path`
270/// does not stall a live tick (~40–120 ms on dense outdoor maps).
271pub fn prewarm_static_paint(world: &NavWorld) {
272    let _ = world.ensure_static_paint(|| paint_static_nav(world));
273}
274
275fn heuristic(ax: i16, ay: i16, bx: i16, by: i16, min_cost: u16) -> u32 {
276    let dx = (ax - bx).unsigned_abs() as u32;
277    let dy = (ay - by).unsigned_abs() as u32;
278    let diag = dx.min(dy);
279    let straight = dx.max(dy) - diag;
280    // Octile distance assumes DEFAULT_COST=10 orthogonal / 14 diagonal; scale by min walkable.
281    let octile = diag * 14 + straight * 10;
282    octile * (min_cost as u32) / (DEFAULT_COST as u32)
283}
284
285fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
286    let (mut x0, mut y0) = from;
287    let (x1, y1) = to;
288    let max_end_cost = grid
289        .move_cost(from.0, from.1)
290        .max(grid.move_cost(to.0, to.1));
291    let dx = (x1 - x0).abs();
292    let dy = (y1 - y0).abs();
293    let sx = if x0 < x1 { 1 } else { -1 };
294    let sy = if y0 < y1 { 1 } else { -1 };
295    let mut err = dx - dy;
296    loop {
297        if !grid.is_walkable(x0, y0) {
298            return false;
299        }
300        // Do not collapse Fastest detours through slower terrain (e.g. road → bog shortcut).
301        if grid.move_cost(x0, y0) > max_end_cost {
302            return false;
303        }
304        if x0 == x1 && y0 == y1 {
305            break;
306        }
307        let e2 = err * 2;
308        if e2 > -dy {
309            err -= dy;
310            x0 += sx;
311        }
312        if e2 < dx {
313            err += dx;
314            y0 += sy;
315        }
316    }
317    true
318}
319
320fn simplify_path(
321    grid: &NavGrid,
322    world: &NavWorld,
323    came_from: &HashMap<(i16, i16), (i16, i16)>,
324    start: (i16, i16),
325    goal: (i16, i16),
326    goal_center: (f32, f32),
327) -> Vec<(f32, f32)> {
328    let mut cells = vec![goal];
329    let mut current = goal;
330    while current != start {
331        let Some(&prev) = came_from.get(&current) else {
332            break;
333        };
334        cells.push(prev);
335        current = prev;
336    }
337    cells.reverse();
338
339    if cells.is_empty() {
340        return vec![goal_center];
341    }
342
343    let mut waypoints: Vec<(i16, i16)> = Vec::new();
344    let mut anchor = 0usize;
345    waypoints.push(cells[0]);
346    for i in 1..cells.len() {
347        if i + 1 < cells.len() {
348            let from = cell_center(cells[anchor].0, cells[anchor].1);
349            let to = if cells[i + 1] == goal {
350                goal_center
351            } else {
352                cell_center(cells[i + 1].0, cells[i + 1].1)
353            };
354            if line_clear(grid, cells[anchor], cells[i + 1])
355                && segment_clear_world(grid, world, from, to)
356            {
357                continue;
358            }
359        }
360        waypoints.push(cells[i]);
361        anchor = i;
362    }
363
364    let mut out: Vec<(f32, f32)> = waypoints.iter().map(|&(x, y)| cell_center(x, y)).collect();
365    if let Some(last) = out.last_mut() {
366        *last = goal_center;
367    }
368
369    if path_segments_clear(grid, world, &out) {
370        return out;
371    }
372
373    let mut fallback: Vec<(f32, f32)> = cells.iter().map(|&(x, y)| cell_center(x, y)).collect();
374    if let Some(last) = fallback.last_mut() {
375        *last = goal_center;
376    }
377    fallback
378}
379
380fn path_segments_clear(grid: &NavGrid, world: &NavWorld, path: &[(f32, f32)]) -> bool {
381    path.windows(2)
382        .all(|w| segment_clear_world(grid, world, w[0], w[1]))
383}
384
385/// Set `FLATLAND_NAV_DEBUG=1` to print why a corridor may be sealed (kinds/costs/blocked).
386fn eprintln_nav_corridor_debug(
387    world: &NavWorld,
388    grid: &NavGrid,
389    mode: PathMode,
390    from_x: f32,
391    from_y: f32,
392    to_x: f32,
393    to_y: f32,
394    sx: i16,
395    sy: i16,
396    gx: i16,
397    gy: i16,
398) {
399    let sk = world.terrain_kind_at(from_x, from_y);
400    let gk = world.terrain_kind_at(to_x, to_y);
401    eprintln!(
402        "[nav-debug] mode={mode:?} start=({from_x:.1},{from_y:.1})→cell({sx},{sy}) kind={sk:?} cost={} walk={} | goal=({to_x:.1},{to_y:.1})→cell({gx},{gy}) kind={gk:?} cost={} walk={} | buildings={} circles={} kind_nav_rows={}",
403        if grid.in_bounds(sx, sy) {
404            grid.move_cost(sx, sy)
405        } else {
406            0
407        },
408        grid.in_bounds(sx, sy) && grid.is_walkable(sx, sy),
409        if grid.in_bounds(gx, gy) {
410            grid.move_cost(gx, gy)
411        } else {
412            0
413        },
414        grid.in_bounds(gx, gy) && grid.is_walkable(gx, gy),
415        world.buildings.len(),
416        world.circles.len(),
417        world.kind_nav.iter().count(),
418    );
419    // Sample the axis-aligned corridor (straight cell line) for sealed pavement.
420    let mut x0 = sx;
421    let mut y0 = sy;
422    let x1 = gx;
423    let y1 = gy;
424    let dx = (x1 - x0).abs();
425    let dy = (y1 - y0).abs();
426    let sx_step: i16 = if x0 < x1 { 1 } else { -1 };
427    let sy_step: i16 = if y0 < y1 { 1 } else { -1 };
428    let mut err = dx - dy;
429    let mut blocked_n = 0u32;
430    let mut samples = 0u32;
431    loop {
432        samples += 1;
433        if grid.in_bounds(x0, y0) {
434            let walk = grid.is_walkable(x0, y0);
435            let cost = grid.move_cost(x0, y0);
436            let (cx, cy) = cell_center(x0, y0);
437            let kind = world.terrain_kind_at(cx, cy);
438            if !walk {
439                blocked_n += 1;
440                if blocked_n <= 12 {
441                    let hit_b = world.buildings.iter().find(|b| {
442                        let pad = PLAYER_RADIUS_M;
443                        let hw = b.width_m / 2.0 + pad;
444                        let hd = b.depth_m / 2.0 + pad;
445                        cx >= b.x - hw && cx <= b.x + hw && cy >= b.y - hd && cy <= b.y + hd
446                    });
447                    let hit_c = world.circles.iter().find(|o| {
448                        let r = o.radius_m + PLAYER_RADIUS_M + PATH_CLEARANCE_M;
449                        (cx - o.x).hypot(cy - o.y) <= r
450                    });
451                    eprintln!(
452                        "[nav-debug]   BLOCKED cell({x0},{y0}) kind={kind:?} cost={cost} building={} circle={}",
453                        hit_b.map(|b| b.id.as_str()).unwrap_or("-"),
454                        hit_c
455                            .map(|c| format!("({:.1},{:.1})", c.x, c.y))
456                            .unwrap_or_else(|| "-".into()),
457                    );
458                }
459            }
460        }
461        if x0 == x1 && y0 == y1 {
462            break;
463        }
464        let e2 = err * 2;
465        if e2 > -dy {
466            err -= dy;
467            x0 += sx_step;
468        }
469        if e2 < dx {
470            err += dx;
471            y0 += sy_step;
472        }
473        if samples > 512 {
474            break;
475        }
476    }
477    eprintln!(
478        "[nav-debug] straight-line samples={samples} blocked={blocked_n} (if blocked>0 A* cannot stay on the line)"
479    );
480}
481
482fn segment_clear_world(grid: &NavGrid, world: &NavWorld, from: (f32, f32), to: (f32, f32)) -> bool {
483    let (fx, fy) = from;
484    let (tx, ty) = to;
485    let dist = (tx - fx).hypot(ty - fy);
486    let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
487    for step in 0..=steps {
488        let t = step as f32 / steps as f32;
489        let x = fx + (tx - fx) * t;
490        let y = fy + (ty - fy) * t;
491        if collides_player_at(x, y, world) {
492            return false;
493        }
494    }
495    let (cx0, cy0) = world_to_cell(fx, fy);
496    let (cx1, cy1) = world_to_cell(tx, ty);
497    line_clear(grid, (cx0, cy0), (cx1, cy1))
498}
499
500/// Plan a path from `(from_x, from_y, from_z)` to `(to_x, to_y)` using goal z inferred from `from_z`.
501pub fn find_path(
502    world: &NavWorld,
503    from_x: f32,
504    from_y: f32,
505    from_z: f32,
506    to_x: f32,
507    to_y: f32,
508    mode: PathMode,
509) -> Option<Vec<(f32, f32)>> {
510    let to_z = goal_z_for(world, to_x, to_y, from_z);
511    find_path_with_goal_z(world, from_x, from_y, from_z, to_x, to_y, to_z, mode)
512}
513
514pub fn find_path_with_goal_z(
515    world: &NavWorld,
516    from_x: f32,
517    from_y: f32,
518    from_z: f32,
519    to_x: f32,
520    to_y: f32,
521    to_z: f32,
522    mode: PathMode,
523) -> Option<Vec<(f32, f32)>> {
524    let grid = build_grid(world, from_z, to_z, mode, from_x, from_y, to_x, to_y);
525    let (sx, sy) = world_to_cell(from_x, from_y);
526    let (gx, gy) = world_to_cell(to_x, to_y);
527
528    let nav_debug = std::env::var_os("FLATLAND_NAV_DEBUG").is_some();
529    if nav_debug {
530        eprintln_nav_corridor_debug(world, &grid, mode, from_x, from_y, to_x, to_y, sx, sy, gx, gy);
531    }
532
533    if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
534        return None;
535    }
536
537    let mut goal_x = gx;
538    let mut goal_y = gy;
539    if !grid.is_walkable(goal_x, goal_y) {
540        let mut found = None;
541        'search: for radius in 1..=16i16 {
542            for dy in -radius..=radius {
543                for dx in -radius..=radius {
544                    if dx.abs() != radius && dy.abs() != radius {
545                        continue;
546                    }
547                    let x = gx + dx;
548                    let y = gy + dy;
549                    if grid.is_walkable(x, y) {
550                        found = Some((x, y));
551                        break 'search;
552                    }
553                }
554            }
555        }
556        let (x, y) = found?;
557        goal_x = x;
558        goal_y = y;
559    }
560
561    let goal_key = (goal_x, goal_y);
562
563    let mut start_x = sx;
564    let mut start_y = sy;
565    if !grid.is_walkable(start_x, start_y) {
566        let mut found = None;
567        'start: for radius in 1..=16i16 {
568            for dy in -radius..=radius {
569                for dx in -radius..=radius {
570                    if dx.abs() != radius && dy.abs() != radius {
571                        continue;
572                    }
573                    let x = sx + dx;
574                    let y = sy + dy;
575                    if grid.is_walkable(x, y) {
576                        found = Some((x, y));
577                        break 'start;
578                    }
579                }
580            }
581        }
582        let (x, y) = found?;
583        start_x = x;
584        start_y = y;
585    }
586    let start_key = (start_x, start_y);
587
588    if start_key == goal_key {
589        return Some(vec![cell_center(goal_x, goal_y)]);
590    }
591
592    let mut open = BinaryHeap::new();
593    let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
594    let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
595    let h_scale = grid.min_cost;
596
597    g_score.insert(start_key, 0);
598    open.push(OpenNode {
599        f: heuristic(start_x, start_y, goal_x, goal_y, h_scale),
600        g: 0,
601        x: start_x,
602        y: start_y,
603    });
604
605    const NEIGHBORS: [(i16, i16, u32); 8] = [
606        (1, 0, 10),
607        (-1, 0, 10),
608        (0, 1, 10),
609        (0, -1, 10),
610        (1, 1, 14),
611        (1, -1, 14),
612        (-1, 1, 14),
613        (-1, -1, 14),
614    ];
615
616    let mut expansions = 0u32;
617    while let Some(current) = open.pop() {
618        if (current.x, current.y) == goal_key {
619            let path = simplify_path(
620                &grid,
621                world,
622                &came_from,
623                start_key,
624                goal_key,
625                cell_center(goal_x, goal_y),
626            );
627            if std::env::var_os("FLATLAND_NAV_DEBUG").is_some() {
628                let max_dev = path
629                    .iter()
630                    .map(|(x, y)| {
631                        // Distance from the start→goal chord.
632                        let (ax, ay) = (from_x, from_y);
633                        let (bx, by) = (to_x, to_y);
634                        let abx = bx - ax;
635                        let aby = by - ay;
636                        let ab2 = abx * abx + aby * aby;
637                        if ab2 < 1e-6 {
638                            return 0.0;
639                        }
640                        let t = ((x - ax) * abx + (y - ay) * aby) / ab2;
641                        let t = t.clamp(0.0, 1.0);
642                        let px = ax + abx * t;
643                        let py = ay + aby * t;
644                        (x - px).hypot(y - py)
645                    })
646                    .fold(0.0f32, f32::max);
647                eprintln!(
648                    "[nav-debug] path waypoints={} max_dev_from_chord={max_dev:.2}m first={:?} last={:?}",
649                    path.len(),
650                    path.first(),
651                    path.last()
652                );
653            }
654            return Some(path);
655        }
656        let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
657            continue;
658        };
659        if current.g > best_g {
660            continue;
661        }
662        expansions = expansions.saturating_add(1);
663        if expansions > MAX_ASTAR_EXPANSIONS {
664            return None;
665        }
666
667        for (dx, dy, step_base) in NEIGHBORS {
668            let nx = current.x + dx;
669            let ny = current.y + dy;
670            if !grid.is_walkable(nx, ny) {
671                continue;
672            }
673            if dx != 0 && dy != 0 {
674                if !grid.is_walkable(current.x + dx, current.y)
675                    || !grid.is_walkable(current.x, current.y + dy)
676                {
677                    continue;
678                }
679            }
680            // Obstacles are already stamped into `blocked` (circles + buildings).
681            // Per-edge world sampling here used to dominate failed long-range searches.
682            let step = step_base * grid.move_cost(nx, ny) / (DEFAULT_COST as u32);
683            let tentative = best_g + step;
684            let key = (nx, ny);
685            if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
686                continue;
687            }
688            came_from.insert(key, (current.x, current.y));
689            g_score.insert(key, tentative);
690            open.push(OpenNode {
691                f: tentative + heuristic(nx, ny, goal_x, goal_y, h_scale),
692                g: tentative,
693                x: nx,
694                y: ny,
695            });
696        }
697    }
698
699    None
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use crate::grid::collides_player_at;
706
707    fn open_world() -> NavWorld {
708        NavWorld::new(
709            64.0,
710            64.0,
711            vec![],
712            vec![],
713            vec![],
714            vec![],
715            vec![],
716            vec![],
717            crate::grid::TerrainNavTable::unit_test_defaults(),
718        )
719    }
720
721    #[test]
722    fn path_on_open_field() {
723        let world = open_world();
724        let path = find_path(&world, 10.0, 10.0, 0.0, 20.0, 15.0, PathMode::Fastest).expect("path");
725        assert!(!path.is_empty());
726        let last = *path.last().unwrap();
727        assert!((last.0 - 20.5).abs() < 1.0);
728        assert!((last.1 - 15.5).abs() < 1.0);
729    }
730
731    #[test]
732    fn path_routes_around_building_footprint() {
733        let mut world = open_world();
734        world.buildings.push(flatland_protocol::BuildingView {
735            id: "storage".into(),
736            label: "Storage".into(),
737            x: 15.0,
738            y: 12.0,
739            width_m: 6.0,
740            depth_m: 4.0,
741            interior_blueprint: None,
742            tags: vec![],
743            market_boundary_zone_ids: vec![],
744            market_max_volume: None,
745            wall_set: None,
746            roof_set: None,
747        });
748        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0, PathMode::Fastest).expect("path around building");
749        for (x, y) in &path {
750            assert!(
751                !collides_player_at(*x, *y, &world),
752                "path must not cut through building at ({x},{y})"
753            );
754        }
755    }
756
757    #[test]
758    fn path_routes_around_blocking_tree() {
759        let mut world = open_world();
760        world.circles.push(crate::grid::NavBlockingCircle {
761            x: 15.0,
762            y: 12.0,
763            radius_m: 0.8,
764        });
765        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0, PathMode::Fastest).expect("path around tree");
766        for (x, y) in &path {
767            let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
768            assert!(!near_tree, "path should not cut through tree at ({x},{y})");
769        }
770    }
771
772    #[test]
773    fn unreachable_goal_fails_fast_on_large_map() {
774        use std::time::Instant;
775        let mut world = NavWorld::new(
776        256.0,
777        256.0,
778        vec![],
779        vec![],
780        vec![],
781        vec![],
782        vec![],
783        vec![],
784        crate::grid::TerrainNavTable::unit_test_defaults(),
785    );
786        // Full-height deep-water barrier splits the map so A* would flood otherwise.
787        world.terrain_zones.push(flatland_protocol::TerrainZoneView {
788            id: "moat".into(),
789            x0: 120.0,
790            y0: 0.0,
791            x1: 136.0,
792            y1: 256.0,
793            kind: flatland_protocol::TerrainKindView::DeepWater,
794            elevation: 0.0,
795            glyph: None,
796            color: None,
797            tile_id: None,
798            z_order: 0,
799            channel_start_tick: None,
800            channel_end_tick: None,
801        });
802        let start = Instant::now();
803        let path = find_path(&world, 40.0, 40.0, 0.0, 200.0, 200.0, PathMode::Fastest);
804        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
805        assert!(path.is_none(), "moat should make goal unreachable");
806        assert!(
807            elapsed_ms < 150.0,
808            "unreachable search must stay under 150ms (debug), took {elapsed_ms:.1}ms"
809        );
810    }
811
812    #[test]
813    fn default_cost_overlay_flood_stays_fast() {
814        use std::time::Instant;
815        let mut world = NavWorld::new(
816        256.0,
817        256.0,
818        vec![],
819        vec![],
820        vec![],
821        vec![],
822        vec![],
823        vec![],
824        crate::grid::TerrainNavTable::unit_test_defaults(),
825    );
826        // Mimic an interior entry flooding `terrain_zones` with cheap (Grass-cost)
827        // overlay rects. Painting these is a no-op for nav, so build_grid must skip
828        // them instead of costing O(zone cells) per zone (~1s stall).
829        for i in 0..600i16 {
830            world.terrain_zones.push(flatland_protocol::TerrainZoneView {
831                id: format!("rt:{i}").into(),
832                x0: 0.0,
833                y0: 0.0,
834                x1: 256.0,
835                y1: 256.0,
836                kind: flatland_protocol::TerrainKindView::Grass,
837                elevation: 0.0,
838                glyph: None,
839                color: None,
840                tile_id: None,
841                z_order: 0,
842                channel_start_tick: None,
843                channel_end_tick: None,
844            });
845        }
846        let start = Instant::now();
847        let path = find_path(&world, 40.0, 40.0, 0.0, 200.0, 200.0, PathMode::Fastest);
848        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
849        assert!(path.is_some(), "open field must remain reachable");
850        assert!(
851            elapsed_ms < 150.0,
852            "default-cost overlay flood must stay under 150ms (debug), took {elapsed_ms:.1}ms"
853        );
854    }
855
856    #[test]
857    fn z_platform_with_many_zones_stays_fast() {
858        use std::time::Instant;
859        // Reproduce the building-enter stall: outdoor-sized map + many terrain zones +
860        // a single interior z_platform. Per-cell elevation_at would be O(cells × zones)
861        // (~1s); painted elev must keep this fast.
862        let mut world = NavWorld::new(
863        512.0,
864        256.0,
865        vec![],
866        vec![flatland_protocol::ZPlatformView {
867                id: "floor_0".into(),
868                z: 0.0,
869                x0: 0.0,
870                y0: 0.0,
871                x1: 16.0,
872                y1: 16.0,
873            }],
874        vec![],
875        vec![],
876        vec![],
877        vec![],
878        crate::grid::TerrainNavTable::unit_test_defaults(),
879    );
880        for i in 0..400i16 {
881            world.terrain_zones.push(flatland_protocol::TerrainZoneView {
882                id: format!("zone:{i}").into(),
883                x0: (i % 32) as f32 * 8.0,
884                y0: (i / 32) as f32 * 8.0,
885                x1: (i % 32) as f32 * 8.0 + 8.0,
886                y1: (i / 32) as f32 * 8.0 + 8.0,
887                kind: flatland_protocol::TerrainKindView::Dirt,
888                elevation: 0.0,
889                glyph: None,
890                color: None,
891                tile_id: None,
892                z_order: 0,
893                channel_start_tick: None,
894                channel_end_tick: None,
895            });
896        }
897        let start = Instant::now();
898        let path = find_path(&world, 4.0, 4.0, 0.0, 12.0, 12.0, PathMode::Fastest);
899        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
900        assert!(path.is_some(), "interior cells must remain reachable");
901        assert!(
902            elapsed_ms < 200.0,
903            "z_platform + many zones must stay under 200ms (debug), took {elapsed_ms:.1}ms"
904        );
905    }
906
907    fn zone(
908        id: &str,
909        x0: f32,
910        y0: f32,
911        x1: f32,
912        y1: f32,
913        kind: flatland_protocol::TerrainKindView,
914    ) -> flatland_protocol::TerrainZoneView {
915        flatland_protocol::TerrainZoneView {
916            id: id.into(),
917            x0,
918            y0,
919            x1,
920            y1,
921            kind,
922            elevation: 0.0,
923            glyph: None,
924            color: None,
925            tile_id: None,
926            z_order: 0,
927            channel_start_tick: None,
928            channel_end_tick: None,
929        }
930    }
931
932    #[test]
933    fn fastest_prefers_road_detour_over_bog() {
934        // Long bog on the straight line; short northern road bypass — Fastest ETA prefers road.
935        let mut world = open_world();
936        world.terrain_zones.push(zone(
937            "bog",
938            14.0,
939            10.0,
940            50.0,
941            22.0,
942            flatland_protocol::TerrainKindView::Bog,
943        ));
944        world.terrain_zones.push(zone(
945            "road",
946            10.0,
947            24.0,
948            54.0,
949            28.0,
950            flatland_protocol::TerrainKindView::Road,
951        ));
952        let path = find_path(
953            &world,
954            12.0,
955            16.0,
956            0.0,
957            52.0,
958            16.0,
959            PathMode::Fastest,
960        )
961        .expect("fastest path");
962        let max_y = path.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max);
963        assert!(
964            max_y > 23.0,
965            "Fastest should climb onto the road (max_y={max_y}), path={path:?}"
966        );
967    }
968
969    #[test]
970    fn direct_crosses_bog_when_shorter() {
971        let mut world = open_world();
972        world.terrain_zones.push(zone(
973            "bog",
974            14.0,
975            10.0,
976            50.0,
977            22.0,
978            flatland_protocol::TerrainKindView::Bog,
979        ));
980        world.terrain_zones.push(zone(
981            "road",
982            10.0,
983            24.0,
984            54.0,
985            28.0,
986            flatland_protocol::TerrainKindView::Road,
987        ));
988        let path = find_path(
989            &world,
990            12.0,
991            16.0,
992            0.0,
993            52.0,
994            16.0,
995            PathMode::Direct,
996        )
997        .expect("direct path");
998        let max_y = path.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max);
999        assert!(
1000            max_y < 23.0,
1001            "Direct should stay near the straight line through bog (max_y={max_y})"
1002        );
1003    }
1004
1005    #[test]
1006    fn highest_z_order_wins_road_over_bog() {
1007        // Sim movement uses max z_order; pathfinding must match so roads aren't
1008        // treated as the older bog/water underneath.
1009        let mut world = open_world();
1010        world.terrain_zones.push(zone(
1011            "bog",
1012            10.0,
1013            10.0,
1014            54.0,
1015            22.0,
1016            flatland_protocol::TerrainKindView::Bog,
1017        ));
1018        world.terrain_zones.last_mut().unwrap().z_order = 0;
1019        world.terrain_zones.push(zone(
1020            "road",
1021            10.0,
1022            14.0,
1023            54.0,
1024            18.0,
1025            flatland_protocol::TerrainKindView::Road,
1026        ));
1027        world.terrain_zones.last_mut().unwrap().z_order = 10;
1028        let path = find_path(
1029            &world,
1030            12.0,
1031            16.0,
1032            0.0,
1033            52.0,
1034            16.0,
1035            PathMode::Fastest,
1036        )
1037        .expect("path");
1038        let max_dev = path
1039            .iter()
1040            .map(|p| (p.1 - 16.0).abs())
1041            .fold(0.0f32, f32::max);
1042        assert!(
1043            max_dev < 3.0,
1044            "path should stay on the road strip (max |y-16|={max_dev}), path={path:?}"
1045        );
1046    }
1047
1048    #[test]
1049    fn fastest_stays_on_road_when_endpoints_on_road() {
1050        // Narrow road with grass on both sides — geometric shortcut leaves the road;
1051        // corridor bias must keep Fastest on the pavement when both ends are on-road.
1052        let mut world = open_world();
1053        world.terrain_zones.push(zone(
1054            "road",
1055            10.0,
1056            15.0,
1057            54.0,
1058            17.0,
1059            flatland_protocol::TerrainKindView::Road,
1060        ));
1061        world.terrain_zones.last_mut().unwrap().z_order = 5;
1062        let path = find_path(
1063            &world,
1064            12.0,
1065            16.0,
1066            0.0,
1067            52.0,
1068            16.0,
1069            PathMode::Fastest,
1070        )
1071        .expect("path");
1072        let max_dev = path
1073            .iter()
1074            .map(|p| (p.1 - 16.0).abs())
1075            .fold(0.0f32, f32::max);
1076        assert!(
1077            max_dev < 2.5,
1078            "path should hug the road strip (max |y-16|={max_dev}), path={path:?}"
1079        );
1080    }
1081
1082    #[test]
1083    fn building_clearance_does_not_seal_adjacent_road() {
1084        // Repro: West Storage-style pad used to block road cells the player can walk
1085        // (collides_player_at uses PLAYER_RADIUS only), forcing a grass detour.
1086        let mut world = open_world();
1087        world.world_width_m = 200.0;
1088        world.world_height_m = 130.0;
1089        world.terrain_zones.push(zone(
1090            "road",
1091            159.0,
1092            106.0,
1093            176.0,
1094            107.0,
1095            flatland_protocol::TerrainKindView::Road,
1096        ));
1097        world.terrain_zones.last_mut().unwrap().z_order = 10;
1098        world.buildings.push(flatland_protocol::BuildingView {
1099            id: "town_storage_west".into(),
1100            label: "West Storage".into(),
1101            x: 164.0,
1102            y: 102.0,
1103            width_m: 8.0,
1104            depth_m: 6.5,
1105            interior_blueprint: None,
1106            tags: vec![],
1107            market_boundary_zone_ids: vec![],
1108            market_max_volume: None,
1109            wall_set: None,
1110            roof_set: None,
1111        });
1112        // Road cell centers under the old over-padded footprint must stay walkable.
1113        assert!(
1114            !collides_player_at(164.0, 106.5, &world),
1115            "runtime collision must allow the road beside the building"
1116        );
1117        let path = find_path(
1118            &world,
1119            172.2,
1120            106.5,
1121            0.0,
1122            159.5,
1123            106.5,
1124            PathMode::Fastest,
1125        )
1126        .expect("path along road");
1127        let max_dev = path
1128            .iter()
1129            .map(|p| (p.1 - 106.5).abs())
1130            .fold(0.0f32, f32::max);
1131        assert!(
1132            max_dev < 1.5,
1133            "must stay on the road, not detour south (max |y-106.5|={max_dev}), path={path:?}"
1134        );
1135    }
1136
1137    #[test]
1138    fn eta_costs_match_speed_table() {
1139        use crate::grid::{terrain_cost, terrain_move_speed_mult, TerrainNavTable};
1140        let table = TerrainNavTable::unit_test_defaults();
1141        let road_speed = terrain_move_speed_mult(flatland_protocol::TerrainKindView::Road, &table);
1142        let expected = (DEFAULT_COST as f32 / road_speed).round() as u16;
1143        assert_eq!(
1144            terrain_cost(
1145                flatland_protocol::TerrainKindView::Road,
1146                PathMode::Fastest,
1147                &table
1148            ),
1149            expected
1150        );
1151        assert_eq!(
1152            terrain_cost(
1153                flatland_protocol::TerrainKindView::Bog,
1154                PathMode::Direct,
1155                &table
1156            ),
1157            DEFAULT_COST
1158        );
1159        assert_eq!(
1160            terrain_cost(
1161                flatland_protocol::TerrainKindView::DeepWater,
1162                PathMode::Direct,
1163                &table
1164            ),
1165            u16::MAX
1166        );
1167    }
1168}