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    Player,
21    Npc,
22    QuestBoard,
23    ExitDoor,
24    EnterDoor,
25    Well,
26    Water,
27    Loot,
28    ChestPickup,
29    Harvest,
30}
31
32impl UseWorldKind {
33    pub fn verb(self) -> &'static str {
34        match self {
35            Self::Player => "Whisper/Trade",
36            Self::Npc => "Talk/Trade",
37            Self::QuestBoard => "Read board",
38            Self::ExitDoor => "Exit",
39            Self::EnterDoor => "Enter",
40            Self::Well => "Use well",
41            Self::Water => "Fill water",
42            Self::Loot => "Pick up",
43            Self::ChestPickup => "Pick up chest",
44            Self::Harvest => "Harvest",
45        }
46    }
47
48    /// Sort key within the same distance band (lower = preferred), matching interact priority.
49    pub fn interact_priority(self) -> u8 {
50        match self {
51            Self::Player => 0,
52            Self::Npc => 0,
53            Self::QuestBoard => 1,
54            Self::ExitDoor => 2,
55            Self::EnterDoor => 3,
56            Self::Well => 4,
57            Self::Water => 5,
58            Self::Loot => 10,
59            Self::ChestPickup => 11,
60            Self::Harvest => 12,
61        }
62    }
63
64    /// Cascade stage for `use_nearest` (0 = interact layer, then loot, chest, harvest).
65    pub fn cascade_stage(self) -> u8 {
66        match self {
67            Self::Player
68            | Self::Npc
69            | Self::QuestBoard
70            | Self::ExitDoor
71            | Self::EnterDoor
72            | Self::Well
73            | Self::Water => 0,
74            Self::Loot => 1,
75            Self::ChestPickup => 2,
76            Self::Harvest => 3,
77        }
78    }
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct UseWorldCandidate {
83    pub id: String,
84    pub kind: UseWorldKind,
85    pub label: String,
86    pub x: f32,
87    pub y: f32,
88    pub distance_m: f32,
89    pub range_m: f32,
90    /// True when within the range `f` would accept for this kind.
91    pub in_range: bool,
92}
93
94impl UseWorldCandidate {
95    pub fn hint_line(&self) -> String {
96        if self.label.trim().is_empty() {
97            format!("f → {} ({:.1}m)", self.kind.verb(), self.distance_m)
98        } else {
99            format!(
100                "f → {} {} ({:.1}m)",
101                self.kind.verb(),
102                self.label,
103                self.distance_m
104            )
105        }
106    }
107}
108
109/// Prefer `label` / room names over opaque ids (see prefer-labels-over-ids rule).
110fn friendly_or_id(label: &str, id: &str) -> String {
111    let trimmed = label.trim();
112    if trimmed.is_empty() {
113        id.to_string()
114    } else {
115        trimmed.to_string()
116    }
117}
118
119fn door_use_label(state: &GameState, door: &flatland_protocol::DoorView, is_exit: bool) -> String {
120    if is_exit {
121        return "outdoors".into();
122    }
123    if let Some(map) = &state.interior_map {
124        if let Some(rd) = map.room_doors.iter().find(|d| d.id == door.id) {
125            let room_name = |room_id: &str| {
126                map.rooms
127                    .iter()
128                    .find(|r| r.id == room_id)
129                    .map(|r| friendly_or_id(&r.label, room_id))
130                    .unwrap_or_else(|| room_id.to_string())
131            };
132            return format!("{} ↔ {}", room_name(&rd.room_a), room_name(&rd.room_b));
133        }
134    }
135    state
136        .buildings
137        .iter()
138        .find(|b| b.id == door.building_id)
139        .map(|b| friendly_or_id(&b.label, &b.id))
140        .unwrap_or_else(|| door.building_id.clone())
141}
142
143#[derive(Debug, Clone, PartialEq, Default)]
144pub struct UseWorldProbe {
145    /// What `f` will actually do right now (if anything).
146    pub primary: Option<UseWorldCandidate>,
147    /// All AOI candidates within [`USE_WORLD_NEARBY_SCAN_M`], for dim rings.
148    pub candidates: Vec<UseWorldCandidate>,
149}
150
151impl UseWorldProbe {
152    pub fn hint_line(&self) -> String {
153        match &self.primary {
154            Some(c) => c.hint_line(),
155            None => "f → nothing in range".into(),
156        }
157    }
158}
159
160impl GameState {
161    /// Analyze AOI for `f` presentation (rings, HUD hint). Does not submit intents.
162    pub fn probe_use_world(&self) -> UseWorldProbe {
163        let (px, py) = self.player_position();
164        let inside = self.effective_inside_building();
165        let mut candidates: Vec<UseWorldCandidate> = Vec::new();
166
167        let push = |list: &mut Vec<UseWorldCandidate>,
168                    id: String,
169                    kind: UseWorldKind,
170                    label: String,
171                    x: f32,
172                    y: f32,
173                    range_m: f32| {
174            let distance_m = distance(px, py, x, y);
175            if distance_m > USE_WORLD_NEARBY_SCAN_M {
176                return;
177            }
178            list.push(UseWorldCandidate {
179                id,
180                kind,
181                label,
182                x,
183                y,
184                distance_m,
185                range_m,
186                in_range: distance_m <= range_m,
187            });
188        };
189
190        for npc in &self.npcs {
191            push(
192                &mut candidates,
193                npc.id.clone(),
194                UseWorldKind::Npc,
195                npc.label.clone(),
196                npc.x,
197                npc.y,
198                INTERACTION_RADIUS_M,
199            );
200        }
201
202        for entity in &self.entities {
203            if entity.id == self.entity_id {
204                continue;
205            }
206            // Other players: have a label and are not wildlife/NPC (NPCs live in `npcs`).
207            // Agents/hired workers may appear in entities — prefer those with vitals + label.
208            if entity.label.trim().is_empty() {
209                continue;
210            }
211            if self.npcs.iter().any(|n| n.id == entity.id.to_string()) {
212                continue;
213            }
214            // Skip if this looks like a wildlife/NPC entity already covered — players have vitals.
215            if entity.vitals.is_none() {
216                continue;
217            }
218            let x = entity.transform.position.x;
219            let y = entity.transform.position.y;
220            push(
221                &mut candidates,
222                entity.id.to_string(),
223                UseWorldKind::Player,
224                entity.label.clone(),
225                x,
226                y,
227                INTERACTION_RADIUS_M,
228            );
229        }
230
231        for door in &self.doors {
232            if let Some(ref bid) = inside {
233                if door.building_id != *bid {
234                    continue;
235                }
236                let is_exit = door.portal.is_some();
237                let (kind, range) = if is_exit {
238                    (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
239                } else {
240                    (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
241                };
242                push(
243                    &mut candidates,
244                    door.id.clone(),
245                    kind,
246                    door_use_label(self, door, is_exit),
247                    door.x,
248                    door.y,
249                    range,
250                );
251                continue;
252            }
253            push(
254                &mut candidates,
255                door.id.clone(),
256                UseWorldKind::EnterDoor,
257                door_use_label(self, door, false),
258                door.x,
259                door.y,
260                DOOR_INTERACTION_RADIUS_M,
261            );
262        }
263
264        if inside.is_none() {
265            for inter in &self.interactables {
266                if inter.kind == "quest_board" {
267                    push(
268                        &mut candidates,
269                        inter.id.clone(),
270                        UseWorldKind::QuestBoard,
271                        inter.label.clone(),
272                        inter.x,
273                        inter.y,
274                        QUEST_BOARD_INTERACTION_RADIUS_M,
275                    );
276                }
277            }
278            for building in &self.buildings {
279                if !building.tags.iter().any(|t| t == "well") {
280                    continue;
281                }
282                push(
283                    &mut candidates,
284                    building.id.clone(),
285                    UseWorldKind::Well,
286                    building.label.clone(),
287                    building.x,
288                    building.y,
289                    INTERACTION_RADIUS_M,
290                );
291            }
292            if self.in_shallow_water() {
293                push(
294                    &mut candidates,
295                    "water_source".into(),
296                    UseWorldKind::Water,
297                    "Shallow water".into(),
298                    px,
299                    py,
300                    INTERACTION_RADIUS_M,
301                );
302            }
303        }
304
305        for drop in &self.ground_drops {
306            let label = if drop.quantity > 1 {
307                format!("{} ×{}", drop.template_id, drop.quantity)
308            } else {
309                drop.template_id.clone()
310            };
311            push(
312                &mut candidates,
313                drop.id.clone(),
314                UseWorldKind::Loot,
315                label,
316                drop.x,
317                drop.y,
318                INTERACTION_RADIUS_M,
319            );
320        }
321
322        for chest in &self.placed_containers {
323            let _browse = CONTAINER_RANGE_M;
324            let mut label = chest.display_name.clone();
325            if let Some(who) = self.lodging_occupancy_label(&chest.id) {
326                label = format!("{label} ({who})");
327            }
328            push(
329                &mut candidates,
330                chest.id.clone(),
331                UseWorldKind::ChestPickup,
332                label,
333                chest.x,
334                chest.y,
335                CHEST_PICKUP_RANGE_M,
336            );
337        }
338
339        for node in &self.resource_nodes {
340            if !matches!(
341                node.state,
342                flatland_protocol::ResourceNodeState::Available
343                    | flatland_protocol::ResourceNodeState::Harvesting
344            ) {
345                continue;
346            }
347            push(
348                &mut candidates,
349                node.id.clone(),
350                UseWorldKind::Harvest,
351                node.label.clone(),
352                node.x,
353                node.y,
354                HARVEST_RANGE_M,
355            );
356        }
357
358        let primary = pick_primary(&candidates);
359
360        UseWorldProbe {
361            primary,
362            candidates,
363        }
364    }
365}
366
367fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
368    let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
369    if in_range.is_empty() {
370        return None;
371    }
372
373    let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
374    let stage = if has_interact {
375        0
376    } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
377        1
378    } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
379        2
380    } else {
381        3
382    };
383
384    let mut best: Option<&UseWorldCandidate> = None;
385    for c in in_range
386        .into_iter()
387        .filter(|c| c.kind.cascade_stage() == stage)
388    {
389        let replace = match best {
390            None => true,
391            Some(b) if c.distance_m < b.distance_m - 0.05 => true,
392            Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
393                c.kind.interact_priority() < b.kind.interact_priority()
394            }
395            _ => false,
396        };
397        if replace {
398            best = Some(c);
399        }
400    }
401    best.cloned()
402}