Skip to main content

flatland_client_lib/
navigation.rs

1//! Client-side pathfinding and auto-navigation toward a map target.
2
3use 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}