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