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