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