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