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 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 let mut cost = vec![DEFAULT_COST; len];
107 let mut kind_at = vec![flatland_protocol::TerrainKindView::Grass; len];
108 let mut elev = vec![0.0f32; len];
111 let min_cost = min_walkable_cost(mode, &world.terrain_zones, &world.kind_nav);
112
113 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 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 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 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 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 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(¤t) 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
347fn 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 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
462pub 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 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 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 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 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 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 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 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 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 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 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}