flatland-client-lib 0.2.20

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Client-side pathfinding and auto-navigation toward a map target.

use flatland_pathfinding::{circles_from_views, NavWorld, PathSession, PathSteer};

#[cfg(test)]
use flatland_pathfinding::{find_path_with_goal_z, PATH_CLEARANCE_M, PLAYER_RADIUS_M};

use crate::game::GameState;

fn nav_world_from_state(state: &GameState) -> NavWorld {
    NavWorld {
        world_width_m: state.world_width_m,
        world_height_m: state.world_height_m,
        terrain_zones: state.terrain_zones.clone(),
        z_platforms: state.z_platforms.clone(),
        z_transitions: state.z_transitions.clone(),
        buildings: state.buildings.clone(),
        doors: state.doors.clone(),
        circles: circles_from_views(&state.resource_nodes, &state.npcs),
    }
}

#[derive(Debug, Clone)]
pub struct AutoNavigator {
    inner: PathSession,
}

impl AutoNavigator {
    pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
        let world = nav_world_from_state(state);
        let (px, py, pz) = state.player_position_with_z();
        PathSession::plan(&world, px, py, pz, goal_x, goal_y).map(|inner| Self { inner })
    }

    pub fn active(&self) -> bool {
        self.inner.active()
    }

    pub fn replan(&mut self, state: &GameState) -> bool {
        let world = nav_world_from_state(state);
        let (px, py, pz) = state.player_position_with_z();
        self.inner.replan(&world, px, py, pz)
    }

    pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
        self.inner.note_progress(px, py)
    }

    pub fn steer(
        &mut self,
        px: f32,
        py: f32,
        pz: f32,
        state: &GameState,
    ) -> Option<(f32, f32, f32, bool)> {
        let world = nav_world_from_state(state);
        self.inner.steer(px, py, pz, &world).map(|s: PathSteer| {
            (s.forward, s.strafe, s.vertical, s.sprint)
        })
    }

    pub fn goal_x(&self) -> f32 {
        self.inner.goal_x
    }

    pub fn goal_y(&self) -> f32 {
        self.inner.goal_y
    }

    pub fn goal_z(&self) -> f32 {
        self.inner.goal_z
    }
}

#[cfg(test)]
fn find_path(
    state: &GameState,
    from_x: f32,
    from_y: f32,
    from_z: f32,
    to_x: f32,
    to_y: f32,
    to_z: f32,
) -> Option<Vec<(f32, f32)>> {
    let world = nav_world_from_state(state);
    find_path_with_goal_z(&world, from_x, from_y, from_z, to_x, to_y, to_z)
}

#[cfg(test)]
mod tests {
    use super::*;
    use flatland_protocol::{EntityState, ResourceNodeState, Transform, Velocity2D, WorldCoord};

