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