use crate::game::{GameState, CONTAINER_RANGE_M};
const INTERACTION_RADIUS_M: f32 = 1.5;
const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
const CHEST_PICKUP_RANGE_M: f32 = 2.0;
const HARVEST_RANGE_M: f32 = 1.5;
pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;
fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
(ax - bx).hypot(ay - by)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UseWorldKind {
Player,
Npc,
QuestBoard,
ExitDoor,
EnterDoor,
Well,
Water,
Loot,
ChestPickup,
Harvest,
}
impl UseWorldKind {
pub fn verb(self) -> &'static str {
match self {
Self::Player => "Whisper/Trade",
Self::Npc => "Talk/Trade",
Self::QuestBoard => "Read board",
Self::ExitDoor => "Exit",
Self::EnterDoor => "Enter",
Self::Well => "Use well",
Self::Water => "Fill water",
Self::Loot => "Pick up",
Self::ChestPickup => "Pick up chest",
Self::Harvest => "Harvest",
}
}
pub fn interact_priority(self) -> u8 {
match self {
Self::Player => 0,
Self::Npc => 0,
Self::QuestBoard => 1,
Self::ExitDoor => 2,
Self::EnterDoor => 3,
Self::Well => 4,
Self::Water => 5,
Self::Loot => 10,
Self::ChestPickup => 11,
Self::Harvest => 12,
}
}
pub fn cascade_stage(self) -> u8 {
match self {
Self::Player
| Self::Npc
| Self::QuestBoard
| Self::ExitDoor
| Self::EnterDoor
| Self::Well
| Self::Water => 0,
Self::Loot => 1,
Self::ChestPickup => 2,
Self::Harvest => 3,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct UseWorldCandidate {
pub id: String,
pub kind: UseWorldKind,
pub label: String,
pub x: f32,
pub y: f32,
pub distance_m: f32,
pub range_m: f32,
pub in_range: bool,
}
impl UseWorldCandidate {
pub fn hint_line(&self) -> String {
if self.label.trim().is_empty() {
format!("f → {} ({:.1}m)", self.kind.verb(), self.distance_m)
} else {
format!(
"f → {} {} ({:.1}m)",
self.kind.verb(),
self.label,
self.distance_m
)
}
}
}
fn friendly_or_id(label: &str, id: &str) -> String {
let trimmed = label.trim();
if trimmed.is_empty() {
id.to_string()
} else {
trimmed.to_string()
}
}
fn door_use_label(state: &GameState, door: &flatland_protocol::DoorView, is_exit: bool) -> String {
if is_exit {
return "outdoors".into();
}
if let Some(map) = &state.interior_map {
if let Some(rd) = map.room_doors.iter().find(|d| d.id == door.id) {
let room_name = |room_id: &str| {
map.rooms
.iter()
.find(|r| r.id == room_id)
.map(|r| friendly_or_id(&r.label, room_id))
.unwrap_or_else(|| room_id.to_string())
};
return format!("{} ↔ {}", room_name(&rd.room_a), room_name(&rd.room_b));
}
}
state
.buildings
.iter()
.find(|b| b.id == door.building_id)
.map(|b| friendly_or_id(&b.label, &b.id))
.unwrap_or_else(|| door.building_id.clone())
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct UseWorldProbe {
pub primary: Option<UseWorldCandidate>,
pub candidates: Vec<UseWorldCandidate>,
}
impl UseWorldProbe {
pub fn hint_line(&self) -> String {
match &self.primary {
Some(c) => c.hint_line(),
None => "f → nothing in range".into(),
}
}
}
impl GameState {
pub fn probe_use_world(&self) -> UseWorldProbe {
let (px, py) = self.player_position();
let inside = self.effective_inside_building();
let mut candidates: Vec<UseWorldCandidate> = Vec::new();
let push = |list: &mut Vec<UseWorldCandidate>,
id: String,
kind: UseWorldKind,
label: String,
x: f32,
y: f32,
range_m: f32| {
let distance_m = distance(px, py, x, y);
if distance_m > USE_WORLD_NEARBY_SCAN_M {
return;
}
list.push(UseWorldCandidate {
id,
kind,
label,
x,
y,
distance_m,
range_m,
in_range: distance_m <= range_m,
});
};
for npc in &self.npcs {
push(
&mut candidates,
npc.id.clone(),
UseWorldKind::Npc,
npc.label.clone(),
npc.x,
npc.y,
INTERACTION_RADIUS_M,
);
}
for entity in &self.entities {
if entity.id == self.entity_id {
continue;
}
if entity.label.trim().is_empty() {
continue;
}
if self.npcs.iter().any(|n| n.id == entity.id.to_string()) {
continue;
}
if self
.hired_workers
.iter()
.any(|w| w.entity_id == entity.id)
{
continue;
}
if entity.vitals.is_none() {
continue;
}
let x = entity.transform.position.x;
let y = entity.transform.position.y;
push(
&mut candidates,
entity.id.to_string(),
UseWorldKind::Player,
entity.label.clone(),
x,
y,
INTERACTION_RADIUS_M,
);
}
for door in &self.doors {
if let Some(ref bid) = inside {
if door.building_id != *bid {
continue;
}
let is_exit = door.portal.is_some();
let (kind, range) = if is_exit {
(UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
} else {
(UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
};
push(
&mut candidates,
door.id.clone(),
kind,
door_use_label(self, door, is_exit),
door.x,
door.y,
range,
);
continue;
}
push(
&mut candidates,
door.id.clone(),
UseWorldKind::EnterDoor,
door_use_label(self, door, false),
door.x,
door.y,
DOOR_INTERACTION_RADIUS_M,
);
}
if inside.is_none() {
for inter in &self.interactables {
if inter.kind == "quest_board" {
push(
&mut candidates,
inter.id.clone(),
UseWorldKind::QuestBoard,
inter.label.clone(),
inter.x,
inter.y,
QUEST_BOARD_INTERACTION_RADIUS_M,
);
}
}
for building in &self.buildings {
if !building.tags.iter().any(|t| t == "well") {
continue;
}
push(
&mut candidates,
building.id.clone(),
UseWorldKind::Well,
building.label.clone(),
building.x,
building.y,
INTERACTION_RADIUS_M,
);
}
if self.in_shallow_water() {
push(
&mut candidates,
"water_source".into(),
UseWorldKind::Water,
"Shallow water".into(),
px,
py,
INTERACTION_RADIUS_M,
);
}
}
for drop in &self.ground_drops {
let label = if drop.quantity > 1 {
format!("{} ×{}", drop.template_id, drop.quantity)
} else {
drop.template_id.clone()
};
push(
&mut candidates,
drop.id.clone(),
UseWorldKind::Loot,
label,
drop.x,
drop.y,
INTERACTION_RADIUS_M,
);
}
for chest in &self.placed_containers {
let _browse = CONTAINER_RANGE_M;
let mut label = chest.display_name.clone();
if let Some(who) = self.lodging_occupancy_label(&chest.id) {
label = format!("{label} ({who})");
}
push(
&mut candidates,
chest.id.clone(),
UseWorldKind::ChestPickup,
label,
chest.x,
chest.y,
CHEST_PICKUP_RANGE_M,
);
}
for node in &self.resource_nodes {
if !matches!(
node.state,
flatland_protocol::ResourceNodeState::Available
| flatland_protocol::ResourceNodeState::Harvesting
) {
continue;
}
push(
&mut candidates,
node.id.clone(),
UseWorldKind::Harvest,
node.label.clone(),
node.x,
node.y,
HARVEST_RANGE_M,
);
}
let primary = pick_primary(&candidates);
UseWorldProbe {
primary,
candidates,
}
}
}
fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
if in_range.is_empty() {
return None;
}
let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
let stage = if has_interact {
0
} else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
1
} else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
2
} else {
3
};
let mut best: Option<&UseWorldCandidate> = None;
for c in in_range
.into_iter()
.filter(|c| c.kind.cascade_stage() == stage)
{
let replace = match best {
None => true,
Some(b) if c.distance_m < b.distance_m - 0.05 => true,
Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
c.kind.interact_priority() < b.kind.interact_priority()
}
_ => false,
};
if replace {
best = Some(c);
}
}
best.cloned()
}