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