use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
use flatland_protocol::{
AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
CombatSlotHud, CombatTargetHud,
DoorView, EntityId, EntityState, Intent, LifeState, NpcView, RotationPreset, Seq, SessionId,
TerrainKindView, TerrainZoneView, Tick,
};
use crate::session::{PlayConnection, SessionEvent};
const KEY_TEMPLATE: &str = "container_key";
const PROP_LOCK_ID: &str = "lock_id";
const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
const PROP_CUSTOM_NAME: &str = "custom_name";
const PROP_LOCKED: &str = "locked";
fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
stack
.props
.get(PROP_LOCKED)
.is_some_and(|v| v == "true" || v == "1")
}
const MAX_LOG_LINES: usize = 200;
const INTERACTION_RADIUS_M: f32 = 1.5;
const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
const CRAFT_STAMINA_COST: f32 = 3.0;
#[derive(Debug, Clone, Default)]
pub struct InventoryHint {
pub display_name: String,
pub category: String,
pub base_mass: Option<f32>,
pub base_volume: Option<f32>,
pub capacity_volume: Option<f32>,
pub stackable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RotationEditorMode {
#[default]
List,
EditSequence,
PickAbility,
EditLabel,
}
#[derive(Debug, Clone, Default)]
pub struct RotationEditorState {
pub mode: RotationEditorMode,
pub list_index: usize,
pub ability_index: usize,
pub picker_index: usize,
pub draft: Option<RotationPreset>,
pub label_buffer: String,
}
impl RotationEditorState {
pub fn reset(&mut self) {
*self = Self::default();
}
}
pub const CONTAINER_RANGE_M: f32 = 3.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InventorySection {
Worn,
Person,
Nearby,
}
pub fn body_slot_label(slot: BodySlot) -> &'static str {
match slot {
BodySlot::Head => "Head",
BodySlot::Body => "Body",
BodySlot::Arms => "Arms",
BodySlot::Legs => "Legs",
BodySlot::Feet => "Feet",
BodySlot::Back => "Back",
BodySlot::Waist => "Waist",
}
}
fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
if template_id.contains("backpack") {
Some(BodySlot::Back)
} else if template_id.contains("belt") {
Some(BodySlot::Waist)
} else if template_id.contains("cap") || template_id.contains("hat") || template_id.contains("helm") {
Some(BodySlot::Head)
} else if template_id.contains("shirt") || template_id.contains("robe") || template_id.contains("vest") {
Some(BodySlot::Body)
} else if template_id.contains("sleeves") || template_id.contains("gloves") || template_id.contains("gauntlets") {
Some(BodySlot::Arms)
} else if template_id.contains("pants") || template_id.contains("leggings") {
Some(BodySlot::Legs)
} else if template_id.contains("boots") || template_id.contains("shoes") {
Some(BodySlot::Feet)
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct InventoryRow {
pub depth: usize,
pub stack: flatland_protocol::ItemStack,
pub from: flatland_protocol::InventoryLocation,
pub from_parent_instance_id: Option<uuid::Uuid>,
pub is_equip_shell: bool,
pub is_chest_shell: bool,
pub section: InventorySection,
}
#[derive(Debug, Clone)]
pub struct NearbyContainer {
pub view: flatland_protocol::PlacedContainerView,
pub distance_m: f32,
pub rows: Vec<InventoryRow>,
}
#[derive(Debug, Clone)]
pub struct MoveOption {
pub label: String,
pub kind: MoveOptionKind,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MoveOptionKind {
Move {
location: flatland_protocol::InventoryLocation,
parent_instance_id: Option<uuid::Uuid>,
},
Drop,
Cancel,
}
#[derive(Debug, Clone)]
pub struct MovePicker {
pub item_instance_id: uuid::Uuid,
pub from: flatland_protocol::InventoryLocation,
pub item_label: String,
pub template_id: String,
pub stack_quantity: u32,
pub quantity: u32,
pub options: Vec<MoveOption>,
}
#[derive(Debug, Clone)]
pub struct DestroyPicker {
pub item_instance_id: uuid::Uuid,
pub from: flatland_protocol::InventoryLocation,
pub item_label: String,
pub stack_quantity: u32,
pub quantity: u32,
}
fn push_inventory_rows(
rows: &mut Vec<InventoryRow>,
depth: usize,
stack: &flatland_protocol::ItemStack,
from: &flatland_protocol::InventoryLocation,
from_parent_instance_id: Option<uuid::Uuid>,
section: InventorySection,
) {
rows.push(InventoryRow {
depth,
stack: stack.clone(),
from: from.clone(),
from_parent_instance_id,
is_equip_shell: false,
is_chest_shell: false,
section,
});
for child in &stack.contents {
push_inventory_rows(
rows,
depth + 1,
child,
from,
stack.item_instance_id,
section,
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShopTab {
#[default]
Buy,
Sell,
}
#[derive(Debug, Clone)]
pub struct GameState {
pub session_id: SessionId,
pub entity_id: EntityId,
pub character_id: Option<uuid::Uuid>,
pub tick: Tick,
pub chunk_rev: u64,
pub content_rev: u64,
pub entities: Vec<EntityState>,
pub player: Option<EntityState>,
pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
pub ground_drops: Vec<flatland_protocol::GroundDropView>,
pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
pub buildings: Vec<BuildingView>,
pub doors: Vec<DoorView>,
pub npcs: Vec<NpcView>,
pub blueprints: Vec<BlueprintView>,
pub world_width_m: f32,
pub world_height_m: f32,
pub terrain_zones: Vec<TerrainZoneView>,
pub world_clock: flatland_protocol::WorldClock,
pub inventory: std::collections::HashMap<String, u32>,
pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
pub logs: VecDeque<String>,
pub intents_sent: u64,
pub ticks_received: u64,
pub connected: bool,
pub disconnect_reason: Option<String>,
pub show_stats: bool,
pub show_craft_menu: bool,
pub craft_menu_index: usize,
pub craft_batch_quantity: u32,
pub show_shop_menu: bool,
pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
pub shop_tab: ShopTab,
pub shop_menu_index: usize,
pub shop_quantity: u32,
pub show_inventory_menu: bool,
pub inventory_menu_index: usize,
pub show_move_picker: bool,
pub move_picker_index: usize,
pub move_picker: Option<MovePicker>,
pub show_destroy_picker: bool,
pub destroy_confirm_pending: bool,
pub destroy_picker: Option<DestroyPicker>,
pub show_rename_prompt: bool,
pub rename_buffer: String,
pub combat_target: Option<EntityId>,
pub combat_target_label: Option<String>,
pub in_combat: bool,
pub auto_attack: bool,
pub combat_has_los: bool,
pub attack_cd_ticks: u64,
pub gcd_ticks: u64,
pub weapon_ability_id: String,
pub mainhand_template_id: Option<String>,
pub mainhand_label: Option<String>,
pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
pub carry_mass: f32,
pub carry_mass_max: f32,
pub encumbrance: flatland_protocol::EncumbranceState,
pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
pub combat_target_detail: Option<CombatTargetHud>,
pub cast_progress: Option<CastProgressHud>,
pub ability_cooldowns: Vec<AbilityCooldownHud>,
pub blocking_active: bool,
pub max_target_slots: u8,
pub combat_slots: Vec<CombatSlotHud>,
pub rotation_presets: Vec<RotationPreset>,
pub show_loadout_menu: bool,
pub show_rotation_editor: bool,
pub loadout_menu_index: usize,
pub rotation_editor: RotationEditorState,
pub harvest_in_progress: bool,
pub harvest_started_at: Option<Instant>,
pub pending_craft_ack: Option<(u32, String, u32)>,
}
impl GameState {
pub fn push_log(&mut self, line: impl Into<String>) {
self.logs.push_back(line.into());
while self.logs.len() > MAX_LOG_LINES {
self.logs.pop_front();
}
}
pub fn is_alive(&self) -> bool {
self.player
.as_ref()
.and_then(|p| p.vitals)
.map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
.unwrap_or(true)
}
pub fn clear_harvest_state(&mut self) {
self.harvest_in_progress = false;
self.harvest_started_at = None;
}
fn harvest_state_stale(&self) -> bool {
match self.harvest_started_at {
Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
None => self.harvest_in_progress,
}
}
pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
self.player.as_ref().and_then(|p| p.vitals)
}
pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
let materials_ok = blueprint.inputs.iter().all(|input| {
self.inventory
.get(&input.template_id)
.copied()
.unwrap_or(0)
>= input.quantity
});
let tools_ok = blueprint.required_tools.iter().all(|tool| {
self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
});
let station_ok = match blueprint.station.as_deref() {
None | Some("hand") => true,
Some(tag) => self.player_at_station_tag(tag),
};
materials_ok && tools_ok && station_ok
}
pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
if !self.can_craft_blueprint(blueprint) {
return 0;
}
let mut limit = u32::MAX;
for input in &blueprint.inputs {
if input.quantity == 0 {
continue;
}
let have = self
.inventory
.get(&input.template_id)
.copied()
.unwrap_or(0);
limit = limit.min(have / input.quantity);
}
for tool in &blueprint.required_tools {
if tool.consumed {
let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
limit = limit.min(have);
}
}
let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
if CRAFT_STAMINA_COST > 0.0 {
limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
}
limit
}
pub fn clamp_craft_batch_quantity(&mut self) {
let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
self.craft_batch_quantity = 1;
return;
};
let max = self.max_craft_batches(bp).max(1);
self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
}
pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
return;
};
let max = self.max_craft_batches(&bp).max(1);
let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
self.craft_batch_quantity = next as u32;
}
pub fn craft_batch_set_max(&mut self) {
let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
return;
};
let max = self.max_craft_batches(&bp);
self.craft_batch_quantity = if max == 0 { 1 } else { max };
}
pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
let preserve_ui = self.show_shop_menu;
let tab = self.shop_tab;
let index = self.shop_menu_index;
let qty = self.shop_quantity;
self.show_shop_menu = true;
self.show_craft_menu = false;
self.show_inventory_menu = false;
self.show_stats = false;
self.shop_catalog = Some(catalog);
if preserve_ui {
self.shop_tab = tab;
self.shop_menu_index = index;
self.shop_quantity = qty;
} else {
self.shop_tab = ShopTab::Buy;
self.shop_menu_index = 0;
self.shop_quantity = 1;
}
self.clamp_shop_selection();
}
pub fn shop_list_len(&self) -> usize {
let Some(catalog) = &self.shop_catalog else {
return 0;
};
match self.shop_tab {
ShopTab::Buy => catalog.sells.len(),
ShopTab::Sell => catalog.buys.len(),
}
}
pub fn shop_menu_move(&mut self, delta: i32) {
let n = self.shop_list_len();
if n == 0 {
return;
}
let idx = self.shop_menu_index as i32;
let next = (idx + delta).rem_euclid(n as i32);
self.shop_menu_index = next as usize;
self.clamp_shop_quantity();
}
pub fn shop_quantity_adjust(&mut self, delta: i32) {
let max = self.shop_quantity_max();
let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
self.shop_quantity = next as u32;
}
pub(crate) fn clamp_shop_selection(&mut self) {
let n = self.shop_list_len();
if n == 0 {
self.shop_menu_index = 0;
} else {
self.shop_menu_index = self.shop_menu_index.min(n - 1);
}
self.clamp_shop_quantity();
}
fn shop_quantity_max(&self) -> u32 {
let Some(catalog) = &self.shop_catalog else {
return 1;
};
match self.shop_tab {
ShopTab::Buy => {
if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
return 1;
}
}
99
}
ShopTab::Sell => catalog
.buys
.get(self.shop_menu_index)
.map(|l| l.quantity)
.unwrap_or(1)
.max(1),
}
}
pub fn shop_quantity_set_max(&mut self) {
self.shop_quantity = self.shop_quantity_max();
}
fn clamp_shop_quantity(&mut self) {
self.shop_quantity = self.shop_quantity.clamp(1, self.shop_quantity_max());
}
pub fn player_at_station_tag(&self, tag: &str) -> bool {
let (px, py) = self.player_position();
self.building_interior_at(px, py)
.is_some_and(|b| b.tags.iter().any(|t| t == tag))
}
pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
if self.can_craft_blueprint(blueprint) {
return None;
}
let mut missing = Vec::new();
for input in &blueprint.inputs {
let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
if have < input.quantity {
missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
}
}
for tool in &blueprint.required_tools {
let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
if have < 1 {
missing.push(format!("tool: {}", tool.item));
}
}
if let Some(station) = blueprint.station.as_deref() {
if station != "hand" && !self.player_at_station_tag(station) {
missing.push(format!("station: {station} (enter building)"));
}
}
if missing.is_empty() {
None
} else {
Some(missing.join(", "))
}
}
pub fn player_entity(&self) -> Option<&EntityState> {
self.player
.as_ref()
.or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
}
pub fn player_position(&self) -> (f32, f32) {
self.player_entity()
.map(|p| (p.transform.position.x, p.transform.position.y))
.unwrap_or((0.0, 0.0))
}
pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
let mut rows: Vec<(String, u32, String)> = self
.inventory
.iter()
.filter(|(_, q)| **q > 0)
.map(|(id, qty)| {
let label = self
.inventory_hints
.get(id)
.map(|h| h.display_name.clone())
.unwrap_or_else(|| id.clone());
(id.clone(), *qty, label)
})
.collect();
rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
rows
}
pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
self.inventory_hints
.get(template_id)
.map(|h| h.category.as_str())
.filter(|c| !c.is_empty())
}
pub fn item_base_mass(&self, template_id: &str) -> f32 {
self.inventory_hints
.get(template_id)
.and_then(|h| h.base_mass)
.unwrap_or(0.5)
}
pub fn item_base_volume(&self, template_id: &str) -> f32 {
self.inventory_hints
.get(template_id)
.and_then(|h| h.base_volume)
.unwrap_or(1.0)
}
pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
let unit = stack
.base_mass
.unwrap_or_else(|| self.item_base_mass(&stack.template_id));
unit * stack.quantity as f32
}
fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
let unit = stack.base_volume.unwrap_or(1.0);
unit * stack.quantity as f32
+ stack
.contents
.iter()
.map(Self::stack_tree_volume)
.sum::<f32>()
}
fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
contents.iter().map(Self::stack_tree_volume).sum()
}
fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
self.inventory_hints
.get(template_id)
.and_then(|h| h.capacity_volume)
.filter(|c| *c > 0.0)
}
fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
stack
.capacity_volume
.filter(|c| *c > 0.0)
.or_else(|| self.template_capacity_volume(&stack.template_id))
}
pub fn container_volume_label(&self, row: &InventoryRow) -> String {
let Some((used, cap)) = self.container_volume_stats(row) else {
return String::new();
};
let free = (cap - used).max(0.0);
format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
}
fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
if row.is_chest_shell {
let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
return None;
};
let chest = self
.placed_containers
.iter()
.find(|c| c.id == *container_id)?;
let cap = self
.stack_capacity_volume(&row.stack)
.or(chest.capacity_volume.filter(|c| *c > 0.0))?;
let used = if chest.accessible {
Self::contents_used_volume(&chest.contents)
} else {
0.0
};
return Some((used, cap));
}
let cap = self.stack_capacity_volume(&row.stack)?;
let used = Self::contents_used_volume(&row.stack.contents);
Some((used, cap))
}
pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
if row.is_chest_shell {
return true;
}
if row.is_equip_shell {
return self.inventory_item_category(&row.stack.template_id) == Some("container");
}
self.inventory_item_category(&row.stack.template_id) == Some("container")
|| row.stack.capacity_volume.is_some_and(|c| c > 0.0)
}
fn container_stack_for(
&self,
location: &flatland_protocol::InventoryLocation,
parent_instance_id: Option<uuid::Uuid>,
) -> Option<flatland_protocol::ItemStack> {
match location {
flatland_protocol::InventoryLocation::Root => {
let pid = parent_instance_id?;
self.find_stack_by_instance(&self.inventory_stacks, pid)
}
flatland_protocol::InventoryLocation::Worn { slot } => {
let worn = self.worn.get(slot)?;
if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
Some(worn.clone())
} else {
self.find_stack_by_instance(&worn.contents, parent_instance_id?)
}
}
flatland_protocol::InventoryLocation::Placed { container_id } => {
let chest = self.placed_containers.iter().find(|c| c.id == *container_id)?;
if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
Some(flatland_protocol::ItemStack {
template_id: chest.template_id.clone(),
quantity: 1,
item_instance_id: chest.item_instance_id,
props: Default::default(),
contents: chest.contents.clone(),
display_name: Some(chest.display_name.clone()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: self
.inventory_hints
.get(&chest.template_id)
.and_then(|h| h.capacity_volume),
stackable: None,
})
} else {
self.find_stack_by_instance(&chest.contents, parent_instance_id?)
}
}
}
}
fn find_stack_by_instance(
&self,
stacks: &[flatland_protocol::ItemStack],
instance_id: uuid::Uuid,
) -> Option<flatland_protocol::ItemStack> {
for stack in stacks {
if stack.item_instance_id == Some(instance_id) {
return Some(stack.clone());
}
if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
return Some(found);
}
}
None
}
pub fn max_movable_to(
&self,
template_id: &str,
stack_qty: u32,
from: &flatland_protocol::InventoryLocation,
to: &flatland_protocol::InventoryLocation,
parent_instance_id: Option<uuid::Uuid>,
) -> u32 {
let unit_vol = self.item_base_volume(template_id);
let unit_mass = self.item_base_mass(template_id);
let mut limit = stack_qty;
if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
let cap = parent
.capacity_volume
.or_else(|| {
self.inventory_hints
.get(&parent.template_id)
.and_then(|h| h.capacity_volume)
})
.unwrap_or(0.0);
if cap > 0.0 && unit_vol > 0.0 {
let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
}
}
let to_person = matches!(
to,
flatland_protocol::InventoryLocation::Root | flatland_protocol::InventoryLocation::Worn { .. }
);
let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
if to_person && from_placed && unit_mass > 0.0 {
let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
if self.encumbrance == flatland_protocol::EncumbranceState::Over {
limit = 0;
} else {
limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
}
}
limit.max(0).min(stack_qty)
}
pub fn move_picker_max_at_selection(&self) -> u32 {
let Some(picker) = &self.move_picker else {
return 1;
};
let Some(opt) = picker.options.get(self.move_picker_index) else {
return picker.stack_quantity;
};
match &opt.kind {
MoveOptionKind::Cancel | MoveOptionKind::Drop => picker.stack_quantity,
MoveOptionKind::Move {
location,
parent_instance_id,
} => self.max_movable_to(
&picker.template_id,
picker.stack_quantity,
&picker.from,
location,
*parent_instance_id,
),
}
}
pub fn clamp_move_picker_quantity(&mut self) {
let max = self.move_picker_max_at_selection();
if let Some(picker) = &mut self.move_picker {
if max == 0 {
picker.quantity = 1;
} else {
picker.quantity = picker.quantity.clamp(1, max);
}
}
}
pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
let max = self.move_picker_max_at_selection().max(1);
if let Some(picker) = &mut self.move_picker {
let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
picker.quantity = next as u32;
}
}
pub fn move_picker_set_quantity_max(&mut self) {
let max = self.move_picker_max_at_selection();
if let Some(picker) = &mut self.move_picker {
picker.quantity = if max == 0 { 1 } else { max.min(picker.stack_quantity) };
}
}
pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
if let Some(picker) = &mut self.destroy_picker {
let max = picker.stack_quantity.max(1);
let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
picker.quantity = next as u32;
}
}
pub fn destroy_picker_set_quantity_max(&mut self) {
if let Some(picker) = &mut self.destroy_picker {
picker.quantity = picker.stack_quantity.max(1);
}
}
pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
let have = self.inventory.get(template_id).copied().unwrap_or(0);
(have, have >= need)
}
pub fn currency_display(&self) -> String {
crate::currency::currency_line(&self.inventory)
}
pub fn in_shallow_water(&self) -> bool {
let (px, py) = self.player_position();
self.terrain_at(px, py)
.is_some_and(|k| k == TerrainKindView::ShallowWater)
}
pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
self.terrain_zones.iter().find_map(|z| {
if x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1 {
Some(z.kind)
} else {
None
}
})
}
pub fn building_interior_at(&self, px: f32, py: f32) -> Option<&BuildingView> {
self.buildings
.iter()
.find(|b| point_in_building_interior(px, py, b))
}
pub fn effective_inside_building(&self) -> Option<String> {
let (px, py) = self.player_position();
if let Some(b) = self.building_interior_at(px, py) {
return Some(b.id.clone());
}
if is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m) {
if let Some(id) = self
.player_entity()
.and_then(|p| p.inside_building.clone())
{
return Some(id);
}
if self.buildings.len() == 1 {
return Some(self.buildings[0].id.clone());
}
}
None
}
pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
self.inventory_stacks = stacks.to_vec();
self.inventory.clear();
self.inventory_hints.clear();
fn walk(
stacks: &[flatland_protocol::ItemStack],
inventory: &mut std::collections::HashMap<String, u32>,
hints: &mut std::collections::HashMap<String, InventoryHint>,
) {
for stack in stacks {
*inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
if stack.display_name.is_some()
|| stack.category.is_some()
|| stack.base_mass.is_some()
|| stack.base_volume.is_some()
{
hints.insert(
stack.template_id.clone(),
InventoryHint {
display_name: stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone()),
category: stack.category.clone().unwrap_or_default(),
base_mass: stack.base_mass,
base_volume: stack.base_volume,
capacity_volume: stack.capacity_volume,
stackable: stack.stackable.unwrap_or(true),
},
);
}
walk(&stack.contents, inventory, hints);
}
}
walk(stacks, &mut self.inventory, &mut self.inventory_hints);
for item in self.worn.values() {
walk(std::slice::from_ref(item), &mut self.inventory, &mut self.inventory_hints);
}
}
pub fn worn_rows(&self) -> Vec<InventoryRow> {
let mut rows = Vec::new();
for (slot, item) in &self.worn {
let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
rows.push(InventoryRow {
depth: 0,
stack: item.clone(),
from: from.clone(),
from_parent_instance_id: None,
is_equip_shell: true,
is_chest_shell: false,
section: InventorySection::Worn,
});
for child in &item.contents {
push_inventory_rows(
&mut rows,
1,
child,
&from,
item.item_instance_id,
InventorySection::Worn,
);
}
}
rows
}
pub fn person_rows(&self) -> Vec<InventoryRow> {
let mut rows = Vec::new();
for stack in &self.inventory_stacks {
push_inventory_rows(
&mut rows,
0,
stack,
&flatland_protocol::InventoryLocation::Root,
None,
InventorySection::Person,
);
}
rows
}
pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
let mut rows = self.worn_rows();
rows.extend(self.person_rows());
rows.into_iter().map(|r| (r.depth, r.stack)).collect()
}
pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
let (px, py) = self.player_position();
let mut list: Vec<NearbyContainer> = self
.placed_containers
.iter()
.filter_map(|c| {
let distance_m = (c.x - px).hypot(c.y - py);
if distance_m > CONTAINER_RANGE_M {
return None;
}
let mut rows = Vec::new();
let from = flatland_protocol::InventoryLocation::Placed {
container_id: c.id.clone(),
};
rows.push(InventoryRow {
depth: 0,
stack: flatland_protocol::ItemStack {
template_id: c.template_id.clone(),
quantity: 1,
item_instance_id: c.item_instance_id,
props: Default::default(),
contents: Vec::new(),
display_name: Some(c.display_name.clone()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: c.capacity_volume,
stackable: None,
},
from: from.clone(),
from_parent_instance_id: None,
is_equip_shell: false,
is_chest_shell: true,
section: InventorySection::Nearby,
});
if c.accessible {
for child in &c.contents {
push_inventory_rows(
&mut rows,
1,
child,
&from,
c.item_instance_id,
InventorySection::Nearby,
);
}
}
Some(NearbyContainer {
view: c.clone(),
distance_m,
rows,
})
})
.collect();
list.sort_by(|a, b| {
a.distance_m
.partial_cmp(&b.distance_m)
.unwrap_or(std::cmp::Ordering::Equal)
});
list
}
pub fn nearest_placed_container(
&self,
max_dist: f32,
) -> Option<flatland_protocol::PlacedContainerView> {
let (px, py) = self.player_position();
self.placed_containers
.iter()
.filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
.min_by(|a, b| {
let da = (a.x - px).hypot(a.y - py);
let db = (b.x - px).hypot(b.y - py);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.cloned()
}
pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
let mut rows = self.worn_rows();
rows.extend(self.person_rows());
for nc in self.nearby_containers() {
rows.extend(nc.rows);
}
rows
}
pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
self.inventory_selectable_rows()
.into_iter()
.nth(self.inventory_menu_index)
}
pub fn move_destinations_for(
&self,
from: &flatland_protocol::InventoryLocation,
from_parent_instance_id: Option<uuid::Uuid>,
moving_instance_id: Option<uuid::Uuid>,
moving_template_id: &str,
) -> Vec<MoveOption> {
let mut opts = Vec::new();
if *from != flatland_protocol::InventoryLocation::Root {
opts.push(MoveOption {
label: "On your person (loose)".into(),
kind: MoveOptionKind::Move {
location: flatland_protocol::InventoryLocation::Root,
parent_instance_id: None,
},
});
}
for (slot, item) in &self.worn {
if item.category.as_deref() != Some("container") {
continue;
}
let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
let shell_name = item
.display_name
.clone()
.unwrap_or_else(|| item.template_id.clone());
if *slot != BodySlot::Waist
&& item.item_instance_id != moving_instance_id
&& Self::is_volume_container_stack(item)
{
Self::push_move_destination(
&mut opts,
format!("{shell_name} (worn {})", body_slot_label(*slot)),
location.clone(),
item.item_instance_id,
from,
from_parent_instance_id,
);
}
if *slot == BodySlot::Waist
&& Self::attaches_to_belt_loop(moving_template_id)
&& item.item_instance_id != moving_instance_id
{
Self::push_move_destination(
&mut opts,
format!("{shell_name} (belt loop)"),
location.clone(),
item.item_instance_id,
from,
from_parent_instance_id,
);
}
let context = if *slot == BodySlot::Waist {
format!("on {shell_name}")
} else {
format!("in {shell_name}")
};
Self::append_nested_container_destinations(
&mut opts,
location,
item,
&context,
from,
from_parent_instance_id,
moving_instance_id,
);
}
for nc in self.nearby_containers() {
if !nc.view.accessible {
continue;
}
let location = flatland_protocol::InventoryLocation::Placed {
container_id: nc.view.id.clone(),
};
Self::push_move_destination(
&mut opts,
format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
location,
nc.view.item_instance_id,
from,
from_parent_instance_id,
);
}
let allow_drop = moving_instance_id
.and_then(|id| self.stack_for_instance(id))
.map(|stack| !self.key_drop_blocked(&stack))
.unwrap_or(moving_template_id != KEY_TEMPLATE);
if allow_drop {
opts.push(MoveOption {
label: "Drop on the ground".into(),
kind: MoveOptionKind::Drop,
});
}
opts.push(MoveOption {
label: "Cancel".into(),
kind: MoveOptionKind::Cancel,
});
opts
}
fn is_same_container_dest(
dest_location: &flatland_protocol::InventoryLocation,
dest_parent: Option<uuid::Uuid>,
from: &flatland_protocol::InventoryLocation,
from_parent: Option<uuid::Uuid>,
) -> bool {
dest_location == from && dest_parent == from_parent
}
fn push_move_destination(
opts: &mut Vec<MoveOption>,
label: String,
location: flatland_protocol::InventoryLocation,
parent_instance_id: Option<uuid::Uuid>,
from: &flatland_protocol::InventoryLocation,
from_parent_instance_id: Option<uuid::Uuid>,
) {
if Self::is_same_container_dest(
&location,
parent_instance_id,
from,
from_parent_instance_id,
) {
return;
}
opts.push(MoveOption {
label,
kind: MoveOptionKind::Move {
location,
parent_instance_id,
},
});
}
fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
stack.capacity_volume.is_some_and(|c| c > 0.0)
}
fn attaches_to_belt_loop(template_id: &str) -> bool {
matches!(template_id, "leather_pouch" | "dimensional_pouch")
}
fn append_nested_container_destinations(
opts: &mut Vec<MoveOption>,
location: flatland_protocol::InventoryLocation,
container: &flatland_protocol::ItemStack,
context: &str,
from: &flatland_protocol::InventoryLocation,
from_parent_instance_id: Option<uuid::Uuid>,
moving_instance_id: Option<uuid::Uuid>,
) {
for child in &container.contents {
if Self::is_volume_container_stack(child)
&& child.item_instance_id != moving_instance_id
{
let name = child
.display_name
.clone()
.unwrap_or_else(|| child.template_id.clone());
Self::push_move_destination(
opts,
format!("{name} ({context})"),
location.clone(),
child.item_instance_id,
from,
from_parent_instance_id,
);
}
let nested_context = format!(
"in {}",
child
.display_name
.as_deref()
.unwrap_or(&child.template_id)
);
Self::append_nested_container_destinations(
opts,
location.clone(),
child,
&nested_context,
from,
from_parent_instance_id,
moving_instance_id,
);
}
}
fn clamp_inventory_indices(&mut self) {
let n = self.inventory_selectable_rows().len();
self.inventory_menu_index = if n == 0 {
0
} else {
self.inventory_menu_index.min(n - 1)
};
if let Some(picker) = &self.move_picker {
let pn = picker.options.len();
self.move_picker_index = if pn == 0 {
0
} else {
self.move_picker_index.min(pn - 1)
};
}
}
fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
self.tick = snapshot.tick;
self.chunk_rev = snapshot.chunk_rev;
self.content_rev = snapshot.content_rev;
self.resource_nodes = snapshot.resource_nodes.clone();
self.ground_drops = snapshot.ground_drops.clone();
self.placed_containers = snapshot.placed_containers.clone();
self.world_width_m = snapshot.world_width_m;
self.world_height_m = snapshot.world_height_m;
self.world_clock = snapshot.world_clock;
self.terrain_zones = snapshot.terrain_zones.clone();
self.buildings = snapshot.buildings.clone();
self.doors = snapshot.doors.clone();
self.npcs = snapshot.npcs.clone();
self.blueprints = snapshot.blueprints.clone();
self.sync_inventory_from_stacks(&snapshot.inventory);
self.player = snapshot
.entities
.iter()
.find(|e| e.id == entity_id)
.cloned();
self.entities = snapshot.entities.clone();
}
fn refresh_inventory_ui(&mut self) {
if let Some(picker) = &self.move_picker {
let instance_id = picker.item_instance_id;
let still_exists = self
.inventory_selectable_rows()
.iter()
.any(|r| r.stack.item_instance_id == Some(instance_id));
if !still_exists {
self.move_picker = None;
self.show_move_picker = false;
}
}
if let Some(picker) = &self.destroy_picker {
let instance_id = picker.item_instance_id;
let still_exists = self
.inventory_selectable_rows()
.iter()
.any(|r| r.stack.item_instance_id == Some(instance_id));
if !still_exists {
self.destroy_picker = None;
self.show_destroy_picker = false;
self.destroy_confirm_pending = false;
}
}
self.clamp_inventory_indices();
}
fn apply_combat_hud(&mut self, combat: &CombatHud) {
self.in_combat = combat.in_combat;
self.auto_attack = combat.auto_attack;
self.combat_has_los = combat.has_los;
self.attack_cd_ticks = combat.attack_cd_ticks;
self.gcd_ticks = combat.gcd_ticks;
self.weapon_ability_id = combat.ability_id.clone();
self.mainhand_template_id = combat.mainhand_template_id.clone();
self.mainhand_label = combat.mainhand_label.clone();
self.worn = combat.worn.iter().cloned().collect();
self.carry_mass = combat.carry_mass;
self.carry_mass_max = combat.carry_mass_max;
self.encumbrance = combat.encumbrance;
self.cast_progress = combat.cast.clone();
self.ability_cooldowns = combat.ability_cooldowns.clone();
self.blocking_active = combat.blocking_active;
self.max_target_slots = combat.max_target_slots.max(1);
self.combat_slots = combat.slots.clone();
self.rotation_presets = combat.rotation_presets.clone();
self.combat_target_detail = combat.target.clone();
self.combat_target = combat.target_entity_id;
if let Some(label) = &combat.target_label {
self.combat_target_label = Some(label.clone());
} else if let Some(id) = combat.target_entity_id {
self.combat_target_label = self
.entities
.iter()
.find(|e| e.id == id)
.map(|e| e.label.clone())
.or_else(|| self.combat_target_label.clone());
}
self.refresh_inventory_ui();
}
pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
self.combat_slots
.iter()
.find(|s| s.slot_index == slot)
.and_then(|s| s.target_entity_id)
.or_else(|| {
if slot == 1 {
self.combat_target
} else {
None
}
})
}
pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
self.combat_candidates()
}
pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
let (px, py) = self.player_position();
let dist = |id: EntityId| {
self.entities
.iter()
.find(|e| e.id == id)
.map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
.unwrap_or(f32::MAX)
};
let mut allies = Vec::new();
if let Some(me) = self.player.as_ref() {
let alive = me
.vitals
.as_ref()
.map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
.unwrap_or(true);
if alive {
allies.push((self.entity_id, "Yourself".into()));
}
}
for entity in &self.entities {
if entity.id == self.entity_id {
continue;
}
if entity.vitals.is_some() {
let alive = entity
.vitals
.as_ref()
.map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
.unwrap_or(true);
if alive {
allies.push((entity.id, entity.label.clone()));
}
}
}
allies.sort_by(|(a, _), (b, _)| {
if *a == self.entity_id {
return std::cmp::Ordering::Less;
}
if *b == self.entity_id {
return std::cmp::Ordering::Greater;
}
dist(*a)
.partial_cmp(&dist(*b))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut monsters = self.combat_candidates();
monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
allies.into_iter().chain(monsters).collect()
}
fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
match slot_index {
2 => self.t2_candidates(),
_ => self.t1_candidates(),
}
}
pub(crate) fn restore_from_welcome(
&mut self,
session_id: SessionId,
entity_id: EntityId,
snapshot: &flatland_protocol::Snapshot,
) {
self.clear_harvest_state();
self.disconnect_reason = None;
self.show_stats = false;
self.show_craft_menu = false;
self.show_shop_menu = false;
self.shop_catalog = None;
self.show_inventory_menu = false;
self.session_id = session_id;
self.entity_id = entity_id;
self.connected = true;
self.apply_snapshot_fields(snapshot, entity_id);
if let Some(combat) = &snapshot.combat {
self.apply_combat_hud(combat);
let stacks = self.inventory_stacks.clone();
self.sync_inventory_from_stacks(&stacks);
}
}
fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
self.tick = delta.tick;
self.world_clock = delta.world_clock;
if delta.entities.is_empty() {
if let Some(combat) = &delta.combat {
self.apply_combat_hud(combat);
let stacks = self.inventory_stacks.clone();
self.sync_inventory_from_stacks(&stacks);
}
return;
}
if !delta.buildings.is_empty() {
self.buildings = delta.buildings.clone();
}
if !delta.blueprints.is_empty() {
self.blueprints = delta.blueprints.clone();
}
self.sync_inventory_from_stacks(&delta.inventory);
if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
self.player = Some(updated.clone());
}
self.entities = delta.entities.clone();
if self.player.is_none() {
self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
}
if let Some(player) = self.player.as_ref() {
let (px, py) = (player.transform.position.x, player.transform.position.y);
let stale_inside = player.inside_building.is_some()
&& self.building_interior_at(px, py).is_none()
&& !is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m);
if stale_inside {
self.player.as_mut().unwrap().inside_building = None;
}
}
if !delta.resource_nodes.is_empty() {
self.resource_nodes = delta.resource_nodes.clone();
}
if self.player.as_ref().is_none_or(|p| p.inside_building.is_none()) {
self.ground_drops = delta.ground_drops.clone();
self.placed_containers = delta.placed_containers.clone();
}
if !delta.doors.is_empty() {
self.doors = delta.doors.clone();
}
if !delta.npcs.is_empty() {
self.npcs = delta.npcs.clone();
}
if let Some(combat) = &delta.combat {
self.apply_combat_hud(combat);
let stacks = self.inventory_stacks.clone();
self.sync_inventory_from_stacks(&stacks);
} else {
self.refresh_inventory_ui();
}
}
pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
let (px, py) = self.player_position();
let mut out = Vec::new();
for npc in &self.npcs {
let Some(eid) = npc.entity_id else {
continue;
};
let alive = npc
.life_state
.is_none_or(|s| s == LifeState::Alive);
let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
if alive && has_hp {
out.push((eid, npc.label.clone()));
}
}
out.sort_by(|(a_id, a_label), (b_id, b_label)| {
let dist = |id: EntityId| {
self.entities
.iter()
.find(|e| e.id == id)
.map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
.unwrap_or(f32::MAX)
};
dist(*a_id)
.partial_cmp(&dist(*b_id))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a_label.cmp(b_label))
.then_with(|| a_id.cmp(b_id))
});
out
}
pub fn refresh_combat_target_label(&mut self) {
let Some(id) = self.combat_target else {
return;
};
if let Some((_, label)) = self
.combat_candidates()
.into_iter()
.find(|(eid, _)| *eid == id)
{
self.combat_target_label = Some(label);
} else if let Some(label) = self.entities.iter().find(|e| e.id == id).map(|e| e.label.clone())
{
self.combat_target_label = Some(label);
}
}
pub fn nearest_interact_target(&self) -> Option<String> {
let (px, py) = self.player_position();
let inside = self.effective_inside_building();
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Npc,
ExitDoor,
EnterDoor,
Well,
Water,
}
fn kind_priority(kind: Kind) -> u8 {
match kind {
Kind::Npc => 0,
Kind::ExitDoor => 1,
Kind::EnterDoor => 2,
Kind::Well => 3,
Kind::Water => 4,
}
}
let mut best: Option<(f32, Kind, String)> = None;
let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
if dist > max {
return;
}
let replace = match best {
None => true,
Some((bd, _bk, _)) if dist < bd - 0.05 => true,
Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
kind_priority(kind) < kind_priority(bk)
}
_ => false,
};
if replace {
best = Some((dist, kind, id));
}
};
for npc in &self.npcs {
consider(
distance(px, py, npc.x, npc.y),
INTERACTION_RADIUS_M,
Kind::Npc,
npc.id.clone(),
);
}
for door in &self.doors {
if inside.is_none() && door.is_exit {
continue;
}
if let Some(ref bid) = inside {
if door.building_id != *bid {
continue;
}
}
let max = if inside.is_some() && door.is_exit {
INTERACTION_RADIUS_M
} else {
DOOR_INTERACTION_RADIUS_M
};
let kind = if door.is_exit {
Kind::ExitDoor
} else {
Kind::EnterDoor
};
consider(
distance(px, py, door.x, door.y),
max,
kind,
door.id.clone(),
);
}
if inside.is_none() {
for building in &self.buildings {
if !building.tags.iter().any(|t| t == "well") {
continue;
}
consider(
distance(px, py, building.x, building.y),
INTERACTION_RADIUS_M,
Kind::Well,
building.id.clone(),
);
}
if self.in_shallow_water() {
consider(0.0, INTERACTION_RADIUS_M, Kind::Water, "water_source".into());
}
}
best.map(|(_, _, id)| id)
}
pub fn template_display_name(&self, template_id: &str) -> String {
self.inventory_hints
.get(template_id)
.map(|h| h.display_name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| humanize_template_id(template_id))
}
pub fn placed_container_public_label(
&self,
c: &flatland_protocol::PlacedContainerView,
) -> String {
let is_owner = match (self.character_id, c.owner_character_id) {
(Some(me), Some(owner)) => me == owner,
_ => false,
};
if is_owner {
c.display_name.clone()
} else {
self.template_display_name(&c.template_id)
}
}
pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
if stack.template_id != KEY_TEMPLATE {
return None;
}
if let Some(name) = stack
.props
.get(PROP_OPENS_CONTAINER_NAME)
.filter(|n| !n.is_empty())
{
return Some(name.clone());
}
let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
self.container_name_for_lock_id(opens)
}
pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
if stack.template_id == KEY_TEMPLATE {
self.template_display_name(KEY_TEMPLATE)
} else {
stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone())
}
}
pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
if stack.template_id != KEY_TEMPLATE {
return String::new();
}
match self.key_pair_chest_label(stack) {
Some(chest) if self.key_drop_blocked(stack) => {
format!(" [key for {chest} — can't drop while locked]")
}
Some(chest) => format!(" [key for {chest}]"),
None => " [key — unpaired]".into(),
}
}
pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
for c in &self.placed_containers {
if c.lock_id.as_deref() == Some(lock) {
return Some(c.display_name.clone());
}
}
Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
self.worn.values().find_map(|worn| {
Self::container_name_in_stacks(std::slice::from_ref(worn), lock)
})
})
}
pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
if stack.template_id != KEY_TEMPLATE {
return false;
}
let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
return false;
};
for c in &self.placed_containers {
if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
return true;
}
}
if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
return true;
}
self.worn.values().any(|worn| {
Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens)
})
}
fn container_name_in_stacks(
stacks: &[flatland_protocol::ItemStack],
lock: &str,
) -> Option<String> {
fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
for s in stacks {
if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
return Some(GameState::stack_container_label(s));
}
if let Some(name) = walk(&s.contents, lock) {
return Some(name);
}
}
None
}
walk(stacks, lock)
}
fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
stack
.props
.get(PROP_CUSTOM_NAME)
.cloned()
.or_else(|| stack.display_name.clone())
.unwrap_or_else(|| stack.template_id.clone())
}
fn has_locked_container_with_lock(
stacks: &[flatland_protocol::ItemStack],
lock: &str,
) -> bool {
fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
for s in stacks {
if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
return true;
}
if walk(&s.contents, lock) {
return true;
}
}
false
}
walk(stacks, lock)
}
fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
return Some(stack.clone());
}
for worn in self.worn.values() {
if worn.item_instance_id == Some(instance_id) {
return Some(worn.clone());
}
if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
return Some(stack.clone());
}
}
None
}
pub fn location_context_lines(&self) -> Vec<ContextLine> {
let (px, py) = self.player_position();
let mut lines = Vec::new();
if let Some(kind) = self.terrain_at(px, py) {
lines.push(ContextLine {
on_top: true,
text: format!("Terrain: {}", terrain_kind_label(kind)),
});
}
if let Some(building) = self.building_interior_at(px, py) {
lines.push(ContextLine {
on_top: true,
text: format!("Inside: {}", building.label),
});
} else if let Some(id) = self.effective_inside_building() {
if let Some(b) = self.buildings.iter().find(|b| b.id == id) {
lines.push(ContextLine {
on_top: false,
text: format!("Near: {} (exterior)", b.label),
});
}
}
let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
for node in &self.resource_nodes {
let dist = distance(px, py, node.x, node.y);
if dist > NEARBY_SCAN_M {
continue;
}
let on_top = dist <= ON_TOP_RADIUS_M;
let state = match node.state {
flatland_protocol::ResourceNodeState::Available => " — press , to harvest",
flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
};
let prefix = if on_top { "On" } else { "Near" };
nearby.push((
dist,
ContextLine {
on_top,
text: format!(
"{prefix}: {} ({dist:.1}m){state}",
node.label
),
},
));
}
for drop in &self.ground_drops {
let dist = distance(px, py, drop.x, drop.y);
if dist > INTERACTION_RADIUS_M {
continue;
}
let on_top = dist <= ON_TOP_RADIUS_M;
let name = self.template_display_name(&drop.template_id);
let prefix = if on_top { "On" } else { "Near" };
let qty = if drop.quantity > 1 {
format!(" ×{}", drop.quantity)
} else {
String::new()
};
nearby.push((
dist,
ContextLine {
on_top,
text: format!(
"{prefix}: {name}{qty} ({dist:.1}m) — p pickup"
),
},
));
}
for c in &self.placed_containers {
let dist = distance(px, py, c.x, c.y);
if dist > CONTAINER_RANGE_M {
continue;
}
let on_top = dist <= ON_TOP_RADIUS_M;
let name = self.placed_container_public_label(c);
let lock = if c.locked { " [locked]" } else { "" };
let prefix = if on_top { "On" } else { "Near" };
nearby.push((
dist,
ContextLine {
on_top,
text: format!("{prefix}: {name}{lock} ({dist:.1}m)"),
},
));
}
for npc in &self.npcs {
let dist = distance(px, py, npc.x, npc.y);
if dist > NEARBY_SCAN_M {
continue;
}
let on_top = dist <= ON_TOP_RADIUS_M;
let prefix = if on_top { "On" } else { "Near" };
nearby.push((
dist,
ContextLine {
on_top,
text: format!(
"{prefix}: {} ({dist:.1}m) — f talk",
npc.label
),
},
));
}
for door in &self.doors {
let dist = distance(px, py, door.x, door.y);
if dist > DOOR_INTERACTION_RADIUS_M {
continue;
}
let building = self
.buildings
.iter()
.find(|b| b.id == door.building_id)
.map(|b| b.label.as_str())
.unwrap_or(door.building_id.as_str());
let action = if door.is_exit { "exit" } else { "enter" };
nearby.push((
dist,
ContextLine {
on_top: dist <= ON_TOP_RADIUS_M,
text: format!("{building} door ({dist:.1}m) — f {action}"),
},
));
}
if self.in_shallow_water() {
let already = self
.terrain_at(px, py)
.is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
if !already {
nearby.push((
0.0,
ContextLine {
on_top: true,
text: "Shallow water — f fill bottle".into(),
},
));
} else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
line.text.push_str(" — f fill bottle");
}
}
for entity in &self.entities {
if entity.id == self.entity_id {
continue;
}
let dist = distance(px, py, entity.transform.position.x, entity.transform.position.y);
if dist > NEARBY_SCAN_M {
continue;
}
let label = if entity.label.is_empty() {
format!("entity {}", entity.id)
} else {
entity.label.clone()
};
nearby.push((
dist,
ContextLine {
on_top: dist <= ON_TOP_RADIUS_M,
text: format!("Near: {label} ({dist:.1}m)"),
},
));
}
nearby.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
});
lines.extend(nearby.into_iter().map(|(_, l)| l));
if lines.is_empty() {
lines.push(ContextLine {
on_top: false,
text: "(nothing notable nearby)".into(),
});
}
lines
}
}
#[derive(Debug, Clone)]
pub struct ContextLine {
pub on_top: bool,
pub text: String,
}
const ON_TOP_RADIUS_M: f32 = 0.65;
const NEARBY_SCAN_M: f32 = 5.0;
fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
use flatland_protocol::TerrainKindView;
match kind {
TerrainKindView::Grass => "Grass",
TerrainKindView::Hill => "Hills",
TerrainKindView::Bog => "Bog",
TerrainKindView::ShallowWater => "Shallow water",
TerrainKindView::DeepWater => "Deep water",
}
}
fn humanize_template_id(template_id: &str) -> String {
template_id
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn point_in_building_interior(px: f32, py: f32, building: &BuildingView) -> bool {
px >= building.interior_origin_x
&& px <= building.interior_origin_x + building.width_m
&& py >= building.interior_origin_y
&& py <= building.interior_origin_y + building.depth_m
}
fn is_instanced_world_coord(px: f32, py: f32, world_w: f32, world_h: f32) -> bool {
if world_w > 0.0 && world_h > 0.0 {
px < 0.0 || py < 0.0 || px > world_w || py > world_h
} else {
px > 256.0 || py > 256.0
}
}
const HARVEST_RANGE_M: f32 = 1.5;
pub struct GameClient<S: PlayConnection> {
session: S,
seq: Seq,
pub state: GameState,
last_move_forward: f32,
last_move_strafe: f32,
}
impl<S: PlayConnection> GameClient<S> {
pub fn new(session: S) -> Self {
let session_id = session.session_id();
let entity_id = session.entity_id();
Self {
session,
seq: 0,
last_move_forward: 0.0,
last_move_strafe: 0.0,
state: GameState {
session_id,
entity_id,
character_id: None,
tick: 0,
chunk_rev: 0,
content_rev: 0,
entities: Vec::new(),
player: None,
resource_nodes: Vec::new(),
ground_drops: Vec::new(),
placed_containers: Vec::new(),
buildings: Vec::new(),
doors: Vec::new(),
npcs: Vec::new(),
blueprints: Vec::new(),
world_width_m: 0.0,
world_height_m: 0.0,
terrain_zones: Vec::new(),
world_clock: flatland_protocol::WorldClock::default(),
inventory: std::collections::HashMap::new(),
inventory_hints: std::collections::HashMap::new(),
logs: VecDeque::new(),
intents_sent: 0,
ticks_received: 0,
connected: false,
disconnect_reason: None,
show_stats: false,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
shop_tab: ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
show_inventory_menu: false,
inventory_menu_index: 0,
show_move_picker: false,
show_rename_prompt: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
in_combat: false,
auto_attack: true,
combat_has_los: false,
attack_cd_ticks: 0,
gcd_ticks: 0,
weapon_ability_id: "unarmed".into(),
mainhand_template_id: None,
mainhand_label: None,
worn: BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
combat_target_detail: None,
cast_progress: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
show_loadout_menu: false,
show_rotation_editor: false,
loadout_menu_index: 0,
rotation_editor: RotationEditorState::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
},
}
}
pub fn entity_id(&self) -> EntityId {
self.state.entity_id
}
pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
if self.state.connected {
return Ok(());
}
loop {
match self.session.next_event().await {
Some(SessionEvent::Welcome {
session_id,
entity_id,
snapshot,
}) => {
self.state.restore_from_welcome(session_id, entity_id, &snapshot);
self.state.push_log(format!(
"Connected — session {session_id}, entity {entity_id}"
));
return Ok(());
}
Some(SessionEvent::Disconnected { .. }) => {
anyhow::bail!("disconnected before welcome");
}
Some(_) => continue,
None => anyhow::bail!("session closed before welcome"),
}
}
}
pub fn drain_events(&mut self) {
while let Some(event) = self.session.try_next_event() {
if self.handle_event_sync(event).is_err() {
break;
}
}
}
pub async fn next_event(&mut self) -> Option<SessionEvent> {
self.session.next_event().await
}
pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
self.handle_event_sync(event)
}
fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
match event {
SessionEvent::Welcome {
session_id,
entity_id,
snapshot,
} => {
let resumed = self.state.connected;
self.state
.restore_from_welcome(session_id, entity_id, &snapshot);
if resumed {
self.state.push_log(format!(
"Session restored — session {session_id}, entity {entity_id}"
));
}
}
SessionEvent::ContentUpdated { snapshot } => {
self.state.apply_snapshot_fields(&snapshot, self.state.entity_id);
self.state.push_log(format!(
"World updated (content rev {})",
snapshot.content_rev
));
}
SessionEvent::Tick(delta) => {
self.state.apply_tick_fields(&delta, self.state.entity_id);
self.state.ticks_received += 1;
}
SessionEvent::IntentAck {
entity_id,
seq,
tick,
} => {
crate::harvest_trace!(
entity_id,
seq,
tick,
"client received intent ack"
);
if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
if *craft_seq == seq {
let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
if batches > 1 {
self.state
.push_log(format!("Crafting {label} ×{batches}…"));
} else {
self.state.push_log(format!("Crafting {label}…"));
}
}
}
}
SessionEvent::Chat(msg) => {
let label = match msg.channel {
flatland_protocol::ChatChannel::Nearby => "nearby",
flatland_protocol::ChatChannel::WhisperStone => "whisper",
};
self.state.push_log(format!(
"[{label}] {}: {}",
msg.from_name, msg.text
));
}
SessionEvent::HarvestResult(result) => {
self.state.clear_harvest_state();
crate::harvest_trace!(
entity_id = self.state.entity_id,
node_id = %result.node_id,
template = %result.item_template,
quantity = result.quantity,
client_tick = self.state.tick,
"client applied harvest result"
);
*self
.state
.inventory
.entry(result.item_template.clone())
.or_insert(0) += result.quantity;
self.state.push_log(format!(
"Harvested {} x{}",
result.item_template, result.quantity
));
}
SessionEvent::CraftResult(result) => {
for stack in &result.consumed {
if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
*qty = qty.saturating_sub(stack.quantity);
if *qty == 0 {
self.state.inventory.remove(&stack.template_id);
}
}
}
for stack in &result.outputs {
*self
.state
.inventory
.entry(stack.template_id.clone())
.or_insert(0) += stack.quantity;
}
if let Some(output) = result.outputs.first() {
if result.batch_total > 1 {
self.state.push_log(format!(
"Crafted {} x{} ({}/{})",
output.template_id,
output.quantity,
result.batch_index,
result.batch_total
));
} else {
self.state.push_log(format!(
"Crafted {} x{}",
output.template_id, output.quantity
));
}
} else {
self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
}
}
SessionEvent::Death(notice) => {
self.state.clear_harvest_state();
self.state.push_log(notice.message.clone());
self.state.push_log(format!(
"Respawned at ({:.1}, {:.1})",
notice.respawn_x, notice.respawn_y
));
}
SessionEvent::Interaction(notice) => {
if notice.message.starts_with("Harvest failed:") {
self.state.clear_harvest_state();
}
if notice.message.starts_with("Can't do that:") {
self.state.pending_craft_ack = None;
}
if notice.message.starts_with("Cast failed:") {
self.state.cast_progress = None;
}
if notice.message.contains("slain the") {
self.state.combat_target = None;
self.state.combat_target_label = None;
}
self.state.push_log(notice.message.clone());
}
SessionEvent::ShopOpened(catalog) => {
self.state.apply_shop_catalog(catalog);
}
SessionEvent::UseResult(result) => {
if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
*qty = qty.saturating_sub(1);
if *qty == 0 {
self.state.inventory.remove(&result.template_id);
}
}
self.state.push_log(format!(
"Consumed {} (+{:.0} hunger, +{:.0} thirst)",
result.template_id, result.hunger_restored, result.thirst_restored
));
}
SessionEvent::Disconnected { reason } => {
self.state.clear_harvest_state();
self.state.connected = false;
self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
if let Some(r) = &self.state.disconnect_reason {
self.state.push_log(format!("Disconnected: {r}"));
} else {
self.state.push_log("Disconnected from server");
}
}
}
Ok(())
}
pub fn is_connected(&self) -> bool {
self.state.connected
}
pub fn close_overlays(&mut self) {
self.state.show_stats = false;
self.state.show_craft_menu = false;
self.state.show_shop_menu = false;
self.state.shop_catalog = None;
self.state.show_inventory_menu = false;
self.state.show_loadout_menu = false;
self.state.show_rotation_editor = false;
self.state.rotation_editor.reset();
self.state.show_rename_prompt = false;
self.state.rename_buffer.clear();
self.state.show_move_picker = false;
self.state.move_picker = None;
self.state.show_destroy_picker = false;
self.state.destroy_confirm_pending = false;
self.state.destroy_picker = None;
}
pub fn back_on_esc(&mut self) -> bool {
if self.state.show_rename_prompt {
self.cancel_rename_prompt();
return true;
}
if self.state.show_destroy_picker {
if self.state.destroy_confirm_pending {
self.cancel_destroy_confirm();
} else {
self.close_destroy_picker();
}
return true;
}
if self.state.show_move_picker {
self.close_move_picker();
return true;
}
if self.state.show_rotation_editor {
match self.state.rotation_editor.mode {
RotationEditorMode::List => {
self.state.show_rotation_editor = false;
self.state.rotation_editor.reset();
}
RotationEditorMode::EditLabel => {
self.state.rotation_editor.label_buffer.clear();
self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
}
RotationEditorMode::PickAbility => {
self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
}
RotationEditorMode::EditSequence => {
self.state.rotation_editor.draft = None;
self.state.rotation_editor.mode = RotationEditorMode::List;
}
}
return true;
}
if self.state.show_inventory_menu {
self.close_inventory_menu();
return true;
}
if self.state.show_craft_menu {
self.close_craft_menu();
return true;
}
if self.state.show_shop_menu {
self.close_shop_menu();
return true;
}
if self.state.show_loadout_menu {
self.state.show_loadout_menu = false;
return true;
}
if self.state.show_stats {
self.state.show_stats = false;
return true;
}
false
}
pub fn toggle_stats(&mut self) {
self.state.show_stats = !self.state.show_stats;
if self.state.show_stats {
self.state.show_craft_menu = false;
self.state.show_shop_menu = false;
self.state.shop_catalog = None;
self.state.show_inventory_menu = false;
}
}
pub fn open_inventory_menu(&mut self) {
self.state.show_inventory_menu = true;
self.state.show_craft_menu = false;
self.state.show_shop_menu = false;
self.state.shop_catalog = None;
self.state.show_stats = false;
self.state.show_move_picker = false;
self.state.move_picker = None;
self.state.show_destroy_picker = false;
self.state.destroy_confirm_pending = false;
self.state.destroy_picker = None;
self.state.show_rename_prompt = false;
self.state.rename_buffer.clear();
self.state.clamp_inventory_indices();
}
pub fn close_inventory_menu(&mut self) {
self.state.show_inventory_menu = false;
self.state.show_move_picker = false;
self.state.move_picker = None;
self.state.show_destroy_picker = false;
self.state.destroy_confirm_pending = false;
self.state.destroy_picker = None;
self.state.show_rename_prompt = false;
self.state.rename_buffer.clear();
}
pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if !self.state.row_is_renameable_container(&row) {
anyhow::bail!("only storage containers can be renamed");
}
let current = row
.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone());
self.state.rename_buffer = current;
self.state.show_rename_prompt = true;
self.state.show_move_picker = false;
self.state.show_destroy_picker = false;
self.state.destroy_confirm_pending = false;
Ok(())
}
pub fn cancel_rename_prompt(&mut self) {
self.state.show_rename_prompt = false;
self.state.rename_buffer.clear();
}
pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
let name = self.state.rename_buffer.trim().to_string();
if name.is_empty() {
anyhow::bail!("name cannot be empty");
}
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
let Some(instance_id) = row.stack.item_instance_id else {
anyhow::bail!("item has no instance id");
};
self.seq += 1;
self.session
.submit_intent(Intent::RenameContainer {
entity_id: self.state.entity_id,
item_instance_id: instance_id,
location: row.from.clone(),
name,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.show_rename_prompt = false;
self.state.rename_buffer.clear();
Ok(())
}
pub fn toggle_inventory_menu(&mut self) {
if self.state.show_inventory_menu {
self.close_inventory_menu();
} else {
self.open_inventory_menu();
}
}
pub fn inventory_menu_move(&mut self, delta: i32) {
if self.state.show_move_picker {
let n = self
.state
.move_picker
.as_ref()
.map(|p| p.options.len())
.unwrap_or(0);
if n == 0 {
return;
}
let idx = self.state.move_picker_index as i32;
self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
self.state.clamp_move_picker_quantity();
return;
}
let n = self.state.inventory_selectable_rows().len();
if n == 0 {
return;
}
let idx = self.state.inventory_menu_index as i32;
self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
if self.state.show_destroy_picker {
if self.state.destroy_confirm_pending {
return self.confirm_destroy_item().await;
}
return self.request_destroy_confirm();
}
if self.state.show_move_picker {
return self.confirm_move_picker().await;
}
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if row.is_equip_shell {
let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
anyhow::bail!("not a worn item");
};
return self.equip_worn(slot, None).await;
}
if row.is_chest_shell {
let flatland_protocol::InventoryLocation::Placed { container_id } = row.from else {
anyhow::bail!("not a placed chest");
};
return self.toggle_placed_chest_lock(&container_id).await;
}
let template_id = row.stack.template_id.clone();
let instance_id = row.stack.item_instance_id;
let category = self.state.inventory_item_category(&template_id);
let on_person = row.from == flatland_protocol::InventoryLocation::Root;
if category == Some("weapon") {
return self.equip_mainhand(Some(template_id)).await;
}
if (category == Some("container") || category == Some("armor")) && on_person {
if let Some(inst) = instance_id {
if template_id.contains("chest") {
return self.place_container(inst).await;
}
if let Some(slot) = guess_body_slot(&template_id) {
return self.equip_worn(slot, Some(inst)).await;
}
}
}
if category == Some("consumable") && on_person {
return self.use_item(&template_id).await;
}
self.open_move_picker()
}
pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if row.is_equip_shell {
anyhow::bail!("this is a worn bag — press Enter to unequip it");
}
if row.is_chest_shell {
anyhow::bail!("select the chest and press Enter or l to lock/unlock it");
}
let Some(instance_id) = row.stack.item_instance_id else {
anyhow::bail!("item has no instance id");
};
let options = self.state.move_destinations_for(
&row.from,
row.from_parent_instance_id,
row.stack.item_instance_id,
&row.stack.template_id,
);
let item_label = row
.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone());
self.state.move_picker = Some(MovePicker {
item_instance_id: instance_id,
from: row.from,
item_label,
template_id: row.stack.template_id.clone(),
stack_quantity: row.stack.quantity,
quantity: row.stack.quantity,
options,
});
self.state.move_picker_index = 0;
self.state.show_move_picker = true;
self.state.show_destroy_picker = false;
self.state.destroy_confirm_pending = false;
self.state.destroy_picker = None;
Ok(())
}
pub fn close_move_picker(&mut self) {
self.state.show_move_picker = false;
self.state.move_picker = None;
}
pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
self.state.move_picker_adjust_quantity(delta);
}
pub fn move_picker_set_quantity_max(&mut self) {
self.state.move_picker_set_quantity_max();
}
pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
self.state.destroy_picker_adjust_quantity(delta);
}
pub fn destroy_picker_set_quantity_max(&mut self) {
self.state.destroy_picker_set_quantity_max();
}
async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.move_picker.clone() else {
self.close_move_picker();
return Ok(());
};
let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
self.close_move_picker();
return Ok(());
};
match option.kind {
MoveOptionKind::Cancel => {
self.close_move_picker();
}
MoveOptionKind::Drop => {
self.close_move_picker();
if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
if self.state.key_drop_blocked(&stack) {
anyhow::bail!("cannot drop the key while its chest is locked");
}
}
self.drop_item(picker.item_instance_id, picker.from).await?;
self.state.push_log(format!("Dropped {}", picker.item_label));
}
MoveOptionKind::Move {
location,
parent_instance_id,
} => {
self.close_move_picker();
let qty = if picker.quantity >= picker.stack_quantity {
None
} else {
Some(picker.quantity)
};
self.move_item(
picker.item_instance_id,
picker.from,
location,
parent_instance_id,
qty,
)
.await?;
let moved = qty.unwrap_or(picker.stack_quantity);
if moved >= picker.stack_quantity {
self.state.push_log(format!("Moved {}", picker.item_label));
} else {
self.state.push_log(format!(
"Moved {} ×{} of {}",
picker.item_label, moved, picker.stack_quantity
));
}
}
}
Ok(())
}
pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if row.is_equip_shell {
anyhow::bail!("unequip the bag first (Enter), then drop from your person");
}
if row.is_chest_shell {
anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
}
let Some(inst) = row.stack.item_instance_id else {
anyhow::bail!("item has no instance id");
};
if self.state.key_drop_blocked(&row.stack) {
anyhow::bail!("cannot drop the key while its chest is locked");
}
let label = row
.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone());
self.drop_item(inst, row.from).await?;
self.state.push_log(format!("Dropped {label}"));
Ok(())
}
pub async fn drop_item(
&mut self,
item_instance_id: uuid::Uuid,
from: flatland_protocol::InventoryLocation,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::DropItem {
entity_id: self.state.entity_id,
item_instance_id,
from,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if row.is_equip_shell {
anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
}
if row.is_chest_shell {
anyhow::bail!("can't destroy a placed chest from the inventory list");
}
let Some(instance_id) = row.stack.item_instance_id else {
anyhow::bail!("item has no instance id");
};
if self.state.key_drop_blocked(&row.stack) {
anyhow::bail!("cannot destroy the key while its chest is locked");
}
let item_label = row
.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone());
self.state.destroy_picker = Some(DestroyPicker {
item_instance_id: instance_id,
from: row.from,
item_label,
stack_quantity: row.stack.quantity,
quantity: row.stack.quantity,
});
self.state.destroy_confirm_pending = false;
self.state.show_destroy_picker = true;
self.state.show_move_picker = false;
self.state.move_picker = None;
Ok(())
}
pub fn close_destroy_picker(&mut self) {
self.state.show_destroy_picker = false;
self.state.destroy_confirm_pending = false;
self.state.destroy_picker = None;
}
pub fn cancel_destroy_confirm(&mut self) {
self.state.destroy_confirm_pending = false;
}
pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
if self.state.destroy_picker.is_none() {
self.close_destroy_picker();
return Ok(());
}
self.state.destroy_confirm_pending = true;
Ok(())
}
pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.destroy_picker.clone() else {
self.close_destroy_picker();
return Ok(());
};
let qty = if picker.quantity >= picker.stack_quantity {
None
} else {
Some(picker.quantity)
};
self.destroy_item(picker.item_instance_id, picker.from, qty)
.await?;
let destroyed = qty.unwrap_or(picker.stack_quantity);
if destroyed >= picker.stack_quantity {
self.state
.push_log(format!("Destroyed {}", picker.item_label));
} else {
self.state.push_log(format!(
"Destroyed {} ×{} of {}",
picker.item_label, destroyed, picker.stack_quantity
));
}
self.close_destroy_picker();
Ok(())
}
pub async fn destroy_item(
&mut self,
item_instance_id: uuid::Uuid,
from: flatland_protocol::InventoryLocation,
quantity: Option<u32>,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::DestroyItem {
entity_id: self.state.entity_id,
item_instance_id,
from,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
if let Some(row) = self.state.inventory_selected_row() {
if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
return self.toggle_placed_chest_lock(container_id).await;
}
}
self.toggle_nearby_chest_lock().await
}
pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
let chest = self
.state
.placed_containers
.iter()
.find(|c| c.id == container_id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("chest not found"))?;
let (px, py) = self.state.player_position();
if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
anyhow::bail!("too far from {}", chest.display_name);
}
if !chest.accessible && chest.locked {
anyhow::bail!(
"need the matching key for {} (each crafted chest has its own key)",
chest.display_name
);
}
let lock = !chest.locked;
self.set_container_locked(
flatland_protocol::InventoryLocation::Placed {
container_id: chest.id.clone(),
},
lock,
)
.await?;
self.state.push_log(if lock {
format!("Locked {}", chest.display_name)
} else {
format!("Unlocked {}", chest.display_name)
});
Ok(())
}
pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
let chest = self
.state
.nearest_placed_container(CONTAINER_RANGE_M)
.ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
self.toggle_placed_chest_lock(&chest.id).await
}
pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
self.equip_mainhand(None).await
}
pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
for slot in slots {
self.equip_worn(slot, None).await?;
}
Ok(())
}
pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
let (px, py) = self.state.player_position();
let nearest = self
.state
.placed_containers
.iter()
.min_by(|a, b| {
let da = (a.x - px).hypot(a.y - py);
let db = (b.x - px).hypot(b.y - py);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.cloned();
let Some(chest) = nearest else {
anyhow::bail!("no chest nearby");
};
if (chest.x - px).hypot(chest.y - py) > 2.0 {
anyhow::bail!("too far from chest");
}
self.pickup_container(chest.id).await
}
pub async fn equip_worn(
&mut self,
slot: BodySlot,
instance_id: Option<uuid::Uuid>,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::EquipWorn {
entity_id: self.state.entity_id,
slot,
instance_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::PlaceContainer {
entity_id: self.state.entity_id,
item_instance_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::PickupContainer {
entity_id: self.state.entity_id,
container_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn move_item(
&mut self,
item_instance_id: uuid::Uuid,
from: flatland_protocol::InventoryLocation,
to: flatland_protocol::InventoryLocation,
to_parent_instance_id: Option<uuid::Uuid>,
quantity: Option<u32>,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::MoveItem {
entity_id: self.state.entity_id,
item_instance_id,
from,
to,
to_parent_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn set_container_locked(
&mut self,
location: flatland_protocol::InventoryLocation,
locked: bool,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::SetContainerLocked {
entity_id: self.state.entity_id,
location,
locked,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Use {
entity_id: self.state.entity_id,
template_id: template_id.to_string(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn open_craft_menu(&mut self) {
self.state.show_craft_menu = true;
self.state.show_shop_menu = false;
self.state.shop_catalog = None;
self.state.show_stats = false;
self.state.show_inventory_menu = false;
if self.state.blueprints.is_empty() {
self.state.craft_menu_index = 0;
self.state.craft_batch_quantity = 1;
return;
}
self.state.craft_menu_index = self
.state
.craft_menu_index
.min(self.state.blueprints.len() - 1);
if let Some(idx) = self
.state
.blueprints
.iter()
.position(|bp| self.state.can_craft_blueprint(bp))
{
self.state.craft_menu_index = idx;
}
self.state.clamp_craft_batch_quantity();
}
pub fn close_craft_menu(&mut self) {
self.state.show_craft_menu = false;
}
pub fn close_shop_menu(&mut self) {
self.state.show_shop_menu = false;
self.state.shop_catalog = None;
}
pub fn shop_tab_toggle(&mut self) {
self.state.shop_tab = match self.state.shop_tab {
ShopTab::Buy => ShopTab::Sell,
ShopTab::Sell => ShopTab::Buy,
};
self.state.shop_menu_index = 0;
self.state.clamp_shop_selection();
}
pub fn shop_menu_move(&mut self, delta: i32) {
self.state.shop_menu_move(delta);
}
pub fn shop_quantity_adjust(&mut self, delta: i32) {
self.state.shop_quantity_adjust(delta);
}
pub fn shop_quantity_set_max(&mut self) {
self.state.shop_quantity_set_max();
}
pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let Some(catalog) = self.state.shop_catalog.clone() else {
anyhow::bail!("no shop open");
};
self.seq += 1;
let seq = self.seq;
match self.state.shop_tab {
ShopTab::Buy => {
let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
anyhow::bail!("nothing selected");
};
if offer.already_owned {
anyhow::bail!("already owned");
}
self.session
.submit_intent(Intent::ShopBuy {
entity_id: self.state.entity_id,
npc_id: catalog.npc_id.clone(),
offer_id: offer.offer_id.clone(),
quantity: self.state.shop_quantity,
seq,
})
.await?;
}
ShopTab::Sell => {
let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
anyhow::bail!("nothing to sell");
};
self.session
.submit_intent(Intent::ShopSell {
entity_id: self.state.entity_id,
npc_id: catalog.npc_id.clone(),
template_id: line.template_id.clone(),
quantity: self.state.shop_quantity.min(line.quantity),
seq,
})
.await?;
}
}
self.state.intents_sent += 1;
Ok(())
}
pub fn craft_menu_move(&mut self, delta: i32) {
let n = self.state.blueprints.len();
if n == 0 {
return;
}
let idx = self.state.craft_menu_index as i32;
let next = (idx + delta).rem_euclid(n as i32);
self.state.craft_menu_index = next as usize;
self.state.clamp_craft_batch_quantity();
}
pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
self.state.craft_batch_adjust_quantity(delta);
}
pub fn craft_batch_set_max(&mut self) {
self.state.craft_batch_set_max();
}
pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
let Some(blueprint) = self
.state
.blueprints
.get(self.state.craft_menu_index)
.cloned()
else {
anyhow::bail!("no blueprints known");
};
if !self.state.can_craft_blueprint(&blueprint) {
let hint = self
.state
.craft_missing_hint(&blueprint)
.unwrap_or_else(|| "missing materials".into());
anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
}
let count = self.state.craft_batch_quantity;
let max = self.state.max_craft_batches(&blueprint);
if max == 0 {
anyhow::bail!("cannot craft {}", blueprint.label);
}
let batches = count.min(max);
self.craft(&blueprint.id, Some(batches)).await?;
self.state.show_craft_menu = false;
Ok(())
}
pub async fn move_by(
&mut self,
forward: f32,
strafe: f32,
vertical: f32,
sprint: bool,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
self.last_move_forward = forward;
self.last_move_strafe = strafe;
}
self.seq += 1;
self.session
.submit_intent(Intent::Move {
entity_id: self.state.entity_id,
forward,
strafe,
vertical,
sprint,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
if !self.state.connected {
crate::harvest_trace!("harvest_nearest rejected: not connected");
anyhow::bail!("not connected");
}
if !self.state.is_alive() {
crate::harvest_trace!("harvest_nearest rejected: player dead");
anyhow::bail!("you are dead");
}
if self.state.harvest_in_progress {
if self.state.harvest_state_stale() {
self.state.clear_harvest_state();
} else {
anyhow::bail!("already harvesting");
}
}
let (px, py) = self
.state
.player
.as_ref()
.map(|p| (p.transform.position.x, p.transform.position.y))
.unwrap_or((0.0, 0.0));
let available = self
.state
.resource_nodes
.iter()
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.count();
let node_id = self
.state
.resource_nodes
.iter()
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
.min_by(|a, b| {
let da = distance(px, py, a.x, a.y);
let db = distance(px, py, b.x, b.y);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|n| n.id.clone());
let Some(node_id) = node_id else {
let has_loot = self.state.ground_drops.iter().any(|d| {
distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
});
if has_loot {
return self.pickup_nearest().await;
}
anyhow::bail!(
"no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press p to pick up"
);
};
self.seq += 1;
let seq = self.seq;
crate::harvest_trace!(
entity_id = self.state.entity_id,
node_id = %node_id,
seq,
px,
py,
available_nodes = available,
"submitting harvest intent"
);
self.session
.submit_intent(Intent::Harvest {
entity_id: self.state.entity_id,
node_id,
seq,
})
.await?;
self.state.intents_sent += 1;
self.state.harvest_in_progress = true;
self.state.harvest_started_at = Some(Instant::now());
self.state.push_log("Harvesting…");
crate::harvest_trace!(entity_id = self.state.entity_id, seq, "harvest intent queued to session");
Ok(())
}
pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let blueprint_id = self
.state
.blueprints
.iter()
.find(|bp| self.state.can_craft_blueprint(bp))
.map(|bp| bp.id.clone())
.ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
self.craft(&blueprint_id, None).await
}
pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Craft {
entity_id: self.state.entity_id,
blueprint_id: blueprint_id.to_string(),
count,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
let (label, batches) = self
.state
.blueprints
.iter()
.find(|b| b.id == blueprint_id)
.map(|b| {
let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
(b.label.as_str(), n)
})
.unwrap_or((blueprint_id, count.unwrap_or(1)));
self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
Ok(())
}
pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let target_id = match self.state.nearest_interact_target() {
Some(id) => id,
None => {
let (px, py) = self.state.player_position();
let has_loot = self.state.ground_drops.iter().any(|d| {
distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
});
if has_loot {
return self.pickup_nearest().await;
}
anyhow::bail!("nothing to interact with nearby (stand on * loot and press p)");
}
};
self.seq += 1;
self.session
.submit_intent(Intent::Interact {
entity_id: self.state.entity_id,
target_id: target_id.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::TestDamage {
entity_id: self.state.entity_id,
amount,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
self.cycle_combat_target_slot(1, reverse).await
}
pub async fn cycle_combat_target_slot(
&mut self,
slot_index: u8,
reverse: bool,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let candidates = self.state.candidates_for_slot(slot_index);
if candidates.is_empty() {
anyhow::bail!("no targets nearby");
}
let current = self.state.target_for_slot(slot_index);
let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
let next_idx = match idx {
None => 0,
Some(i) if reverse => {
if i == 0 {
candidates.len() - 1
} else {
i - 1
}
}
Some(i) => (i + 1) % candidates.len(),
};
if idx == Some(next_idx) && candidates.len() == 1 {
self.clear_combat_target_slot(slot_index).await?;
return Ok(());
}
let (target_id, label) = candidates[next_idx].clone();
self.set_combat_target_slot(slot_index, target_id, &label)
.await
}
pub async fn set_combat_target_slot(
&mut self,
slot_index: u8,
target_id: EntityId,
label: &str,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::SetTargetSlot {
entity_id: self.state.entity_id,
slot_index,
target_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
if slot_index == 1 {
self.state.combat_target = Some(target_id);
self.state.combat_target_label = Some(label.to_string());
}
self.state
.push_log(format!("Slot {slot_index} target: {label}"));
Ok(())
}
pub async fn set_combat_target(
&mut self,
target_id: EntityId,
label: &str,
) -> anyhow::Result<()> {
self.set_combat_target_slot(1, target_id, label).await
}
pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
if slot_index == 1 && self.state.combat_target.is_none() {
return Ok(());
}
self.seq += 1;
self.session
.submit_intent(Intent::ClearTargetSlot {
entity_id: self.state.entity_id,
slot_index,
seq: self.seq,
})
.await?;
if slot_index == 1 {
self.state.combat_target = None;
self.state.combat_target_label = None;
}
self.state.intents_sent += 1;
self.state
.push_log(format!("Slot {slot_index} target cleared"));
Ok(())
}
pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
self.clear_combat_target_slot(1).await
}
pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::AdvanceRotation {
entity_id: self.state.entity_id,
slot_index,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn assign_slot_preset(&mut self, slot_index: u8, preset_id: &str) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::AssignSlotPreset {
entity_id: self.state.entity_id,
slot_index,
preset_id: preset_id.to_string(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
if let Some(slot) = self
.state
.combat_slots
.iter_mut()
.find(|s| s.slot_index == slot_index)
{
slot.preset_id = Some(preset_id.to_string());
if let Some(preset) = self.state.rotation_presets.iter().find(|p| p.id == preset_id) {
slot.preset_label = Some(preset.label.clone());
slot.rotation = preset.abilities.clone();
slot.rotation_index = 0;
}
}
self.state
.push_log(format!("T{slot_index} loadout → {preset_id}"));
Ok(())
}
pub async fn cast_ability(
&mut self,
ability_id: &str,
target_id: Option<EntityId>,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let target_id = target_id
.or_else(|| self.state.target_for_slot(2))
.or_else(|| self.state.target_for_slot(1))
.unwrap_or(self.state.entity_id);
self.seq += 1;
self.session
.submit_intent(Intent::Cast {
entity_id: self.state.entity_id,
ability_id: ability_id.to_string(),
target_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.push_log(format!("Cast {ability_id} → {target_id}"));
Ok(())
}
pub async fn upsert_rotation_preset(
&mut self,
preset: RotationPreset,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::UpsertRotationPreset {
entity_id: self.state.entity_id,
preset: preset.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
if let Some(existing) = self
.state
.rotation_presets
.iter_mut()
.find(|p| p.id == preset.id)
{
*existing = preset.clone();
} else {
self.state.rotation_presets.push(preset.clone());
}
for slot in &mut self.state.combat_slots {
if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
slot.preset_label = Some(preset.label.clone());
slot.rotation = preset.abilities.clone();
}
}
self.state
.push_log(format!("Saved rotation: {}", preset.label));
Ok(())
}
pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::DeleteRotationPreset {
entity_id: self.state.entity_id,
preset_id: preset_id.to_string(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.rotation_presets
.retain(|p| p.id != preset_id);
for slot in &mut self.state.combat_slots {
if slot.preset_id.as_deref() == Some(preset_id) {
slot.preset_id = None;
slot.preset_label = None;
slot.rotation.clear();
slot.rotation_index = 0;
}
}
self.state
.push_log(format!("Deleted rotation: {preset_id}"));
Ok(())
}
pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let enabled = !self
.state
.combat_slots
.iter()
.find(|s| s.slot_index == slot_index)
.map(|s| s.auto_enabled)
.unwrap_or(false);
self.seq += 1;
self.session
.submit_intent(Intent::SetAutoAttack {
entity_id: self.state.entity_id,
slot_index,
enabled,
seq: self.seq,
})
.await?;
if slot_index == 1 {
self.state.auto_attack = enabled;
}
self.state.intents_sent += 1;
self.state.push_log(format!(
"T{slot_index} auto {}",
if enabled { "ON" } else { "OFF" }
));
Ok(())
}
pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
if !self.state.connected {
anyhow::bail!("not connected");
}
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let (px, py) = self.state.player_position();
if self
.state
.ground_drops
.iter()
.all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
{
anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press p");
}
self.seq += 1;
self.session
.submit_intent(Intent::Pickup {
entity_id: self.state.entity_id,
drop_id: None,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
self.toggle_auto_attack_slot(1).await
}
pub async fn dodge(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Dodge {
entity_id: self.state.entity_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.push_log("Dodge!");
Ok(())
}
pub async fn lunge(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let (forward, strafe) = self.last_move_axes();
self.seq += 1;
self.session
.submit_intent(Intent::Lunge {
entity_id: self.state.entity_id,
forward,
strafe,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.push_log("Lunge!");
Ok(())
}
pub fn last_move_axes(&self) -> (f32, f32) {
(self.last_move_forward, self.last_move_strafe)
}
pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Block {
entity_id: self.state.entity_id,
enabled,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
if enabled {
self.state.push_log("Blocking");
}
Ok(())
}
pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::EquipMainhand {
entity_id: self.state.entity_id,
template_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::Say {
entity_id: self.state.entity_id,
channel,
text: text.to_string(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn stop(&mut self) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::Stop {
entity_id: self.state.entity_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn disconnect(&self) {
self.session.disconnect();
}
}
fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
let dx = ax - bx;
let dy = ay - by;
(dx * dx + dy * dy).sqrt()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use flatland_protocol::{
BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
};
fn sample_state() -> GameState {
let mut state = GameState {
session_id: 1,
entity_id: 1,
character_id: None,
tick: 0,
chunk_rev: 0,
content_rev: 0,
entities: vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(128.0, 128.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: None,
attributes: None,
skills: None,
inside_building: None,
}],
player: None,
resource_nodes: vec![ResourceNodeView {
id: "oak-1".into(),
label: "Oak".into(),
x: 130.0,
y: 128.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
}],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![BuildingView {
id: "broker-hut".into(),
label: "Broker".into(),
x: 148.0,
y: 118.0,
width_m: 8.0,
depth_m: 6.0,
interior_origin_x: 404.0,
interior_origin_y: 403.0,
tags: vec![],
}],
doors: vec![flatland_protocol::DoorView {
id: "door-1".into(),
building_id: "broker-hut".into(),
x: 148.0,
y: 118.0,
open: false,
is_exit: false,
}],
npcs: vec![],
blueprints: vec![],
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: Vec::new(),
world_clock: flatland_protocol::WorldClock::default(),
inventory: std::collections::HashMap::new(),
inventory_hints: std::collections::HashMap::new(),
logs: VecDeque::new(),
intents_sent: 0,
ticks_received: 0,
connected: true,
disconnect_reason: None,
show_stats: false,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
shop_tab: ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
show_inventory_menu: false,
inventory_menu_index: 0,
show_move_picker: false,
show_rename_prompt: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
in_combat: false,
auto_attack: true,
combat_has_los: false,
attack_cd_ticks: 0,
gcd_ticks: 0,
weapon_ability_id: "unarmed".into(),
mainhand_template_id: None,
mainhand_label: None,
worn: BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
combat_target_detail: None,
cast_progress: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
show_loadout_menu: false,
show_rotation_editor: false,
loadout_menu_index: 0,
rotation_editor: RotationEditorState::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
};
state.player = state.entities.first().cloned();
state
}
#[test]
fn empty_entity_tick_preserves_welcome_snapshot() {
let mut state = sample_state();
state.inventory.insert("carrot".into(), 3);
let delta = TickDelta {
tick: 1,
entities: vec![],
resource_nodes: vec![],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![],
doors: vec![],
npcs: vec![],
inventory: vec![],
blueprints: vec![],
world_clock: flatland_protocol::WorldClock::default(),
combat: None,
};
state.apply_tick_fields(&delta, 1);
assert_eq!(state.entities.len(), 1);
assert!(state.player.is_some());
assert_eq!(state.inventory.get("carrot"), Some(&3));
assert_eq!(state.resource_nodes.len(), 1);
}
#[test]
fn tick_preserves_world_layers_when_delta_omits_them() {
let mut state = sample_state();
let delta = TickDelta {
tick: 1,
entities: state.entities.clone(),
resource_nodes: vec![],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![],
doors: vec![],
npcs: vec![],
inventory: vec![],
blueprints: vec![],
world_clock: flatland_protocol::WorldClock::default(),
combat: None,
};
state.apply_tick_fields(&delta, 1);
assert_eq!(state.resource_nodes.len(), 1);
assert_eq!(state.buildings.len(), 1);
assert_eq!(state.doors.len(), 1);
}
#[test]
fn tick_updates_resource_nodes_when_server_sends_them() {
let mut state = sample_state();
let delta = TickDelta {
tick: 1,
entities: state.entities.clone(),
resource_nodes: vec![ResourceNodeView {
id: "oak-1".into(),
label: "Oak".into(),
x: 130.0,
y: 128.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Cooldown,
blocking: true,
blocking_radius_m: 0.8,
}],
buildings: vec![],
doors: vec![],
npcs: vec![],
inventory: vec![],
blueprints: vec![],
world_clock: flatland_protocol::WorldClock::default(),
ground_drops: vec![],
placed_containers: vec![],
combat: None,
};
state.apply_tick_fields(&delta, 1);
assert!(matches!(
state.resource_nodes[0].state,
ResourceNodeState::Cooldown
));
}
#[test]
fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
let mut state = GameState {
session_id: 1,
entity_id: 1,
character_id: None,
tick: 0,
chunk_rev: 0,
content_rev: 0,
entities: vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(406.0, 403.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: None,
attributes: None,
skills: None,
inside_building: None,
}],
player: None,
resource_nodes: vec![],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![BuildingView {
id: "broker_hut".into(),
label: "Broker".into(),
x: 158.0,
y: 124.0,
width_m: 8.0,
depth_m: 6.0,
interior_origin_x: 400.0,
interior_origin_y: 400.0,
tags: vec![],
}],
doors: vec![flatland_protocol::DoorView {
id: "broker_hut_exit".into(),
building_id: "broker_hut".into(),
x: 404.0,
y: 400.0,
open: true,
is_exit: true,
}],
npcs: vec![flatland_protocol::NpcView {
id: "ada_broker".into(),
label: "Ada".into(),
x: 406.0,
y: 403.0,
building_id: Some("broker_hut".into()),
role: "broker".into(),
entity_id: None,
life_state: None,
hp_pct: None,
}],
blueprints: vec![],
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: Vec::new(),
world_clock: flatland_protocol::WorldClock::default(),
inventory: std::collections::HashMap::new(),
inventory_hints: std::collections::HashMap::new(),
logs: VecDeque::new(),
intents_sent: 0,
ticks_received: 0,
connected: true,
disconnect_reason: None,
show_stats: false,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
shop_tab: ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
show_inventory_menu: false,
inventory_menu_index: 0,
show_move_picker: false,
show_rename_prompt: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
in_combat: false,
auto_attack: true,
combat_has_los: false,
attack_cd_ticks: 0,
gcd_ticks: 0,
weapon_ability_id: "unarmed".into(),
mainhand_template_id: None,
mainhand_label: None,
worn: BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
combat_target_detail: None,
cast_progress: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
show_loadout_menu: false,
show_rotation_editor: false,
loadout_menu_index: 0,
rotation_editor: RotationEditorState::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
};
state.player = state.entities.first().cloned();
assert_eq!(
state.nearest_interact_target().as_deref(),
Some("ada_broker")
);
}
#[test]
fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
let mut state = sample_state();
state.placed_containers = vec![
flatland_protocol::PlacedContainerView {
id: "near".into(),
template_id: "wooden_chest_small".into(),
display_name: "Wooden Chest".into(),
x: 130.0,
y: 128.0,
z: 0.0,
locked: true,
accessible: true,
owner_character_id: None,
contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
lock_id: None,
capacity_volume: None,
item_instance_id: Some(uuid::Uuid::from_u128(1)),
},
flatland_protocol::PlacedContainerView {
id: "far".into(),
template_id: "wooden_chest_small".into(),
display_name: "Distant Chest".into(),
x: 128.0 + CONTAINER_RANGE_M + 5.0,
y: 128.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: None,
contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
lock_id: None,
capacity_volume: None,
item_instance_id: Some(uuid::Uuid::from_u128(2)),
},
];
let nearby = state.nearby_containers();
assert_eq!(nearby.len(), 1, "far chest must not appear once out of range");
assert_eq!(nearby[0].view.id, "near");
assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
assert!(nearby[0].rows[0].is_chest_shell);
state.placed_containers[0].accessible = false;
let nearby = state.nearby_containers();
assert_eq!(nearby.len(), 1);
assert_eq!(nearby[0].rows.len(), 1);
assert!(nearby[0].rows[0].is_chest_shell);
}
#[test]
fn placed_container_public_label_hides_owner_custom_name() {
let owner = uuid::Uuid::from_u128(99);
let mut state = sample_state();
state.character_id = Some(uuid::Uuid::from_u128(1));
state.inventory_hints.insert(
"wooden_chest_medium".into(),
InventoryHint {
display_name: "Medium Wooden Chest".into(),
category: "container".into(),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: false,
},
);
let chest = flatland_protocol::PlacedContainerView {
id: "c1".into(),
template_id: "wooden_chest_medium".into(),
display_name: "Barry's Loot #a3f2".into(),
x: 128.0,
y: 128.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: Some(owner),
contents: vec![],
lock_id: None,
capacity_volume: None,
item_instance_id: None,
};
assert_eq!(
state.placed_container_public_label(&chest),
"Medium Wooden Chest"
);
state.character_id = Some(owner);
assert_eq!(
state.placed_container_public_label(&chest),
"Barry's Loot #a3f2"
);
}
#[test]
fn location_context_lists_nearby_resource_node() {
let mut state = sample_state();
state.player = state.entities.first().cloned();
state.resource_nodes[0].x = 128.2;
state.resource_nodes[0].y = 128.0;
let lines = state.location_context_lines();
assert!(
lines.iter().any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
"expected resource node in context: {:?}",
lines
);
}
#[test]
fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
let mut state = sample_state();
state.worn.insert(
BodySlot::Back,
flatland_protocol::ItemStack {
template_id: "travel_backpack".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(3)),
props: Default::default(),
contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
display_name: None,
category: None,
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
state.placed_containers = vec![flatland_protocol::PlacedContainerView {
id: "chest-1".into(),
template_id: "wooden_chest_small".into(),
display_name: "Wooden Chest".into(),
x: 129.0,
y: 128.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: None,
contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
lock_id: None,
capacity_volume: None,
item_instance_id: Some(uuid::Uuid::from_u128(4)),
}];
let rows = state.inventory_selectable_rows();
let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
assert_eq!(
sections,
vec![
InventorySection::Worn, InventorySection::Worn, InventorySection::Person, InventorySection::Nearby, InventorySection::Nearby, ]
);
assert_eq!(rows[0].stack.template_id, "travel_backpack");
assert!(rows[0].is_equip_shell);
assert_eq!(rows[1].stack.template_id, "iron_ore");
assert_eq!(rows[1].depth, 1);
assert_eq!(rows[2].stack.template_id, "lumber");
assert!(rows[3].is_chest_shell);
assert_eq!(rows[4].stack.template_id, "wood_axe");
assert_eq!(rows[4].depth, 1);
}
#[test]
fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
let mut state = sample_state();
let back_id = uuid::Uuid::from_u128(5);
state.worn.insert(
BodySlot::Back,
flatland_protocol::ItemStack {
template_id: "travel_backpack".into(),
quantity: 1,
item_instance_id: Some(back_id),
props: Default::default(),
contents: Vec::new(),
display_name: None,
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: Some(80.0),
stackable: None,
},
);
state.placed_containers = vec![flatland_protocol::PlacedContainerView {
id: "chest-1".into(),
template_id: "wooden_chest_small".into(),
display_name: "Wooden Chest".into(),
x: 129.0,
y: 128.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: None,
contents: Vec::new(),
lock_id: None,
capacity_volume: None,
item_instance_id: Some(uuid::Uuid::from_u128(6)),
}];
let opts = state.move_destinations_for(
&flatland_protocol::InventoryLocation::Root,
None,
None,
"lumber",
);
assert!(!opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
)));
assert!(opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, parent_instance_id, .. }
if *location == flatland_protocol::InventoryLocation::Worn {
slot: BodySlot::Back,
} && *parent_instance_id == Some(back_id)
)));
assert!(opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, .. }
if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
)));
assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
assert!(matches!(
opts[opts.len() - 2].kind,
MoveOptionKind::Drop
));
let from_backpack = flatland_protocol::InventoryLocation::Worn {
slot: BodySlot::Back,
};
let opts = state.move_destinations_for(
&from_backpack,
Some(back_id),
None,
"iron_ore",
);
assert!(!opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, parent_instance_id, .. }
if *location == from_backpack && *parent_instance_id == Some(back_id)
)));
assert!(opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
)));
}
#[test]
fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
let mut state = sample_state();
state.worn.insert(
BodySlot::Waist,
flatland_protocol::ItemStack {
template_id: "simple_belt".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(10)),
props: Default::default(),
contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
display_name: None,
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
state.worn.insert(
BodySlot::Head,
flatland_protocol::ItemStack {
template_id: "cloth_cap".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(11)),
props: Default::default(),
contents: Vec::new(),
display_name: None,
category: Some("armor".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
state.worn.insert(
BodySlot::Back,
flatland_protocol::ItemStack {
template_id: "travel_backpack".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(12)),
props: Default::default(),
contents: Vec::new(),
display_name: None,
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
let rows = state.worn_rows();
assert_eq!(rows.len(), 4);
assert_eq!(rows[0].stack.template_id, "cloth_cap");
assert!(rows[0].is_equip_shell);
assert_eq!(rows[1].stack.template_id, "travel_backpack");
assert!(rows[1].is_equip_shell);
assert_eq!(rows[2].stack.template_id, "simple_belt");
assert!(rows[2].is_equip_shell);
assert_eq!(rows[3].stack.template_id, "leather_pouch");
assert_eq!(rows[3].depth, 1);
assert!(!rows[3].is_equip_shell);
}
#[test]
fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
let mut state = sample_state();
state.worn.insert(
BodySlot::Waist,
flatland_protocol::ItemStack {
template_id: "simple_belt".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(20)),
props: Default::default(),
contents: Vec::new(),
display_name: Some("Simple Belt".into()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
state.worn.insert(
BodySlot::Head,
flatland_protocol::ItemStack {
template_id: "cloth_cap".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(21)),
props: Default::default(),
contents: Vec::new(),
display_name: Some("Cloth Cap".into()),
category: Some("armor".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
let opts = state.move_destinations_for(
&flatland_protocol::InventoryLocation::Root,
None,
None,
"leather_pouch",
);
assert!(
opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, .. }
if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
)),
"belt loop must be offered when moving a pouch"
);
assert!(
!opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, .. }
if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
)),
"armor slots can't hold other items and must not appear as move destinations"
);
let belt_opt = opts
.iter()
.find(|o| matches!(
&o.kind,
MoveOptionKind::Move { location, .. }
if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
))
.unwrap();
assert!(belt_opt.label.contains("belt loop"));
let opts = state.move_destinations_for(
&flatland_protocol::InventoryLocation::Root,
None,
None,
"lumber",
);
assert!(
!opts.iter().any(|o| o.label.contains("belt loop")),
"loose materials must not target the belt shell — only nested pouches"
);
}
#[test]
fn move_destinations_for_offers_dimensional_pouch_on_belt() {
let mut state = sample_state();
let belt_id = uuid::Uuid::from_u128(30);
let pouch_id = uuid::Uuid::from_u128(31);
state.worn.insert(
BodySlot::Waist,
flatland_protocol::ItemStack {
template_id: "simple_belt".into(),
quantity: 1,
item_instance_id: Some(belt_id),
props: Default::default(),
contents: vec![flatland_protocol::ItemStack {
template_id: "dimensional_pouch".into(),
quantity: 1,
item_instance_id: Some(pouch_id),
props: Default::default(),
contents: Vec::new(),
display_name: Some("Dimensional Pouch".into()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: Some(200.0),
stackable: None,
}],
display_name: Some("Simple Belt".into()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
},
);
let opts = state.move_destinations_for(
&flatland_protocol::InventoryLocation::Root,
None,
None,
"iron_ore",
);
assert!(
opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::Move {
location,
parent_instance_id,
..
} if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
&& *parent_instance_id == Some(pouch_id)
)),
"dimensional pouch clipped on belt must accept loose items"
);
assert!(
opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
"destination label should name the pouch"
);
}
#[test]
fn container_volume_label_on_placed_chest_shell() {
let mut state = sample_state();
state.placed_containers = vec![flatland_protocol::PlacedContainerView {
id: "chest-1".into(),
template_id: "wooden_chest_small".into(),
display_name: "Camp Chest".into(),
x: 129.0,
y: 128.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: None,
contents: vec![flatland_protocol::ItemStack {
template_id: "iron_ore".into(),
quantity: 2,
item_instance_id: None,
props: Default::default(),
contents: Vec::new(),
display_name: None,
category: None,
base_mass: None,
base_volume: Some(2.0),
capacity_volume: None,
stackable: None,
}],
lock_id: None,
capacity_volume: Some(60.0),
item_instance_id: Some(uuid::Uuid::from_u128(4)),
}];
let nearby = state.nearby_containers();
let label = state.container_volume_label(&nearby[0].rows[0]);
assert!(
label.contains("vol 4/60"),
"expected used/cap in label, got {label}"
);
assert!(label.contains("56 free"), "expected free space, got {label}");
}
#[test]
fn key_pair_chest_label_from_placed_lock_id() {
let mut state = sample_state();
let owner = uuid::Uuid::from_u128(77);
state.character_id = Some(owner);
let lock = uuid::Uuid::from_u128(99).to_string();
state.placed_containers = vec![flatland_protocol::PlacedContainerView {
id: "chest-1".into(),
template_id: "wooden_chest_small".into(),
display_name: "Barry's Loot #a3f2".into(),
x: 129.0,
y: 128.0,
z: 0.0,
locked: true,
accessible: true,
owner_character_id: Some(owner),
contents: Vec::new(),
lock_id: Some(lock.clone()),
capacity_volume: None,
item_instance_id: Some(uuid::Uuid::from_u128(4)),
}];
let key_id = uuid::Uuid::from_u128(5);
let key = flatland_protocol::ItemStack {
template_id: KEY_TEMPLATE.into(),
quantity: 1,
item_instance_id: Some(key_id),
props: BTreeMap::from([
(PROP_OPENS_LOCK_ID.into(), lock),
(PROP_OPENS_CONTAINER_NAME.into(), "Barry's Loot #a3f2".into()),
]),
contents: Vec::new(),
display_name: Some("Container Key".into()),
category: Some("key".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
};
state.inventory_stacks = vec![key.clone()];
assert_eq!(
state.key_pair_chest_label(&key).as_deref(),
Some("Barry's Loot #a3f2")
);
assert!(state.key_drop_blocked(&key));
}
#[test]
fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
let mut state = sample_state();
let lock = uuid::Uuid::from_u128(101).to_string();
let key = flatland_protocol::ItemStack {
template_id: KEY_TEMPLATE.into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(7)),
props: BTreeMap::from([
(PROP_OPENS_LOCK_ID.into(), lock),
(PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
]),
contents: Vec::new(),
display_name: None,
category: Some("key".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
};
state.placed_containers.clear();
assert_eq!(
state.key_pair_chest_label(&key).as_deref(),
Some("Camp Stash")
);
}
#[test]
fn key_drop_allowed_when_paired_chest_unlocked() {
let mut state = sample_state();
let lock = uuid::Uuid::from_u128(100).to_string();
let key_id = uuid::Uuid::from_u128(6);
state.placed_containers = vec![flatland_protocol::PlacedContainerView {
id: "chest-1".into(),
template_id: "wooden_chest_small".into(),
display_name: "Camp Chest".into(),
x: 129.0,
y: 128.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: None,
contents: Vec::new(),
lock_id: Some(lock.clone()),
capacity_volume: None,
item_instance_id: None,
}];
let key = flatland_protocol::ItemStack {
template_id: KEY_TEMPLATE.into(),
quantity: 1,
item_instance_id: Some(key_id),
props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
contents: Vec::new(),
display_name: None,
category: Some("key".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
};
state.inventory_stacks = vec![key.clone()];
assert!(!state.key_drop_blocked(&key));
let opts = state.move_destinations_for(
&flatland_protocol::InventoryLocation::Root,
None,
Some(key_id),
KEY_TEMPLATE,
);
assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
}
}