1use 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;
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 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 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 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 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
128fn 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 pub primary: Option<UseWorldCandidate>,
215 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 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 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 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 if self
301 .hired_workers
302 .iter()
303 .any(|w| w.entity_id == entity.id)
304 {
305 continue;
306 }
307 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 (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 if !self.placed_container_in_current_space(chest) {
433 continue;
434 }
435 let _browse = CONTAINER_RANGE_M;
436 let mut label = chest.display_name.clone();
437 if let Some(who) = self.lodging_occupancy_label(&chest.id) {
438 label = format!("{label} ({who})");
439 }
440 push(
441 &mut candidates,
442 chest.id.clone(),
443 UseWorldKind::ChestPickup,
444 label,
445 chest.x,
446 chest.y,
447 CHEST_PICKUP_RANGE_M,
448 );
449 }
450
451 for node in &self.resource_nodes {
452 if node.id.starts_with("preview:") {
453 continue;
454 }
455 if node.harvest_off {
456 continue;
457 }
458 if !matches!(
459 node.state,
460 flatland_protocol::ResourceNodeState::Available
461 | flatland_protocol::ResourceNodeState::Harvesting
462 ) {
463 continue;
464 }
465 push(
466 &mut candidates,
467 node.id.clone(),
468 UseWorldKind::Harvest,
469 node.label.clone(),
470 node.x,
471 node.y,
472 HARVEST_RANGE_M,
473 );
474 }
475
476 let primary = pick_primary(&candidates);
477
478 UseWorldProbe {
479 primary,
480 candidates,
481 }
482 }
483
484 pub fn hover_hint_at(&self, wx: f32, wy: f32) -> Option<String> {
486 let mut best_d = WORLD_HOVER_PICK_M;
487 let mut best: Option<String> = None;
488
489 for b in &self.buildings {
490 let hw = b.width_m * 0.5;
491 let hd = b.depth_m * 0.5;
492 if wx < b.x - hw || wx > b.x + hw || wy < b.y - hd || wy > b.y + hd {
493 continue;
494 }
495 let d = distance(wx, wy, b.x, b.y);
496 if d <= best_d {
497 best_d = d;
498 best = Some(friendly_or_id(&b.label, &b.id));
499 }
500 }
501
502 let probe = self.probe_use_world_at(wx, wy);
503 for c in &probe.candidates {
504 let d = distance(wx, wy, c.x, c.y);
505 if d > WORLD_HOVER_PICK_M {
506 continue;
507 }
508 if d <= best_d {
509 best_d = d;
510 best = Some(hover_label_for_candidate(c));
511 }
512 }
513 best
514 }
515}
516
517fn hover_label_for_candidate(c: &UseWorldCandidate) -> String {
518 let name = friendly_or_id(&c.label, &c.id);
519 match c.kind {
520 UseWorldKind::Npc
521 | UseWorldKind::HiredWorker
522 | UseWorldKind::Player
523 | UseWorldKind::Harvest
524 | UseWorldKind::Loot
525 | UseWorldKind::ChestPickup
526 | UseWorldKind::QuestBoard
527 | UseWorldKind::Well
528 | UseWorldKind::Water => name,
529 UseWorldKind::EnterDoor | UseWorldKind::OpenDoor | UseWorldKind::CloseDoor => {
530 format!("Door: {name}")
531 }
532 UseWorldKind::ExitDoor => name,
533 }
534}
535
536fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
537 let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
538 if in_range.is_empty() {
539 return None;
540 }
541
542 let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
543 let stage = if has_interact {
544 0
545 } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
546 1
547 } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
548 2
549 } else {
550 3
551 };
552
553 let mut best: Option<&UseWorldCandidate> = None;
554 for c in in_range
555 .into_iter()
556 .filter(|c| c.kind.cascade_stage() == stage)
557 {
558 let replace = match best {
559 None => true,
560 Some(b) if c.distance_m < b.distance_m - 0.05 => true,
561 Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
562 c.kind.interact_priority() < b.kind.interact_priority()
563 }
564 _ => false,
565 };
566 if replace {
567 best = Some(c);
568 }
569 }
570 best.cloned()
571}