1use std::cmp::Ordering;
4use std::collections::{BinaryHeap, HashMap};
5
6use flatland_protocol::{
7 BuildingView, DoorView, LifeState, ResourceNodeState, TerrainKindView, TerrainZoneView,
8};
9
10use crate::game::GameState;
11
12#[derive(Debug, Clone, Copy)]
13struct BlockingCircle {
14 x: f32,
15 y: f32,
16 radius_m: f32,
17}
18
19#[derive(Debug, Clone)]
20struct NavObstacles {
21 circles: Vec<BlockingCircle>,
22 buildings: Vec<BuildingView>,
23}
24
25impl NavObstacles {
26 fn from_state(state: &GameState) -> Self {
27 let mut circles = Vec::new();
28 for node in &state.resource_nodes {
29 if node.blocking
30 && matches!(
31 node.state,
32 ResourceNodeState::Available | ResourceNodeState::Harvesting
33 )
34 {
35 let radius = if node.blocking_radius_m > 0.0 {
36 node.blocking_radius_m
37 } else {
38 0.8
39 };
40 circles.push(BlockingCircle {
41 x: node.x,
42 y: node.y,
43 radius_m: radius,
44 });
45 }
46 }
47 for npc in &state.npcs {
48 let alive = npc.life_state != Some(LifeState::Dead)
49 && npc.hp_pct.is_none_or(|h| h > 0.0);
50 if alive {
51 circles.push(BlockingCircle {
52 x: npc.x,
53 y: npc.y,
54 radius_m: 0.55,
55 });
56 }
57 }
58 Self {
59 circles,
60 buildings: state.buildings.clone(),
61 }
62 }
63}
64
65fn collides_player_at(x: f32, y: f32, obstacles: &NavObstacles) -> bool {
66 if obstacles.circles.iter().any(|o| circle_overlap(x, y, PLAYER_RADIUS_M, o.x, o.y, o.radius_m)) {
67 return true;
68 }
69 let pad = PLAYER_RADIUS_M;
70 obstacles.buildings.iter().any(|b| {
71 let hw = b.width_m / 2.0 + pad;
72 let hd = b.depth_m / 2.0 + pad;
73 x >= b.x - hw && x <= b.x + hw && y >= b.y - hd && y <= b.y + hd
74 })
75}
76
77fn circle_overlap(ax: f32, ay: f32, ar: f32, bx: f32, by: f32, br: f32) -> bool {
78 let dx = ax - bx;
79 let dy = ay - by;
80 let min_dist = ar + br;
81 dx * dx + dy * dy < min_dist * min_dist
82}
83
84fn segment_clear_world(
85 grid: &NavGrid,
86 obstacles: &NavObstacles,
87 from: (f32, f32),
88 to: (f32, f32),
89) -> bool {
90 let (fx, fy) = from;
91 let (tx, ty) = to;
92 let dist = (tx - fx).hypot(ty - fy);
93 let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
94 for step in 0..=steps {
95 let t = step as f32 / steps as f32;
96 let x = fx + (tx - fx) * t;
97 let y = fy + (ty - fy) * t;
98 if collides_player_at(x, y, obstacles) {
99 return false;
100 }
101 }
102 let (cx0, cy0) = world_to_cell(fx, fy);
103 let (cx1, cy1) = world_to_cell(tx, ty);
104 line_clear(grid, (cx0, cy0), (cx1, cy1))
105}
106
107const PLAYER_RADIUS_M: f32 = 0.45;
108const PATH_CLEARANCE_M: f32 = 0.35;
110const WAYPOINT_RADIUS_M: f32 = 0.85;
111const ARRIVE_RADIUS_M: f32 = 0.75;
112const SPRINT_LEG_M: f32 = 4.0;
113const SEGMENT_SAMPLE_M: f32 = 0.3;
115const STUCK_TICKS_BEFORE_REPLAN: u32 = 6;
117const MAX_REPLAN_ATTEMPTS: u32 = 5;
119
120#[derive(Debug, Clone)]
121pub struct AutoNavigator {
122 waypoints: Vec<(f32, f32)>,
123 waypoint_index: usize,
124 pub goal_x: f32,
125 pub goal_y: f32,
126 last_progress_x: f32,
127 last_progress_y: f32,
128 stuck_ticks: u32,
129 replan_attempts: u32,
130}
131
132impl AutoNavigator {
133 pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
134 let (px, py) = state.player_position();
135 let path = find_path(state, px, py, goal_x, goal_y)?;
136 if path.is_empty() {
137 return None;
138 }
139 Some(Self {
140 waypoints: path,
141 waypoint_index: 0,
142 goal_x,
143 goal_y,
144 last_progress_x: px,
145 last_progress_y: py,
146 stuck_ticks: 0,
147 replan_attempts: 0,
148 })
149 }
150
151 pub fn active(&self) -> bool {
152 self.waypoint_index < self.waypoints.len()
153 }
154
155 pub fn replan(&mut self, state: &GameState) -> bool {
158 let (px, py) = state.player_position();
159 if let Some(path) = find_path(state, px, py, self.goal_x, self.goal_y) {
160 if !path.is_empty() {
161 let changed = path != self.waypoints;
162 self.waypoints = path;
163 self.waypoint_index = 0;
164 self.stuck_ticks = 0;
165 self.last_progress_x = px;
166 self.last_progress_y = py;
167 if changed {
168 self.replan_attempts = 0;
169 } else {
170 self.replan_attempts = self.replan_attempts.saturating_add(1);
171 }
172 return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
173 }
174 }
175 self.replan_attempts = self.replan_attempts.saturating_add(1);
176 self.replan_attempts < MAX_REPLAN_ATTEMPTS
177 }
178
179 pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
181 if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
182 self.last_progress_x = px;
183 self.last_progress_y = py;
184 self.stuck_ticks = 0;
185 self.replan_attempts = 0;
186 return false;
187 }
188 self.stuck_ticks = self.stuck_ticks.saturating_add(1);
189 self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
190 }
191
192 pub fn steer(&mut self, px: f32, py: f32) -> Option<(f32, f32, bool)> {
194 while self.waypoint_index < self.waypoints.len() {
195 let (wx, wy) = self.waypoints[self.waypoint_index];
196 let dx = wx - px;
197 let dy = wy - py;
198 let dist = (dx * dx + dy * dy).sqrt();
199 if dist < WAYPOINT_RADIUS_M {
200 self.waypoint_index += 1;
201 continue;
202 }
203 let forward = (dy / dist).clamp(-1.0, 1.0);
204 let strafe = (dx / dist).clamp(-1.0, 1.0);
205 let sprint = dist > SPRINT_LEG_M;
206 return Some((forward, strafe, sprint));
207 }
208 let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
209 if goal_dist > ARRIVE_RADIUS_M {
210 let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
211 let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
212 return Some((forward, strafe, goal_dist > SPRINT_LEG_M));
213 }
214 None
215 }
216}
217
218#[derive(Clone, Copy, Eq, PartialEq)]
219struct OpenNode {
220 f: u32,
221 g: u32,
222 x: i16,
223 y: i16,
224}
225
226impl Ord for OpenNode {
227 fn cmp(&self, other: &Self) -> Ordering {
228 other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
229 }
230}
231
232impl PartialOrd for OpenNode {
233 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
234 Some(self.cmp(other))
235 }
236}
237
238struct NavGrid {
239 width: i16,
240 height: i16,
241 blocked: Vec<bool>,
242 cost: Vec<u16>,
243}
244
245impl NavGrid {
246 fn idx(&self, x: i16, y: i16) -> usize {
247 (y as usize) * (self.width as usize) + (x as usize)
248 }
249
250 fn in_bounds(&self, x: i16, y: i16) -> bool {
251 x >= 0 && y >= 0 && x < self.width && y < self.height
252 }
253
254 fn is_walkable(&self, x: i16, y: i16) -> bool {
255 self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
256 }
257
258 fn move_cost(&self, x: i16, y: i16) -> u32 {
259 self.cost[self.idx(x, y)] as u32
260 }
261
262 fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
263 if self.in_bounds(x, y) {
264 let idx = self.idx(x, y);
265 self.blocked[idx] = blocked;
266 }
267 }
268}
269
270fn terrain_at(zones: &[TerrainZoneView], x: f32, y: f32) -> TerrainKindView {
271 for zone in zones {
272 if x >= zone.x0 && x < zone.x1 && y >= zone.y0 && y < zone.y1 {
273 return zone.kind;
274 }
275 }
276 TerrainKindView::Grass
277}
278
279fn terrain_cost(kind: TerrainKindView) -> u16 {
280 match kind {
281 TerrainKindView::Grass => 10,
282 TerrainKindView::Hill => 14,
283 TerrainKindView::Bog => 25,
284 TerrainKindView::ShallowWater => 40,
285 TerrainKindView::DeepWater => u16::MAX,
286 }
287}
288
289fn block_circle(grid: &mut NavGrid, cx: f32, cy: f32, radius_m: f32) {
290 let block_r = radius_m + PLAYER_RADIUS_M + PATH_CLEARANCE_M;
291 let r = block_r.ceil() as i16;
292 let ix = cx.floor() as i16;
293 let iy = cy.floor() as i16;
294 for dy in -r..=r {
295 for dx in -r..=r {
296 let x = ix + dx;
297 let y = iy + dy;
298 if !grid.in_bounds(x, y) {
299 continue;
300 }
301 let cell_cx = x as f32 + 0.5;
302 let cell_cy = y as f32 + 0.5;
303 if (cell_cx - cx).hypot(cell_cy - cy) <= block_r {
304 grid.set_blocked(x, y, true);
305 }
306 }
307 }
308}
309
310fn mark_building_footprint(grid: &mut NavGrid, building: &BuildingView) {
312 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
313 let hw = building.width_m / 2.0;
314 let hd = building.depth_m / 2.0;
315 let x0 = (building.x - hw - pad).floor() as i16;
316 let y0 = (building.y - hd - pad).floor() as i16;
317 let x1 = (building.x + hw + pad).ceil() as i16 - 1;
318 let y1 = (building.y + hd + pad).ceil() as i16 - 1;
319 if x1 < x0 || y1 < y0 {
320 return;
321 }
322 for x in x0..=x1 {
323 for y in y0..=y1 {
324 grid.set_blocked(x, y, true);
325 }
326 }
327}
328
329fn clear_door_cells(grid: &mut NavGrid, doors: &[DoorView]) {
330 for door in doors {
333 if door.open {
334 let (x, y) = world_to_cell(door.x, door.y);
335 for dx in -1i16..=1 {
336 for dy in -1i16..=1 {
337 if dx.abs() + dy.abs() <= 1 {
338 grid.set_blocked(x + dx, y + dy, false);
339 }
340 }
341 }
342 }
343 }
344}
345
346fn build_grid(state: &GameState, obstacles: &NavObstacles) -> NavGrid {
347 let width = state.world_width_m.max(1.0).ceil() as i16;
348 let height = state.world_height_m.max(1.0).ceil() as i16;
349 let len = (width as usize) * (height as usize);
350 let mut blocked = vec![false; len];
351 let mut cost = vec![10u16; len];
352
353 for y in 0..height {
354 for x in 0..width {
355 let cx = x as f32 + 0.5;
356 let cy = y as f32 + 0.5;
357 let kind = terrain_at(&state.terrain_zones, cx, cy);
358 let tc = terrain_cost(kind);
359 let idx = (y as usize) * (width as usize) + (x as usize);
360 cost[idx] = tc;
361 if tc == u16::MAX {
362 blocked[idx] = true;
363 }
364 }
365 }
366
367 let mut grid = NavGrid {
368 width,
369 height,
370 blocked,
371 cost,
372 };
373
374 for circle in &obstacles.circles {
375 block_circle(&mut grid, circle.x, circle.y, circle.radius_m);
376 }
377
378 for building in &state.buildings {
379 mark_building_footprint(&mut grid, building);
380 }
381
382 clear_door_cells(&mut grid, &state.doors);
383
384 grid
385}
386
387fn world_to_cell(x: f32, y: f32) -> (i16, i16) {
388 (x.floor() as i16, y.floor() as i16)
389}
390
391fn cell_center(x: i16, y: i16) -> (f32, f32) {
392 (x as f32 + 0.5, y as f32 + 0.5)
393}
394
395fn heuristic(ax: i16, ay: i16, bx: i16, by: i16) -> u32 {
396 let dx = (ax - bx).unsigned_abs() as u32;
397 let dy = (ay - by).unsigned_abs() as u32;
398 let diag = dx.min(dy);
399 let straight = dx.max(dy) - diag;
400 diag * 14 + straight * 10
401}
402
403fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
404 let (mut x0, mut y0) = from;
405 let (x1, y1) = to;
406 let dx = (x1 - x0).abs();
407 let dy = (y1 - y0).abs();
408 let sx = if x0 < x1 { 1 } else { -1 };
409 let sy = if y0 < y1 { 1 } else { -1 };
410 let mut err = dx - dy;
411 loop {
412 if !grid.is_walkable(x0, y0) {
413 return false;
414 }
415 if x0 == x1 && y0 == y1 {
416 break;
417 }
418 let e2 = err * 2;
419 if e2 > -dy {
420 err -= dy;
421 x0 += sx;
422 }
423 if e2 < dx {
424 err += dx;
425 y0 += sy;
426 }
427 }
428 true
429}
430
431fn find_path(state: &GameState, from_x: f32, from_y: f32, to_x: f32, to_y: f32) -> Option<Vec<(f32, f32)>> {
432 let obstacles = NavObstacles::from_state(state);
433 let grid = build_grid(state, &obstacles);
434 let (sx, sy) = world_to_cell(from_x, from_y);
435 let (gx, gy) = world_to_cell(to_x, to_y);
436
437 if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
438 return None;
439 }
440
441 let mut goal_x = gx;
442 let mut goal_y = gy;
443 if !grid.is_walkable(goal_x, goal_y) {
444 let mut found = None;
445 'search: for radius in 1..=16i16 {
446 for dy in -radius..=radius {
447 for dx in -radius..=radius {
448 if dx.abs() != radius && dy.abs() != radius {
449 continue;
450 }
451 let x = gx + dx;
452 let y = gy + dy;
453 if grid.is_walkable(x, y) {
454 found = Some((x, y));
455 break 'search;
456 }
457 }
458 }
459 }
460 let (x, y) = found?;
461 goal_x = x;
462 goal_y = y;
463 }
464
465 let goal_key = (goal_x, goal_y);
466
467 let mut start_x = sx;
469 let mut start_y = sy;
470 if !grid.is_walkable(start_x, start_y) {
471 let mut found = None;
472 'start: for radius in 1..=16i16 {
473 for dy in -radius..=radius {
474 for dx in -radius..=radius {
475 if dx.abs() != radius && dy.abs() != radius {
476 continue;
477 }
478 let x = sx + dx;
479 let y = sy + dy;
480 if grid.is_walkable(x, y) {
481 found = Some((x, y));
482 break 'start;
483 }
484 }
485 }
486 }
487 let (x, y) = found?;
488 start_x = x;
489 start_y = y;
490 }
491 let start_key = (start_x, start_y);
492
493 if start_key == goal_key {
494 return Some(vec![cell_center(goal_x, goal_y)]);
495 }
496
497 let mut open = BinaryHeap::new();
498 let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
499 let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
500
501 g_score.insert(start_key, 0);
502 open.push(OpenNode {
503 f: heuristic(start_x, start_y, goal_x, goal_y),
504 g: 0,
505 x: start_x,
506 y: start_y,
507 });
508
509 const NEIGHBORS: [(i16, i16, u32); 8] = [
510 (1, 0, 10),
511 (-1, 0, 10),
512 (0, 1, 10),
513 (0, -1, 10),
514 (1, 1, 14),
515 (1, -1, 14),
516 (-1, 1, 14),
517 (-1, -1, 14),
518 ];
519
520 while let Some(current) = open.pop() {
521 if (current.x, current.y) == goal_key {
522 return Some(simplify_path(
523 &grid,
524 &obstacles,
525 &came_from,
526 start_key,
527 goal_key,
528 cell_center(goal_x, goal_y),
529 ));
530 }
531 let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
532 continue;
533 };
534 if current.g > best_g {
535 continue;
536 }
537
538 for (dx, dy, step_base) in NEIGHBORS {
539 let nx = current.x + dx;
540 let ny = current.y + dy;
541 if !grid.is_walkable(nx, ny) {
542 continue;
543 }
544 if dx != 0 && dy != 0 {
545 if !grid.is_walkable(current.x + dx, current.y)
546 || !grid.is_walkable(current.x, current.y + dy)
547 {
548 continue;
549 }
550 }
551 let from = cell_center(current.x, current.y);
552 let to = cell_center(nx, ny);
553 if !segment_clear_world(&grid, &obstacles, from, to) {
554 continue;
555 }
556 let step = step_base * grid.move_cost(nx, ny) / 10;
557 let tentative = best_g + step;
558 let key = (nx, ny);
559 if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
560 continue;
561 }
562 came_from.insert(key, (current.x, current.y));
563 g_score.insert(key, tentative);
564 open.push(OpenNode {
565 f: tentative + heuristic(nx, ny, goal_x, goal_y),
566 g: tentative,
567 x: nx,
568 y: ny,
569 });
570 }
571 }
572
573 None
574}
575
576fn simplify_path(
577 grid: &NavGrid,
578 obstacles: &NavObstacles,
579 came_from: &HashMap<(i16, i16), (i16, i16)>,
580 start: (i16, i16),
581 goal: (i16, i16),
582 goal_center: (f32, f32),
583) -> Vec<(f32, f32)> {
584 let mut cells = vec![goal];
585 let mut current = goal;
586 while current != start {
587 let Some(&prev) = came_from.get(¤t) else {
588 break;
589 };
590 cells.push(prev);
591 current = prev;
592 }
593 cells.reverse();
594
595 if cells.is_empty() {
596 return vec![goal_center];
597 }
598
599 let mut waypoints: Vec<(i16, i16)> = Vec::new();
600 let mut anchor = 0usize;
601 waypoints.push(cells[0]);
602 for i in 1..cells.len() {
603 if i + 1 < cells.len() {
604 let from = cell_center(cells[anchor].0, cells[anchor].1);
605 let to = if cells[i + 1] == goal {
606 goal_center
607 } else {
608 cell_center(cells[i + 1].0, cells[i + 1].1)
609 };
610 if line_clear(grid, cells[anchor], cells[i + 1])
611 && segment_clear_world(grid, obstacles, from, to)
612 {
613 continue;
614 }
615 }
616 waypoints.push(cells[i]);
617 anchor = i;
618 }
619
620 let mut out: Vec<(f32, f32)> = waypoints
621 .iter()
622 .map(|&(x, y)| cell_center(x, y))
623 .collect();
624 if let Some(last) = out.last_mut() {
625 *last = goal_center;
626 }
627
628 if path_segments_clear(&grid, &obstacles, &out) {
629 return out;
630 }
631
632 let mut fallback: Vec<(f32, f32)> = cells
634 .iter()
635 .map(|&(x, y)| cell_center(x, y))
636 .collect();
637 if let Some(last) = fallback.last_mut() {
638 *last = goal_center;
639 }
640 fallback
641}
642
643fn path_segments_clear(grid: &NavGrid, obstacles: &NavObstacles, path: &[(f32, f32)]) -> bool {
644 path.windows(2).all(|w| segment_clear_world(grid, obstacles, w[0], w[1]))
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use flatland_protocol::{EntityState, Transform, Velocity2D, WorldCoord};
651
652 fn empty_state() -> GameState {
653 GameState {
654 session_id: Default::default(),
655 entity_id: 1,
656 character_id: None,
657 tick: 0,
658 chunk_rev: 0,
659 content_rev: 0,
660 entities: vec![],
661 player: Some(EntityState {
662 id: 1,
663 transform: Transform {
664 position: WorldCoord::surface(10.0, 10.0),
665 yaw: 0.0,
666 velocity: Velocity2D { vx: 0.0, vy: 0.0 },
667 },
668 label: "p".into(),
669 vitals: None,
670 attributes: None,
671 skills: None,
672 inside_building: None,
673 }),
674 resource_nodes: vec![],
675 ground_drops: vec![],
676 placed_containers: vec![],
677 buildings: vec![],
678 doors: vec![],
679 npcs: vec![],
680 blueprints: vec![],
681 world_width_m: 64.0,
682 world_height_m: 64.0,
683 terrain_zones: vec![],
684 world_clock: Default::default(),
685 inventory: Default::default(),
686 inventory_hints: Default::default(),
687 logs: Default::default(),
688 intents_sent: 0,
689 ticks_received: 0,
690 connected: true,
691 disconnect_reason: None,
692 show_stats: false,
693 show_craft_menu: false,
694 craft_menu_index: 0,
695 craft_batch_quantity: 1,
696 show_inventory_menu: false,
697 inventory_menu_index: 0,
698 show_move_picker: false,
699 show_rename_prompt: false,
700 rename_buffer: String::new(),
701 move_picker_index: 0,
702 move_picker: None,
703 combat_target: None,
704 combat_target_label: None,
705 in_combat: false,
706 auto_attack: false,
707 combat_has_los: false,
708 attack_cd_ticks: 0,
709 gcd_ticks: 0,
710 weapon_ability_id: String::new(),
711 mainhand_template_id: None,
712 mainhand_label: None,
713 worn: std::collections::BTreeMap::new(),
714 carry_mass: 0.0,
715 carry_mass_max: 0.0,
716 encumbrance: flatland_protocol::EncumbranceState::Light,
717 inventory_stacks: Vec::new(),
718 combat_target_detail: None,
719 cast_progress: None,
720 ability_cooldowns: vec![],
721 blocking_active: false,
722 max_target_slots: 1,
723 combat_slots: vec![],
724 rotation_presets: vec![],
725 show_loadout_menu: false,
726 show_rotation_editor: false,
727 loadout_menu_index: 0,
728 rotation_editor: Default::default(),
729 harvest_in_progress: false,
730 harvest_started_at: None,
731 pending_craft_ack: None,
732 }
733 }
734
735 #[test]
736 fn path_on_open_field() {
737 let state = empty_state();
738 let path = find_path(&state, 10.0, 10.0, 20.0, 15.0).expect("path");
739 assert!(!path.is_empty());
740 let last = *path.last().unwrap();
741 assert!((last.0 - 20.5).abs() < 1.0);
742 assert!((last.1 - 15.5).abs() < 1.0);
743 }
744
745 #[test]
746 fn path_routes_around_blocking_tree() {
747 let mut state = empty_state();
748 state.resource_nodes.push(flatland_protocol::ResourceNodeView {
749 id: "oak".into(),
750 label: "Oak".into(),
751 x: 15.0,
752 y: 12.0,
753 z: 0.0,
754 item_template: "oak_log".into(),
755 state: ResourceNodeState::Available,
756 blocking: true,
757 blocking_radius_m: 0.8,
758 });
759 let path = find_path(&state, 10.0, 12.0, 20.0, 12.0).expect("path around tree");
760 for (x, y) in &path {
761 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
762 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
763 }
764 }
765
766 #[test]
767 fn path_segments_avoid_tree_corner_cut() {
768 let mut state = empty_state();
769 state.resource_nodes.push(flatland_protocol::ResourceNodeView {
770 id: "oak".into(),
771 label: "Oak".into(),
772 x: 15.0,
773 y: 12.0,
774 z: 0.0,
775 item_template: "oak_log".into(),
776 state: ResourceNodeState::Available,
777 blocking: true,
778 blocking_radius_m: 0.8,
779 });
780 let path = find_path(&state, 10.0, 11.0, 20.0, 13.0).expect("diagonal path");
781 let obstacles = NavObstacles::from_state(&state);
782 let grid = build_grid(&state, &obstacles);
783 assert!(
784 path_segments_clear(&grid, &obstacles, &path),
785 "every leg must clear tree collision: {path:?}"
786 );
787 }
788
789 #[test]
790 fn path_routes_around_building_with_clearance() {
791 let mut state = empty_state();
792 state.buildings.push(flatland_protocol::BuildingView {
794 id: "hut".into(),
795 label: "Hut".into(),
796 x: 20.0,
797 y: 20.0,
798 width_m: 6.0,
799 depth_m: 6.0,
800 interior_origin_x: 0.0,
801 interior_origin_y: 0.0,
802 tags: vec![],
803 });
804 let path = find_path(&state, 10.0, 20.0, 30.0, 20.0).expect("path around building");
805 assert!(!path.is_empty());
806 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
807 let x0 = 20.0 - 3.0 - pad;
808 let x1 = 20.0 + 3.0 + pad;
809 let y0 = 20.0 - 3.0 - pad;
810 let y1 = 20.0 + 3.0 + pad;
811 for (x, y) in &path {
812 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
813 assert!(
814 !inside,
815 "path waypoint ({x},{y}) intersects inflated building footprint"
816 );
817 }
818 let last = *path.last().unwrap();
819 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
820 }
821
822 #[test]
823 fn auto_navigator_finishes_near_goal() {
824 let state = empty_state();
825 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
826 let (fx, fy) = state.player_position();
827 let steer = nav.steer(fx, fy).expect("steer");
828 assert!(steer.0.abs() + steer.1.abs() > 0.0);
829 }
830}