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