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 CHEST_PICKUP_RANGE_M: f32 = 2.0;
9const HARVEST_RANGE_M: f32 = 1.5;
10pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;
12pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum UseWorldKind {
22 Player,
23 Npc,
24 HiredWorker,
25 QuestBoard,
26 ExitDoor,
27 EnterDoor,
28 OpenDoor,
30 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 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 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 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 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
136fn 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 pub primary: Option<UseWorldCandidate>,
226 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 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 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 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 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
312 continue;
313 }
314 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 (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 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 pub fn hover_hint_at(&self, wx: f32, wy: f32) -> Option<String> {
479 self.hover_hint_at_respecting(wx, wy, |_| true)
480 }
481
482 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}