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 ground_drops: vec![],
170 placed_containers: vec![],
171 buildings: vec![],
172 doors: vec![],
173 interior_map: None,
174 npcs: vec![],
175 blueprints: vec![],
176 building_materials: vec![],
177 world_width_m: 64.0,
178 world_height_m: 64.0,
179 world_x0: 0.0,
180 world_y0: 0.0,
181 terrain_zones: vec![],
182 z_platforms: vec![],
183 z_transitions: vec![],
184 z_bands_outdoor_backup: None,
185 world_clock: Default::default(),
186 inventory: Default::default(),
187 inventory_hints: Default::default(),
188 item_catalog: Default::default(),
189 logs: Default::default(),
190 intents_sent: 0,
191 ticks_received: 0,
192 connected: true,
193 disconnect_reason: None,
194 show_stats: false,
195 hud_log_hidden: false,
196 show_equip_menu: false,
197 equip_menu_index: 0,
198 show_craft_menu: false,
199 show_plot_build_menu: false,
200 plot_build_focus_wall: true,
201 plot_build_wall_index: 0,
202 plot_build_roof_index: 0,
203 craft_menu_index: 0,
204 craft_batch_quantity: 1,
205 craft_tab: crate::CraftTab::Ready,
206 craft_filter: String::new(),
207 craft_filter_focused: false,
208 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
209 show_shop_menu: false,
210 shop_catalog: None,
211 bank_panel: None,
212 bank_menu_index: 0,
213 bank_ui_mode: crate::game::BankUiMode::Menu,
214 storage_panel: None,
215 market_panel: None,
216 market_menu_index: 0,
217 market_filter: String::new(),
218 market_filter_focused: false,
219 market_category_filter: None,
220 market_buy_confirm: None,
221 market_ui_mode: crate::game::MarketUiMode::Browse,
222 storage_menu_index: 0,
223 storage_ui_mode: crate::game::StorageUiMode::Menu,
224 shop_tab: crate::game::ShopTab::default(),
225 shop_menu_index: 0,
226 shop_quantity: 1,
227 shop_trade_log: std::collections::VecDeque::new(),
228 show_npc_verb_menu: false,
229 npc_verb_target: None,
230 npc_verb_index: 0,
231 npc_verb_notice: None,
232 player_verbs: Default::default(),
233 social_chat: Default::default(),
234 trade_ui: Default::default(),
235 whisper_pouch_ui: Default::default(),
236 show_npc_chat: false,
237 npc_chat: None,
238 show_inventory_menu: false,
239 inventory_menu_index: 0,
240 inventory_tab: crate::game::InventoryTab::OnPerson,
241 inventory_filter: String::new(),
242 inventory_filter_focused: false,
243 show_move_picker: false,
244 show_rename_prompt: false,
245 rename_plot_id: None,
246 highlighted_plot_id: None,
247 show_worker_rename: false,
248 rename_buffer: String::new(),
249 move_picker_index: 0,
250 move_picker: None,
251 show_grant_picker: false,
252 grant_picker_index: 0,
253 grant_picker: None,
254 show_destroy_picker: false,
255 destroy_confirm_pending: false,
256 destroy_picker: None,
257 combat_target: None,
258 combat_target_label: None,
259 ground_target: None,
260 combat_fx: Vec::new(),
261 ground_hazards: Vec::new(),
262 property_zones: Vec::new(),
263 tax_zones: Vec::new(),
264 growth_zones: Vec::new(),
265 biome_zones: Vec::new(),
266 terrain_kind_nav: Vec::new(),
267 property_plots: Vec::new(),
268 property_plot_settings: None,
269 claim_mode: None,
270 relocate_mode: None,
271 sell_plot_confirm: None,
272 sell_plot_armed_at: None,
273 show_plant_menu: false,
274 plant_menu_index: 0,
275 show_farm_access: false,
276 farm_access_name_draft: String::new(),
277 farm_access_discount_bps: 0,
278 farm_access_index: 0,
279 plant_quantity: 1,
280 in_combat: false,
281 auto_attack: false,
282 combat_has_los: false,
283 attack_cd_ticks: 0,
284 gcd_ticks: 0,
285 weapon_ability_id: String::new(),
286 mainhand_template_id: None,
287 mainhand_label: None,
288 mainhand_instance_id: None,
289 offhand_template_id: None,
290 offhand_label: None,
291 offhand_instance_id: None,
292 mainhand_hand_slots: 1,
293 defense: None,
294 worn: std::collections::BTreeMap::new(),
295 carry_mass: 0.0,
296 carry_mass_max: 0.0,
297 encumbrance: flatland_protocol::EncumbranceState::Light,
298 move_speed_mps: 0.0,
299 move_speed_mult: 0.0,
300 inventory_stacks: Vec::new(),
301 keychain_stacks: Vec::new(),
302 whisper_pouch_stacks: Vec::new(),
303 combat_target_detail: None,
304 statuses: Vec::new(),
305 cast_progress: None,
306 timed_channel: None,
307 plot_build_offer: None,
308 ability_cooldowns: vec![],
309 blocking_active: false,
310 max_target_slots: 1,
311 combat_slots: vec![],
312 rotation_presets: vec![],
313 known_abilities: Vec::new(),
314 ability_meta: std::collections::HashMap::new(),
315 ability_mastery: std::collections::HashMap::new(),
316 hotbar: vec![None; 9],
317 max_abilities_per_rotation: 0,
318 show_loadout_menu: false,
319 show_keychain_menu: false,
320 keychain_menu_index: 0,
321 show_rotation_editor: false,
322 loadout_menu_index: 0,
323 loadout_hotbar_slot: 1,
324 loadout_ability_index: 0,
325 loadout_focus_presets: false,
326 rotation_editor: Default::default(),
327 harvest_in_progress: false,
328 harvest_started_at: None,
329 pending_craft_ack: None,
330 craft_channel_blueprint_id: None,
331 pending_worker_job_ack: None,
332 attending_worker_instance_id: None,
333 quest_log: Vec::new(),
334 interactables: Vec::new(),
335 ledger: None,
336 career: None,
337 character_sheet_tab: crate::CharacterSheetTab::Character,
338 ledger_period: crate::LedgerPeriod::Day,
339 show_quest_offer: false,
340 pending_quest_offers: Vec::new(),
341 quest_offer_index: 0,
342 show_quest_menu: false,
343 quest_menu_index: 0,
344 quest_withdraw_confirm: false,
345 hired_workers: Vec::new(),
346 show_workers_menu: false,
347 workers_menu_index: 0,
348 worker_dismiss_confirmation: None,
349 workers_menu_compact: false,
350 worker_step_display: std::collections::BTreeMap::new(),
351 worker_error_display: std::collections::BTreeMap::new(),
352 worker_health_ring_until: std::collections::BTreeMap::new(),
353 pending_worker_hire_since: None,
354 show_worker_give_picker: false,
355 worker_give_picker_index: 0,
356 worker_give_picker: None,
357 show_worker_give_target_picker: false,
358 worker_give_target_picker_index: 0,
359 worker_give_target_picker: None,
360 show_worker_take_picker: false,
361 worker_take_picker_index: 0,
362 worker_take_picker: None,
363 show_worker_teach_picker: false,
364 worker_teach_picker_index: 0,
365 worker_teach_picker: None,
366 worker_route_editor: None,
367 progression_curve: None,
368 }
369 }
370
371 #[test]
372 fn path_on_open_field() {
373 let state = empty_state();
374 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
375 assert!(!path.is_empty());
376 let last = *path.last().unwrap();
377 assert!((last.0 - 20.5).abs() < 1.0);
378 assert!((last.1 - 15.5).abs() < 1.0);
379 }
380
381 #[test]
382 fn path_routes_around_blocking_tree() {
383 let mut state = empty_state();
384 state
385 .resource_nodes
386 .push(flatland_protocol::ResourceNodeView {
387 id: "oak".into(),
388 label: "Oak".into(),
389 x: 15.0,
390 y: 12.0,
391 z: 0.0,
392 item_template: "oak_log".into(),
393 state: ResourceNodeState::Available,
394 blocking: true,
395 blocking_radius_m: 0.8,
396 harvest_off: false,
397 tile_id: None,
398 yaw: 0.0,
399 pitch: 0.0,
400 roll: 0.0,
401 draw_scale: 1.0,
402 sprite_mode: None,
403 growth_progress: None,
404 presentation_state: None,
405 channel_start_tick: None,
406 channel_end_tick: None,
407 harvest_drop_templates: vec![],
408 });
409 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
410 for (x, y) in &path {
411 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
412 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
413 }
414 }
415
416 #[test]
417 fn path_routes_around_building_with_clearance() {
418 let mut state = empty_state();
419 state.buildings.push(flatland_protocol::BuildingView {
420 id: "hut".into(),
421 label: "Hut".into(),
422 x: 20.0,
423 y: 20.0,
424 width_m: 6.0,
425 depth_m: 6.0,
426 interior_blueprint: None,
427 tags: vec![],
428 market_boundary_zone_ids: vec![],
429 market_max_volume: None,
430 wall_set: None,
431 roof_set: None,
432 });
433 let path =
434 find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
435 assert!(!path.is_empty());
436 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
437 let x0 = 20.0 - 3.0 - pad;
438 let x1 = 20.0 + 3.0 + pad;
439 let y0 = 20.0 - 3.0 - pad;
440 let y1 = 20.0 + 3.0 + pad;
441 for (x, y) in &path {
442 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
443 assert!(
444 !inside,
445 "path waypoint ({x},{y}) intersects inflated building footprint"
446 );
447 }
448 let last = *path.last().unwrap();
449 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
450 }
451
452 #[test]
453 fn path_routes_around_placed_chest() {
454 let mut state = empty_state();
455 state
456 .placed_containers
457 .push(flatland_protocol::PlacedContainerView {
458 id: "chest-1".into(),
459 template_id: "wooden_chest_small".into(),
460 display_name: "Chest".into(),
461 x: 15.0,
462 y: 12.0,
463 z: 0.0,
464 locked: false,
465 accessible: true,
466 owner_character_id: None,
467 contents: vec![],
468 lock_id: None,
469 capacity_volume: None,
470 item_instance_id: None,
471 tile_id: None,
472 worker_lodging_capacity: None,
473 blocking: true,
474 blocking_radius_m: 0.8,
475 building_id: None,
476 });
477 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around chest");
478 for (x, y) in &path {
479 let near = (*x - 15.0).hypot(*y - 12.0);
480 assert!(
481 near >= 1.0,
482 "path should not cut through chest pad at ({x},{y}) dist={near}"
483 );
484 }
485 }
486
487 #[test]
488 fn auto_navigator_finishes_near_goal() {
489 let state = empty_state();
490 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
491 let (fx, fy, fz) = state.player_position_with_z();
492 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
493 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
494 }
495}