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;
12
13fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14 (ax - bx).hypot(ay - by)
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum UseWorldKind {
20 Player,
21 Npc,
22 QuestBoard,
23 ExitDoor,
24 EnterDoor,
25 Well,
26 Water,
27 Loot,
28 ChestPickup,
29 Harvest,
30}
31
32impl UseWorldKind {
33 pub fn verb(self) -> &'static str {
34 match self {
35 Self::Player => "Whisper/Trade",
36 Self::Npc => "Talk/Trade",
37 Self::QuestBoard => "Read board",
38 Self::ExitDoor => "Exit",
39 Self::EnterDoor => "Enter",
40 Self::Well => "Use well",
41 Self::Water => "Fill water",
42 Self::Loot => "Pick up",
43 Self::ChestPickup => "Pick up chest",
44 Self::Harvest => "Harvest",
45 }
46 }
47
48 pub fn interact_priority(self) -> u8 {
50 match self {
51 Self::Player => 0,
52 Self::Npc => 0,
53 Self::QuestBoard => 1,
54 Self::ExitDoor => 2,
55 Self::EnterDoor => 3,
56 Self::Well => 4,
57 Self::Water => 5,
58 Self::Loot => 10,
59 Self::ChestPickup => 11,
60 Self::Harvest => 12,
61 }
62 }
63
64 pub fn cascade_stage(self) -> u8 {
66 match self {
67 Self::Player
68 | Self::Npc
69 | Self::QuestBoard
70 | Self::ExitDoor
71 | Self::EnterDoor
72 | Self::Well
73 | Self::Water => 0,
74 Self::Loot => 1,
75 Self::ChestPickup => 2,
76 Self::Harvest => 3,
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct UseWorldCandidate {
83 pub id: String,
84 pub kind: UseWorldKind,
85 pub label: String,
86 pub x: f32,
87 pub y: f32,
88 pub distance_m: f32,
89 pub range_m: f32,
90 pub in_range: bool,
92}
93
94impl UseWorldCandidate {
95 pub fn hint_line(&self) -> String {
96 if self.label.trim().is_empty() {
97 format!("f → {} ({:.1}m)", self.kind.verb(), self.distance_m)
98 } else {
99 format!(
100 "f → {} {} ({:.1}m)",
101 self.kind.verb(),
102 self.label,
103 self.distance_m
104 )
105 }
106 }
107}
108
109fn friendly_or_id(label: &str, id: &str) -> String {
111 let trimmed = label.trim();
112 if trimmed.is_empty() {
113 id.to_string()
114 } else {
115 trimmed.to_string()
116 }
117}
118
119fn door_use_label(state: &GameState, door: &flatland_protocol::DoorView, is_exit: bool) -> String {
120 if is_exit {
121 return "outdoors".into();
122 }
123 if let Some(map) = &state.interior_map {
124 if let Some(rd) = map.room_doors.iter().find(|d| d.id == door.id) {
125 let room_name = |room_id: &str| {
126 map.rooms
127 .iter()
128 .find(|r| r.id == room_id)
129 .map(|r| friendly_or_id(&r.label, room_id))
130 .unwrap_or_else(|| room_id.to_string())
131 };
132 return format!("{} ↔ {}", room_name(&rd.room_a), room_name(&rd.room_b));
133 }
134 }
135 state
136 .buildings
137 .iter()
138 .find(|b| b.id == door.building_id)
139 .map(|b| friendly_or_id(&b.label, &b.id))
140 .unwrap_or_else(|| door.building_id.clone())
141}
142
143#[derive(Debug, Clone, PartialEq, Default)]
144pub struct UseWorldProbe {
145 pub primary: Option<UseWorldCandidate>,
147 pub candidates: Vec<UseWorldCandidate>,
149}
150
151impl UseWorldProbe {
152 pub fn hint_line(&self) -> String {
153 match &self.primary {
154 Some(c) => c.hint_line(),
155 None => "f → nothing in range".into(),
156 }
157 }
158}
159
160impl GameState {
161 pub fn probe_use_world(&self) -> UseWorldProbe {
163 let (px, py) = self.player_position();
164 let inside = self.effective_inside_building();
165 let mut candidates: Vec<UseWorldCandidate> = Vec::new();
166
167 let push = |list: &mut Vec<UseWorldCandidate>,
168 id: String,
169 kind: UseWorldKind,
170 label: String,
171 x: f32,
172 y: f32,
173 range_m: f32| {
174 let distance_m = distance(px, py, x, y);
175 if distance_m > USE_WORLD_NEARBY_SCAN_M {
176 return;
177 }
178 list.push(UseWorldCandidate {
179 id,
180 kind,
181 label,
182 x,
183 y,
184 distance_m,
185 range_m,
186 in_range: distance_m <= range_m,
187 });
188 };
189
190 for npc in &self.npcs {
191 push(
192 &mut candidates,
193 npc.id.clone(),
194 UseWorldKind::Npc,
195 npc.label.clone(),
196 npc.x,
197 npc.y,
198 INTERACTION_RADIUS_M,
199 );
200 }
201
202 for entity in &self.entities {
203 if entity.id == self.entity_id {
204 continue;
205 }
206 if entity.label.trim().is_empty() {
209 continue;
210 }
211 if self.npcs.iter().any(|n| n.id == entity.id.to_string()) {
212 continue;
213 }
214 if self
216 .hired_workers
217 .iter()
218 .any(|w| w.entity_id == entity.id)
219 {
220 continue;
221 }
222 if entity.vitals.is_none() {
224 continue;
225 }
226 let x = entity.transform.position.x;
227 let y = entity.transform.position.y;
228 push(
229 &mut candidates,
230 entity.id.to_string(),
231 UseWorldKind::Player,
232 entity.label.clone(),
233 x,
234 y,
235 INTERACTION_RADIUS_M,
236 );
237 }
238
239 for door in &self.doors {
240 if let Some(ref bid) = inside {
241 if door.building_id != *bid {
242 continue;
243 }
244 let is_exit = door.portal.is_some();
245 let (kind, range) = if is_exit {
246 (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
247 } else {
248 (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
249 };
250 push(
251 &mut candidates,
252 door.id.clone(),
253 kind,
254 door_use_label(self, door, is_exit),
255 door.x,
256 door.y,
257 range,
258 );
259 continue;
260 }
261 push(
262 &mut candidates,
263 door.id.clone(),
264 UseWorldKind::EnterDoor,
265 door_use_label(self, door, false),
266 door.x,
267 door.y,
268 DOOR_INTERACTION_RADIUS_M,
269 );
270 }
271
272 if inside.is_none() {
273 for inter in &self.interactables {
274 if inter.kind == "quest_board" {
275 push(
276 &mut candidates,
277 inter.id.clone(),
278 UseWorldKind::QuestBoard,
279 inter.label.clone(),
280 inter.x,
281 inter.y,
282 QUEST_BOARD_INTERACTION_RADIUS_M,
283 );
284 }
285 }
286 for building in &self.buildings {
287 if !building.tags.iter().any(|t| t == "well") {
288 continue;
289 }
290 push(
291 &mut candidates,
292 building.id.clone(),
293 UseWorldKind::Well,
294 building.label.clone(),
295 building.x,
296 building.y,
297 INTERACTION_RADIUS_M,
298 );
299 }
300 if self.in_shallow_water() {
301 push(
302 &mut candidates,
303 "water_source".into(),
304 UseWorldKind::Water,
305 "Shallow water".into(),
306 px,
307 py,
308 INTERACTION_RADIUS_M,
309 );
310 }
311 }
312
313 for drop in &self.ground_drops {
314 let label = if drop.quantity > 1 {
315 format!("{} ×{}", drop.template_id, drop.quantity)
316 } else {
317 drop.template_id.clone()
318 };
319 push(
320 &mut candidates,
321 drop.id.clone(),
322 UseWorldKind::Loot,
323 label,
324 drop.x,
325 drop.y,
326 INTERACTION_RADIUS_M,
327 );
328 }
329
330 for chest in &self.placed_containers {
331 let _browse = CONTAINER_RANGE_M;
332 let mut label = chest.display_name.clone();
333 if let Some(who) = self.lodging_occupancy_label(&chest.id) {
334 label = format!("{label} ({who})");
335 }
336 push(
337 &mut candidates,
338 chest.id.clone(),
339 UseWorldKind::ChestPickup,
340 label,
341 chest.x,
342 chest.y,
343 CHEST_PICKUP_RANGE_M,
344 );
345 }
346
347 for node in &self.resource_nodes {
348 if !matches!(
349 node.state,
350 flatland_protocol::ResourceNodeState::Available
351 | flatland_protocol::ResourceNodeState::Harvesting
352 ) {
353 continue;
354 }
355 push(
356 &mut candidates,
357 node.id.clone(),
358 UseWorldKind::Harvest,
359 node.label.clone(),
360 node.x,
361 node.y,
362 HARVEST_RANGE_M,
363 );
364 }
365
366 let primary = pick_primary(&candidates);
367
368 UseWorldProbe {
369 primary,
370 candidates,
371 }
372 }
373}
374
375fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
376 let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
377 if in_range.is_empty() {
378 return None;
379 }
380
381 let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
382 let stage = if has_interact {
383 0
384 } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
385 1
386 } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
387 2
388 } else {
389 3
390 };
391
392 let mut best: Option<&UseWorldCandidate> = None;
393 for c in in_range
394 .into_iter()
395 .filter(|c| c.kind.cascade_stage() == stage)
396 {
397 let replace = match best {
398 None => true,
399 Some(b) if c.distance_m < b.distance_m - 0.05 => true,
400 Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
401 c.kind.interact_priority() < b.kind.interact_priority()
402 }
403 _ => false,
404 };
405 if replace {
406 best = Some(c);
407 }
408 }
409 best.cloned()
410}