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            // Hired workers use the workers menu / NPC dialogue — not Whisper/Trade.
215            if self
216                .hired_workers
217                .iter()
218                .any(|w| w.entity_id == entity.id)
219            {
220                continue;
221            }
222            // Skip if this looks like a wildlife/NPC entity already covered — players have vitals.
223            if entity.vitals.is_none() {
224                continue;
225            }
226            let x = entity.transform.position.x;
227            let y = entity.transform.position.y;
228            push(
229                &mut candidates,
230                entity.id.to_string(),
231                UseWorldKind::Player,
232                entity.label.clone(),
233                x,
234                y,
235                INTERACTION_RADIUS_M,
236            );
237        }
238
239        for door in &self.doors {
240            if let Some(ref bid) = inside {
241                if door.building_id != *bid {
242                    continue;
243                }
244                let is_exit = door.portal.is_some();
245                let (kind, range) = if is_exit {
246                    (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
247                } else {
248                    (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
249                };
250                push(
251                    &mut candidates,
252                    door.id.clone(),
253                    kind,
254                    door_use_label(self, door, is_exit),
255                    door.x,
256                    door.y,
257                    range,
258                );
259                continue;
260            }
261            push(
262                &mut candidates,
263                door.id.clone(),
264                UseWorldKind::EnterDoor,
265                door_use_label(self, door, false),
266                door.x,
267                door.y,
268                DOOR_INTERACTION_RADIUS_M,
269            );
270        }
271
272        if inside.is_none() {
273            for inter in &self.interactables {
274                if inter.kind == "quest_board" {
275                    push(
276                        &mut candidates,
277                        inter.id.clone(),
278                        UseWorldKind::QuestBoard,
279                        inter.label.clone(),
280                        inter.x,
281                        inter.y,
282                        QUEST_BOARD_INTERACTION_RADIUS_M,
283                    );
284                }
285            }
286            for building in &self.buildings {
287                if !building.tags.iter().any(|t| t == "well") {
288                    continue;
289                }
290                push(
291                    &mut candidates,
292                    building.id.clone(),
293                    UseWorldKind::Well,
294                    building.label.clone(),
295                    building.x,
296                    building.y,
297                    INTERACTION_RADIUS_M,
298                );
299            }
300            if self.in_shallow_water() {
301                push(
302                    &mut candidates,
303                    "water_source".into(),
304                    UseWorldKind::Water,
305                    "Shallow water".into(),
306                    px,
307                    py,
308                    INTERACTION_RADIUS_M,
309                );
310            }
311        }
312
313        for drop in &self.ground_drops {
314            let label = if drop.quantity > 1 {
315                format!("{} ×{}", drop.template_id, drop.quantity)
316            } else {
317                drop.template_id.clone()
318            };
319            push(
320                &mut candidates,
321                drop.id.clone(),
322                UseWorldKind::Loot,
323                label,
324                drop.x,
325                drop.y,
326                INTERACTION_RADIUS_M,
327            );
328        }
329
330        for chest in &self.placed_containers {
331            let _browse = CONTAINER_RANGE_M;
332            let mut label = chest.display_name.clone();
333            if let Some(who) = self.lodging_occupancy_label(&chest.id) {
334                label = format!("{label} ({who})");
335            }
336            push(
337                &mut candidates,
338                chest.id.clone(),
339                UseWorldKind::ChestPickup,
340                label,
341                chest.x,
342                chest.y,
343                CHEST_PICKUP_RANGE_M,
344            );
345        }
346
347        for node in &self.resource_nodes {
348            if !matches!(
349                node.state,
350                flatland_protocol::ResourceNodeState::Available
351                    | flatland_protocol::ResourceNodeState::Harvesting
352            ) {
353                continue;
354            }
355            push(
356                &mut candidates,
357                node.id.clone(),
358                UseWorldKind::Harvest,
359                node.label.clone(),
360                node.x,
361                node.y,
362                HARVEST_RANGE_M,
363            );
364        }
365
366        let primary = pick_primary(&candidates);
367
368        UseWorldProbe {
369            primary,
370            candidates,
371        }
372    }
373}
374
375fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
376    let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
377    if in_range.is_empty() {
378        return None;
379    }
380
381    let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
382    let stage = if has_interact {
383        0
384    } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
385        1
386    } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
387        2
388    } else {
389        3
390    };
391
392    let mut best: Option<&UseWorldCandidate> = None;
393    for c in in_range
394        .into_iter()
395        .filter(|c| c.kind.cascade_stage() == stage)
396    {
397        let replace = match best {
398            None => true,
399            Some(b) if c.distance_m < b.distance_m - 0.05 => true,
400            Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
401                c.kind.interact_priority() < b.kind.interact_priority()
402            }
403            _ => false,
404        };
405        if replace {
406            best = Some(c);
407        }
408    }
409    best.cloned()
410}