1use flatland_pathfinding::{
4 circles_from_placed_containers, circles_from_views, snap_nav_goal, NavWorld, PathMode,
5 PathSession, PathSteer,
6};
7
8#[cfg(test)]
9use flatland_pathfinding::{find_path_with_goal_z, PATH_CLEARANCE_M, PLAYER_RADIUS_M};
10
11use crate::client_config::ClientConfig;
12use crate::game::GameState;
13
14fn nav_world_from_state(state: &GameState) -> NavWorld {
15 let mut circles = circles_from_views(&state.resource_nodes, &state.npcs);
16 circles.extend(circles_from_placed_containers(&state.placed_containers));
17 let mut kind_nav = flatland_pathfinding::TerrainNavTable::default();
18 for row in &state.terrain_kind_nav {
19 kind_nav.set(
20 row.kind,
21 flatland_pathfinding::TerrainKindNavParams {
22 move_speed_mult: row.move_speed_mult,
23 impassable: row.impassable,
24 },
25 );
26 }
27 if state.terrain_kind_nav.is_empty() {
30 kind_nav = flatland_pathfinding::TerrainNavTable::unit_test_defaults();
31 }
32 NavWorld::new(
33 state.world_width_m,
34 state.world_height_m,
35 state.terrain_zones.clone(),
36 state.z_platforms.clone(),
37 state.z_transitions.clone(),
38 state.buildings.clone(),
39 state.doors.clone(),
40 circles,
41 kind_nav,
42 )
43}
44
45fn client_path_mode() -> PathMode {
46 ClientConfig::load().auto_nav_path_mode()
47}
48
49#[derive(Debug, Clone)]
50pub struct AutoNavigator {
51 inner: PathSession,
52}
53
54impl AutoNavigator {
55 pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
56 let world = nav_world_from_state(state);
57 let (goal_x, goal_y) = snap_nav_goal(&world, goal_x, goal_y);
58 let (px, py, pz) = state.player_position_with_z();
59 let mode = client_path_mode();
60 PathSession::plan(&world, px, py, pz, goal_x, goal_y, mode).map(|inner| Self { inner })
61 }
62
63 pub fn active(&self) -> bool {
64 self.inner.active()
65 }
66
67 pub fn replan(&mut self, state: &GameState) -> bool {
68 let world = nav_world_from_state(state);
69 let (px, py, pz) = state.player_position_with_z();
70 self.inner.mode = client_path_mode();
72 self.inner.replan(&world, px, py, pz)
73 }
74
75 pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
76 self.inner.note_progress(px, py)
77 }
78
79 pub fn steer(
80 &mut self,
81 px: f32,
82 py: f32,
83 pz: f32,
84 state: &GameState,
85 ) -> Option<(f32, f32, f32, bool)> {
86 let world = nav_world_from_state(state);
87 self.inner
88 .steer(px, py, pz, &world)
89 .map(|s: PathSteer| (s.forward, s.strafe, s.vertical, s.sprint))
90 }
91
92 pub fn goal_x(&self) -> f32 {
93 self.inner.goal_x
94 }
95
96 pub fn goal_y(&self) -> f32 {
97 self.inner.goal_y
98 }
99
100 pub fn goal_z(&self) -> f32 {
101 self.inner.goal_z
102 }
103
104 pub fn mode(&self) -> PathMode {
105 self.inner.mode
106 }
107}
108
109#[cfg(test)]
110fn find_path(
111 state: &GameState,
112 from_x: f32,
113 from_y: f32,
114 from_z: f32,
115 to_x: f32,
116 to_y: f32,
117 to_z: f32,
118) -> Option<Vec<(f32, f32)>> {
119 let world = nav_world_from_state(state);
120 find_path_with_goal_z(
121 &world,
122 from_x,
123 from_y,
124 from_z,
125 to_x,
126 to_y,
127 to_z,
128 PathMode::Fastest,
129 )
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use flatland_protocol::{EntityState, ResourceNodeState, Transform, Velocity2D, WorldCoord};
136
137 fn empty_state() -> GameState {
138 GameState {
139 session_id: Default::default(),
140 entity_id: 1,
141 character_id: None,
142 tick: 0,
143 chunk_rev: 0,
144 content_rev: 0,
145 publish_rev: 0,
146 entities: vec![],
147 player: Some(EntityState {
148 id: 1,
149 transform: Transform {
150 position: WorldCoord::surface(10.0, 10.0),
151 yaw: 0.0,
152 velocity: Velocity2D { vx: 0.0, vy: 0.0 },
153 },
154 label: "p".into(),
155 vitals: None,
156 attributes: None,
157 skills: None,
158 inside_building: None,
159 tile_id: None,
160 paperdoll_ref: None,
161 draw_scale: 1.0,
162 presentation_state: None,
163 sprite_mode: None,
164 progression_xp: None,
165 combat_cues: vec![],
166 statuses: vec![],
167 }),
168 resource_nodes: vec![],
169 harvest_route_nodes: vec![],
170 ground_drops: vec![],
171 placed_containers: vec![],
172 buildings: vec![],
173 doors: vec![],
174 interior_map: None,
175 npcs: vec![],
176 blueprints: vec![],
177 building_materials: vec![],
178 world_width_m: 64.0,
179 world_height_m: 64.0,
180 world_x0: 0.0,
181 world_y0: 0.0,
182 terrain_zones: vec![],
183 z_platforms: vec![],
184 z_transitions: vec![],
185 z_bands_outdoor_backup: None,
186 world_clock: Default::default(),
187 inventory: Default::default(),
188 inventory_hints: Default::default(),
189 item_catalog: Default::default(),
190 logs: Default::default(),
191 intents_sent: 0,
192 ticks_received: 0,
193 connected: true,
194 disconnect_reason: None,
195 show_stats: false,
196 hud_log_hidden: false,
197 show_equip_menu: false,
198 equip_menu_index: 0,
199 show_craft_menu: false,
200 show_plot_build_menu: false,
201 plot_build_focus_wall: true,
202 plot_build_wall_index: 0,
203 plot_build_roof_index: 0,
204 craft_menu_index: 0,
205 craft_batch_quantity: 1,
206 craft_tab: crate::CraftTab::Ready,
207 craft_filter: String::new(),
208 craft_filter_focused: false,
209 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
210 show_shop_menu: false,
211 shop_catalog: None,
212 bank_panel: None,
213 bank_menu_index: 0,
214 bank_ui_mode: crate::game::BankUiMode::Menu,
215 storage_panel: None,
216 market_panel: None,
217 market_menu_index: 0,
218 market_filter: String::new(),
219 market_filter_focused: false,
220 market_category_filter: None,
221 market_buy_confirm: None,
222 market_ui_mode: crate::game::MarketUiMode::Browse,
223 storage_menu_index: 0,
224 storage_ui_mode: crate::game::StorageUiMode::Menu,
225 shop_tab: crate::game::ShopTab::default(),
226 shop_menu_index: 0,
227 shop_quantity: 1,
228 shop_trade_log: std::collections::VecDeque::new(),
229 show_npc_verb_menu: false,
230 npc_verb_target: None,
231 npc_verb_index: 0,
232 npc_verb_notice: None,
233 player_verbs: Default::default(),
234 social_chat: Default::default(),
235 trade_ui: Default::default(),
236 whisper_pouch_ui: Default::default(),
237 show_npc_chat: false,
238 npc_chat: None,
239 show_inventory_menu: false,
240 inventory_menu_index: 0,
241 inventory_tab: crate::game::InventoryTab::OnPerson,
242 inventory_filter: String::new(),
243 inventory_filter_focused: false,
244 show_move_picker: false,
245 show_rename_prompt: false,
246 rename_plot_id: None,
247 highlighted_plot_id: None,
248 show_worker_rename: false,
249 rename_buffer: String::new(),
250 move_picker_index: 0,
251 move_picker: None,
252 show_grant_picker: false,
253 grant_picker_index: 0,
254 grant_picker: None,
255 show_destroy_picker: false,
256 destroy_confirm_pending: false,
257 destroy_picker: None,
258 combat_target: None,
259 combat_target_label: None,
260 ground_target: None,
261 combat_fx: Vec::new(),
262 ground_hazards: Vec::new(),
263 property_zones: Vec::new(),
264 tax_zones: Vec::new(),
265 growth_zones: Vec::new(),
266 biome_zones: Vec::new(),
267 terrain_kind_nav: Vec::new(),
268 property_plots: Vec::new(),
269 property_plot_settings: None,
270 claim_mode: None,
271 relocate_mode: None,
272 sell_plot_confirm: None,
273 sell_plot_armed_at: None,
274 show_plant_menu: false,
275 plant_menu_index: 0,
276 show_farm_access: false,
277 farm_access_name_draft: String::new(),
278 farm_access_discount_bps: 0,
279 farm_access_index: 0,
280 plant_quantity: 1,
281 in_combat: false,
282 auto_attack: false,
283 combat_has_los: false,
284 attack_cd_ticks: 0,
285 gcd_ticks: 0,
286 weapon_ability_id: String::new(),
287 mainhand_template_id: None,
288 mainhand_label: None,
289 mainhand_instance_id: None,
290 offhand_template_id: None,
291 offhand_label: None,
292 offhand_instance_id: None,
293 mainhand_hand_slots: 1,
294 defense: None,
295 worn: std::collections::BTreeMap::new(),
296 carry_mass: 0.0,
297 carry_mass_max: 0.0,
298 encumbrance: flatland_protocol::EncumbranceState::Light,
299 move_speed_mps: 0.0,
300 move_speed_mult: 0.0,
301 inventory_stacks: Vec::new(),
302 keychain_stacks: Vec::new(),
303 whisper_pouch_stacks: Vec::new(),
304 combat_target_detail: None,
305 statuses: Vec::new(),
306 cast_progress: None,
307 timed_channel: None,
308 plot_build_offer: None,
309 ability_cooldowns: vec![],
310 blocking_active: false,
311 max_target_slots: 1,
312 combat_slots: vec![],
313 rotation_presets: vec![],
314 known_abilities: Vec::new(),
315 ability_meta: std::collections::HashMap::new(),
316 ability_mastery: std::collections::HashMap::new(),
317 hotbar: vec![None; 9],
318 max_abilities_per_rotation: 0,
319 show_loadout_menu: false,
320 show_keychain_menu: false,
321 keychain_menu_index: 0,
322 show_rotation_editor: false,
323 loadout_menu_index: 0,
324 loadout_hotbar_slot: 1,
325 loadout_ability_index: 0,
326 loadout_focus_presets: false,
327 rotation_editor: Default::default(),
328 harvest_in_progress: false,
329 harvest_started_at: None,
330 pending_craft_ack: None,
331 craft_channel_blueprint_id: None,
332 pending_worker_job_ack: None,
333 attending_worker_instance_id: None,
334 quest_log: Vec::new(),
335 interactables: Vec::new(),
336 ledger: None,
337 career: None,
338 character_sheet_tab: crate::CharacterSheetTab::Character,
339 ledger_period: crate::LedgerPeriod::Day,
340 show_quest_offer: false,
341 pending_quest_offers: Vec::new(),
342 quest_offer_index: 0,
343 show_quest_menu: false,
344 quest_menu_index: 0,
345 quest_withdraw_confirm: false,
346 hired_workers: Vec::new(),
347 show_workers_menu: false,
348 workers_menu_index: 0,
349 worker_dismiss_confirmation: None,
350 workers_menu_compact: false,
351 worker_step_display: std::collections::BTreeMap::new(),
352 worker_error_display: std::collections::BTreeMap::new(),
353 worker_health_ring_until: std::collections::BTreeMap::new(),
354 pending_worker_hire_since: None,
355 show_worker_give_picker: false,
356 worker_give_picker_index: 0,
357 worker_give_picker: None,
358 show_worker_give_target_picker: false,
359 worker_give_target_picker_index: 0,
360 worker_give_target_picker: None,
361 show_worker_take_picker: false,
362 worker_take_picker_index: 0,
363 worker_take_picker: None,
364 show_worker_teach_picker: false,
365 worker_teach_picker_index: 0,
366 worker_teach_picker: None,
367 worker_route_editor: None,
368 progression_curve: None,
369 }
370 }
371
372 #[test]
373 fn path_on_open_field() {
374 let state = empty_state();
375 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
376 assert!(!path.is_empty());
377 let last = *path.last().unwrap();
378 assert!((last.0 - 20.5).abs() < 1.0);
379 assert!((last.1 - 15.5).abs() < 1.0);
380 }
381
382 #[test]
383 fn path_routes_around_blocking_tree() {
384 let mut state = empty_state();
385 state
386 .resource_nodes
387 .push(flatland_protocol::ResourceNodeView {
388 id: "oak".into(),
389 label: "Oak".into(),
390 x: 15.0,
391 y: 12.0,
392 z: 0.0,
393 item_template: "oak_log".into(),
394 state: ResourceNodeState::Available,
395 blocking: true,
396 blocking_radius_m: 0.8,
397 harvest_off: false,
398 tile_id: None,
399 yaw: 0.0,
400 pitch: 0.0,
401 roll: 0.0,
402 draw_scale: 1.0,
403 sprite_mode: None,
404 growth_progress: None,
405 presentation_state: None,
406 channel_start_tick: None,
407 channel_end_tick: None,
408 harvest_drop_templates: vec![],
409 });
410 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
411 for (x, y) in &path {
412 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
413 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
414 }
415 }
416
417 #[test]
418 fn path_routes_around_building_with_clearance() {
419 let mut state = empty_state();
420 state.buildings.push(flatland_protocol::BuildingView {
421 id: "hut".into(),
422 label: "Hut".into(),
423 x: 20.0,
424 y: 20.0,
425 width_m: 6.0,
426 depth_m: 6.0,
427 interior_blueprint: None,
428 tags: vec![],
429 market_boundary_zone_ids: vec![],
430 market_max_volume: None,
431 wall_set: None,
432 roof_set: None,
433 });
434 let path =
435 find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
436 assert!(!path.is_empty());
437 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
438 let x0 = 20.0 - 3.0 - pad;
439 let x1 = 20.0 + 3.0 + pad;
440 let y0 = 20.0 - 3.0 - pad;
441 let y1 = 20.0 + 3.0 + pad;
442 for (x, y) in &path {
443 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
444 assert!(
445 !inside,
446 "path waypoint ({x},{y}) intersects inflated building footprint"
447 );
448 }
449 let last = *path.last().unwrap();
450 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
451 }
452
453 #[test]
454 fn path_routes_around_placed_chest() {
455 let mut state = empty_state();
456 state
457 .placed_containers
458 .push(flatland_protocol::PlacedContainerView {
459 id: "chest-1".into(),
460 template_id: "wooden_chest_small".into(),
461 display_name: "Chest".into(),
462 x: 15.0,
463 y: 12.0,
464 z: 0.0,
465 locked: false,
466 accessible: true,
467 owner_character_id: None,
468 contents: vec![],
469 lock_id: None,
470 capacity_volume: None,
471 item_instance_id: None,
472 tile_id: None,
473 worker_lodging_capacity: None,
474 blocking: true,
475 blocking_radius_m: 0.8,
476 building_id: None,
477 });
478 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around chest");
479 for (x, y) in &path {
480 let near = (*x - 15.0).hypot(*y - 12.0);
481 assert!(
482 near >= 1.0,
483 "path should not cut through chest pad at ({x},{y}) dist={near}"
484 );
485 }
486 }
487
488 #[test]
489 fn auto_navigator_finishes_near_goal() {
490 let state = empty_state();
491 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
492 let (fx, fy, fz) = state.player_position_with_z();
493 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
494 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
495 }
496}