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