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