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        if self.label.trim().is_empty() {
93            format!("f → {} ({:.1}m)", self.kind.verb(), self.distance_m)
94        } else {
95            format!(
96                "f → {} {} ({:.1}m)",
97                self.kind.verb(),
98                self.label,
99                self.distance_m
100            )
101        }
102    }
103}
104
105/// Prefer `label` / room names over opaque ids (see prefer-labels-over-ids rule).
106fn friendly_or_id(label: &str, id: &str) -> String {
107    let trimmed = label.trim();
108    if trimmed.is_empty() {
109        id.to_string()
110    } else {
111        trimmed.to_string()
112    }
113}
114
115fn door_use_label(state: &GameState, door: &flatland_protocol::DoorView, is_exit: bool) -> String {
116    if is_exit {
117        return "outdoors".into();
118    }
119    if let Some(map) = &state.interior_map {
120        if let Some(rd) = map.room_doors.iter().find(|d| d.id == door.id) {
121            let room_name = |room_id: &str| {
122                map.rooms
123                    .iter()
124                    .find(|r| r.id == room_id)
125                    .map(|r| friendly_or_id(&r.label, room_id))
126                    .unwrap_or_else(|| room_id.to_string())
127            };
128            return format!("{} ↔ {}", room_name(&rd.room_a), room_name(&rd.room_b));
129        }
130    }
131    state
132        .buildings
133        .iter()
134        .find(|b| b.id == door.building_id)
135        .map(|b| friendly_or_id(&b.label, &b.id))
136        .unwrap_or_else(|| door.building_id.clone())
137}
138
139#[derive(Debug, Clone, PartialEq, Default)]
140pub struct UseWorldProbe {
141    /// What `f` will actually do right now (if anything).
142    pub primary: Option<UseWorldCandidate>,
143    /// All AOI candidates within [`USE_WORLD_NEARBY_SCAN_M`], for dim rings.
144    pub candidates: Vec<UseWorldCandidate>,
145}
146
147impl UseWorldProbe {
148    pub fn hint_line(&self) -> String {
149        match &self.primary {
150            Some(c) => c.hint_line(),
151            None => "f → nothing in range".into(),
152        }
153    }
154}
155
156impl GameState {
157    /// Analyze AOI for `f` presentation (rings, HUD hint). Does not submit intents.
158    pub fn probe_use_world(&self) -> UseWorldProbe {
159        let (px, py) = self.player_position();
160        let inside = self.effective_inside_building();
161        let mut candidates: Vec<UseWorldCandidate> = Vec::new();
162
163        let push = |list: &mut Vec<UseWorldCandidate>,
164                    id: String,
165                    kind: UseWorldKind,
166                    label: String,
167                    x: f32,
168                    y: f32,
169                    range_m: f32| {
170            let distance_m = distance(px, py, x, y);
171            if distance_m > USE_WORLD_NEARBY_SCAN_M {
172                return;
173            }
174            list.push(UseWorldCandidate {
175                id,
176                kind,
177                label,
178                x,
179                y,
180                distance_m,
181                range_m,
182                in_range: distance_m <= range_m,
183            });
184        };
185
186        for npc in &self.npcs {
187            push(
188                &mut candidates,
189                npc.id.clone(),
190                UseWorldKind::Npc,
191                npc.label.clone(),
192                npc.x,
193                npc.y,
194                INTERACTION_RADIUS_M,
195            );
196        }
197
198        for door in &self.doors {
199            if let Some(ref bid) = inside {
200                if door.building_id != *bid {
201                    continue;
202                }
203                let is_exit = door.portal.is_some();
204                let (kind, range) = if is_exit {
205                    (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
206                } else {
207                    (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
208                };
209                push(
210                    &mut candidates,
211                    door.id.clone(),
212                    kind,
213                    door_use_label(self, door, is_exit),
214                    door.x,
215                    door.y,
216                    range,
217                );
218                continue;
219            }
220            push(
221                &mut candidates,
222                door.id.clone(),
223                UseWorldKind::EnterDoor,
224                door_use_label(self, door, false),
225                door.x,
226                door.y,
227                DOOR_INTERACTION_RADIUS_M,
228            );
229        }
230
231        if inside.is_none() {
232            for inter in &self.interactables {
233                if inter.kind == "quest_board" {
234                    push(
235                        &mut candidates,
236                        inter.id.clone(),
237                        UseWorldKind::QuestBoard,
238                        inter.label.clone(),
239                        inter.x,
240                        inter.y,
241                        QUEST_BOARD_INTERACTION_RADIUS_M,
242                    );
243                }
244            }
245            for building in &self.buildings {
246                if !building.tags.iter().any(|t| t == "well") {
247                    continue;
248                }
249                push(
250                    &mut candidates,
251                    building.id.clone(),
252                    UseWorldKind::Well,
253                    building.label.clone(),
254                    building.x,
255                    building.y,
256                    INTERACTION_RADIUS_M,
257                );
258            }
259            if self.in_shallow_water() {
260                push(
261                    &mut candidates,
262                    "water_source".into(),
263                    UseWorldKind::Water,
264                    "Shallow water".into(),
265                    px,
266                    py,
267                    INTERACTION_RADIUS_M,
268                );
269            }
270        }
271
272        for drop in &self.ground_drops {
273            let label = if drop.quantity > 1 {
274                format!("{} ×{}", drop.template_id, drop.quantity)
275            } else {
276                drop.template_id.clone()
277            };
278            push(
279                &mut candidates,
280                drop.id.clone(),
281                UseWorldKind::Loot,
282                label,
283                drop.x,
284                drop.y,
285                INTERACTION_RADIUS_M,
286            );
287        }
288
289        for chest in &self.placed_containers {
290            let _browse = CONTAINER_RANGE_M;
291            let mut label = chest.display_name.clone();
292            if let Some(who) = self.lodging_occupancy_label(&chest.id) {
293                label = format!("{label} ({who})");
294            }
295            push(
296                &mut candidates,
297                chest.id.clone(),
298                UseWorldKind::ChestPickup,
299                label,
300                chest.x,
301                chest.y,
302                CHEST_PICKUP_RANGE_M,
303            );
304        }
305
306        for node in &self.resource_nodes {
307            if !matches!(
308                node.state,
309                flatland_protocol::ResourceNodeState::Available
310                    | flatland_protocol::ResourceNodeState::Harvesting
311            ) {
312                continue;
313            }
314            push(
315                &mut candidates,
316                node.id.clone(),
317                UseWorldKind::Harvest,
318                node.label.clone(),
319                node.x,
320                node.y,
321                HARVEST_RANGE_M,
322            );
323        }
324
325        let primary = pick_primary(&candidates);
326
327        UseWorldProbe {
328            primary,
329            candidates,
330        }
331    }
332}
333
334fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
335    let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
336    if in_range.is_empty() {
337        return None;
338    }
339
340    let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
341    let stage = if has_interact {
342        0
343    } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
344        1
345    } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
346        2
347    } else {
348        3
349    };
350
351    let mut best: Option<&UseWorldCandidate> = None;
352    for c in in_range
353        .into_iter()
354        .filter(|c| c.kind.cascade_stage() == stage)
355    {
356        let replace = match best {
357            None => true,
358            Some(b) if c.distance_m < b.distance_m - 0.05 => true,
359            Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
360                c.kind.interact_priority() < b.kind.interact_priority()
361            }
362            _ => false,
363        };
364        if replace {
365            best = Some(c);
366        }
367    }
368    best.cloned()
369}