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