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 ability_mastery: std::collections::HashMap::new(),
257 hotbar: vec![None; 9],
258 max_abilities_per_rotation: 0,
259 show_loadout_menu: false,
260 show_keychain_menu: false,
261 keychain_menu_index: 0,
262 show_rotation_editor: false,
263 loadout_menu_index: 0,
264 loadout_hotbar_slot: 1,
265 loadout_ability_index: 0,
266 loadout_focus_presets: false,
267 rotation_editor: Default::default(),
268 harvest_in_progress: false,
269 harvest_started_at: None,
270 pending_craft_ack: None,
271 pending_worker_job_ack: None,
272 attending_worker_instance_id: None,
273 quest_log: Vec::new(),
274 interactables: Vec::new(),
275 ledger: None,
276 career: None,
277 character_sheet_tab: crate::CharacterSheetTab::Character,
278 ledger_period: crate::LedgerPeriod::Day,
279 show_quest_offer: false,
280 pending_quest_offer: None,
281 show_quest_menu: false,
282 quest_menu_index: 0,
283 quest_withdraw_confirm: false,
284 hired_workers: Vec::new(),
285 show_workers_menu: false,
286 workers_menu_index: 0,
287 workers_menu_compact: false,
288 worker_step_display: std::collections::BTreeMap::new(),
289 worker_error_display: std::collections::BTreeMap::new(),
290 show_worker_give_picker: false,
291 worker_give_picker_index: 0,
292 worker_give_picker: None,
293 show_worker_give_target_picker: false,
294 worker_give_target_picker_index: 0,
295 worker_give_target_picker: None,
296 show_worker_take_picker: false,
297 worker_take_picker_index: 0,
298 worker_take_picker: None,
299 show_worker_teach_picker: false,
300 worker_teach_picker_index: 0,
301 worker_teach_picker: None,
302 worker_route_editor: None,
303 progression_curve: None,
304 }
305 }
306
307 #[test]
308 fn path_on_open_field() {
309 let state = empty_state();
310 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
311 assert!(!path.is_empty());
312 let last = *path.last().unwrap();
313 assert!((last.0 - 20.5).abs() < 1.0);
314 assert!((last.1 - 15.5).abs() < 1.0);
315 }
316
317 #[test]
318 fn path_routes_around_blocking_tree() {
319 let mut state = empty_state();
320 state
321 .resource_nodes
322 .push(flatland_protocol::ResourceNodeView {
323 id: "oak".into(),
324 label: "Oak".into(),
325 x: 15.0,
326 y: 12.0,
327 z: 0.0,
328 item_template: "oak_log".into(),
329 state: ResourceNodeState::Available,
330 blocking: true,
331 blocking_radius_m: 0.8,
332 tile_id: None,
333 yaw: 0.0,
334 pitch: 0.0,
335 roll: 0.0,
336 draw_scale: 1.0,
337 sprite_mode: None,
338 growth_progress: None,
339 presentation_state: None,
340 channel_start_tick: None,
341 channel_end_tick: None,
342 harvest_drop_templates: vec![],
343 });
344 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
345 for (x, y) in &path {
346 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
347 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
348 }
349 }
350
351 #[test]
352 fn path_routes_around_building_with_clearance() {
353 let mut state = empty_state();
354 state.buildings.push(flatland_protocol::BuildingView {
355 id: "hut".into(),
356 label: "Hut".into(),
357 x: 20.0,
358 y: 20.0,
359 width_m: 6.0,
360 depth_m: 6.0,
361 interior_blueprint: None,
362 tags: vec![],
363 market_boundary_zone_ids: vec![],
364 market_max_volume: None,
365 wall_set: None,
366 roof_set: None,
367 });
368 let path =
369 find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
370 assert!(!path.is_empty());
371 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
372 let x0 = 20.0 - 3.0 - pad;
373 let x1 = 20.0 + 3.0 + pad;
374 let y0 = 20.0 - 3.0 - pad;
375 let y1 = 20.0 + 3.0 + pad;
376 for (x, y) in &path {
377 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
378 assert!(
379 !inside,
380 "path waypoint ({x},{y}) intersects inflated building footprint"
381 );
382 }
383 let last = *path.last().unwrap();
384 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
385 }
386
387 #[test]
388 fn path_routes_around_placed_chest() {
389 let mut state = empty_state();
390 state.placed_containers.push(flatland_protocol::PlacedContainerView {
391 id: "chest-1".into(),
392 template_id: "wooden_chest_small".into(),
393 display_name: "Chest".into(),
394 x: 15.0,
395 y: 12.0,
396 z: 0.0,
397 locked: false,
398 accessible: true,
399 owner_character_id: None,
400 contents: vec![],
401 lock_id: None,
402 capacity_volume: None,
403 item_instance_id: None,
404 tile_id: None,
405 worker_lodging_capacity: None,
406 blocking: true,
407 blocking_radius_m: 0.8,
408 });
409 let path =
410 find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around chest");
411 for (x, y) in &path {
412 let near = (*x - 15.0).hypot(*y - 12.0);
413 assert!(
414 near >= 1.0,
415 "path should not cut through chest pad at ({x},{y}) dist={near}"
416 );
417 }
418 }
419
420 #[test]
421 fn auto_navigator_finishes_near_goal() {
422 let state = empty_state();
423 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
424 let (fx, fy, fz) = state.player_position_with_z();
425 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
426 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
427 }
428}