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