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