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