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