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