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