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 entity.vitals.is_none() {
216 continue;
217 }
218 let x = entity.transform.position.x;
219 let y = entity.transform.position.y;
220 push(
221 &mut candidates,
222 entity.id.to_string(),
223 UseWorldKind::Player,
224 entity.label.clone(),
225 x,
226 y,
227 INTERACTION_RADIUS_M,
228 );
229 }
230
231 for door in &self.doors {
232 if let Some(ref bid) = inside {
233 if door.building_id != *bid {
234 continue;
235 }
236 let is_exit = door.portal.is_some();
237 let (kind, range) = if is_exit {
238 (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
239 } else {
240 (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
241 };
242 push(
243 &mut candidates,
244 door.id.clone(),
245 kind,
246 door_use_label(self, door, is_exit),
247 door.x,
248 door.y,
249 range,
250 );
251 continue;
252 }
253 push(
254 &mut candidates,
255 door.id.clone(),
256 UseWorldKind::EnterDoor,
257 door_use_label(self, door, false),
258 door.x,
259 door.y,
260 DOOR_INTERACTION_RADIUS_M,
261 );
262 }
263
264 if inside.is_none() {
265 for inter in &self.interactables {
266 if inter.kind == "quest_board" {
267 push(
268 &mut candidates,
269 inter.id.clone(),
270 UseWorldKind::QuestBoard,
271 inter.label.clone(),
272 inter.x,
273 inter.y,
274 QUEST_BOARD_INTERACTION_RADIUS_M,
275 );
276 }
277 }
278 for building in &self.buildings {
279 if !building.tags.iter().any(|t| t == "well") {
280 continue;
281 }
282 push(
283 &mut candidates,
284 building.id.clone(),
285 UseWorldKind::Well,
286 building.label.clone(),
287 building.x,
288 building.y,
289 INTERACTION_RADIUS_M,
290 );
291 }
292 if self.in_shallow_water() {
293 push(
294 &mut candidates,
295 "water_source".into(),
296 UseWorldKind::Water,
297 "Shallow water".into(),
298 px,
299 py,
300 INTERACTION_RADIUS_M,
301 );
302 }
303 }
304
305 for drop in &self.ground_drops {
306 let label = if drop.quantity > 1 {
307 format!("{} ×{}", drop.template_id, drop.quantity)
308 } else {
309 drop.template_id.clone()
310 };
311 push(
312 &mut candidates,
313 drop.id.clone(),
314 UseWorldKind::Loot,
315 label,
316 drop.x,
317 drop.y,
318 INTERACTION_RADIUS_M,
319 );
320 }
321
322 for chest in &self.placed_containers {
323 let _browse = CONTAINER_RANGE_M;
324 let mut label = chest.display_name.clone();
325 if let Some(who) = self.lodging_occupancy_label(&chest.id) {
326 label = format!("{label} ({who})");
327 }
328 push(
329 &mut candidates,
330 chest.id.clone(),
331 UseWorldKind::ChestPickup,
332 label,
333 chest.x,
334 chest.y,
335 CHEST_PICKUP_RANGE_M,
336 );
337 }
338
339 for node in &self.resource_nodes {
340 if !matches!(
341 node.state,
342 flatland_protocol::ResourceNodeState::Available
343 | flatland_protocol::ResourceNodeState::Harvesting
344 ) {
345 continue;
346 }
347 push(
348 &mut candidates,
349 node.id.clone(),
350 UseWorldKind::Harvest,
351 node.label.clone(),
352 node.x,
353 node.y,
354 HARVEST_RANGE_M,
355 );
356 }
357
358 let primary = pick_primary(&candidates);
359
360 UseWorldProbe {
361 primary,
362 candidates,
363 }
364 }
365}
366
367fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
368 let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
369 if in_range.is_empty() {
370 return None;
371 }
372
373 let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
374 let stage = if has_interact {
375 0
376 } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
377 1
378 } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
379 2
380 } else {
381 3
382 };
383
384 let mut best: Option<&UseWorldCandidate> = None;
385 for c in in_range
386 .into_iter()
387 .filter(|c| c.kind.cascade_stage() == stage)
388 {
389 let replace = match best {
390 None => true,
391 Some(b) if c.distance_m < b.distance_m - 0.05 => true,
392 Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
393 c.kind.interact_priority() < b.kind.interact_priority()
394 }
395 _ => false,
396 };
397 if replace {
398 best = Some(c);
399 }
400 }
401 best.cloned()
402}