flatland-client-lib 0.2.11

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Probe what `f` / [`crate::GameClient::use_nearest`] would do — shared by gfx highlights.

use crate::game::{GameState, CONTAINER_RANGE_M};

const INTERACTION_RADIUS_M: f32 = 1.5;
const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
const CHEST_PICKUP_RANGE_M: f32 = 2.0;
const HARVEST_RANGE_M: f32 = 1.5;
/// Candidates beyond this are omitted from the probe list.
pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;

fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
    (ax - bx).hypot(ay - by)
}

/// What kind of world-use target this is (drives tint + verb).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UseWorldKind {
    Npc,
    QuestBoard,
    ExitDoor,
    EnterDoor,
    Well,
    Water,
    Loot,
    ChestPickup,
    Harvest,
}

impl UseWorldKind {
    pub fn verb(self) -> &'static str {
        match self {
            Self::Npc => "Talk/Trade",
            Self::QuestBoard => "Read board",
            Self::ExitDoor => "Exit",
            Self::EnterDoor => "Enter",
            Self::Well => "Use well",
            Self::Water => "Fill water",
            Self::Loot => "Pick up",
            Self::ChestPickup => "Pick up chest",
            Self::Harvest => "Harvest",
        }
    }

    /// Sort key within the same distance band (lower = preferred), matching interact priority.
    pub fn interact_priority(self) -> u8 {
        match self {
            Self::Npc => 0,
            Self::QuestBoard => 1,
            Self::ExitDoor => 2,
            Self::EnterDoor => 3,
            Self::Well => 4,
            Self::Water => 5,
            Self::Loot => 10,
            Self::ChestPickup => 11,
            Self::Harvest => 12,
        }
    }

    /// Cascade stage for `use_nearest` (0 = interact layer, then loot, chest, harvest).
    pub fn cascade_stage(self) -> u8 {
        match self {
            Self::Npc
            | Self::QuestBoard
            | Self::ExitDoor
            | Self::EnterDoor
            | Self::Well
            | Self::Water => 0,
            Self::Loot => 1,
            Self::ChestPickup => 2,
            Self::Harvest => 3,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct UseWorldCandidate {
    pub id: String,
    pub kind: UseWorldKind,
    pub label: String,
    pub x: f32,
    pub y: f32,
    pub distance_m: f32,
    pub range_m: f32,
    /// True when within the range `f` would accept for this kind.
    pub in_range: bool,
}

impl UseWorldCandidate {
    pub fn hint_line(&self) -> String {
        format!(
            "f → {} {} ({:.1}m)",
            self.kind.verb(),
            self.label,
            self.distance_m
        )
    }
}

#[derive(Debug, Clone, PartialEq, Default)]
pub struct UseWorldProbe {
    /// What `f` will actually do right now (if anything).
    pub primary: Option<UseWorldCandidate>,
    /// All AOI candidates within [`USE_WORLD_NEARBY_SCAN_M`], for dim rings.
    pub candidates: Vec<UseWorldCandidate>,
}

impl UseWorldProbe {
    pub fn hint_line(&self) -> String {
        match &self.primary {
            Some(c) => c.hint_line(),
            None => "f → nothing in range".into(),
        }
    }
}

impl GameState {
    /// Analyze AOI for `f` presentation (rings, HUD hint). Does not submit intents.
    pub fn probe_use_world(&self) -> UseWorldProbe {
        let (px, py) = self.player_position();
        let inside = self.effective_inside_building();
        let mut candidates: Vec<UseWorldCandidate> = Vec::new();

        let push = |list: &mut Vec<UseWorldCandidate>,
                    id: String,
                    kind: UseWorldKind,
                    label: String,
                    x: f32,
                    y: f32,
                    range_m: f32| {
            let distance_m = distance(px, py, x, y);
            if distance_m > USE_WORLD_NEARBY_SCAN_M {
                return;
            }
            list.push(UseWorldCandidate {
                id,
                kind,
                label,
                x,
                y,
                distance_m,
                range_m,
                in_range: distance_m <= range_m,
            });
        };

        for npc in &self.npcs {
            push(
                &mut candidates,
                npc.id.clone(),
                UseWorldKind::Npc,
                npc.label.clone(),
                npc.x,
                npc.y,
                INTERACTION_RADIUS_M,
            );
        }

        for door in &self.doors {
            if let Some(ref bid) = inside {
                if door.building_id != *bid {
                    continue;
                }
                let is_exit = door.portal.is_some();
                let (kind, range) = if is_exit {
                    (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
                } else {
                    (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
                };
                push(
                    &mut candidates,
                    door.id.clone(),
                    kind,
                    if is_exit {
                        "Exit".into()
                    } else {
                        format!("Door ({})", door.building_id)
                    },
                    door.x,
                    door.y,
                    range,
                );
                continue;
            }
            push(
                &mut candidates,
                door.id.clone(),
                UseWorldKind::EnterDoor,
                format!("Door ({})", door.building_id),
                door.x,
                door.y,
                DOOR_INTERACTION_RADIUS_M,
            );
        }

        if inside.is_none() {
            for inter in &self.interactables {
                if inter.kind == "quest_board" {
                    push(
                        &mut candidates,
                        inter.id.clone(),
                        UseWorldKind::QuestBoard,
                        inter.label.clone(),
                        inter.x,
                        inter.y,
                        QUEST_BOARD_INTERACTION_RADIUS_M,
                    );
                }
            }
            for building in &self.buildings {
                if !building.tags.iter().any(|t| t == "well") {
                    continue;
                }
                push(
                    &mut candidates,
                    building.id.clone(),
                    UseWorldKind::Well,
                    building.label.clone(),
                    building.x,
                    building.y,
                    INTERACTION_RADIUS_M,
                );
            }
            if self.in_shallow_water() {
                push(
                    &mut candidates,
                    "water_source".into(),
                    UseWorldKind::Water,
                    "Shallow water".into(),
                    px,
                    py,
                    INTERACTION_RADIUS_M,
                );
            }
        }

        for drop in &self.ground_drops {
            let label = if drop.quantity > 1 {
                format!("{} ×{}", drop.template_id, drop.quantity)
            } else {
                drop.template_id.clone()
            };
            push(
                &mut candidates,
                drop.id.clone(),
                UseWorldKind::Loot,
                label,
                drop.x,
                drop.y,
                INTERACTION_RADIUS_M,
            );
        }

        for chest in &self.placed_containers {
            let _browse = CONTAINER_RANGE_M;
            let mut label = chest.display_name.clone();
            if let Some(who) = self.lodging_occupancy_label(&chest.id) {
                label = format!("{label} ({who})");
            }
            push(
                &mut candidates,
                chest.id.clone(),
                UseWorldKind::ChestPickup,
                label,
                chest.x,
                chest.y,
                CHEST_PICKUP_RANGE_M,
            );
        }

        for node in &self.resource_nodes {
            if !matches!(
                node.state,
                flatland_protocol::ResourceNodeState::Available
                    | flatland_protocol::ResourceNodeState::Harvesting
            ) {
                continue;
            }
            push(
                &mut candidates,
                node.id.clone(),
                UseWorldKind::Harvest,
                node.label.clone(),
                node.x,
                node.y,
                HARVEST_RANGE_M,
            );
        }

        let primary = pick_primary(&candidates);

        UseWorldProbe {
            primary,
            candidates,
        }
    }
}

fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
    let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
    if in_range.is_empty() {
        return None;
    }

    let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
    let stage = if has_interact {
        0
    } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
        1
    } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
        2
    } else {
        3
    };

    let mut best: Option<&UseWorldCandidate> = None;
    for c in in_range
        .into_iter()
        .filter(|c| c.kind.cascade_stage() == stage)
    {
        let replace = match best {
            None => true,
            Some(b) if c.distance_m < b.distance_m - 0.05 => true,
            Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
                c.kind.interact_priority() < b.kind.interact_priority()
            }
            _ => false,
        };
        if replace {
            best = Some(c);
        }
    }
    best.cloned()
}