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, 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
14const ASTAR_EXPANSIONS_FLOOR: u32 = 800;
22const ASTAR_EXPANSIONS_CEILING: u32 = 4_000;
23const 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
36const 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 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 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 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 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 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 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
293pub 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 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 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(¤t) 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
409fn 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 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
524pub 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 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 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 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 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 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 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 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 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 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(&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}