1use 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 DOOR_WORKER_YIELD_M: f32 = 2.5;
10const DOOR_WORKER_APPROACH_M: f32 = 3.0;
12const CHEST_PICKUP_RANGE_M: f32 = 2.0;
13const HARVEST_RANGE_M: f32 = 1.5;
14pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;
16pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum UseWorldKind {
26 Player,
27 Npc,
28 HiredWorker,
29 QuestBoard,
30 ExitDoor,
31 EnterDoor,
32 OpenDoor,
34 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 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 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 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 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
140fn 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
226fn 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
257pub(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
270pub(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 pub primary: Option<UseWorldCandidate>,
297 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 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 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 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 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
386 continue;
387 }
388 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 (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 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 pub fn hover_hint_at(&self, wx: f32, wy: f32) -> Option<String> {
553 self.hover_hint_at_respecting(wx, wy, |_| true)
554 }
555
556 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}