Skip to main content

flatland_client_lib/
use_world.rs

1//! Probe what `f` / [`crate::GameClient::use_nearest`] would do — shared by gfx highlights.
2
3use crate::game::{GameState, CONTAINER_RANGE_M};
4
5const INTERACTION_RADIUS_M: f32 = 1.5;
6const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
7const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
8const CHEST_PICKUP_RANGE_M: f32 = 2.0;
9const HARVEST_RANGE_M: f32 = 1.5;
10/// Candidates beyond this are omitted from the probe list.
11pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;
12
13fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14    (ax - bx).hypot(ay - by)
15}
16
17/// What kind of world-use target this is (drives tint + verb).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum UseWorldKind {
20    Npc,
21    QuestBoard,
22    ExitDoor,
23    EnterDoor,
24    Well,
25    Water,
26    Loot,
27    ChestPickup,
28    Harvest,
29}
30
31impl UseWorldKind {
32    pub fn verb(self) -> &'static str {
33        match self {
34            Self::Npc => "Talk/Trade",
35            Self::QuestBoard => "Read board",
36            Self::ExitDoor => "Exit",
37            Self::EnterDoor => "Enter",
38            Self::Well => "Use well",
39            Self::Water => "Fill water",
40            Self::Loot => "Pick up",
41            Self::ChestPickup => "Pick up chest",
42            Self::Harvest => "Harvest",
43        }
44    }
45
46    /// Sort key within the same distance band (lower = preferred), matching interact priority.
47    pub fn interact_priority(self) -> u8 {
48        match self {
49            Self::Npc => 0,
50            Self::QuestBoard => 1,
51            Self::ExitDoor => 2,
52            Self::EnterDoor => 3,
53            Self::Well => 4,
54            Self::Water => 5,
55            Self::Loot => 10,
56            Self::ChestPickup => 11,
57            Self::Harvest => 12,
58        }
59    }
60
61    /// Cascade stage for `use_nearest` (0 = interact layer, then loot, chest, harvest).
62    pub fn cascade_stage(self) -> u8 {
63        match self {
64            Self::Npc
65            | Self::QuestBoard
66            | Self::ExitDoor
67            | Self::EnterDoor
68            | Self::Well
69            | Self::Water => 0,
70            Self::Loot => 1,
71            Self::ChestPickup => 2,
72            Self::Harvest => 3,
73        }
74    }
75}
76
77#[derive(Debug, Clone, PartialEq)]
78pub struct UseWorldCandidate {
79    pub id: String,
80    pub kind: UseWorldKind,
81    pub label: String,
82    pub x: f32,
83    pub y: f32,
84    pub distance_m: f32,
85    pub range_m: f32,
86    /// True when within the range `f` would accept for this kind.
87    pub in_range: bool,
88}
89
90impl UseWorldCandidate {
91    pub fn hint_line(&self) -> String {
92        format!(
93            "f → {} {} ({:.1}m)",
94            self.kind.verb(),
95            self.label,
96            self.distance_m
97        )
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Default)]
102pub struct UseWorldProbe {
103    /// What `f` will actually do right now (if anything).
104    pub primary: Option<UseWorldCandidate>,
105    /// All AOI candidates within [`USE_WORLD_NEARBY_SCAN_M`], for dim rings.
106    pub candidates: Vec<UseWorldCandidate>,
107}
108
109impl UseWorldProbe {
110    pub fn hint_line(&self) -> String {
111        match &self.primary {
112            Some(c) => c.hint_line(),
113            None => "f → nothing in range".into(),
114        }
115    }
116}
117
118impl GameState {
119    /// Analyze AOI for `f` presentation (rings, HUD hint). Does not submit intents.
120    pub fn probe_use_world(&self) -> UseWorldProbe {
121        let (px, py) = self.player_position();
122        let inside = self.effective_inside_building();
123        let mut candidates: Vec<UseWorldCandidate> = Vec::new();
124
125        let push = |list: &mut Vec<UseWorldCandidate>,
126                    id: String,
127                    kind: UseWorldKind,
128                    label: String,
129                    x: f32,
130                    y: f32,
131                    range_m: f32| {
132            let distance_m = distance(px, py, x, y);
133            if distance_m > USE_WORLD_NEARBY_SCAN_M {
134                return;
135            }
136            list.push(UseWorldCandidate {
137                id,
138                kind,
139                label,
140                x,
141                y,
142                distance_m,
143                range_m,
144                in_range: distance_m <= range_m,
145            });
146        };
147
148        for npc in &self.npcs {
149            push(
150                &mut candidates,
151                npc.id.clone(),
152                UseWorldKind::Npc,
153                npc.label.clone(),
154                npc.x,
155                npc.y,
156                INTERACTION_RADIUS_M,
157            );
158        }
159
160        for door in &self.doors {
161            if let Some(ref bid) = inside {
162                if door.building_id != *bid {
163                    continue;
164                }
165                let is_exit = door.portal.is_some();
166                let (kind, range) = if is_exit {
167                    (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
168                } else {
169                    (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
170                };
171                push(
172                    &mut candidates,
173                    door.id.clone(),
174                    kind,
175                    if is_exit {
176                        "Exit".into()
177                    } else {
178                        format!("Door ({})", door.building_id)
179                    },
180                    door.x,
181                    door.y,
182                    range,
183                );
184                continue;
185            }
186            push(
187                &mut candidates,
188                door.id.clone(),
189                UseWorldKind::EnterDoor,
190                format!("Door ({})", door.building_id),
191                door.x,
192                door.y,
193                DOOR_INTERACTION_RADIUS_M,
194            );
195        }
196
197        if inside.is_none() {
198            for inter in &self.interactables {
199                if inter.kind == "quest_board" {
200                    push(
201                        &mut candidates,
202                        inter.id.clone(),
203                        UseWorldKind::QuestBoard,
204                        inter.label.clone(),
205                        inter.x,
206                        inter.y,
207                        QUEST_BOARD_INTERACTION_RADIUS_M,
208                    );
209                }
210            }
211            for building in &self.buildings {
212                if !building.tags.iter().any(|t| t == "well") {
213                    continue;
214                }
215                push(
216                    &mut candidates,
217                    building.id.clone(),
218                    UseWorldKind::Well,
219                    building.label.clone(),
220                    building.x,
221                    building.y,
222                    INTERACTION_RADIUS_M,
223                );
224            }
225            if self.in_shallow_water() {
226                push(
227                    &mut candidates,
228                    "water_source".into(),
229                    UseWorldKind::Water,
230                    "Shallow water".into(),
231                    px,
232                    py,
233                    INTERACTION_RADIUS_M,
234                );
235            }
236        }
237
238        for drop in &self.ground_drops {
239            let label = if drop.quantity > 1 {
240                format!("{} ×{}", drop.template_id, drop.quantity)
241            } else {
242                drop.template_id.clone()
243            };
244            push(
245                &mut candidates,
246                drop.id.clone(),
247                UseWorldKind::Loot,
248                label,
249                drop.x,
250                drop.y,
251                INTERACTION_RADIUS_M,
252            );
253        }
254
255        for chest in &self.placed_containers {
256            let _browse = CONTAINER_RANGE_M;
257            push(
258                &mut candidates,
259                chest.id.clone(),
260                UseWorldKind::ChestPickup,
261                chest.display_name.clone(),
262                chest.x,
263                chest.y,
264                CHEST_PICKUP_RANGE_M,
265            );
266        }
267
268        for node in &self.resource_nodes {
269            if !matches!(
270                node.state,
271                flatland_protocol::ResourceNodeState::Available
272                    | flatland_protocol::ResourceNodeState::Harvesting
273            ) {
274                continue;
275            }
276            push(
277                &mut candidates,
278                node.id.clone(),
279                UseWorldKind::Harvest,
280                node.label.clone(),
281                node.x,
282                node.y,
283                HARVEST_RANGE_M,
284            );
285        }
286
287        let primary = pick_primary(&candidates);
288
289        UseWorldProbe {
290            primary,
291            candidates,
292        }
293    }
294}
295
296fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
297    let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
298    if in_range.is_empty() {
299        return None;
300    }
301
302    let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
303    let stage = if has_interact {
304        0
305    } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
306        1
307    } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
308        2
309    } else {
310        3
311    };
312
313    let mut best: Option<&UseWorldCandidate> = None;
314    for c in in_range
315        .into_iter()
316        .filter(|c| c.kind.cascade_stage() == stage)
317    {
318        let replace = match best {
319            None => true,
320            Some(b) if c.distance_m < b.distance_m - 0.05 => true,
321            Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
322                c.kind.interact_priority() < b.kind.interact_priority()
323            }
324            _ => false,
325        };
326        if replace {
327            best = Some(c);
328        }
329    }
330    best.cloned()
331}