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