    fn empty_state() -> GameState {
        GameState {
            session_id: Default::default(),
            entity_id: 1,
            character_id: None,
            tick: 0,
            chunk_rev: 0,
            content_rev: 0,
            publish_rev: 0,
            entities: vec![],
            player: Some(EntityState {
                id: 1,
                transform: Transform {
                    position: WorldCoord::surface(10.0, 10.0),
                    yaw: 0.0,
                    velocity: Velocity2D { vx: 0.0, vy: 0.0 },
                },
                label: "p".into(),
                vitals: None,
                attributes: None,
                skills: None,
                inside_building: None,
                tile_id: None,
                paperdoll_ref: None,
                presentation_state: None,
                sprite_mode: None,
                progression_xp: None,
            }),
            resource_nodes: vec![],
            ground_drops: vec![],
            placed_containers: vec![],
            buildings: vec![],
            doors: vec![],
            interior_map: None,
            npcs: vec![],
            blueprints: vec![],
            world_width_m: 64.0,
            world_height_m: 64.0,
            terrain_zones: vec![],
            z_platforms: vec![],
            z_transitions: vec![],
            world_clock: Default::default(),
            inventory: Default::default(),
            inventory_hints: Default::default(),
            logs: Default::default(),
            intents_sent: 0,
            ticks_received: 0,
            connected: true,
            disconnect_reason: None,
            show_stats: false,
            show_equip_menu: false,
            equip_menu_index: 0,
            show_craft_menu: false,
            craft_menu_index: 0,
            craft_batch_quantity: 1,
            show_shop_menu: false,
            shop_catalog: None,
            bank_panel: None,
            bank_menu_index: 0,
            bank_ui_mode: crate::game::BankUiMode::Menu,
            storage_panel: None,
            storage_menu_index: 0,
            storage_ui_mode: crate::game::StorageUiMode::Menu,
            shop_tab: crate::game::ShopTab::default(),
            shop_menu_index: 0,
            shop_quantity: 1,
            shop_trade_log: std::collections::VecDeque::new(),
            show_npc_verb_menu: false,
            npc_verb_target: None,
            npc_verb_index: 0,
            show_npc_chat: false,
            npc_chat: None,
            show_inventory_menu: false,
            inventory_menu_index: 0,
            inventory_tab: crate::game::InventoryTab::OnPerson,
            inventory_filter: String::new(),
            inventory_filter_focused: false,
            show_move_picker: false,
            show_rename_prompt: false,
            show_worker_rename: false,
            rename_buffer: String::new(),
            move_picker_index: 0,
            move_picker: None,
            show_grant_picker: false,
            grant_picker_index: 0,
            grant_picker: None,
            show_destroy_picker: false,
            destroy_confirm_pending: false,
            destroy_picker: None,
            combat_target: None,
            combat_target_label: None,
            combat_fx: Vec::new(),
            in_combat: false,
            auto_attack: false,
            combat_has_los: false,
            attack_cd_ticks: 0,
            gcd_ticks: 0,
            weapon_ability_id: String::new(),
            mainhand_template_id: None,
            mainhand_label: None,
            offhand_template_id: None,
            offhand_label: None,
            mainhand_hand_slots: 1,
            defense: None,
            worn: std::collections::BTreeMap::new(),
            carry_mass: 0.0,
            carry_mass_max: 0.0,
            encumbrance: flatland_protocol::EncumbranceState::Light,
            inventory_stacks: Vec::new(),
            keychain_stacks: Vec::new(),
            combat_target_detail: None,
            statuses: Vec::new(),
            cast_progress: None,
            ability_cooldowns: vec![],
            blocking_active: false,
            max_target_slots: 1,
            combat_slots: vec![],
            rotation_presets: vec![],
            known_abilities: Vec::new(),
            hotbar: vec![None; 9],
            max_abilities_per_rotation: 0,
            show_loadout_menu: false,
            show_keychain_menu: false,
            keychain_menu_index: 0,
            show_rotation_editor: false,
            loadout_menu_index: 0,
            loadout_hotbar_slot: 1,
            loadout_ability_index: 0,
            loadout_focus_presets: false,
            rotation_editor: Default::default(),
            harvest_in_progress: false,
            harvest_started_at: None,
            pending_craft_ack: None,
            pending_worker_job_ack: None,
            quest_log: Vec::new(),
            interactables: Vec::new(),
            ledger: None,
            career: None,
            character_sheet_tab: crate::CharacterSheetTab::Character,
            ledger_period: crate::LedgerPeriod::Day,
            show_quest_offer: false,
            pending_quest_offer: None,
            show_quest_menu: false,
            quest_menu_index: 0,
            quest_withdraw_confirm: false,
            hired_workers: Vec::new(),
            show_workers_menu: false,
            workers_menu_index: 0,
            workers_menu_compact: false,
            worker_step_display: std::collections::BTreeMap::new(),
            worker_error_display: std::collections::BTreeMap::new(),
            show_worker_give_picker: false,
            worker_give_picker_index: 0,
            worker_give_picker: None,
            show_worker_give_target_picker: false,
            worker_give_target_picker_index: 0,
            worker_give_target_picker: None,
            show_worker_take_picker: false,
            worker_take_picker_index: 0,
            worker_take_picker: None,
            show_worker_teach_picker: false,
            worker_teach_picker_index: 0,
            worker_teach_picker: None,
            worker_route_editor: None,
            progression_curve: None,
        }
    }

    #[test]
    fn path_on_open_field() {
        let state = empty_state();
        let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
        assert!(!path.is_empty());
        let last = *path.last().unwrap();
        assert!((last.0 - 20.5).abs() < 1.0);
        assert!((last.1 - 15.5).abs() < 1.0);
    }

    #[test]
    fn path_routes_around_blocking_tree() {
        let mut state = empty_state();
        state
            .resource_nodes
            .push(flatland_protocol::ResourceNodeView {
                id: "oak".into(),
                label: "Oak".into(),
                x: 15.0,
                y: 12.0,
                z: 0.0,
                item_template: "oak_log".into(),
                state: ResourceNodeState::Available,
                blocking: true,
                blocking_radius_m: 0.8,
                tile_id: None,
                paperdoll_ref: None,
                yaw: 0.0,
                pitch: 0.0,
                roll: 0.0,
                draw_scale: 1.0,
                sprite_mode: None,
                presentation_state: None,
            });
        let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
        for (x, y) in &path {
            let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
            assert!(!near_tree, "path should not cut through tree at ({x},{y})");
        }
    }

    #[test]
    fn path_routes_around_building_with_clearance() {
        let mut state = empty_state();
        state.buildings.push(flatland_protocol::BuildingView {
            id: "hut".into(),
            label: "Hut".into(),
            x: 20.0,
            y: 20.0,
            width_m: 6.0,
            depth_m: 6.0,
            interior_blueprint: None,
            tags: vec![],
        });
        let path =
            find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
        assert!(!path.is_empty());
        let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
        let x0 = 20.0 - 3.0 - pad;
        let x1 = 20.0 + 3.0 + pad;
        let y0 = 20.0 - 3.0 - pad;
        let y1 = 20.0 + 3.0 + pad;
        for (x, y) in &path {
            let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
            assert!(
                !inside,
                "path waypoint ({x},{y}) intersects inflated building footprint"
            );
        }
        let last = *path.last().unwrap();
        assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
    }

    #[test]
    fn auto_navigator_finishes_near_goal() {
        let state = empty_state();
        let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
        let (fx, fy, fz) = state.player_position_with_z();
        let steer = nav.steer(fx, fy, fz, &state).expect("steer");
        assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
    }
}