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