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.steer(px, py, pz, &world).map(|s: PathSteer| {
88 (s.forward, s.strafe, s.vertical, s.sprint)
89 })
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 property_zones: Vec::new(),
256 tax_zones: Vec::new(),
257 growth_zones: Vec::new(),
258 biome_zones: Vec::new(),
259 terrain_kind_nav: Vec::new(),
260 property_plots: Vec::new(),
261 property_plot_settings: None,
262 claim_mode: None,
263 relocate_mode: None,
264 sell_plot_confirm: None,
265 sell_plot_armed_at: None,
266 show_plant_menu: false,
267 plant_menu_index: 0,
268 show_farm_access: false,
269 farm_access_name_draft: String::new(),
270 farm_access_discount_bps: 0,
271 farm_access_index: 0,
272 plant_quantity: 1,
273 in_combat: false,
274 auto_attack: false,
275 combat_has_los: false,
276 attack_cd_ticks: 0,
277 gcd_ticks: 0,
278 weapon_ability_id: String::new(),
279 mainhand_template_id: None,
280 mainhand_label: None,
281 mainhand_instance_id: None,
282 offhand_template_id: None,
283 offhand_label: None,
284 offhand_instance_id: None,
285 mainhand_hand_slots: 1,
286 defense: None,
287 worn: std::collections::BTreeMap::new(),
288 carry_mass: 0.0,
289 carry_mass_max: 0.0,
290 encumbrance: flatland_protocol::EncumbranceState::Light,
291 inventory_stacks: Vec::new(),
292 keychain_stacks: Vec::new(),
293 whisper_pouch_stacks: Vec::new(),
294 combat_target_detail: None,
295 statuses: Vec::new(),
296 cast_progress: None,
297 timed_channel: None,
298 plot_build_offer: None,
299 ability_cooldowns: vec![],
300 blocking_active: false,
301 max_target_slots: 1,
302 combat_slots: vec![],
303 rotation_presets: vec![],
304 known_abilities: Vec::new(),
305 ability_meta: std::collections::HashMap::new(),
306 ability_mastery: std::collections::HashMap::new(),
307 hotbar: vec![None; 9],
308 max_abilities_per_rotation: 0,
309 show_loadout_menu: false,
310 show_keychain_menu: false,
311 keychain_menu_index: 0,
312 show_rotation_editor: false,
313 loadout_menu_index: 0,
314 loadout_hotbar_slot: 1,
315 loadout_ability_index: 0,
316 loadout_focus_presets: false,
317 rotation_editor: Default::default(),
318 harvest_in_progress: false,
319 harvest_started_at: None,
320 pending_craft_ack: None,
321 pending_worker_job_ack: None,
322 attending_worker_instance_id: None,
323 quest_log: Vec::new(),
324 interactables: Vec::new(),
325 ledger: None,
326 career: None,
327 character_sheet_tab: crate::CharacterSheetTab::Character,
328 ledger_period: crate::LedgerPeriod::Day,
329 show_quest_offer: false,
330 pending_quest_offer: None,
331 show_quest_menu: false,
332 quest_menu_index: 0,
333 quest_withdraw_confirm: false,
334 hired_workers: Vec::new(),
335 show_workers_menu: false,
336 workers_menu_index: 0,
337 workers_menu_compact: false,
338 worker_step_display: std::collections::BTreeMap::new(),
339 worker_error_display: std::collections::BTreeMap::new(),
340 show_worker_give_picker: false,
341 worker_give_picker_index: 0,
342 worker_give_picker: None,
343 show_worker_give_target_picker: false,
344 worker_give_target_picker_index: 0,
345 worker_give_target_picker: None,
346 show_worker_take_picker: false,
347 worker_take_picker_index: 0,
348 worker_take_picker: None,
349 show_worker_teach_picker: false,
350 worker_teach_picker_index: 0,
351 worker_teach_picker: None,
352 worker_route_editor: None,
353 progression_curve: None,
354 }
355 }
356
357 #[test]
358 fn path_on_open_field() {
359 let state = empty_state();
360 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
361 assert!(!path.is_empty());
362 let last = *path.last().unwrap();
363 assert!((last.0 - 20.5).abs() < 1.0);
364 assert!((last.1 - 15.5).abs() < 1.0);
365 }
366
367 #[test]
368 fn path_routes_around_blocking_tree() {
369 let mut state = empty_state();
370 state
371 .resource_nodes
372 .push(flatland_protocol::ResourceNodeView {
373 id: "oak".into(),
374 label: "Oak".into(),
375 x: 15.0,
376 y: 12.0,
377 z: 0.0,
378 item_template: "oak_log".into(),
379 state: ResourceNodeState::Available,
380 blocking: true,
381 blocking_radius_m: 0.8,
382 harvest_off: false,
383 tile_id: None,
384 yaw: 0.0,
385 pitch: 0.0,
386 roll: 0.0,
387 draw_scale: 1.0,
388 sprite_mode: None,
389 growth_progress: None,
390 presentation_state: None,
391 channel_start_tick: None,
392 channel_end_tick: None,
393 harvest_drop_templates: vec![],
394 });
395 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
396 for (x, y) in &path {
397 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
398 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
399 }
400 }
401
402 #[test]
403 fn path_routes_around_building_with_clearance() {
404 let mut state = empty_state();
405 state.buildings.push(flatland_protocol::BuildingView {
406 id: "hut".into(),
407 label: "Hut".into(),
408 x: 20.0,
409 y: 20.0,
410 width_m: 6.0,
411 depth_m: 6.0,
412 interior_blueprint: None,
413 tags: vec![],
414 market_boundary_zone_ids: vec![],
415 market_max_volume: None,
416 wall_set: None,
417 roof_set: None,
418 });
419 let path =
420 find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
421 assert!(!path.is_empty());
422 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
423 let x0 = 20.0 - 3.0 - pad;
424 let x1 = 20.0 + 3.0 + pad;
425 let y0 = 20.0 - 3.0 - pad;
426 let y1 = 20.0 + 3.0 + pad;
427 for (x, y) in &path {
428 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
429 assert!(
430 !inside,
431 "path waypoint ({x},{y}) intersects inflated building footprint"
432 );
433 }
434 let last = *path.last().unwrap();
435 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
436 }
437
438 #[test]
439 fn path_routes_around_placed_chest() {
440 let mut state = empty_state();
441 state.placed_containers.push(flatland_protocol::PlacedContainerView {
442 id: "chest-1".into(),
443 template_id: "wooden_chest_small".into(),
444 display_name: "Chest".into(),
445 x: 15.0,
446 y: 12.0,
447 z: 0.0,
448 locked: false,
449 accessible: true,
450 owner_character_id: None,
451 contents: vec![],
452 lock_id: None,
453 capacity_volume: None,
454 item_instance_id: None,
455 tile_id: None,
456 worker_lodging_capacity: None,
457 blocking: true,
458 blocking_radius_m: 0.8,
459 building_id: None,
460 });
461 let path =
462 find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around chest");
463 for (x, y) in &path {
464 let near = (*x - 15.0).hypot(*y - 12.0);
465 assert!(
466 near >= 1.0,
467 "path should not cut through chest pad at ({x},{y}) dist={near}"
468 );
469 }
470 }
471
472 #[test]
473 fn auto_navigator_finishes_near_goal() {
474 let state = empty_state();
475 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
476 let (fx, fy, fz) = state.player_position_with_z();
477 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
478 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
479 }
480}