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