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 Well,
29 Water,
30 Loot,
31 ChestPickup,
32 Harvest,
33}
34
35impl UseWorldKind {
36 pub fn verb(self) -> &'static str {
37 match self {
38 Self::Player => "Whisper/Trade",
39 Self::Npc => "Talk/Trade",
40 Self::HiredWorker => "Manage",
41 Self::QuestBoard => "Read board",
42 Self::ExitDoor => "Exit",
43 Self::EnterDoor => "Enter",
44 Self::Well => "Use well",
45 Self::Water => "Fill water",
46 Self::Loot => "Pick up",
47 Self::ChestPickup => "Pick up chest",
48 Self::Harvest => "Harvest",
49 }
50 }
51
52 pub fn interact_priority(self) -> u8 {
54 match self {
55 Self::Player => 0,
56 Self::Npc => 0,
57 Self::HiredWorker => 0,
58 Self::QuestBoard => 1,
59 Self::ExitDoor => 2,
60 Self::EnterDoor => 3,
61 Self::Well => 4,
62 Self::Water => 5,
63 Self::Loot => 10,
64 Self::ChestPickup => 11,
65 Self::Harvest => 12,
66 }
67 }
68
69 pub fn cascade_stage(self) -> u8 {
71 match self {
72 Self::Player
73 | Self::Npc
74 | Self::HiredWorker
75 | Self::QuestBoard
76 | Self::ExitDoor
77 | Self::EnterDoor
78 | Self::Well
79 | Self::Water => 0,
80 Self::Loot => 1,
81 Self::ChestPickup => 2,
82 Self::Harvest => 3,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub struct UseWorldCandidate {
89 pub id: String,
90 pub kind: UseWorldKind,
91 pub label: String,
92 pub x: f32,
93 pub y: f32,
94 pub distance_m: f32,
95 pub range_m: f32,
96 pub in_range: bool,
98}
99
100impl UseWorldCandidate {
101 pub fn hint_line(&self) -> String {
102 if self.label.trim().is_empty() {
103 format!("f → {} ({:.1}m)", self.kind.verb(), self.distance_m)
104 } else {
105 format!(
106 "f → {} {} ({:.1}m)",
107 self.kind.verb(),
108 self.label,
109 self.distance_m
110 )
111 }
112 }
113}
114
115fn friendly_or_id(label: &str, id: &str) -> String {
117 let trimmed = label.trim();
118 if trimmed.is_empty() {
119 id.to_string()
120 } else {
121 trimmed.to_string()
122 }
123}
124
125fn door_use_label(state: &GameState, door: &flatland_protocol::DoorView, is_exit: bool) -> String {
126 if is_exit {
127 return "outdoors".into();
128 }
129 if let Some(map) = &state.interior_map {
130 if let Some(rd) = map.room_doors.iter().find(|d| d.id == door.id) {
131 let room_name = |room_id: &str| {
132 map.rooms
133 .iter()
134 .find(|r| r.id == room_id)
135 .map(|r| friendly_or_id(&r.label, room_id))
136 .unwrap_or_else(|| room_id.to_string())
137 };
138 return format!("{} ↔ {}", room_name(&rd.room_a), room_name(&rd.room_b));
139 }
140 }
141 state
142 .buildings
143 .iter()
144 .find(|b| b.id == door.building_id)
145 .map(|b| friendly_or_id(&b.label, &b.id))
146 .unwrap_or_else(|| door.building_id.clone())
147}
148
149#[derive(Debug, Clone, PartialEq, Default)]
150pub struct UseWorldProbe {
151 pub primary: Option<UseWorldCandidate>,
153 pub candidates: Vec<UseWorldCandidate>,
155}
156
157impl UseWorldProbe {
158 pub fn hint_line(&self) -> String {
159 match &self.primary {
160 Some(c) => c.hint_line(),
161 None => "f → nothing in range".into(),
162 }
163 }
164}
165
166impl GameState {
167 pub fn probe_use_world(&self) -> UseWorldProbe {
169 let (px, py) = self.player_position();
170 self.probe_use_world_at(px, py)
171 }
172
173 pub fn probe_use_world_at(&self, wx: f32, wy: f32) -> UseWorldProbe {
175 let inside = self.effective_inside_building();
176 let mut candidates: Vec<UseWorldCandidate> = Vec::new();
177
178 let push = |list: &mut Vec<UseWorldCandidate>,
179 id: String,
180 kind: UseWorldKind,
181 label: String,
182 x: f32,
183 y: f32,
184 range_m: f32| {
185 let distance_m = distance(wx, wy, x, y);
186 if distance_m > USE_WORLD_NEARBY_SCAN_M {
187 return;
188 }
189 list.push(UseWorldCandidate {
190 id,
191 kind,
192 label,
193 x,
194 y,
195 distance_m,
196 range_m,
197 in_range: distance_m <= range_m,
198 });
199 };
200
201 for npc in &self.npcs {
202 push(
203 &mut candidates,
204 npc.id.clone(),
205 UseWorldKind::Npc,
206 npc.label.clone(),
207 npc.x,
208 npc.y,
209 INTERACTION_RADIUS_M,
210 );
211 }
212
213 for worker in &self.hired_workers {
214 push(
215 &mut candidates,
216 worker.instance_id.clone(),
217 UseWorldKind::HiredWorker,
218 worker.label.clone(),
219 worker.x,
220 worker.y,
221 INTERACTION_RADIUS_M,
222 );
223 }
224
225 for entity in &self.entities {
226 if entity.id == self.entity_id {
227 continue;
228 }
229 if entity.label.trim().is_empty() {
232 continue;
233 }
234 if self.npcs.iter().any(|n| n.id == entity.id.to_string()) {
235 continue;
236 }
237 if self
239 .hired_workers
240 .iter()
241 .any(|w| w.entity_id == entity.id)
242 {
243 continue;
244 }
245 if entity.vitals.is_none() {
247 continue;
248 }
249 let x = entity.transform.position.x;
250 let y = entity.transform.position.y;
251 push(
252 &mut candidates,
253 entity.id.to_string(),
254 UseWorldKind::Player,
255 entity.label.clone(),
256 x,
257 y,
258 INTERACTION_RADIUS_M,
259 );
260 }
261
262 for door in &self.doors {
263 if let Some(ref bid) = inside {
264 if door.building_id != *bid {
265 continue;
266 }
267 let is_exit = door.portal.is_some();
268 let (kind, range) = if is_exit {
269 (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
270 } else {
271 (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
272 };
273 push(
274 &mut candidates,
275 door.id.clone(),
276 kind,
277 door_use_label(self, door, is_exit),
278 door.x,
279 door.y,
280 range,
281 );
282 continue;
283 }
284 push(
285 &mut candidates,
286 door.id.clone(),
287 UseWorldKind::EnterDoor,
288 door_use_label(self, door, false),
289 door.x,
290 door.y,
291 DOOR_INTERACTION_RADIUS_M,
292 );
293 }
294
295 if inside.is_none() {
296 for inter in &self.interactables {
297 if inter.kind == "quest_board" {
298 push(
299 &mut candidates,
300 inter.id.clone(),
301 UseWorldKind::QuestBoard,
302 inter.label.clone(),
303 inter.x,
304 inter.y,
305 QUEST_BOARD_INTERACTION_RADIUS_M,
306 );
307 }
308 }
309 for building in &self.buildings {
310 if !building.tags.iter().any(|t| t == "well") {
311 continue;
312 }
313 push(
314 &mut candidates,
315 building.id.clone(),
316 UseWorldKind::Well,
317 building.label.clone(),
318 building.x,
319 building.y,
320 INTERACTION_RADIUS_M,
321 );
322 }
323 if self.in_shallow_water() {
324 let (px, py) = self.player_position();
325 if distance(wx, wy, px, py) <= INTERACTION_RADIUS_M {
326 push(
327 &mut candidates,
328 "water_source".into(),
329 UseWorldKind::Water,
330 "Shallow water".into(),
331 px,
332 py,
333 INTERACTION_RADIUS_M,
334 );
335 }
336 }
337 }
338
339 for drop in &self.ground_drops {
340 let display = drop
341 .display_name
342 .as_deref()
343 .filter(|s| !s.is_empty())
344 .unwrap_or(&drop.template_id);
345 let label = if drop.quantity > 1 {
346 format!("{} ×{}", display, drop.quantity)
347 } else {
348 display.to_string()
349 };
350 push(
351 &mut candidates,
352 drop.id.clone(),
353 UseWorldKind::Loot,
354 label,
355 drop.x,
356 drop.y,
357 INTERACTION_RADIUS_M,
358 );
359 }
360
361 for chest in &self.placed_containers {
362 let _browse = CONTAINER_RANGE_M;
363 let mut label = chest.display_name.clone();
364 if let Some(who) = self.lodging_occupancy_label(&chest.id) {
365 label = format!("{label} ({who})");
366 }
367 push(
368 &mut candidates,
369 chest.id.clone(),
370 UseWorldKind::ChestPickup,
371 label,
372 chest.x,
373 chest.y,
374 CHEST_PICKUP_RANGE_M,
375 );
376 }
377
378 for node in &self.resource_nodes {
379 if node.id.starts_with("preview:") {
380 continue;
381 }
382 if !matches!(
383 node.state,
384 flatland_protocol::ResourceNodeState::Available
385 | flatland_protocol::ResourceNodeState::Harvesting
386 ) {
387 continue;
388 }
389 push(
390 &mut candidates,
391 node.id.clone(),
392 UseWorldKind::Harvest,
393 node.label.clone(),
394 node.x,
395 node.y,
396 HARVEST_RANGE_M,
397 );
398 }
399
400 let primary = pick_primary(&candidates);
401
402 UseWorldProbe {
403 primary,
404 candidates,
405 }
406 }
407
408 pub fn hover_hint_at(&self, wx: f32, wy: f32) -> Option<String> {
410 let mut best_d = WORLD_HOVER_PICK_M;
411 let mut best: Option<String> = None;
412
413 for b in &self.buildings {
414 let hw = b.width_m * 0.5;
415 let hd = b.depth_m * 0.5;
416 if wx < b.x - hw || wx > b.x + hw || wy < b.y - hd || wy > b.y + hd {
417 continue;
418 }
419 let d = distance(wx, wy, b.x, b.y);
420 if d <= best_d {
421 best_d = d;
422 best = Some(friendly_or_id(&b.label, &b.id));
423 }
424 }
425
426 let probe = self.probe_use_world_at(wx, wy);
427 for c in &probe.candidates {
428 let d = distance(wx, wy, c.x, c.y);
429 if d > WORLD_HOVER_PICK_M {
430 continue;
431 }
432 if d <= best_d {
433 best_d = d;
434 best = Some(hover_label_for_candidate(c));
435 }
436 }
437 best
438 }
439}
440
441fn hover_label_for_candidate(c: &UseWorldCandidate) -> String {
442 let name = friendly_or_id(&c.label, &c.id);
443 match c.kind {
444 UseWorldKind::Npc
445 | UseWorldKind::HiredWorker
446 | UseWorldKind::Player
447 | UseWorldKind::Harvest
448 | UseWorldKind::Loot
449 | UseWorldKind::ChestPickup
450 | UseWorldKind::QuestBoard
451 | UseWorldKind::Well
452 | UseWorldKind::Water => name,
453 UseWorldKind::EnterDoor => format!("Door: {name}"),
454 UseWorldKind::ExitDoor => name,
455 }
456}
457
458fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
459 let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
460 if in_range.is_empty() {
461 return None;
462 }
463
464 let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
465 let stage = if has_interact {
466 0
467 } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
468 1
469 } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
470 2
471 } else {
472 3
473 };
474
475 let mut best: Option<&UseWorldCandidate> = None;
476 for c in in_range
477 .into_iter()
478 .filter(|c| c.kind.cascade_stage() == stage)
479 {
480 let replace = match best {
481 None => true,
482 Some(b) if c.distance_m < b.distance_m - 0.05 => true,
483 Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
484 c.kind.interact_priority() < b.kind.interact_priority()
485 }
486 _ => false,
487 };
488 if replace {
489 best = Some(c);
490 }
491 }
492 best.cloned()
493}