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