use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::time::{Duration, Instant};
use flatland_protocol::{
AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
ZPlatformView, ZTransitionView,
};
use crate::session::{PlayConnection, SessionEvent};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CharacterSheetTab {
#[default]
Character,
Ledger,
Career,
}
impl CharacterSheetTab {
pub fn cycle(self) -> Self {
match self {
Self::Character => Self::Ledger,
Self::Ledger => Self::Career,
Self::Career => Self::Character,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LedgerPeriod {
#[default]
Day,
Week,
Month,
Lifetime,
}
impl LedgerPeriod {
pub fn label(self) -> &'static str {
match self {
Self::Day => "Day",
Self::Week => "Week",
Self::Month => "Month",
Self::Lifetime => "All",
}
}
pub fn cycle(self) -> Self {
match self {
Self::Day => Self::Week,
Self::Week => Self::Month,
Self::Month => Self::Lifetime,
Self::Lifetime => Self::Day,
}
}
pub fn from_digit(c: char) -> Option<Self> {
match c {
'1' => Some(Self::Day),
'2' => Some(Self::Week),
'3' => Some(Self::Month),
'4' => Some(Self::Lifetime),
_ => None,
}
}
}
const KEY_TEMPLATE: &str = "container_key";
const PROPERTY_DEED_TEMPLATE: &str = "property_deed";
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";
#[derive(Debug, Clone, PartialEq)]
pub struct ClaimModeState {
pub zone_id: String,
pub width_m: u32,
pub height_m: u32,
pub anchor_x: f32,
pub anchor_y: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RelocateModeState {
pub container_id: String,
pub label: String,
pub cursor_x: f32,
pub cursor_y: f32,
}
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 MAX_SHOP_TRADE_LOG_LINES: usize = 40;
const INTERACTION_RADIUS_M: f32 = 1.5;
const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
const CRAFT_STAMINA_COST: f32 = 3.0;
const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
#[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,
pub listable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadoutHotbarChoice {
pub binding: String,
pub label: String,
pub meta: Option<String>,
}
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InventoryTab {
#[default]
OnPerson,
Nearby,
}
impl InventoryTab {
pub fn label(self) -> &'static str {
match self {
Self::OnPerson => "On person",
Self::Nearby => "Nearby storage",
}
}
pub fn cycle(self, forward: bool) -> Self {
match (self, forward) {
(Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
(Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
}
}
}
pub const LIST_PAGE_SIZE: usize = 10;
pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
if filter.is_empty() {
return true;
}
haystack
.to_ascii_lowercase()
.contains(&filter.to_ascii_lowercase())
}
pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
if len == 0 {
return 0;
}
let page = LIST_PAGE_SIZE as i32;
let next = index as i32 + pages * page;
next.clamp(0, (len as i32) - 1) as usize
}
pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
if len == 0 {
return 0;
}
let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
if matching.is_empty() {
return index.min(len - 1);
}
let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
matching[next]
}
pub fn page_filtered_index(
index: usize,
pages: i32,
len: usize,
pred: impl Fn(usize) -> bool,
) -> usize {
if len == 0 {
return 0;
}
let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
if matching.is_empty() {
return index.min(len - 1);
}
let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
let next = page_list_index(pos, pages, matching.len());
matching[next]
}
pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
match category {
"weapon" | "ammo" => ("Weapons", 0),
"armor" | "shield" | "offhand" => ("Armor", 1),
"consumable" => ("Consumables", 2),
"resource" | "harvest_node" | "seed" => ("Resources", 3),
"container" | "lodging" => ("Containers", 4),
"currency" | "key" => ("Currency & keys", 5),
"tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
_ => ("Other", 7),
}
}
pub fn category_default_listable(category: &str) -> bool {
!matches!(
category,
"currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
)
}
fn parse_bank_copper_amount(input: &str) -> Option<u64> {
let s = input.trim();
if s.is_empty() {
return Some(0);
}
s.parse::<u64>().ok()
}
fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
let s = input.trim();
if s.is_empty() || s == "0" {
return Some(None);
}
let n = s.parse::<u32>().ok()?;
if n == 0 {
return Some(None);
}
Some(Some(n))
}
fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
let name = stack
.display_name
.as_deref()
.unwrap_or(stack.template_id.as_str());
if stack.quantity > 1 {
format!("{name} ×{}", stack.quantity)
} else {
name.to_string()
}
}
pub fn body_slot_label(slot: BodySlot) -> &'static str {
match slot {
BodySlot::Head => "Head",
BodySlot::Chest => "Chest",
BodySlot::Forearms => "Forearms",
BodySlot::Legs => "Legs",
BodySlot::Feet => "Feet",
BodySlot::Cloak => "Cloak",
BodySlot::Back => "Back",
BodySlot::Waist => "Waist",
BodySlot::Earrings => "Earrings",
BodySlot::Necklace => "Necklace",
BodySlot::Eyeglasses => "Eyeglasses",
BodySlot::RingLeft1 => "Ring L1",
BodySlot::RingLeft2 => "Ring L2",
BodySlot::RingRight1 => "Ring R1",
BodySlot::RingRight2 => "Ring R2",
}
}
fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
let cat = stack.category.as_deref().unwrap_or("");
match mode {
"while_equipped" => {
stack.equip_slot.is_some()
|| cat == "weapon"
|| cat == "shield"
|| cat == "offhand"
|| cat == "armor"
}
_ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
}
}
fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
if grant_tags.is_empty() {
return true;
}
let target_tags: Vec<&str> = stack
.props
.get("allowed_enchant_tags")
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
if target_tags.is_empty() {
return true;
}
grant_tags.iter().any(|t| target_tags.contains(t))
}
pub const DEFAULT_TICK_HZ: u32 = 30;
pub fn format_binding_ttl(
binding: &flatland_protocol::ItemStatusBinding,
tick: u64,
tick_hz: u32,
) -> String {
let Some(expires) = binding.expires_at_tick else {
return "permanent".into();
};
let hz = tick_hz.max(1) as f32;
let remaining = expires.saturating_sub(tick) as f32 / hz;
if remaining <= 0.0 {
return "expired".into();
}
if remaining >= 120.0 {
format!("{:.0}m left", remaining / 60.0)
} else if remaining >= 10.0 {
format!("{remaining:.0}s left")
} else {
format!("{remaining:.1}s left")
}
}
pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
match mode {
flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
}
}
pub fn format_status_bindings_suffix(
bindings: &[flatland_protocol::ItemStatusBinding],
tick: u64,
tick_hz: u32,
) -> String {
if bindings.is_empty() {
return String::new();
}
let parts: Vec<String> = bindings
.iter()
.map(|b| {
format!(
"{} ({}, {})",
b.effect_id,
format_binding_mode(b.mode),
format_binding_ttl(b, tick, tick_hz)
)
})
.collect();
format!(" · {}", parts.join("; "))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EquipPaperdollRow {
Body { slot: BodySlot, filled: bool },
Mainhand { filled: bool },
Offhand { filled: bool, locked: bool },
}
pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
.iter()
.map(|slot| EquipPaperdollRow::Body {
slot: *slot,
filled: state.worn.contains_key(slot),
})
.collect();
let two_hand = state.mainhand_hand_slots >= 2;
rows.push(EquipPaperdollRow::Mainhand {
filled: state.mainhand_template_id.is_some(),
});
rows.push(EquipPaperdollRow::Offhand {
filled: state.offhand_template_id.is_some(),
locked: two_hand,
});
rows
}
fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
for stack in &state.inventory_stacks {
let matches = stack
.equip_slot
.map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
.unwrap_or(false)
|| guess_body_slot(&stack.template_id) == Some(slot);
if matches {
return stack.item_instance_id;
}
}
None
}
fn is_client_ring(slot: BodySlot) -> bool {
matches!(
slot,
BodySlot::RingLeft1
| BodySlot::RingLeft2
| BodySlot::RingRight1
| BodySlot::RingRight2
)
}
fn first_inventory_weapon(state: &GameState) -> Option<String> {
for stack in &state.inventory_stacks {
if stack.category.as_deref() == Some("weapon") {
return Some(stack.template_id.clone());
}
}
None
}
fn first_inventory_offhand(state: &GameState) -> Option<String> {
for stack in &state.inventory_stacks {
let cat = stack.category.as_deref().unwrap_or("");
if matches!(cat, "shield" | "offhand") {
return Some(stack.template_id.clone());
}
}
None
}
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("cloak") || template_id.contains("cape") {
Some(BodySlot::Cloak)
} 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")
|| template_id.contains("chest")
|| template_id.contains("jerkin")
{
Some(BodySlot::Chest)
} else if template_id.contains("sleeves")
|| template_id.contains("gloves")
|| template_id.contains("gauntlets")
{
Some(BodySlot::Forearms)
} 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 if template_id.contains("earring") {
Some(BodySlot::Earrings)
} else if template_id.contains("necklace") || template_id.contains("amulet") {
Some(BodySlot::Necklace)
} else if template_id.contains("glass")
|| template_id.contains("spectacles")
|| template_id.contains("goggles")
{
Some(BodySlot::Eyeglasses)
} else if template_id.contains("ring") {
Some(BodySlot::RingLeft1)
} 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 InventoryRowView {
pub depth: usize,
pub text: String,
pub title: String,
pub mass_kg: Option<f32>,
pub volume: Option<(f32, f32)>,
pub instance_tooltip: Option<String>,
}
#[derive(Debug, Clone)]
pub enum InventoryBrowserLine {
Section(String),
SlotLabel(String),
Hint(String),
Blank,
Item {
selectable_index: usize,
selected: bool,
depth: usize,
text: String,
title: String,
mass_kg: Option<f32>,
volume: Option<(f32, f32)>,
instance_tooltip: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum BankUiMode {
#[default]
Menu,
DepositAmount {
input: String,
},
WithdrawAmount {
input: String,
},
TransferName {
input: String,
},
TransferAmount {
to_name: String,
input: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum StorageUiMode {
#[default]
Menu,
StorePick {
index: usize,
},
StoreAmount {
pick_index: usize,
item_instance_id: uuid::Uuid,
label: String,
max_qty: u32,
input: String,
},
TakePick {
index: usize,
},
TakeAmount {
pick_index: usize,
item_instance_id: uuid::Uuid,
label: String,
max_qty: u32,
input: String,
},
ShipPick {
dest_building_id: String,
dest_label: String,
index: usize,
},
ShipAmount {
dest_building_id: String,
dest_label: String,
pick_index: usize,
item_instance_id: uuid::Uuid,
label: String,
max_qty: u32,
input: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketListSourceKind {
Person,
TownStorage { building_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum MarketUiMode {
#[default]
Browse,
ListSource {
index: usize,
},
ListPick {
source: MarketListSourceKind,
index: usize,
},
ListAmount {
source: MarketListSourceKind,
pick_index: usize,
item_instance_id: uuid::Uuid,
label: String,
max_qty: u32,
input: String,
},
ListPrice {
source: MarketListSourceKind,
item_instance_id: uuid::Uuid,
label: String,
quantity: Option<u32>,
max_qty: u32,
input: String,
},
}
#[derive(Debug, Clone)]
pub struct StoragePickOption {
pub item_instance_id: uuid::Uuid,
pub label: String,
pub quantity: u32,
pub category: String,
}
#[derive(Debug, Clone)]
pub struct NearbyContainer {
pub view: flatland_protocol::PlacedContainerView,
pub distance_m: f32,
pub rows: Vec<InventoryRow>,
}
#[derive(Debug, Clone)]
pub struct KeychainEntry {
pub stack: flatland_protocol::ItemStack,
pub stowed: bool,
}
#[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>,
},
PickupPlaced {
container_id: String,
nest_location: flatland_protocol::InventoryLocation,
nest_parent_instance_id: Option<uuid::Uuid>,
},
RelocatePlaced {
container_id: String,
},
Use,
GrantApply,
Drop,
SellPlotToCrown {
plot_id: uuid::Uuid,
},
Cancel,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FarmAccessRow {
PublicToggle,
PublicDiscount,
AllowRemove {
character_id: uuid::Uuid,
label: String,
tax_discount_bps: u32,
},
NearbyAdd {
name: String,
},
}
#[derive(Debug, Clone)]
pub struct GrantTargetPicker {
pub grant_instance_id: uuid::Uuid,
pub grant_label: String,
pub effect_id: String,
pub mode: String,
pub options: Vec<GrantTargetOption>,
pub filter: String,
pub filter_focused: bool,
}
#[derive(Debug, Clone)]
pub struct GrantTargetOption {
pub label: String,
pub target_instance_id: uuid::Uuid,
}
#[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>,
pub filter: String,
pub filter_focused: bool,
}
#[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,
}
#[derive(Debug, Clone)]
pub struct WorkerGiveOption {
pub item_instance_id: uuid::Uuid,
pub label: String,
pub quantity: u32,
pub template_id: String,
}
#[derive(Debug, Clone)]
pub struct WorkerGivePicker {
pub worker_instance_id: String,
pub worker_label: String,
pub options: Vec<WorkerGiveOption>,
}
#[derive(Debug, Clone)]
pub struct WorkerGiveTargetOption {
pub instance_id: String,
pub label: String,
pub distance_m: f32,
}
#[derive(Debug, Clone)]
pub struct WorkerGiveTargetPicker {
pub item_instance_id: uuid::Uuid,
pub item_label: String,
pub quantity: Option<u32>,
pub options: Vec<WorkerGiveTargetOption>,
}
#[derive(Debug, Clone)]
pub struct WorkerTakePicker {
pub worker_instance_id: String,
pub worker_label: String,
pub options: Vec<WorkerGiveOption>,
pub quantity: u32,
}
pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
#[derive(Debug, Clone)]
pub struct WorkerTeachOption {
pub blueprint_id: String,
pub label: String,
pub cost_copper: u64,
pub min_level: u32,
pub worker_level: u32,
pub can_afford: bool,
pub level_ok: bool,
}
#[derive(Debug, Clone)]
pub struct WorkerTeachPicker {
pub worker_instance_id: String,
pub worker_label: String,
pub worker_level: u32,
pub options: Vec<WorkerTeachOption>,
}
#[derive(Debug, Clone, Default)]
pub struct StickyWorkerStep {
shown: String,
pending: String,
pending_since: Option<Instant>,
}
impl StickyWorkerStep {
fn from_label(label: String) -> Self {
Self {
shown: label.clone(),
pending: label,
pending_since: Some(Instant::now()),
}
}
fn observe(&mut self, label: &str, now: Instant) {
let pending_since = self.pending_since.unwrap_or(now);
if label == self.pending {
if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
{
self.shown = self.pending.clone();
}
return;
}
self.pending = label.to_string();
self.pending_since = Some(now);
if self.shown.is_empty() {
self.shown = self.pending.clone();
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StickyWorkerError {
message: String,
last_seen: Option<Instant>,
}
impl StickyWorkerError {
fn observe(&mut self, err: Option<&str>, now: Instant) {
if let Some(e) = err {
if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
self.message = e.to_string();
self.last_seen = Some(now);
}
return;
}
if let Some(seen) = self.last_seen {
if now.duration_since(seen) > WORKER_ERROR_HOLD {
self.message.clear();
self.last_seen = None;
}
}
}
pub fn shown(&self, now: Instant) -> Option<&str> {
if self.message.is_empty() {
return None;
}
let seen = self.last_seen?;
if now.duration_since(seen) > WORKER_ERROR_HOLD {
return None;
}
Some(self.message.as_str())
}
}
pub fn worker_attention_line(state: &GameState) -> Option<String> {
use flatland_protocol::WorkerStateView;
let now = Instant::now();
for w in &state.hired_workers {
if matches!(w.state, WorkerStateView::Strike) {
return Some(format!(
"Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
w.label
));
}
if let Some(err) = state
.worker_error_display
.get(&w.instance_id)
.and_then(|s| s.shown(now))
{
if !worker_error_is_hud_noise(err) {
return Some(format!("Worker {}: {err}", w.label));
}
}
if let Some(err) = &w.last_error {
if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
return Some(format!("Worker {}: {err}", w.label));
}
}
}
None
}
pub fn worker_error_is_transient(err: &str) -> bool {
let e = err.to_ascii_lowercase();
e.contains("continuing route")
|| e.contains("storage full")
|| e.starts_with("nothing to withdraw")
}
pub fn worker_error_is_hud_noise(err: &str) -> bool {
let e = err.to_ascii_lowercase();
e.contains("returned to lodging after path")
|| e.contains("path failure")
|| e.contains("no path to")
|| e.contains("pathfinding")
|| e.contains("repathing")
|| e.contains("nudged clear")
}
#[derive(Debug, Clone)]
pub struct PendingWorkerJobAck {
pub seq: u32,
pub worker_instance_id: String,
pub worker_label: String,
pub idle: bool,
pub stop_count: usize,
pub prev_route: Option<flatland_protocol::WorkerRouteView>,
pub prev_mode: flatland_protocol::WorkerModeView,
pub prev_step_label: String,
pub prev_last_error: Option<String>,
}
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,
) {
push_inventory_rows_filtered(
rows,
depth,
stack,
from,
from_parent_instance_id,
section,
"",
);
}
fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
if filter.is_empty() {
return true;
}
let f = filter.to_ascii_lowercase();
let name = stack
.display_name
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
let tid = stack.template_id.to_ascii_lowercase();
name.contains(&f)
|| tid.contains(&f)
|| stack
.contents
.iter()
.any(|c| stack_matches_filter(c, filter))
}
fn push_inventory_rows_filtered(
rows: &mut Vec<InventoryRow>,
depth: usize,
stack: &flatland_protocol::ItemStack,
from: &flatland_protocol::InventoryLocation,
from_parent_instance_id: Option<uuid::Uuid>,
section: InventorySection,
filter: &str,
) {
if !filter.is_empty() && !stack_matches_filter(stack, filter) {
return;
}
let self_hit = filter.is_empty() || {
let f = filter.to_ascii_lowercase();
let name = stack
.display_name
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
let tid = stack.template_id.to_ascii_lowercase();
name.contains(&f) || tid.contains(&f)
};
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 {
if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
push_inventory_rows_filtered(
rows,
depth + 1,
child,
from,
stack.item_instance_id,
section,
if self_hit { "" } else { filter },
);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShopTab {
#[default]
Buy,
Sell,
}
#[derive(Debug, Clone)]
pub struct NpcChatState {
pub npc_id: String,
pub npc_label: String,
pub lines: Vec<String>,
pub input: String,
pub pending: bool,
pub talk_depth: flatland_protocol::NpcTalkDepth,
pub trade_allowed: bool,
pub banner: Option<String>,
}
impl Default for NpcChatState {
fn default() -> Self {
Self {
npc_id: String::new(),
npc_label: String::new(),
lines: Vec::new(),
input: String::new(),
pending: false,
talk_depth: flatland_protocol::NpcTalkDepth::Full,
trade_allowed: true,
banner: None,
}
}
}
#[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 publish_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 interior_map: Option<InteriorMapView>,
pub npcs: Vec<NpcView>,
pub blueprints: Vec<BlueprintView>,
pub world_x0: f32,
pub world_y0: f32,
pub world_width_m: f32,
pub world_height_m: f32,
pub terrain_zones: Vec<TerrainZoneView>,
pub z_platforms: Vec<ZPlatformView>,
pub z_transitions: Vec<ZTransitionView>,
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 hud_log_hidden: bool,
pub show_equip_menu: bool,
pub equip_menu_index: usize,
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 bank_panel: Option<flatland_protocol::BankPanel>,
pub bank_menu_index: usize,
pub bank_ui_mode: BankUiMode,
pub storage_panel: Option<flatland_protocol::StoragePanel>,
pub market_panel: Option<flatland_protocol::MarketPanel>,
pub market_menu_index: usize,
pub market_filter: String,
pub market_filter_focused: bool,
pub market_category_filter: Option<&'static str>,
pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
pub market_ui_mode: MarketUiMode,
pub storage_menu_index: usize,
pub storage_ui_mode: StorageUiMode,
pub shop_tab: ShopTab,
pub shop_menu_index: usize,
pub shop_quantity: u32,
pub shop_trade_log: VecDeque<String>,
pub show_npc_verb_menu: bool,
pub npc_verb_target: Option<String>,
pub npc_verb_index: usize,
pub player_verbs: crate::social::PlayerVerbState,
pub social_chat: crate::social::SocialChatState,
pub trade_ui: crate::social::TradeUiState,
pub whisper_pouch_ui: crate::social::WhisperPouchUi,
pub show_npc_chat: bool,
pub npc_chat: Option<NpcChatState>,
pub show_inventory_menu: bool,
pub inventory_menu_index: usize,
pub inventory_tab: InventoryTab,
pub inventory_filter: String,
pub inventory_filter_focused: bool,
pub show_move_picker: bool,
pub move_picker_index: usize,
pub move_picker: Option<MovePicker>,
pub show_grant_picker: bool,
pub grant_picker_index: usize,
pub grant_picker: Option<GrantTargetPicker>,
pub show_destroy_picker: bool,
pub destroy_confirm_pending: bool,
pub destroy_picker: Option<DestroyPicker>,
pub show_rename_prompt: bool,
pub show_worker_rename: bool,
pub rename_buffer: String,
pub combat_target: Option<EntityId>,
pub combat_target_label: Option<String>,
pub combat_fx: Vec<flatland_protocol::CombatFx>,
pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
pub claim_mode: Option<ClaimModeState>,
pub relocate_mode: Option<RelocateModeState>,
pub sell_plot_confirm: Option<uuid::Uuid>,
pub sell_plot_armed_at: Option<Instant>,
pub show_plant_menu: bool,
pub plant_menu_index: usize,
pub show_farm_access: bool,
pub farm_access_name_draft: String,
pub farm_access_discount_bps: u32,
pub farm_access_index: usize,
pub plant_quantity: u32,
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 offhand_template_id: Option<String>,
pub offhand_label: Option<String>,
pub mainhand_hand_slots: u8,
pub defense: Option<flatland_protocol::DefenseHud>,
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 keychain_stacks: Vec<flatland_protocol::ItemStack>,
pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
pub statuses: Vec<flatland_protocol::StatusEffectHud>,
pub combat_target_detail: Option<CombatTargetHud>,
pub cast_progress: Option<CastProgressHud>,
pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
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 known_abilities: Vec<String>,
pub hotbar: Vec<Option<String>>,
pub max_abilities_per_rotation: u8,
pub show_loadout_menu: bool,
pub show_keychain_menu: bool,
pub keychain_menu_index: usize,
pub show_rotation_editor: bool,
pub loadout_menu_index: usize,
pub loadout_hotbar_slot: u8,
pub loadout_ability_index: usize,
pub loadout_focus_presets: bool,
pub rotation_editor: RotationEditorState,
pub harvest_in_progress: bool,
pub harvest_started_at: Option<Instant>,
pub pending_craft_ack: Option<(u32, String, u32)>,
pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
pub interactables: Vec<flatland_protocol::InteractableView>,
pub ledger: Option<flatland_protocol::PlayerLedgerView>,
pub career: Option<flatland_protocol::PlayerCareerView>,
pub character_sheet_tab: CharacterSheetTab,
pub ledger_period: LedgerPeriod,
pub show_quest_offer: bool,
pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
pub show_quest_menu: bool,
pub quest_menu_index: usize,
pub quest_withdraw_confirm: bool,
pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
pub show_workers_menu: bool,
pub workers_menu_index: usize,
pub workers_menu_compact: bool,
pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
pub worker_error_display: BTreeMap<String, StickyWorkerError>,
pub show_worker_give_picker: bool,
pub worker_give_picker_index: usize,
pub worker_give_picker: Option<WorkerGivePicker>,
pub show_worker_give_target_picker: bool,
pub worker_give_target_picker_index: usize,
pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
pub show_worker_take_picker: bool,
pub worker_take_picker_index: usize,
pub worker_take_picker: Option<WorkerTakePicker>,
pub show_worker_teach_picker: bool,
pub worker_teach_picker_index: usize,
pub worker_teach_picker: Option<WorkerTeachPicker>,
pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
pub attending_worker_instance_id: Option<String>,
pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
}
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 push_shop_trade_log(&mut self, line: impl Into<String>) {
self.shop_trade_log.push_back(line.into());
while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
self.shop_trade_log.pop_front();
}
}
pub fn clear_shop_trade_log(&mut self) {
self.shop_trade_log.clear();
}
fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
if !self.show_shop_menu {
return;
}
let msg = notice.message.trim();
if msg.is_empty() {
return;
}
if notice.coins_delta != 0
|| msg.starts_with("Bought ")
|| msg.starts_with("Sold ")
|| msg.contains("taught you how to craft")
|| msg.starts_with("need ")
{
self.push_shop_trade_log(msg);
}
}
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 npc_verb_options(&self) -> Vec<&'static str> {
let Some(ref id) = self.npc_verb_target else {
return vec![];
};
let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
return vec!["Talk"];
};
let role = npc.role.as_str();
if Self::npc_role_is_bank(role) {
return vec!["Bank", "Talk"];
}
if Self::npc_role_is_storage(role) {
return vec!["Storage", "Talk"];
}
if Self::npc_role_is_market(role) {
return vec!["Market", "Talk"];
}
if npc.can_trade || Self::npc_role_can_trade(role) {
vec!["Talk", "Trade"]
} else {
vec!["Talk"]
}
}
fn npc_role_can_trade(role: &str) -> bool {
matches!(role, "broker" | "cook" | "farmer" | "merchant")
}
fn npc_role_is_bank(role: &str) -> bool {
role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
}
fn npc_role_is_storage(role: &str) -> bool {
role.eq_ignore_ascii_case("storage_manager")
}
fn npc_role_is_market(role: &str) -> bool {
role.eq_ignore_ascii_case("market_clerk")
}
pub fn bank_menu_options(&self) -> Vec<&'static str> {
vec![
"Deposit…",
"Withdraw…",
"Deposit all",
"Withdraw all",
"Transfer…",
]
}
pub fn storage_menu_options(&self) -> Vec<String> {
let mut opts = vec!["Store…".into(), "Take…".into()];
if let Some(panel) = &self.storage_panel {
for dest in &panel.ship_destinations {
opts.push(format!(
"Ship → {} ({} cp / {} ticks)",
dest.label, dest.fee_copper, dest.travel_ticks
));
}
}
opts
}
pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
self.person_rows()
.into_iter()
.filter(|r| r.depth == 0)
.filter_map(|r| {
let id = r.stack.item_instance_id?;
Some(StoragePickOption {
item_instance_id: id,
label: storage_stack_label(&r.stack),
quantity: r.stack.quantity,
category: r.stack.category.clone().unwrap_or_default(),
})
})
.collect()
}
pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
let Some(panel) = &self.storage_panel else {
return Vec::new();
};
panel
.contents
.iter()
.filter_map(|s| {
let id = s.item_instance_id?;
Some(StoragePickOption {
item_instance_id: id,
label: storage_stack_label(s),
quantity: s.quantity,
category: s.category.clone().unwrap_or_default(),
})
})
.collect()
}
pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
let mut opts = Vec::new();
if !self
.market_list_item_options(&MarketListSourceKind::Person)
.is_empty()
{
opts.push((MarketListSourceKind::Person, "On person".into()));
}
if let Some(panel) = &self.market_panel {
for vault in &panel.list_vaults {
let source = MarketListSourceKind::TownStorage {
building_id: vault.building_id.clone(),
};
if self.market_list_item_options(&source).is_empty() {
continue;
}
let label = if vault.building_label.is_empty() {
format!("Town storage ({})", vault.building_id)
} else {
format!("Town storage — {}", vault.building_label)
};
opts.push((source, label));
}
}
opts
}
pub fn market_list_item_options(
&self,
source: &MarketListSourceKind,
) -> Vec<StoragePickOption> {
let filter = self.market_filter.as_str();
let cat_filter = self.market_category_filter;
let mut opts: Vec<StoragePickOption> = match source {
MarketListSourceKind::Person => self
.person_rows()
.into_iter()
.filter(|r| r.depth == 0)
.filter(|r| self.stack_is_market_listable(&r.stack))
.filter_map(|r| {
let id = r.stack.item_instance_id?;
Some(StoragePickOption {
item_instance_id: id,
label: storage_stack_label(&r.stack),
quantity: r.stack.quantity,
category: r
.stack
.category
.clone()
.or_else(|| {
self.inventory_item_category(&r.stack.template_id)
.map(str::to_string)
})
.unwrap_or_default(),
})
})
.collect(),
MarketListSourceKind::TownStorage { building_id } => {
let Some(panel) = &self.market_panel else {
return Vec::new();
};
let Some(vault) = panel
.list_vaults
.iter()
.find(|v| &v.building_id == building_id)
else {
return Vec::new();
};
vault
.contents
.iter()
.filter(|s| self.stack_is_market_listable(s))
.filter_map(|s| {
let id = s.item_instance_id?;
Some(StoragePickOption {
item_instance_id: id,
label: storage_stack_label(s),
quantity: s.quantity,
category: s
.category
.clone()
.or_else(|| {
self.inventory_item_category(&s.template_id)
.map(str::to_string)
})
.unwrap_or_default(),
})
})
.collect()
}
};
opts.retain(|o| {
if !list_label_matches(&o.label, filter) {
return false;
}
if let Some(group) = cat_filter {
inventory_category_group(&o.category).0 == group
} else {
true
}
});
opts
}
fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
if crate::currency::is_currency(&stack.template_id) {
return false;
}
if let Some(flag) = stack.listable {
return flag;
}
if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
return hint.listable;
}
let cat = stack
.category
.as_deref()
.or_else(|| self.inventory_item_category(&stack.template_id))
.unwrap_or("");
category_default_listable(cat)
}
pub fn market_available_category_groups(&self) -> Vec<&'static str> {
let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
match &self.market_ui_mode {
MarketUiMode::ListPick { source, .. } => {
let raw: Vec<_> = match source {
MarketListSourceKind::Person => self
.person_rows()
.into_iter()
.filter(|r| r.depth == 0)
.filter(|r| self.stack_is_market_listable(&r.stack))
.filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
.map(|r| {
r.stack
.category
.clone()
.or_else(|| {
self.inventory_item_category(&r.stack.template_id)
.map(str::to_string)
})
.unwrap_or_default()
})
.collect(),
MarketListSourceKind::TownStorage { building_id } => self
.market_panel
.as_ref()
.and_then(|p| {
p.list_vaults
.iter()
.find(|v| &v.building_id == building_id)
})
.map(|vault| {
vault
.contents
.iter()
.filter(|s| self.stack_is_market_listable(s))
.filter(|s| {
list_label_matches(&storage_stack_label(s), &self.market_filter)
})
.map(|s| {
s.category
.clone()
.or_else(|| {
self.inventory_item_category(&s.template_id)
.map(str::to_string)
})
.unwrap_or_default()
})
.collect::<Vec<_>>()
})
.unwrap_or_default(),
};
for category in raw {
let (label, ord) = inventory_category_group(&category);
seen.insert(ord, label);
}
}
_ => {
if let Some(panel) = &self.market_panel {
for listing in &panel.listings {
if !list_label_matches(&listing.display_name, &self.market_filter)
&& !list_label_matches(&listing.seller_label, &self.market_filter)
{
continue;
}
let (label, ord) = inventory_category_group(&listing.category);
seen.insert(ord, label);
}
}
}
}
seen.into_values().collect()
}
pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
let Some(panel) = &self.market_panel else {
return Vec::new();
};
let filter = self.market_filter.as_str();
let cat_filter = self.market_category_filter;
panel
.listings
.iter()
.enumerate()
.filter(|(_, listing)| {
if !list_label_matches(&listing.display_name, filter)
&& !list_label_matches(&listing.seller_label, filter)
&& !list_label_matches(&listing.template_id, filter)
{
return false;
}
if let Some(group) = cat_filter {
inventory_category_group(&listing.category).0 == group
} else {
true
}
})
.map(|(i, _)| i)
.collect()
}
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.bank_panel = None;
self.show_craft_menu = false;
self.show_inventory_menu = false;
self.show_stats = false;
if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
self.npc_verb_target = Some(catalog.npc_id.clone());
}
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.clear_shop_trade_log();
}
self.show_npc_verb_menu = false;
self.clamp_shop_selection();
}
pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
let same_teller = self
.bank_panel
.as_ref()
.is_some_and(|p| p.npc_id == panel.npc_id);
self.bank_panel = Some(panel);
self.storage_panel = None;
self.market_panel = None;
self.shop_catalog = None;
self.show_shop_menu = false;
self.show_craft_menu = false;
self.show_inventory_menu = false;
self.show_stats = false;
self.show_npc_verb_menu = false;
self.show_npc_chat = false;
self.npc_chat = None;
if !same_teller {
self.bank_menu_index = 0;
self.bank_ui_mode = BankUiMode::Menu;
}
if let Some(panel) = &self.bank_panel {
if self.npc_verb_target.is_none() {
self.npc_verb_target = Some(panel.npc_id.clone());
}
}
}
pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
let same_manager = self
.storage_panel
.as_ref()
.is_some_and(|p| p.npc_id == panel.npc_id);
self.storage_panel = Some(panel);
self.bank_panel = None;
self.market_panel = None;
self.bank_ui_mode = BankUiMode::Menu;
self.shop_catalog = None;
self.show_shop_menu = false;
self.show_craft_menu = false;
self.show_inventory_menu = false;
self.show_stats = false;
self.show_npc_verb_menu = false;
self.show_npc_chat = false;
self.npc_chat = None;
if !same_manager {
self.storage_menu_index = 0;
self.storage_ui_mode = StorageUiMode::Menu;
} else {
self.clamp_storage_pick_index();
}
if let Some(panel) = &self.storage_panel {
if self.npc_verb_target.is_none() {
self.npc_verb_target = Some(panel.npc_id.clone());
}
}
}
pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
self.market_panel = Some(panel);
self.bank_panel = None;
self.storage_panel = None;
self.shop_catalog = None;
self.show_shop_menu = false;
self.show_craft_menu = false;
self.show_inventory_menu = false;
self.show_stats = false;
self.show_npc_verb_menu = false;
self.show_npc_chat = false;
self.npc_chat = None;
self.market_menu_index = 0;
self.market_buy_confirm = None;
self.market_ui_mode = MarketUiMode::Browse;
self.market_filter.clear();
self.market_filter_focused = false;
self.market_category_filter = None;
if let Some(panel) = &self.market_panel {
if self.npc_verb_target.is_none() {
self.npc_verb_target = Some(panel.npc_id.clone());
}
}
}
pub fn clear_market_panel(&mut self) {
self.market_panel = None;
self.market_menu_index = 0;
self.market_buy_confirm = None;
self.market_ui_mode = MarketUiMode::Browse;
self.market_filter.clear();
self.market_filter_focused = false;
self.market_category_filter = None;
}
pub fn clear_bank_panel(&mut self) {
self.bank_panel = None;
self.bank_menu_index = 0;
self.bank_ui_mode = BankUiMode::Menu;
}
pub fn clear_storage_panel(&mut self) {
self.storage_panel = None;
self.storage_menu_index = 0;
self.storage_ui_mode = StorageUiMode::Menu;
}
fn clamp_storage_pick_index(&mut self) {
match &self.storage_ui_mode {
StorageUiMode::StorePick { index } => {
let n = self.storage_store_options().len();
let next = if n == 0 { 0 } else { (*index).min(n - 1) };
self.storage_ui_mode = StorageUiMode::StorePick { index: next };
}
StorageUiMode::TakePick { index } => {
let n = self.storage_vault_options().len();
let next = if n == 0 { 0 } else { (*index).min(n - 1) };
self.storage_ui_mode = StorageUiMode::TakePick { index: next };
}
StorageUiMode::ShipPick {
dest_building_id,
dest_label,
index,
} => {
let n = self.storage_vault_options().len();
let next = if n == 0 { 0 } else { (*index).min(n - 1) };
self.storage_ui_mode = StorageUiMode::ShipPick {
dest_building_id: dest_building_id.clone(),
dest_label: dest_label.clone(),
index: next,
};
}
StorageUiMode::Menu
| StorageUiMode::StoreAmount { .. }
| StorageUiMode::TakeAmount { .. }
| StorageUiMode::ShipAmount { .. } => {}
}
}
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();
if max == 0 {
self.shop_quantity = 0;
return;
}
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(0),
}
}
pub fn shop_quantity_set_max(&mut self) {
self.shop_quantity = self.shop_quantity_max();
}
fn clamp_shop_quantity(&mut self) {
let max = self.shop_quantity_max();
if max == 0 {
self.shop_quantity = 0;
} else {
self.shop_quantity = self.shop_quantity.max(1).min(max);
}
}
pub fn player_at_station_tag(&self, tag: &str) -> bool {
let Some(id) = self.effective_inside_building() else {
return false;
};
self.buildings
.iter()
.find(|b| b.id == id)
.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 apply_client_ui_prefs(&mut self) {
let cfg = crate::client_config::ClientConfig::load();
if let Some(hidden) = cfg.hud_log_hidden {
self.hud_log_hidden = hidden;
}
if let Some(compact) = cfg.workers_menu_compact {
self.workers_menu_compact = compact;
}
}
pub fn player_position(&self) -> (f32, f32) {
let (x, y, _) = self.player_position_with_z();
(x, y)
}
pub fn player_position_with_z(&self) -> (f32, f32, f32) {
if let Some(p) = self.player_entity() {
(
p.transform.position.x,
p.transform.position.y,
p.transform.position.z,
)
} else {
(0.0, 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 stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
stack
.props
.get("grants_item_status_effect")
.map(|s| !s.is_empty())
.unwrap_or(false)
}
pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
stack
.props
.get("grants_item_status_effect")
.map(String::as_str)
.filter(|s| !s.is_empty())
}
pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
stack
.props
.get("grants_item_status_mode")
.map(String::as_str)
.unwrap_or("on_hit")
}
pub fn grant_target_options(
&self,
grant: &flatland_protocol::ItemStack,
) -> Vec<GrantTargetOption> {
let mode = Self::grant_mode(grant);
let grant_tags: Vec<&str> = grant
.props
.get("grants_item_status_tags")
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
let grant_id = grant.item_instance_id;
let mut out = Vec::new();
let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
let Some(iid) = stack.item_instance_id else {
return;
};
if Some(iid) == grant_id {
return;
}
if stack.props.get("enchantable").map(String::as_str) == Some("0") {
return;
}
if !grant_target_matches_mode(stack, mode) {
return;
}
if !grant_tags_match(stack, &grant_tags) {
return;
}
let name = stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone());
let bindings = if stack.status_bindings.is_empty() {
String::new()
} else {
format!(
" · {}",
stack
.status_bindings
.iter()
.map(|b| b.effect_id.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
out.push(GrantTargetOption {
label: format!("{where_label}: {name}{bindings}"),
target_instance_id: iid,
});
};
fn walk(
stacks: &[flatland_protocol::ItemStack],
where_label: &str,
push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
) {
for s in stacks {
push(s, where_label);
if !s.contents.is_empty() {
let nested = format!(
"{where_label}/{}",
s.display_name
.as_deref()
.unwrap_or(s.template_id.as_str())
);
walk(&s.contents, &nested, push);
}
}
}
walk(&self.inventory_stacks, "Bag", &mut push);
for (slot, stack) in &self.worn {
push(stack, body_slot_label(*slot));
let nest = format!(
"{}/{}",
body_slot_label(*slot),
stack
.display_name
.as_deref()
.unwrap_or(stack.template_id.as_str())
);
walk(&stack.contents, &nest, &mut push);
}
out
}
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(),
status_bindings: Vec::new(),
contents: chest.contents.clone(),
display_name: Some(chest.display_name.clone()),
category: Some("container".into()),
capacity_volume: self
.inventory_hints
.get(&chest.template_id)
.and_then(|h| h.capacity_volume),
worker_lodging_capacity: chest.worker_lodging_capacity,
..Default::default()
})
} else {
self.find_stack_by_instance(&chest.contents, parent_instance_id?)
}
}
flatland_protocol::InventoryLocation::Keychain => None,
flatland_protocol::InventoryLocation::WhisperPouch => None,
}
}
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
| MoveOptionKind::Use
| MoveOptionKind::GrantApply
| MoveOptionKind::SellPlotToCrown { .. }
| MoveOptionKind::PickupPlaced { .. }
| MoveOptionKind::RelocatePlaced { .. } => 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_zone_at(x, y).map(|z| z.kind)
}
pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
use std::cell::RefCell;
const CHUNK: i32 = 8;
thread_local! {
static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
RefCell::new(None);
}
let zones = &self.terrain_zones;
if zones.is_empty() {
return None;
}
if zones.len() <= 48 {
return zones
.iter()
.enumerate()
.filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
.max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
.map(|(_, z)| z);
}
let ptr = zones.as_ptr();
let len = zones.len();
INDEX.with(|cell| {
let mut slot = cell.borrow_mut();
let stale = match slot.as_ref() {
Some((p, l, _)) => *p != ptr || *l != len,
None => true,
};
if stale {
let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
std::collections::HashMap::new();
for (zi, z) in zones.iter().enumerate() {
let x0 = z.x0.min(z.x1).floor() as i32;
let y0 = z.y0.min(z.y1).floor() as i32;
let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
let cx0 = x0.div_euclid(CHUNK);
let cy0 = y0.div_euclid(CHUNK);
let cx1 = x1.div_euclid(CHUNK);
let cy1 = y1.div_euclid(CHUNK);
for cy in cy0..=cy1 {
for cx in cx0..=cx1 {
chunks.entry((cx, cy)).or_default().push(zi);
}
}
}
*slot = Some((ptr, len, chunks));
}
let chunks = &slot.as_ref().expect("index").2;
let cx = (x.floor() as i32).div_euclid(CHUNK);
let cy = (y.floor() as i32).div_euclid(CHUNK);
let mut best: Option<(usize, &TerrainZoneView)> = None;
if let Some(list) = chunks.get(&(cx, cy)) {
for &zi in list {
let Some(z) = zones.get(zi) else { continue };
if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
continue;
}
best = match best {
None => Some((zi, z)),
Some((bi, bz)) => {
if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
Some((zi, z))
} else {
Some((bi, bz))
}
}
};
}
}
best.map(|(_, z)| z)
})
}
pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
self.terrain_zone_at(x, y)
.map(|z| z.elevation)
.unwrap_or(0.0)
}
pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
const TOL: f32 = 0.35;
let mut levels = vec![self.elevation_at(x, y)];
for p in &self.z_platforms {
if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
levels.push(p.z);
}
}
levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
levels
}
pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
const TOL: f32 = 0.35;
self.walkable_levels_at(x, y)
.iter()
.any(|&l| (l - z).abs() <= TOL)
}
pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
let mut top = self.elevation_at(x, y);
for p in &self.z_platforms {
if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
top = top.max(p.z);
}
}
top
}
pub fn effective_inside_building(&self) -> Option<String> {
self.player_entity().and_then(|p| p.inside_building.clone())
}
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),
listable: stack.listable.unwrap_or_else(|| {
category_default_listable(
stack.category.as_deref().unwrap_or(""),
)
}),
},
);
}
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 apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
let subtract_items =
notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
for stack in ¬ice.inventory_delta {
if stack.quantity == 0 {
continue;
}
if subtract_items {
crate::currency::drain_template_stacks(
&mut self.inventory_stacks,
&stack.template_id,
stack.quantity,
);
continue;
}
let stackable = self
.inventory_hints
.get(&stack.template_id)
.map(|h| h.stackable)
.or(stack.stackable)
.unwrap_or(true);
if stackable {
if let Some(existing) = self
.inventory_stacks
.iter_mut()
.find(|s| s.template_id == stack.template_id)
{
existing.quantity = existing.quantity.saturating_add(stack.quantity);
if stack.display_name.is_some() {
existing.display_name = stack.display_name.clone();
}
if stack.category.is_some() {
existing.category = stack.category.clone();
}
continue;
}
}
self.inventory_stacks.push(stack.clone());
}
if notice.coins_delta != 0 {
crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
}
if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
let stacks = self.inventory_stacks.clone();
self.sync_inventory_from_stacks(&stacks);
}
self.record_shop_trade_notice(notice);
}
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 giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
self.inventory_stacks
.iter()
.filter_map(|stack| {
let item_instance_id = stack.item_instance_id?;
let label = stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone());
let label = if stack.quantity > 1 {
format!("{label} ×{}", stack.quantity)
} else {
label
};
Some(WorkerGiveOption {
item_instance_id,
label,
quantity: stack.quantity,
template_id: stack.template_id.clone(),
})
})
.collect()
}
pub fn teachable_blueprint_options(
&self,
worker: &flatland_protocol::HiredWorkerView,
) -> Vec<WorkerTeachOption> {
let copper = crate::currency::copper_from_counts(&self.inventory);
let mut options: Vec<WorkerTeachOption> = self
.blueprints
.iter()
.filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
.map(|bp| {
let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
let cost = bp.worker_train_copper;
WorkerTeachOption {
blueprint_id: bp.id.clone(),
label: if bp.label.is_empty() {
bp.id.clone()
} else {
bp.label.clone()
},
cost_copper: cost,
min_level,
worker_level: worker.level,
can_afford: copper >= cost,
level_ok: worker.level >= min_level,
}
})
.collect();
options.sort_by(|a, b| a.label.cmp(&b.label));
options
}
pub fn person_rows(&self) -> Vec<InventoryRow> {
self.person_rows_filtered("")
}
pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
roots.sort_by(|a, b| {
let ca = a
.category
.as_deref()
.or_else(|| self.inventory_item_category(&a.template_id))
.unwrap_or("");
let cb = b
.category
.as_deref()
.or_else(|| self.inventory_item_category(&b.template_id))
.unwrap_or("");
let ga = inventory_category_group(ca).1;
let gb = inventory_category_group(cb).1;
ga.cmp(&gb).then_with(|| {
let na = a
.display_name
.as_deref()
.unwrap_or(a.template_id.as_str());
let nb = b
.display_name
.as_deref()
.unwrap_or(b.template_id.as_str());
na.cmp(nb)
})
});
let mut rows = Vec::new();
for stack in roots {
push_inventory_rows_filtered(
&mut rows,
0,
stack,
&flatland_protocol::InventoryLocation::Root,
None,
InventorySection::Person,
filter,
);
}
rows
}
pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
if filter.is_empty() {
return self.worn_rows();
}
let mut rows = Vec::new();
for (slot, item) in &self.worn {
if !stack_matches_filter(item, filter) {
continue;
}
let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
let self_hit = {
let f = filter.to_ascii_lowercase();
let name = item
.display_name
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
let tid = item.template_id.to_ascii_lowercase();
name.contains(&f) || tid.contains(&f)
};
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 {
if self_hit || stack_matches_filter(child, filter) {
push_inventory_rows_filtered(
&mut rows,
1,
child,
&from,
item.item_instance_id,
InventorySection::Worn,
if self_hit { "" } else { filter },
);
}
}
}
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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: Some(c.display_name.clone()),
category: Some("container".into()),
capacity_volume: c.capacity_volume,
worker_lodging_capacity: c.worker_lodging_capacity,
..Default::default()
},
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 filter = self.inventory_filter.as_str();
match self.inventory_tab {
InventoryTab::OnPerson => {
let mut rows = self.worn_rows_filtered(filter);
rows.extend(self.person_rows_filtered(filter));
rows
}
InventoryTab::Nearby => {
let mut rows = Vec::new();
for nc in self.nearby_containers() {
if filter.is_empty() {
rows.extend(nc.rows);
continue;
}
let shell = nc.rows.first().cloned();
let contents: Vec<_> = nc
.rows
.iter()
.skip(1)
.filter(|r| stack_matches_filter(&r.stack, filter))
.cloned()
.collect();
let shell_hit = shell
.as_ref()
.map(|s| stack_matches_filter(&s.stack, filter))
.unwrap_or(false);
if shell_hit || !contents.is_empty() {
if let Some(s) = shell {
rows.push(s);
}
if shell_hit {
rows.extend(nc.rows.into_iter().skip(1));
} else {
rows.extend(contents);
}
}
}
rows
}
}
}
pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
self.inventory_selectable_rows()
.into_iter()
.nth(self.inventory_menu_index)
}
fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
let cat = self
.inventory_item_category(&row.stack.template_id)
.unwrap_or("");
if cat == "key" {
self.key_inventory_label(&row.stack)
} else {
row.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone())
}
}
fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
let bindings = format_status_bindings_suffix(
&row.stack.status_bindings,
self.tick,
DEFAULT_TICK_HZ,
);
let grant_hint = if Self::stack_is_item_grant(&row.stack) {
let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
let mode = Self::grant_mode(&row.stack);
format!(" [grant {effect} · {mode} — e apply]")
} else {
String::new()
};
let qty = if row.stack.quantity > 1 {
format!(" ×{}", row.stack.quantity)
} else {
String::new()
};
let worn_slot = if row.is_equip_shell {
match row.from {
flatland_protocol::InventoryLocation::Worn { slot } => {
format!(" ({})", body_slot_label(slot))
}
_ => String::new(),
}
} else {
String::new()
};
format!("{grant_hint}{bindings}{qty}{worn_slot}")
}
fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
(
row.stack.template_id.clone(),
self.inventory_row_base_label(row),
self.inventory_row_visible_mod_signature(row),
)
}
fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
for row in self.inventory_selectable_rows() {
if row.stack.item_instance_id.is_none() {
continue;
}
let key = self.inventory_row_instance_identity_key(&row);
*counts.entry(key).or_default() += 1;
}
counts
.into_iter()
.filter(|(_, n)| *n > 1)
.map(|(k, _)| k)
.collect()
}
fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
let hex: String = id
.as_simple()
.to_string()
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect();
let short = if hex.len() >= 4 {
&hex[hex.len() - 4..]
} else {
hex.as_str()
};
format!("Instance {id} (#{short})")
}
pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
let cat = self
.inventory_item_category(&row.stack.template_id)
.unwrap_or("");
let label = self.inventory_row_base_label(row);
let hint: String = if row.is_equip_shell {
" [worn — Enter to unequip]".into()
} else if row.is_chest_shell {
let (locked, lodging_note) = match &row.from {
flatland_protocol::InventoryLocation::Placed { container_id } => {
let locked = self
.placed_containers
.iter()
.find(|c| c.id == *container_id)
.map(|c| c.locked)
.unwrap_or(false);
let lodging_note = self
.lodging_occupancy_label(container_id)
.map(|who| format!(" [lodging: {who}]"))
.unwrap_or_default();
(locked, lodging_note)
}
_ => (false, String::new()),
};
if locked {
format!(" [locked — Enter pick up · l unlock]{lodging_note}")
} else {
format!(" [Enter pick up · l lock]{lodging_note}")
}
} else if cat == "key" {
self.key_inventory_hint(&row.stack)
} else {
match cat {
"weapon" => " [weapon]".into(),
"container" => " [bag/chest/belt]".into(),
"lodging" => " [worker lodging]".into(),
"armor" => " [armor]".into(),
_ => String::new(),
}
};
let qty = if row.stack.quantity > 1 {
format!(" ×{}", row.stack.quantity)
} else {
String::new()
};
let bindings = format_status_bindings_suffix(
&row.stack.status_bindings,
self.tick,
DEFAULT_TICK_HZ,
);
let grant_hint = if Self::stack_is_item_grant(&row.stack) {
let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
let mode = Self::grant_mode(&row.stack);
format!(" [grant {effect} · {mode} — e apply]")
} else {
String::new()
};
let mass = self.stack_mass(&row.stack);
let mass_kg = (mass >= 0.05).then_some(mass);
let mass_str = mass_kg
.map(|m| format!(" {m:.1} kg"))
.unwrap_or_default();
let volume = self.container_volume_stats(row);
let vol_str = self.container_volume_label(row);
let mut title = label.clone();
title.push_str(&qty);
if row.is_equip_shell {
if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
title.push_str(&format!(" ({})", body_slot_label(slot)));
}
}
InventoryRowView {
depth: row.depth,
text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
title: format!("{title}{grant_hint}{bindings}"),
mass_kg,
volume,
instance_tooltip: None,
}
}
fn push_browser_item(
&self,
lines: &mut Vec<InventoryBrowserLine>,
row: &InventoryRow,
global_idx: &mut usize,
target: usize,
highlight: bool,
ambiguous_instance_keys: &HashSet<(String, String, String)>,
) {
let mut view = self.format_inventory_row(row);
if let Some(id) = row.stack.item_instance_id {
let key = self.inventory_row_instance_identity_key(row);
if ambiguous_instance_keys.contains(&key) {
view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
}
}
lines.push(InventoryBrowserLine::Item {
selectable_index: *global_idx,
selected: highlight && *global_idx == target,
depth: view.depth,
text: view.text,
title: view.title,
mass_kg: view.mass_kg,
volume: view.volume,
instance_tooltip: view.instance_tooltip,
});
*global_idx += 1;
}
pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
let mut lines = Vec::new();
let target = self.inventory_menu_index;
let highlight = !self.show_move_picker && !self.show_grant_picker;
let filter = self.inventory_filter.as_str();
let mut global_idx = 0usize;
let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
match self.inventory_tab {
InventoryTab::OnPerson => {
lines.push(InventoryBrowserLine::Section("— Worn —".into()));
let worn = self.worn_rows_filtered(filter);
if worn.is_empty() {
lines.push(InventoryBrowserLine::Hint(
" (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
));
} else {
for row in &worn {
if row.is_equip_shell {
if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
lines.push(InventoryBrowserLine::SlotLabel(format!(
" {}:",
body_slot_label(slot)
)));
}
}
self.push_browser_item(
&mut lines,
row,
&mut global_idx,
target,
highlight,
&ambiguous_instance_keys,
);
}
}
lines.push(InventoryBrowserLine::Blank);
lines.push(InventoryBrowserLine::Section(
"— On you (loose, not worn) —".into(),
));
let person = self.person_rows_filtered(filter);
if person.is_empty() {
lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
} else {
let mut last_group: Option<&'static str> = None;
for row in &person {
if row.depth == 0 {
let cat = row
.stack
.category
.as_deref()
.or_else(|| self.inventory_item_category(&row.stack.template_id))
.unwrap_or("");
let (group, _) = inventory_category_group(cat);
if last_group != Some(group) {
lines.push(InventoryBrowserLine::SlotLabel(format!(
" {group}"
)));
last_group = Some(group);
}
}
self.push_browser_item(
&mut lines,
row,
&mut global_idx,
target,
highlight,
&ambiguous_instance_keys,
);
}
}
}
InventoryTab::Nearby => {
let nearby = self.nearby_containers();
if nearby.is_empty() {
lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
lines.push(InventoryBrowserLine::Hint(
" (none within reach — walk up to a chest)".into(),
));
lines.push(InventoryBrowserLine::Hint(
" Select an on-person item, then m / Enter → move into chest.".into(),
));
} else {
let mut any_visible = false;
for nc in &nearby {
let shell = nc.rows.first();
let contents: Vec<&InventoryRow> = if filter.is_empty() {
nc.rows.iter().skip(1).collect()
} else {
let shell_hit = shell
.map(|s| {
let f = filter.to_ascii_lowercase();
let name = s
.stack
.display_name
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
let tid = s.stack.template_id.to_ascii_lowercase();
name.contains(&f) || tid.contains(&f)
})
.unwrap_or(false);
if shell_hit {
nc.rows.iter().skip(1).collect()
} else {
nc.rows
.iter()
.skip(1)
.filter(|r| stack_matches_filter(&r.stack, filter))
.collect()
}
};
let shell_visible = filter.is_empty()
|| shell
.map(|s| stack_matches_filter(&s.stack, filter))
.unwrap_or(false)
|| !contents.is_empty();
if !shell_visible && shell.is_some() {
continue;
}
any_visible = true;
lines.push(InventoryBrowserLine::Blank);
let lock_note = if nc.view.locked && nc.view.accessible {
" unlocked with your key"
} else if nc.view.locked {
" locked"
} else {
""
};
lines.push(InventoryBrowserLine::Section(format!(
"— {} ({:.0}m away){lock_note} —",
nc.view.display_name, nc.distance_m
)));
if !nc.view.accessible {
lines.push(InventoryBrowserLine::Hint(
" locked — need the matching key (l to try)".into(),
));
} else if nc.rows.is_empty() {
lines.push(InventoryBrowserLine::Hint(
" (empty — switch to On person, select an item, m to move in)"
.into(),
));
} else if let Some(shell_row) = shell {
self.push_browser_item(
&mut lines,
shell_row,
&mut global_idx,
target,
highlight,
&ambiguous_instance_keys,
);
for row in contents {
self.push_browser_item(
&mut lines,
row,
&mut global_idx,
target,
highlight,
&ambiguous_instance_keys,
);
}
}
}
if !any_visible {
lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
lines.push(InventoryBrowserLine::Hint(
" (no matching items — clear filter with Esc)".into(),
));
}
}
}
}
lines
}
pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
let mut opts = Vec::new();
opts.push(MoveOption {
label: "Relocate…".into(),
kind: MoveOptionKind::RelocatePlaced {
container_id: container_id.to_string(),
},
});
opts.push(MoveOption {
label: "On your person (loose)".into(),
kind: MoveOptionKind::PickupPlaced {
container_id: container_id.to_string(),
nest_location: flatland_protocol::InventoryLocation::Root,
nest_parent_instance_id: None,
},
});
for (slot, item) in &self.worn {
if item.category.as_deref() != Some("container") {
continue;
}
if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
continue;
}
let Some(parent_id) = item.item_instance_id else {
continue;
};
let shell_name = item
.display_name
.clone()
.unwrap_or_else(|| item.template_id.clone());
opts.push(MoveOption {
label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
kind: MoveOptionKind::PickupPlaced {
container_id: container_id.to_string(),
nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
nest_parent_instance_id: Some(parent_id),
},
});
Self::append_chest_pickup_nested(
&mut opts,
container_id,
flatland_protocol::InventoryLocation::Worn { slot: *slot },
item,
&format!("in {shell_name}"),
);
}
opts.push(MoveOption {
label: "Cancel".into(),
kind: MoveOptionKind::Cancel,
});
opts
}
fn append_chest_pickup_nested(
opts: &mut Vec<MoveOption>,
container_id: &str,
location: flatland_protocol::InventoryLocation,
parent: &flatland_protocol::ItemStack,
context: &str,
) {
for child in &parent.contents {
if child.category.as_deref() != Some("container") {
continue;
}
if !Self::is_volume_container_stack(child) {
continue;
}
if child.world_placeable == Some(true) {
continue;
}
let Some(child_id) = child.item_instance_id else {
continue;
};
let name = child
.display_name
.clone()
.unwrap_or_else(|| child.template_id.clone());
opts.push(MoveOption {
label: format!("{name} ({context})"),
kind: MoveOptionKind::PickupPlaced {
container_id: container_id.to_string(),
nest_location: location.clone(),
nest_parent_instance_id: Some(child_id),
},
});
Self::append_chest_pickup_nested(
opts,
container_id,
location.clone(),
child,
&format!("in {name}"),
);
}
}
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) && stack.template_id != PROPERTY_DEED_TEMPLATE
})
.unwrap_or(
moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_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 sync_interior_map_context(&mut self) {
if self.effective_inside_building().is_none() {
self.interior_map = None;
}
self.sync_interior_z_bands();
}
fn sync_interior_z_bands(&mut self) {
if self.effective_inside_building().is_some() {
if let Some(map) = &self.interior_map {
if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
self.z_platforms = map.z_platforms.clone();
self.z_transitions = map.z_transitions.clone();
}
}
}
}
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.publish_rev = snapshot.publish_rev;
self.resource_nodes = snapshot.resource_nodes.clone();
self.ground_drops = snapshot.ground_drops.clone();
self.placed_containers = snapshot.placed_containers.clone();
self.world_x0 = snapshot.world_x0;
self.world_y0 = snapshot.world_y0;
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.z_platforms = snapshot.z_platforms.clone();
self.z_transitions = snapshot.z_transitions.clone();
self.buildings = snapshot.buildings.clone();
self.doors = snapshot.doors.clone();
self.interior_map = snapshot.interior_map.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();
self.quest_log = snapshot.quest_log.clone();
self.apply_hired_workers(snapshot.hired_workers.clone());
self.interactables = snapshot.interactables.clone();
self.ledger = snapshot.ledger.clone();
self.career = snapshot.career.clone();
self.combat_fx = snapshot.combat_fx.clone();
self.property_zones = snapshot.property_zones.clone();
self.tax_zones = snapshot.tax_zones.clone();
self.growth_zones = snapshot.growth_zones.clone();
self.biome_zones = snapshot.biome_zones.clone();
self.property_plots = snapshot.property_plots.clone();
self.property_plot_settings = snapshot.property_plot_settings.clone();
self.sync_interior_map_context();
self.refresh_whisper_range();
}
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_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
let selected_id = self
.hired_workers
.get(self.workers_menu_index)
.map(|w| w.instance_id.clone());
workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
let now = Instant::now();
for w in &workers {
let prev_err = self
.hired_workers
.iter()
.find(|p| p.instance_id == w.instance_id)
.and_then(|p| p.last_error.as_deref());
let new_err = w.last_error.as_deref();
if new_err != prev_err {
if let Some(err) = new_err {
if !worker_error_is_transient(err) {
self.push_log(format!("Worker {}: {err}", w.label));
}
}
}
}
let mut next_display = BTreeMap::new();
let mut next_errors = BTreeMap::new();
for w in &workers {
let mut sticky = self
.worker_step_display
.remove(&w.instance_id)
.unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
sticky.observe(&w.step_label, now);
next_display.insert(w.instance_id.clone(), sticky);
let mut err_sticky = self
.worker_error_display
.remove(&w.instance_id)
.unwrap_or_default();
err_sticky.observe(w.last_error.as_deref(), now);
if err_sticky.shown(now).is_some() {
next_errors.insert(w.instance_id.clone(), err_sticky);
}
}
self.worker_step_display = next_display;
self.worker_error_display = next_errors;
self.hired_workers = workers;
self.sync_worker_take_picker_from_hired();
if let Some(id) = selected_id {
if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
self.workers_menu_index = idx;
return;
}
}
if self.workers_menu_index >= self.hired_workers.len() {
self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
}
}
fn sync_worker_take_picker_from_hired(&mut self) {
if !self.show_worker_take_picker {
return;
}
let Some(picker) = self.worker_take_picker.clone() else {
return;
};
let Some(worker) = self
.hired_workers
.iter()
.find(|w| w.instance_id == picker.worker_instance_id)
.cloned()
else {
self.show_worker_take_picker = false;
self.worker_take_picker = None;
self.worker_take_picker_index = 0;
return;
};
let options: Vec<WorkerGiveOption> = worker
.inventory
.iter()
.filter_map(|stack| {
let item_instance_id = stack.item_instance_id?;
let label = stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone());
let label = if stack.quantity > 1 {
format!("{label} ×{}", stack.quantity)
} else {
label
};
Some(WorkerGiveOption {
item_instance_id,
label,
quantity: stack.quantity,
template_id: stack.template_id.clone(),
})
})
.collect();
if options.is_empty() {
self.show_worker_take_picker = false;
self.worker_take_picker = None;
self.worker_take_picker_index = 0;
return;
}
let prev_id = picker
.options
.get(self.worker_take_picker_index)
.map(|o| o.item_instance_id);
let idx = prev_id
.and_then(|id| options.iter().position(|o| o.item_instance_id == id))
.unwrap_or(0)
.min(options.len().saturating_sub(1));
let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
let quantity = picker.quantity.clamp(1, max_qty);
self.worker_take_picker_index = idx;
self.worker_take_picker = Some(WorkerTakePicker {
worker_instance_id: picker.worker_instance_id,
worker_label: picker.worker_label,
options,
quantity,
});
}
pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
self.worker_step_display
.get(worker_instance_id)
.map(|s| s.shown.as_str())
.or_else(|| {
self.hired_workers
.iter()
.find(|w| w.instance_id == worker_instance_id)
.map(|w| w.step_label.as_str())
})
.unwrap_or("")
}
pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
let now = Instant::now();
self.worker_error_display
.get(worker_instance_id)
.and_then(|s| s.shown(now))
.or_else(|| {
self.hired_workers
.iter()
.find(|w| w.instance_id == worker_instance_id)
.and_then(|w| w.last_error.as_deref())
.filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
})
.filter(|e| !worker_error_is_hud_noise(e))
}
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.offhand_template_id = combat.offhand_template_id.clone();
self.offhand_label = combat.offhand_label.clone();
self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
1
} else {
combat.mainhand_hand_slots
};
self.defense = combat.defense.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.timed_channel = combat.timed_channel.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.known_abilities = combat.known_abilities.clone();
self.hotbar = combat.hotbar.clone();
self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
self.keychain_stacks = combat.keychain.clone();
self.whisper_pouch_stacks = combat.whisper_pouch.clone();
self.combat_target_detail = combat.target.clone();
self.statuses = combat.statuses.clone();
self.combat_target = combat.target_entity_id;
if combat.progression_xp_base > 0.0 {
self.progression_curve = Some(flatland_protocol::ProgressionCurve {
baseline_display: combat.progression_baseline,
xp_base: combat.progression_xp_base,
xp_growth: combat.progression_xp_growth,
});
}
if let Some(xp) = &combat.progression_xp {
if let Some(player) = &mut self.player {
player.progression_xp = Some(xp.clone());
if let Some(attrs) = combat.attributes {
player.attributes = Some(attrs);
}
if let Some(skills) = &combat.skills {
player.skills = Some(skills.clone());
}
}
}
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 hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
if !(1..=9).contains(&slot_1_to_9) {
return None;
}
self.hotbar
.get((slot_1_to_9 - 1) as usize)
.and_then(|a| a.as_deref())
.filter(|id| !id.is_empty())
}
pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
let binding = self.hotbar_ability(slot_1_to_9)?;
if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
let name = self
.inventory_hints
.get(template_id)
.map(|h| h.display_name.as_str())
.unwrap_or(template_id);
let qty = self.inventory.get(template_id).copied().unwrap_or(0);
Some(format!("{name}×{qty}"))
} else {
Some(binding.to_string())
}
}
pub fn loadout_ability_choices(&self) -> Vec<String> {
let mut out = self.known_abilities.clone();
let weapon = self.weapon_ability_id.trim();
if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
out.push(weapon.to_string());
}
out
}
pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
let mut out = Vec::new();
for ability in self.loadout_ability_choices() {
let meta = if ability == self.weapon_ability_id {
Some("weapon".into())
} else {
None
};
out.push(LoadoutHotbarChoice {
binding: ability.clone(),
label: ability,
meta,
});
}
let mut consumables: Vec<(String, String, u32)> = Vec::new();
for stack in &self.inventory_stacks {
if Self::stack_is_item_grant(stack) {
continue;
}
if self.inventory_item_category(&stack.template_id) != Some("consumable") {
continue;
}
let qty = stack.quantity.max(1);
if let Some((_, _, existing)) = consumables
.iter_mut()
.find(|(id, _, _)| id == &stack.template_id)
{
*existing = existing.saturating_add(qty);
} else {
let label = stack
.display_name
.clone()
.or_else(|| {
self.inventory_hints
.get(&stack.template_id)
.map(|h| h.display_name.clone())
})
.unwrap_or_else(|| stack.template_id.clone());
consumables.push((stack.template_id.clone(), label, qty));
}
}
consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
for (template_id, label, qty) in consumables {
out.push(LoadoutHotbarChoice {
binding: flatland_protocol::hotbar_consumable_binding(&template_id),
label: format!("{label} ×{qty}"),
meta: Some("use".into()),
});
}
out
}
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 fn pick_combat_target_at(
&self,
wx: f32,
wy: f32,
slot_index: u8,
radius_m: f32,
) -> Option<(EntityId, String)> {
let mut best: Option<(f32, EntityId, String)> = None;
for (id, label) in self.candidates_for_slot(slot_index) {
let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
let d = distance(wx, wy, npc.x, npc.y);
if d <= radius_m {
best = match best {
Some((bd, _, _)) if bd <= d => best,
_ => Some((d, id, label)),
};
}
}
continue;
};
let d = distance(
wx,
wy,
entity.transform.position.x,
entity.transform.position.y,
);
if d <= radius_m {
best = match best {
Some((bd, _, _)) if bd <= d => best,
_ => Some((d, id, label)),
};
}
}
best.map(|(_, id, label)| (id, label))
}
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() {
self.ground_drops = delta.ground_drops.clone();
self.combat_fx = delta.combat_fx.clone();
self.property_plots = delta.property_plots.clone();
self.apply_terrain_overlays(&delta.terrain_overlays);
if let Some(combat) = &delta.combat {
self.apply_combat_hud(combat);
let stacks = self.inventory_stacks.clone();
self.sync_inventory_from_stacks(&stacks);
}
self.refresh_whisper_range();
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();
}
self.sync_interior_map_context();
if !delta.resource_nodes.is_empty() {
self.resource_nodes = delta.resource_nodes.clone();
} else if delta.interior_map.is_some()
|| self.effective_inside_building().is_some()
{
self.resource_nodes = delta.resource_nodes.clone();
}
self.ground_drops = delta.ground_drops.clone();
if self
.player
.as_ref()
.is_none_or(|p| p.inside_building.is_none())
{
self.placed_containers = delta.placed_containers.clone();
}
if !delta.doors.is_empty() {
self.doors = delta.doors.clone();
}
if self.effective_inside_building().is_some() {
if let Some(map) = &delta.interior_map {
self.interior_map = Some(map.clone());
}
} else {
self.interior_map = None;
}
self.sync_interior_z_bands();
self.npcs = delta.npcs.clone();
if !delta.quest_log.is_empty() {
self.quest_log = delta.quest_log.clone();
}
self.apply_hired_workers(delta.hired_workers.clone());
if !delta.interactables.is_empty() {
self.interactables = delta.interactables.clone();
}
if delta.ledger.is_some() {
self.ledger = delta.ledger.clone();
}
if delta.career.is_some() {
self.career = delta.career.clone();
}
self.combat_fx = delta.combat_fx.clone();
if !delta.property_plots.is_empty() {
self.property_plots = delta.property_plots.clone();
}
self.apply_terrain_overlays(&delta.terrain_overlays);
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();
}
self.refresh_whisper_range();
}
fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
self.terrain_zones
.retain(|z| !z.id.starts_with("rt:"));
self.terrain_zones.extend(overlays.iter().cloned());
}
fn refresh_whisper_range(&mut self) {
let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
return;
};
let (px, py) = self.player_position();
let in_range = self.entities.iter().any(|e| {
e.id == peer
&& distance(
px,
py,
e.transform.position.x,
e.transform.position.y,
) <= INTERACTION_RADIUS_M
});
if !in_range {
self.social_chat.cancel_whisper_out_of_range();
}
}
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 active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
self.quest_log
.iter()
.filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
.collect()
}
pub fn has_worker_lodging(&self) -> bool {
self.free_worker_lodging_slots() > 0
}
pub fn free_worker_lodging_slots(&self) -> i64 {
let slots: u32 = self
.placed_containers
.iter()
.filter(|c| match (self.character_id, c.owner_character_id) {
(Some(me), Some(owner)) => me == owner,
(Some(_), None) => false,
(None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
})
.map(|c| c.worker_lodging_capacity.unwrap_or(0))
.sum();
let used = self.hired_workers.len() as u32;
slots as i64 - used as i64
}
pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
let mut names: Vec<String> = self
.hired_workers
.iter()
.filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
.map(|w| w.label.clone())
.collect();
names.sort();
names
}
pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
let is_lodging = self
.placed_containers
.iter()
.find(|c| c.id == container_id)
.is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
if !is_lodging {
return None;
}
let names = self.lodging_occupant_labels(container_id);
Some(if names.is_empty() {
"vacant".into()
} else {
names.join(", ")
})
}
pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
self.quest_log
.iter()
.find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
.or_else(|| {
self.quest_log
.iter()
.find(|q| q.status == flatland_protocol::QuestStatusView::Active)
})
}
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 {
Player,
Npc,
HiredWorker,
QuestBoard,
ExitDoor,
EnterDoor,
Well,
Water,
}
fn kind_priority(kind: Kind) -> u8 {
match kind {
Kind::Player => 0,
Kind::Npc => 0,
Kind::HiredWorker => 0,
Kind::QuestBoard => 1,
Kind::ExitDoor => 2,
Kind::EnterDoor => 3,
Kind::Well => 4,
Kind::Water => 5,
}
}
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 worker in &self.hired_workers {
consider(
distance(px, py, worker.x, worker.y),
INTERACTION_RADIUS_M,
Kind::HiredWorker,
worker.instance_id.clone(),
);
}
for entity in &self.entities {
if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
{
continue;
}
if self
.hired_workers
.iter()
.any(|w| w.entity_id == entity.id)
{
continue;
}
consider(
distance(
px,
py,
entity.transform.position.x,
entity.transform.position.y,
),
INTERACTION_RADIUS_M,
Kind::Player,
entity.id.to_string(),
);
}
for door in &self.doors {
if let Some(ref bid) = inside {
if door.building_id != *bid {
continue;
}
let is_exit = door.portal.is_some();
let max = if is_exit {
INTERACTION_RADIUS_M
} else {
DOOR_INTERACTION_RADIUS_M
};
let kind = if is_exit {
Kind::ExitDoor
} else {
Kind::EnterDoor
};
consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
continue;
}
consider(
distance(px, py, door.x, door.y),
DOOR_INTERACTION_RADIUS_M,
Kind::EnterDoor,
door.id.clone(),
);
}
if inside.is_none() {
for inter in &self.interactables {
if inter.kind == "quest_board" {
consider(
distance(px, py, inter.x, inter.y),
QUEST_BOARD_INTERACTION_RADIUS_M,
Kind::QuestBoard,
inter.id.clone(),
);
}
}
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 nearest_quest_board(&self) -> Option<(String, f32)> {
if self.effective_inside_building().is_some() {
return None;
}
let (px, py) = self.player_position();
self.interactables
.iter()
.filter(|i| i.kind == "quest_board")
.map(|i| {
let label = if i.label.is_empty() {
"Quest board".to_string()
} else {
i.label.clone()
};
(label, distance(px, py, i.x, i.y))
})
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
}
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 route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
use crate::worker_route_editor::{
node_candidates, node_candidates_stable, route_editor_lodging_anchor,
};
let lodging = self
.worker_route_editor
.as_ref()
.and_then(|ed| ed.lodging_container_id.as_deref());
match route_editor_lodging_anchor(lodging, &self.placed_containers) {
Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
None => node_candidates_stable(&self.resource_nodes),
}
}
pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
if dist_m.is_nan() {
return "—".into();
}
let from_bed = self
.worker_route_editor
.as_ref()
.and_then(|ed| ed.lodging_container_id.as_deref())
.and_then(|id| {
self.placed_containers
.iter()
.find(|c| c.id == id)
.map(|c| c.display_name.clone())
});
match from_bed {
Some(bed) => format!("{dist_m:.0}m from {bed}"),
None => format!("{dist_m:.0}m"),
}
}
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 keychain_entries(&self) -> Vec<KeychainEntry> {
let mut out = Vec::new();
for stack in &self.inventory_stacks {
if stack.template_id == KEY_TEMPLATE {
out.push(KeychainEntry {
stack: stack.clone(),
stowed: false,
});
}
}
for stack in &self.keychain_stacks {
if stack.template_id == KEY_TEMPLATE {
out.push(KeychainEntry {
stack: stack.clone(),
stowed: true,
});
}
}
out
}
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))
}
pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
stack.template_id == PROPERTY_DEED_TEMPLATE
}
pub fn is_property_deed_template(template_id: &str) -> bool {
template_id == PROPERTY_DEED_TEMPLATE
}
pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
stack
.props
.get("plot_id")
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
let (px, py) = self.player_position();
let (cx, cy) = self.farm_plot_cell_under_player()?;
let tx = cx as f32 + 0.5;
let ty = cy as f32 + 0.5;
if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
return None;
}
let kind = self
.terrain_at(tx, ty)
.or_else(|| self.terrain_at(px, py));
if kind == Some(TerrainKindView::Tilled) {
return None;
}
if matches!(
kind,
Some(TerrainKindView::ShallowWater)
| Some(TerrainKindView::DeepWater)
| Some(TerrainKindView::Rock)
) {
return None;
}
Some((tx, ty))
}
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 property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
self.property_zones
.iter()
.enumerate()
.filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
.max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
.map(|(_, z)| z)
}
pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
self.tax_zones
.iter()
.enumerate()
.filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
.max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
.map(|(_, z)| z)
}
pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
let mut max_bps = 0u32;
let mut y = y0 + 0.5;
while y < y1 {
let mut x = x0 + 0.5;
while x < x1 {
if let Some(tz) = self.tax_zone_at(x, y) {
max_bps = max_bps.max(tz.rate_bps);
}
x += 1.0;
}
y += 1.0;
}
max_bps
}
pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
let mode = self.claim_mode.as_ref()?;
let w = mode.width_m.max(1) as f32;
let h = mode.height_m.max(1) as f32;
Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
}
pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
let mode = self.relocate_mode.as_ref()?;
let x0 = mode.cursor_x.floor();
let y0 = mode.cursor_y.floor();
Some((x0, y0, x0 + 1.0, y0 + 1.0))
}
pub fn claim_quote(
&self,
) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
let mode = self.claim_mode.as_ref()?;
let zone = self
.property_zones
.iter()
.find(|z| z.id == mode.zone_id)?;
let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
let zone_area = zone_view_area_m2(zone).max(1.0);
let area_frac = (area / zone_area).clamp(0.0, 1.0);
let weight = self
.property_plot_settings
.as_ref()
.map(|s| s.tax_premium_weight)
.unwrap_or(0.5)
.max(0.0);
let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
let purchase = ((zone.crown_price_copper as f64)
* (area_frac as f64)
* (premium as f64))
.ceil()
.max(0.0) as u64;
let upkeep = if zone.upkeep_copper_per_day == 0 {
0
} else {
((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
.ceil()
.max(1.0) as u64
};
let copper = crate::currency::copper_from_counts(&self.inventory);
let can_afford = copper >= purchase;
let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
Some((purchase, upkeep, area, premium, can_afford, valid, reason))
}
fn validate_claim_footprint(
&self,
zone: &flatland_protocol::PropertyZoneView,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
area: f32,
) -> (bool, String) {
let min_area = self
.property_plot_settings
.as_ref()
.map(|s| s.min_plot_area_m2)
.unwrap_or(4.0);
if area + f32::EPSILON < min_area {
return (false, "plot too small".into());
}
if zone.max_area_m2.is_some_and(|m| area > m) {
return (false, "plot exceeds max area".into());
}
if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
return (false, "plot must lie inside the property zone".into());
}
if self.property_plots.iter().any(|p| {
rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
}) {
return (false, "plot overlaps an existing claim".into());
}
(true, String::new())
}
pub fn free_property_zone_under_player(
&self,
) -> Option<&flatland_protocol::PropertyZoneView> {
let (px, py) = self.player_position();
let zone = self.property_zone_at(px, py)?;
if self
.property_plots
.iter()
.any(|p| point_in_plot(px, py, p))
{
return None;
}
Some(zone)
}
pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
let (px, py) = self.player_position();
self.property_plots
.iter()
.find(|p| p.is_mine && point_in_plot(px, py, p))
}
pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
let (px, py) = self.player_position();
self.property_plots
.iter()
.find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
}
pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
if self.farmable_plot_under_player().is_none() {
return None;
}
let (px, py) = self.player_position();
Some((px.floor() as i32, py.floor() as i32))
}
fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
self.resource_nodes.iter().any(|n| {
let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
ncx == cx && ncy == cy
|| ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
})
}
fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
|| self
.terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
.is_some_and(|z| z.kind == TerrainKindView::Tilled);
if !tilled {
return false;
}
!self.resource_node_occupies_farm_cell(cx, cy)
}
pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
return false;
};
self.free_tilled_plant_slot_at(cx, cy)
}
pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
let (px, py) = self.player_position();
for dy in -2..=2 {
for dx in -2..=2 {
let cx = px.floor() as i32 + dx;
let cy = py.floor() as i32 + dy;
let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
continue;
}
if self.free_tilled_plant_slot_at(cx, cy) {
return true;
}
}
}
false
}
fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
stack.quantity > 0
&& (stack.props.contains_key("seed_for")
|| stack.template_id.ends_with("_seed")
|| stack.template_id == "potato_seed"
|| stack.template_id == "carrot_seed")
}
pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
fn walk(
stacks: &[flatland_protocol::ItemStack],
counts: &mut std::collections::HashMap<String, u32>,
) {
for s in stacks {
if GameState::stack_is_farm_seed(s) {
*counts.entry(s.template_id.clone()).or_default() += s.quantity;
}
walk(&s.contents, counts);
}
}
walk(&self.inventory_stacks, &mut counts);
for worn in self.worn.values() {
walk(std::slice::from_ref(worn), &mut counts);
}
let mut out: Vec<_> = counts
.into_iter()
.map(|(template_id, quantity)| {
let label = self
.inventory_hints
.get(&template_id)
.map(|h| h.display_name.clone())
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| humanize_template_id(&template_id));
(template_id, quantity, label)
})
.collect();
out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
out
}
pub fn first_farm_seed_template(&self) -> Option<String> {
self.farm_seed_entries()
.into_iter()
.next()
.map(|(id, _, _)| id)
}
pub fn clamp_plant_menu(&mut self) {
let n = self.farm_seed_entries().len();
if n == 0 {
self.plant_menu_index = 0;
self.plant_quantity = 1;
return;
}
self.plant_menu_index = self.plant_menu_index.min(n - 1);
let max_qty = self
.farm_seed_entries()
.get(self.plant_menu_index)
.map(|(_, q, _)| *q)
.unwrap_or(1)
.max(1);
self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
}
pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
let entries = self.farm_seed_entries();
let (id, max, label) = entries.get(self.plant_menu_index)?;
let qty = self.plant_quantity.min(*max).max(1);
Some((id.clone(), qty, label.clone()))
}
pub fn location_context_lines(&self) -> Vec<ContextLine> {
let (px, py) = self.player_position();
let inside = self.effective_inside_building();
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(id) = inside.as_ref() {
if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
lines.push(ContextLine {
on_top: true,
text: format!("Inside: {}", b.label),
});
}
}
let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
for node in &self.resource_nodes {
if node.id.starts_with("preview:") {
continue;
}
let dist = distance(px, py, node.x, node.y);
if dist > NEARBY_SCAN_M {
continue;
}
let on_top = dist <= ON_TOP_RADIUS_M;
let prefix = if on_top { "On" } else { "Near" };
let name = resource_node_near_display_label(&node.label);
let action = resource_node_near_action_suffix(node);
nearby.push((
dist,
ContextLine {
on_top,
text: format!("{prefix}: {name} ({dist:.1}m){action}"),
},
));
}
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) — f 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) — f pickup"),
},
));
}
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 inside.is_some() && door.portal.is_some() {
"exit"
} else {
"enter"
};
nearby.push((
dist,
ContextLine {
on_top: dist <= ON_TOP_RADIUS_M,
text: format!("{building} door ({dist:.1}m) — f {action}"),
},
));
}
if inside.is_none() {
for inter in &self.interactables {
if inter.kind != "quest_board" {
continue;
}
let dist = distance(px, py, inter.x, inter.y);
if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
continue;
}
let on_top = dist <= ON_TOP_RADIUS_M;
let prefix = if on_top { "On" } else { "Near" };
let label = if inter.label.is_empty() {
"Quest board".to_string()
} else {
inter.label.clone()
};
nearby.push((
dist,
ContextLine {
on_top,
text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
},
));
}
}
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");
}
}
if self.claim_mode.is_some() {
nearby.push((
0.0,
ContextLine {
on_top: true,
text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
.into(),
},
));
} else if let Some(plot) = self.my_plot_under_player() {
let zone = plot
.zone_label
.as_deref()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
self.property_zones
.iter()
.find(|z| z.id == plot.property_zone_id)
.and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
})
.unwrap_or(plot.property_zone_id.as_str());
let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
format!("Your plot ({zone}) — f again to sell to crown")
} else {
format!(
"Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
)
};
nearby.push((
0.0,
ContextLine {
on_top: true,
text: prompt,
},
));
} else if let Some(plot) = self.farmable_plot_under_player() {
let owner = plot
.owner_label
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or("owner");
let disc = if plot.farm_public {
plot.public_tax_discount_bps / 100
} else {
plot.farm_allow
.iter()
.find(|g| Some(g.character_id) == self.character_id)
.map(|g| g.tax_discount_bps / 100)
.unwrap_or(0)
};
nearby.push((
0.0,
ContextLine {
on_top: true,
text: format!(
"Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
),
},
));
} else if let Some(zone) = self.free_property_zone_under_player() {
let label = zone
.label
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(zone.id.as_str());
nearby.push((
0.0,
ContextLine {
on_top: true,
text: format!("Claimable land: {label} — k buy plot"),
},
));
}
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;
pub fn resource_node_near_display_label(label: &str) -> String {
label
.strip_suffix(" (growing)")
.unwrap_or(label)
.to_string()
}
pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
use flatland_protocol::ResourceNodeState;
if let Some(p) = node.growth_progress {
if p < 1.0 - f32::EPSILON {
let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
return format!(" (growing, {pct}%)");
}
return " — f harvest".to_string();
}
match node.state {
ResourceNodeState::Available => " — f harvest".to_string(),
ResourceNodeState::Harvesting => " (being harvested)".to_string(),
ResourceNodeState::Cooldown => " (depleted)".to_string(),
}
}
fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
use flatland_protocol::TerrainKindView;
match kind {
TerrainKindView::Grass => "Grass",
TerrainKindView::Dirt => "Dirt",
TerrainKindView::Tilled => "Tilled",
TerrainKindView::Desert => "Desert",
TerrainKindView::Hill => "Hills",
TerrainKindView::Bog => "Bog",
TerrainKindView::Beach => "Beach",
TerrainKindView::ShallowWater => "Shallow water",
TerrainKindView::DeepWater => "Deep water",
TerrainKindView::Trail => "Trail",
TerrainKindView::Road => "Road",
TerrainKindView::Rock => "Rock",
}
}
fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
crate::world_zones::zone_rects_contain(rects, x, y)
}
fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
zone.rects
.iter()
.map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
.sum()
}
fn claim_rect_fully_inside_zone(
zone: &flatland_protocol::PropertyZoneView,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
) -> bool {
let mut y = y0 + 0.5;
while y < y1 {
let mut x = x0 + 0.5;
while x < x1 {
if !zone_rects_contain(&zone.rects, x, y) {
return false;
}
x += 1.0;
}
y += 1.0;
}
true
}
fn rects_overlap_half_open(
ax0: f32,
ay0: f32,
ax1: f32,
ay1: f32,
bx0: f32,
by0: f32,
bx1: f32,
by1: f32,
) -> bool {
ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
}
fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
}
fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
p.zone_label
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
}
fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
let a = x0.min(x1).floor();
let b = y0.min(y1).floor();
let mut c = x0.max(x1).ceil();
let mut d = y0.max(y1).ceil();
if (c - a) < 1.0 {
c = a + 1.0;
}
if (d - b) < 1.0 {
d = b + 1.0;
}
(a, b, c, d)
}
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(" ")
}
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();
let mut client = 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,
publish_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(),
interior_map: None,
npcs: Vec::new(),
blueprints: Vec::new(),
world_x0: 0.0,
world_y0: 0.0,
world_width_m: 0.0,
world_height_m: 0.0,
terrain_zones: Vec::new(),
z_platforms: Vec::new(),
z_transitions: 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,
hud_log_hidden: false,
show_equip_menu: false,
equip_menu_index: 0,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
bank_panel: None,
bank_menu_index: 0,
bank_ui_mode: BankUiMode::Menu,
storage_panel: None,
market_panel: None,
market_menu_index: 0,
market_filter: String::new(),
market_filter_focused: false,
market_category_filter: None,
market_buy_confirm: None,
market_ui_mode: MarketUiMode::Browse,
storage_menu_index: 0,
storage_ui_mode: StorageUiMode::Menu,
shop_tab: ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
shop_trade_log: VecDeque::new(),
show_npc_verb_menu: false,
npc_verb_target: None,
npc_verb_index: 0,
player_verbs: crate::social::PlayerVerbState::default(),
social_chat: crate::social::SocialChatState::default(),
trade_ui: crate::social::TradeUiState::default(),
whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
show_npc_chat: false,
npc_chat: None,
show_inventory_menu: false,
inventory_menu_index: 0,
inventory_tab: InventoryTab::OnPerson,
inventory_filter: String::new(),
inventory_filter_focused: false,
show_move_picker: false,
show_rename_prompt: false,
show_worker_rename: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_grant_picker: false,
grant_picker_index: 0,
grant_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
combat_fx: Vec::new(),
property_zones: Vec::new(),
tax_zones: Vec::new(),
growth_zones: Vec::new(),
biome_zones: Vec::new(),
property_plots: Vec::new(),
property_plot_settings: None,
claim_mode: None,
relocate_mode: None,
sell_plot_confirm: None,
sell_plot_armed_at: None,
show_plant_menu: false,
plant_menu_index: 0,
show_farm_access: false,
farm_access_name_draft: String::new(),
farm_access_discount_bps: 0,
farm_access_index: 0,
plant_quantity: 1,
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,
offhand_template_id: None,
offhand_label: None,
mainhand_hand_slots: 1,
defense: None,
worn: BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
keychain_stacks: Vec::new(),
whisper_pouch_stacks: Vec::new(),
combat_target_detail: None,
statuses: Vec::new(),
cast_progress: None,
timed_channel: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
known_abilities: Vec::new(),
hotbar: vec![None; 9],
max_abilities_per_rotation: 0,
show_loadout_menu: false,
show_keychain_menu: false,
keychain_menu_index: 0,
show_rotation_editor: false,
loadout_menu_index: 0,
loadout_hotbar_slot: 1,
loadout_ability_index: 0,
loadout_focus_presets: false,
rotation_editor: RotationEditorState::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
pending_worker_job_ack: None,
attending_worker_instance_id: None,
quest_log: Vec::new(),
interactables: Vec::new(),
ledger: None,
career: None,
character_sheet_tab: CharacterSheetTab::Character,
ledger_period: LedgerPeriod::Day,
show_quest_offer: false,
pending_quest_offer: None,
show_quest_menu: false,
quest_menu_index: 0,
quest_withdraw_confirm: false,
hired_workers: Vec::new(),
show_workers_menu: false,
workers_menu_index: 0,
workers_menu_compact: false,
worker_step_display: BTreeMap::new(),
worker_error_display: BTreeMap::new(),
show_worker_give_picker: false,
worker_give_picker_index: 0,
worker_give_picker: None,
show_worker_give_target_picker: false,
worker_give_target_picker_index: 0,
worker_give_target_picker: None,
show_worker_take_picker: false,
worker_take_picker_index: 0,
worker_take_picker: None,
show_worker_teach_picker: false,
worker_teach_picker_index: 0,
worker_teach_picker: None,
worker_route_editor: None,
progression_curve: None,
},
};
client.state.apply_client_ui_prefs();
client
}
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.apply_client_ui_prefs();
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}…"));
}
}
}
if self
.state
.pending_worker_job_ack
.as_ref()
.is_some_and(|p| p.seq == seq)
{
let pending = self.state.pending_worker_job_ack.take().unwrap();
if pending.idle {
self.state.push_log(format!(
"Route cleared for {} — worker idle",
pending.worker_label
));
} else {
self.state.push_log(format!(
"Route saved for {} — {} stop(s), job loop active",
pending.worker_label, pending.stop_count
));
}
if self
.state
.worker_route_editor
.as_ref()
.is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
{
self.close_worker_route_editor();
}
}
}
SessionEvent::Chat(msg) => {
let label = match msg.channel {
flatland_protocol::ChatChannel::Nearby => "nearby",
flatland_protocol::ChatChannel::Direct => "speak",
flatland_protocol::ChatChannel::Whisper => "whisper",
flatland_protocol::ChatChannel::WhisperStone => "stone",
};
let clarity = match msg.clarity {
flatland_protocol::ChatClarity::Clear => "",
flatland_protocol::ChatClarity::Partial => "~",
flatland_protocol::ChatClarity::Heavy => "…",
};
self.state.push_log(format!(
"[{label}{clarity}] {}: {}",
msg.from_name, msg.text
));
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.state
.social_chat
.note_speech(&msg, self.state.entity_id, now_ms);
self.state
.social_chat
.push(crate::social::ChatLogEntry::from_message(
msg,
self.state.entity_id,
));
}
SessionEvent::TradeOpened(panel) => {
self.state.social_chat.pending_trade = None;
let peer = panel.peer_name.clone();
self.state.trade_ui.open(panel);
self.state
.social_chat
.push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
self.state
.social_chat
.push_cue(crate::social::AudioCue::TradeOpened);
}
SessionEvent::TradeClosed { reason } => {
self.state.push_log(reason.clone());
self.state.social_chat.push_system(reason);
self.state.trade_ui.close();
}
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"
);
let msg = if result.quantity == 0 {
format!(
"Harvested {} x0 — nothing dropped (loot table rolled empty)",
result.item_template
)
} else {
format!(
"Harvested {} x{} (on the ground — press P to pick up)",
result.item_template, result.quantity
)
};
self.state.push_log(msg);
}
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 let Some(pending) = self.state.pending_worker_job_ack.take() {
if let Some(w) = self
.state
.hired_workers
.iter_mut()
.find(|w| w.instance_id == pending.worker_instance_id)
{
w.route = pending.prev_route;
w.mode = pending.prev_mode;
w.step_label = pending.prev_step_label;
w.last_error = pending.prev_last_error;
}
let reason = notice
.message
.strip_prefix("Can't do that:")
.unwrap_or(¬ice.message)
.trim();
self.state.push_log(format!(
"Route save failed for {}: {reason}",
pending.worker_label
));
}
let reason = notice
.message
.strip_prefix("Can't do that:")
.unwrap_or(¬ice.message)
.trim();
if reason.contains("already tilled") {
if let Some(plot) = self.state.my_plot_under_player() {
self.state.sell_plot_confirm = Some(plot.plot_id);
self.state.sell_plot_armed_at = Some(Instant::now());
}
}
}
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;
}
if notice.message.contains("wants to trade") {
if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
let from_name = notice
.message
.split(" wants to trade")
.next()
.unwrap_or("Player")
.to_string();
self.state.social_chat.pending_trade =
Some(crate::social::PendingTradeRequest {
from_entity,
from_name: from_name.clone(),
});
self.state.social_chat.push_system(format!(
"{from_name} wants to trade — [Y] accept · [N] decline"
));
self.state
.social_chat
.push_cue(crate::social::AudioCue::TradeOffer);
}
}
if notice.message.starts_with("trade request declined") {
self.state
.social_chat
.push_system(notice.message.clone());
self.state
.social_chat
.push_cue(crate::social::AudioCue::TradeDeclined);
}
self.state.apply_interaction_notice(¬ice);
self.state.push_log(notice.message.clone());
}
SessionEvent::ShopOpened(catalog) => {
self.state.apply_shop_catalog(catalog);
}
SessionEvent::BankOpened(panel) => {
self.state.apply_bank_panel(panel);
}
SessionEvent::StorageOpened(panel) => {
self.state.apply_storage_panel(panel);
}
SessionEvent::MarketOpened(panel) => {
self.state.apply_market_panel(panel);
}
SessionEvent::NpcTalkOpened(opened) => {
self.state.show_npc_verb_menu = false;
if self.state.npc_verb_target.is_none() {
self.state.npc_verb_target = Some(opened.npc_id.clone());
}
let label = opened.npc_label.clone();
let banner = if !opened.trade_allowed {
Some("Trade is unavailable right now.".to_string())
} else {
None
};
self.state.show_npc_chat = true;
self.state.npc_chat = Some(NpcChatState {
npc_id: opened.npc_id,
npc_label: opened.npc_label,
lines: if opened.greeting.is_empty() {
vec![]
} else {
vec![format!("{label}: {}", opened.greeting)]
},
input: String::new(),
pending: opened.greeting.is_empty(),
talk_depth: opened.talk_depth,
trade_allowed: opened.trade_allowed,
banner,
});
}
SessionEvent::NpcTalkPending(_) => {
if let Some(chat) = self.state.npc_chat.as_mut() {
chat.pending = true;
}
}
SessionEvent::NpcTalkReply(reply) => {
if let Some(chat) = self.state.npc_chat.as_mut() {
if chat.npc_id == reply.npc_id {
chat.pending = false;
if reply.trade_disabled {
chat.trade_allowed = false;
chat.banner = Some("Trade is unavailable right now.".to_string());
}
if reply.wind_down {
chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
if chat.banner.is_none() {
chat.banner =
Some("They're wrapping up — keep it brief.".to_string());
}
}
chat.lines
.push(format!("{}: {}", chat.npc_label, reply.line));
}
}
}
SessionEvent::NpcTalkClosed(closed) => {
if self
.state
.npc_chat
.as_ref()
.is_some_and(|c| c.npc_id == closed.npc_id)
{
self.state.show_npc_chat = false;
self.state.npc_chat = None;
}
}
SessionEvent::NpcTalkError(err) => {
self.state.push_log(format!("Talk failed: {}", err.reason));
if let Some(chat) = self.state.npc_chat.as_mut() {
chat.pending = false;
}
}
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);
}
}
}
SessionEvent::QuestOffer(offer) => {
self.state.pending_quest_offer = Some(offer.clone());
self.state.show_quest_offer = true;
self.state
.push_log(format!("Quest offered: {}", offer.title));
}
SessionEvent::QuestAccepted(notice) => {
self.state.show_quest_offer = false;
self.state.pending_quest_offer = None;
self.state.push_log(notice.message);
}
SessionEvent::QuestWithdrawn(notice) => {
self.state.show_quest_menu = false;
self.state.quest_withdraw_confirm = false;
self.state.push_log(notice.message);
}
SessionEvent::QuestStepCompleted(notice) => {
self.state.push_log(notice.message);
}
SessionEvent::QuestCompleted(notice) => {
self.state.push_log(notice.message);
}
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_npc_verb_menu = false;
self.state.npc_verb_target = None;
self.state.show_npc_chat = false;
self.state.npc_chat = 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.show_worker_rename = 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;
self.state.show_quest_offer = false;
self.state.pending_quest_offer = None;
self.state.show_quest_menu = false;
self.state.quest_withdraw_confirm = false;
self.state.show_workers_menu = false;
self.close_worker_give_picker();
self.close_worker_give_target_picker();
self.close_worker_take_picker();
self.close_worker_teach_picker();
self.state.worker_route_editor = None;
self.state.claim_mode = None;
self.state.relocate_mode = None;
self.state.sell_plot_confirm = None;
self.state.sell_plot_armed_at = None;
self.close_farm_access_panel();
if self.state.show_plant_menu {
self.close_plant_menu();
}
}
pub fn back_on_esc(&mut self) -> bool {
if self.state.social_chat.composer_open() {
self.state.social_chat.close_composer();
return true;
}
if self.state.player_verbs.open {
self.state.player_verbs.close();
return true;
}
if self.state.whisper_pouch_ui.open {
self.state.whisper_pouch_ui.open = false;
return true;
}
if self.state.trade_ui.panel.is_some() {
self.state.trade_ui.close();
return true;
}
if self.state.show_rename_prompt {
self.cancel_rename_prompt();
return true;
}
if self.state.show_worker_rename {
self.cancel_worker_rename();
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.claim_mode.is_some() {
self.cancel_claim_mode();
return true;
}
if self.state.relocate_mode.is_some() {
self.cancel_relocate_mode();
return true;
}
if self.state.show_plant_menu {
self.close_plant_menu();
return true;
}
if self.state.show_farm_access {
self.close_farm_access_panel();
return true;
}
if self.state.sell_plot_confirm.is_some() {
self.state.sell_plot_confirm = None;
self.state.sell_plot_armed_at = None;
self.state.push_log("Sell cancelled");
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_keychain_menu {
self.close_keychain_menu();
return true;
}
if self.state.show_quest_offer {
self.quest_offer_decline();
return true;
}
if self.state.show_shop_menu {
return false;
}
if self.state.bank_panel.is_some() {
return false;
}
if self.state.storage_panel.is_some() {
return false;
}
if self.state.market_panel.is_some() {
return false;
}
if self.state.show_npc_chat {
return false;
}
if self.state.show_npc_verb_menu {
self.state.show_npc_verb_menu = false;
self.state.npc_verb_target = None;
return true;
}
if self.state.show_quest_menu {
if self.state.quest_withdraw_confirm {
self.state.quest_withdraw_confirm = false;
} else {
self.state.show_quest_menu = false;
}
return true;
}
if self.state.worker_route_editor.is_some() {
if self.re_at_root_sheet() {
let reopen = self.state.attending_worker_instance_id.clone();
self.close_worker_route_editor();
if let Some(id) = reopen {
if let Some(idx) = self
.state
.hired_workers
.iter()
.position(|w| w.instance_id == id)
{
self.state.workers_menu_index = idx;
self.state.show_workers_menu = true;
}
}
} else {
self.re_sheet_back();
}
return true;
}
if self.state.show_worker_give_picker {
self.close_worker_give_picker();
return true;
}
if self.state.show_worker_give_target_picker {
self.close_worker_give_target_picker();
return true;
}
if self.state.show_worker_take_picker {
self.close_worker_take_picker();
return true;
}
if self.state.show_worker_teach_picker {
self.close_worker_teach_picker();
return true;
}
if self.state.show_workers_menu {
self.close_workers_menu_ui();
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;
}
if self.state.show_equip_menu {
self.state.show_equip_menu = 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.character_sheet_tab = CharacterSheetTab::Character;
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_equip_menu = false;
}
}
pub fn toggle_equip_menu(&mut self) {
self.state.show_equip_menu = !self.state.show_equip_menu;
if self.state.show_equip_menu {
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;
}
}
pub fn cycle_character_sheet_tab(&mut self) {
if self.state.show_stats {
self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
}
}
pub fn set_ledger_period_digit(&mut self, c: char) {
if self.state.show_stats {
if let Some(p) = LedgerPeriod::from_digit(c) {
self.state.ledger_period = p;
self.state.character_sheet_tab = CharacterSheetTab::Ledger;
}
}
}
pub fn cycle_ledger_period(&mut self) {
if self.state.show_stats
&& self.state.character_sheet_tab == CharacterSheetTab::Ledger
{
self.state.ledger_period = self.state.ledger_period.cycle();
}
}
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.inventory_filter_focused = false;
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.close_grant_picker();
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.inventory_filter_focused = false;
}
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_worker_rename = false;
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 open_worker_rename(&mut self) -> anyhow::Result<()> {
let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
anyhow::bail!("no worker selected");
};
self.state.rename_buffer = worker.label.clone();
self.state.show_worker_rename = true;
self.state.show_rename_prompt = false;
Ok(())
}
pub fn cancel_worker_rename(&mut self) {
self.state.show_worker_rename = false;
self.state.rename_buffer.clear();
}
pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
let name = self.state.rename_buffer.trim().to_string();
if name.is_empty() {
anyhow::bail!("name cannot be empty");
}
if name.chars().count() > 32 {
anyhow::bail!("name must be 1–32 characters");
}
let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
anyhow::bail!("no worker selected");
};
let worker_instance_id = worker.instance_id.clone();
self.seq += 1;
self.session
.submit_intent(Intent::RenameHiredWorker {
entity_id: self.state.entity_id,
worker_instance_id: worker_instance_id.clone(),
name: name.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
if let Some(w) = self
.state
.hired_workers
.iter_mut()
.find(|w| w.instance_id == worker_instance_id)
{
w.label = name.clone();
}
if let Some(ed) = self.state.worker_route_editor.as_mut() {
if ed.worker_instance_id == worker_instance_id {
ed.worker_label = name.clone();
}
}
self.state.show_worker_rename = false;
self.state.rename_buffer.clear();
self.state.push_log(format!("Renamed worker to \"{name}\""));
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_grant_picker {
let Some(picker) = self.state.grant_picker.as_ref() else {
return;
};
let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
let filter = picker.filter.clone();
let n = labels.len();
if n == 0 {
return;
}
self.state.grant_picker_index = step_filtered_index(
self.state.grant_picker_index,
delta,
n,
|i| list_label_matches(&labels[i], &filter),
);
return;
}
if self.state.show_move_picker {
let Some(picker) = self.state.move_picker.as_ref() else {
return;
};
let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
let filter = picker.filter.clone();
let n = labels.len();
if n == 0 {
return;
}
self.state.move_picker_index = step_filtered_index(
self.state.move_picker_index,
delta,
n,
|i| list_label_matches(&labels[i], &filter),
);
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 fn inventory_menu_page(&mut self, pages: i32) {
if self.state.show_grant_picker {
let Some(picker) = self.state.grant_picker.as_ref() else {
return;
};
let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
let filter = picker.filter.clone();
let n = labels.len();
self.state.grant_picker_index = page_filtered_index(
self.state.grant_picker_index,
pages,
n,
|i| list_label_matches(&labels[i], &filter),
);
return;
}
if self.state.show_move_picker {
let Some(picker) = self.state.move_picker.as_ref() else {
return;
};
let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
let filter = picker.filter.clone();
let n = labels.len();
self.state.move_picker_index = page_filtered_index(
self.state.move_picker_index,
pages,
n,
|i| list_label_matches(&labels[i], &filter),
);
self.state.clamp_move_picker_quantity();
return;
}
let n = self.state.inventory_selectable_rows().len();
self.state.inventory_menu_index =
page_list_index(self.state.inventory_menu_index, pages, n);
}
pub fn cycle_inventory_tab(&mut self, forward: bool) {
if self.state.show_move_picker
|| self.state.show_grant_picker
|| self.state.show_destroy_picker
|| self.state.show_rename_prompt
|| self.state.inventory_filter_focused
{
return;
}
self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
self.state.inventory_menu_index = 0;
self.state.clamp_inventory_indices();
}
pub fn focus_inventory_filter(&mut self) {
if self.state.show_grant_picker {
if let Some(p) = self.state.grant_picker.as_mut() {
p.filter_focused = true;
}
return;
}
if self.state.show_move_picker {
if let Some(p) = self.state.move_picker.as_mut() {
p.filter_focused = true;
}
return;
}
self.state.inventory_filter_focused = true;
}
pub fn set_inventory_filter(&mut self, filter: String) {
self.state.inventory_filter = filter;
self.state.inventory_menu_index = 0;
self.state.clamp_inventory_indices();
}
pub fn append_inventory_filter_char(&mut self, ch: char) {
if ch.is_control() {
return;
}
if self.state.show_grant_picker {
if let Some(p) = self.state.grant_picker.as_mut() {
if p.filter_focused {
p.filter.push(ch);
self.state.grant_picker_index = 0;
}
}
return;
}
if self.state.show_move_picker {
if let Some(p) = self.state.move_picker.as_mut() {
if p.filter_focused {
p.filter.push(ch);
self.state.move_picker_index = 0;
self.state.clamp_move_picker_quantity();
}
}
return;
}
if !self.state.inventory_filter_focused {
return;
}
self.state.inventory_filter.push(ch);
self.state.inventory_menu_index = 0;
self.state.clamp_inventory_indices();
}
pub fn inventory_filter_backspace(&mut self) {
if self.state.show_grant_picker {
if let Some(p) = self.state.grant_picker.as_mut() {
if p.filter_focused {
p.filter.pop();
self.state.grant_picker_index = 0;
}
}
return;
}
if self.state.show_move_picker {
if let Some(p) = self.state.move_picker.as_mut() {
if p.filter_focused {
p.filter.pop();
self.state.move_picker_index = 0;
self.state.clamp_move_picker_quantity();
}
}
return;
}
if !self.state.inventory_filter_focused {
return;
}
self.state.inventory_filter.pop();
self.state.inventory_menu_index = 0;
self.state.clamp_inventory_indices();
}
pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
if self.state.show_grant_picker {
if let Some(p) = self.state.grant_picker.as_mut() {
if p.filter_focused {
if !p.filter.is_empty() {
p.filter.clear();
self.state.grant_picker_index = 0;
} else {
p.filter_focused = false;
}
return true;
}
if !p.filter.is_empty() {
p.filter.clear();
self.state.grant_picker_index = 0;
return true;
}
}
return false;
}
if self.state.show_move_picker {
if let Some(p) = self.state.move_picker.as_mut() {
if p.filter_focused {
if !p.filter.is_empty() {
p.filter.clear();
self.state.move_picker_index = 0;
self.state.clamp_move_picker_quantity();
} else {
p.filter_focused = false;
}
return true;
}
if !p.filter.is_empty() {
p.filter.clear();
self.state.move_picker_index = 0;
self.state.clamp_move_picker_quantity();
return true;
}
}
return false;
}
if self.state.inventory_filter_focused {
if !self.state.inventory_filter.is_empty() {
self.state.inventory_filter.clear();
self.state.inventory_menu_index = 0;
self.state.clamp_inventory_indices();
} else {
self.state.inventory_filter_focused = false;
}
return true;
}
if !self.state.inventory_filter.is_empty() {
self.state.inventory_filter.clear();
self.state.inventory_menu_index = 0;
self.state.clamp_inventory_indices();
return true;
}
false
}
pub fn craft_menu_page(&mut self, pages: i32) {
let n = self.state.blueprints.len();
self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
self.state.clamp_craft_batch_quantity();
}
pub fn shop_menu_page(&mut self, pages: i32) {
let n = self.state.shop_list_len();
self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
self.state.clamp_shop_quantity();
}
pub fn workers_menu_page(&mut self, pages: i32) {
let n = self.state.hired_workers.len();
self.state.workers_menu_index =
page_list_index(self.state.workers_menu_index, pages, n);
}
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_grant_picker {
return self.confirm_grant_picker().await;
}
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 {
return self.open_chest_pickup_picker();
}
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("lodging") && on_person {
if let Some(inst) = instance_id {
return self.place_container(inst).await;
}
}
if (category == Some("container") || category == Some("armor")) && on_person {
if let Some(inst) = instance_id {
let world_placeable = row.stack.world_placeable == Some(true)
|| template_id.contains("chest");
if world_placeable {
return self.place_container(inst).await;
}
if let Some(slot) = guess_body_slot(&template_id) {
return self.equip_worn(slot, Some(inst)).await;
}
}
}
self.open_move_picker()
}
pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if row.from != flatland_protocol::InventoryLocation::Root {
anyhow::bail!("select a consumable on your person");
}
if GameState::stack_is_item_grant(&row.stack) {
return self.open_grant_target_picker();
}
if GameState::is_property_deed_template(&row.stack.template_id) {
return self.open_move_picker();
}
let category = self
.state
.inventory_item_category(&row.stack.template_id);
if category != Some("consumable") {
anyhow::bail!("selected item is not consumable");
}
self.use_item(&row.stack.template_id).await
}
pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if row.from != flatland_protocol::InventoryLocation::Root {
anyhow::bail!("select a grant item on your person");
}
if !GameState::stack_is_item_grant(&row.stack) {
anyhow::bail!("selected item does not grant onto gear");
}
let Some(grant_instance_id) = row.stack.item_instance_id else {
anyhow::bail!("grant has no instance id");
};
let effect_id = GameState::grant_effect_id(&row.stack)
.unwrap_or("?")
.to_string();
let mode = GameState::grant_mode(&row.stack).to_string();
let options = self.state.grant_target_options(&row.stack);
if options.is_empty() {
anyhow::bail!("no valid gear to apply {effect_id} to");
}
let grant_label = row
.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone());
self.state.show_grant_picker = true;
self.state.grant_picker_index = 0;
self.state.grant_picker = Some(GrantTargetPicker {
grant_instance_id,
grant_label,
effect_id,
mode,
options,
filter: String::new(),
filter_focused: false,
});
Ok(())
}
pub fn close_grant_picker(&mut self) {
self.state.show_grant_picker = false;
self.state.grant_picker = None;
self.state.grant_picker_index = 0;
}
pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.grant_picker.clone() else {
self.close_grant_picker();
return Ok(());
};
let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
self.close_grant_picker();
return Ok(());
};
self.close_grant_picker();
self.use_grant(picker.grant_instance_id, opt.target_instance_id)
.await?;
self.state.push_log(format!(
"Applying {} onto {}…",
picker.effect_id, opt.label
));
Ok(())
}
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 {
return self.open_chest_pickup_picker();
}
let Some(instance_id) = row.stack.item_instance_id else {
anyhow::bail!("item has no instance id");
};
let mut options = self.state.move_destinations_for(
&row.from,
row.from_parent_instance_id,
row.stack.item_instance_id,
&row.stack.template_id,
);
let on_person = row.from == flatland_protocol::InventoryLocation::Root;
let category = self.state.inventory_item_category(&row.stack.template_id);
if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
options.insert(
0,
MoveOption {
label: "Sell plot to crown…".into(),
kind: MoveOptionKind::SellPlotToCrown { plot_id },
},
);
}
}
if on_person && category == Some("consumable") {
if GameState::stack_is_item_grant(&row.stack) {
options.insert(
0,
MoveOption {
label: "Apply onto gear…".into(),
kind: MoveOptionKind::GrantApply,
},
);
} else {
options.insert(
0,
MoveOption {
label: "Use (eat / drink)".into(),
kind: MoveOptionKind::Use,
},
);
}
}
let item_label = row
.stack
.display_name
.clone()
.unwrap_or_else(|| row.stack.template_id.clone());
let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
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: initial_qty.max(1),
options,
filter: String::new(),
filter_focused: false,
});
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;
self.state.clamp_move_picker_quantity();
Ok(())
}
pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
let Some(row) = self.state.inventory_selected_row() else {
anyhow::bail!("inventory empty");
};
if !row.is_chest_shell {
anyhow::bail!("not a placed chest");
}
let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
anyhow::bail!("not a placed chest");
};
let Some(instance_id) = row.stack.item_instance_id else {
anyhow::bail!("chest has no instance id");
};
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.locked && !chest.accessible {
anyhow::bail!(
"need the matching key for {} before picking it up",
chest.display_name
);
}
let options = self.state.chest_pickup_destinations(container_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.clone(),
item_label,
template_id: row.stack.template_id.clone(),
stack_quantity: 1,
quantity: 1,
options,
filter: String::new(),
filter_focused: false,
});
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::Use => {
self.close_move_picker();
self.use_item(&picker.template_id).await?;
}
MoveOptionKind::GrantApply => {
self.close_move_picker();
self.open_grant_target_picker()?;
}
MoveOptionKind::SellPlotToCrown { plot_id } => {
self.close_move_picker();
self.confirm_sell_plot_to_crown(plot_id).await?;
}
MoveOptionKind::RelocatePlaced { container_id } => {
self.close_move_picker();
self.state.show_inventory_menu = false;
self.begin_relocate_container(&container_id)?;
}
MoveOptionKind::Drop => {
self.close_move_picker();
if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
if self.state.deed_bound(&stack) {
anyhow::bail!(
"cannot drop a property deed — store it or trade it to another player"
);
}
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::PickupPlaced {
container_id,
nest_location,
nest_parent_instance_id,
} => {
self.close_move_picker();
self.pickup_container(container_id.clone()).await?;
let nest_into_bag = nest_parent_instance_id.is_some()
|| !matches!(
nest_location,
flatland_protocol::InventoryLocation::Root
);
if nest_into_bag {
self.move_item(
picker.item_instance_id,
flatland_protocol::InventoryLocation::Root,
nest_location,
nest_parent_instance_id,
None,
)
.await?;
self.state
.push_log(format!("Picked up {} into bag", picker.item_label));
} else {
self.state
.push_log(format!("Picked up {}", 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.deed_bound(&row.stack) {
anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
}
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.deed_bound(&row.stack) {
anyhow::bail!(
"cannot destroy a property deed — store it or trade it to another player"
);
}
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 equip_offhand(&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::EquipOffhand {
entity_id: self.state.entity_id,
template_id,
instance_id: None,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
self.equip_offhand(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 async fn use_grant(
&mut self,
grant_instance_id: uuid::Uuid,
target_instance_id: uuid::Uuid,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::UseGrant {
entity_id: self.state.entity_id,
grant_instance_id,
target_instance_id,
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 toggle_keychain_menu(&mut self) {
if self.state.show_keychain_menu {
self.close_keychain_menu();
} else {
self.state.show_keychain_menu = true;
self.state.show_craft_menu = false;
self.state.show_shop_menu = false;
self.state.show_inventory_menu = false;
let n = self.state.keychain_entries().len();
if n == 0 {
self.state.keychain_menu_index = 0;
} else {
self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
}
}
}
pub fn close_keychain_menu(&mut self) {
self.state.show_keychain_menu = false;
}
pub fn keychain_menu_move(&mut self, delta: i32) {
let n = self.state.keychain_entries().len();
if n == 0 {
self.state.keychain_menu_index = 0;
return;
}
let idx = self.state.keychain_menu_index as i32 + delta;
self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
}
pub fn keychain_menu_page(&mut self, pages: i32) {
let n = self.state.keychain_entries().len();
self.state.keychain_menu_index =
page_list_index(self.state.keychain_menu_index, pages, n);
}
pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let entries = self.state.keychain_entries();
let Some(entry) = entries.get(self.state.keychain_menu_index) else {
anyhow::bail!("nothing selected");
};
let Some(instance_id) = entry.stack.item_instance_id else {
anyhow::bail!("key has no instance id");
};
if entry.stowed {
self.move_item(
instance_id,
flatland_protocol::InventoryLocation::Keychain,
flatland_protocol::InventoryLocation::Root,
None,
Some(1),
)
.await
} else {
self.move_item(
instance_id,
flatland_protocol::InventoryLocation::Root,
flatland_protocol::InventoryLocation::Keychain,
None,
Some(1),
)
.await
}
}
pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
let npc_id = self
.state
.shop_catalog
.as_ref()
.map(|c| c.npc_id.clone());
self.state.show_shop_menu = false;
self.state.shop_catalog = None;
self.state.clear_shop_trade_log();
if let Some(npc_id) = npc_id {
self.seq += 1;
self.session
.submit_intent(Intent::ShopClose {
entity_id: self.state.entity_id,
npc_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
}
Ok(())
}
pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
let Some(panel) = self.state.bank_panel.clone() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::BankDeposit {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
amount_copper,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
let Some(panel) = self.state.bank_panel.clone() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::BankWithdraw {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
amount_copper,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn bank_transfer(
&mut self,
to_character_id: Option<uuid::Uuid>,
to_name: String,
amount_copper: u64,
) -> anyhow::Result<()> {
let Some(panel) = self.state.bank_panel.clone() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::BankTransfer {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
to_character_id,
to_name,
amount_copper,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn bank_menu_move(&mut self, delta: i32) {
let n = self.state.bank_menu_options().len();
if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
return;
}
let idx = self.state.bank_menu_index as i32;
self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub fn storage_menu_move(&mut self, delta: i32) {
let n = self.state.storage_menu_options().len();
if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
return;
}
let idx = self.state.storage_menu_index as i32;
self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub fn storage_pick_move(&mut self, delta: i32) {
let n = match &self.state.storage_ui_mode {
StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
self.state.storage_vault_options().len()
}
StorageUiMode::Menu
| StorageUiMode::StoreAmount { .. }
| StorageUiMode::TakeAmount { .. }
| StorageUiMode::ShipAmount { .. } => 0,
};
if n == 0 {
return;
}
match &mut self.state.storage_ui_mode {
StorageUiMode::StorePick { index }
| StorageUiMode::TakePick { index }
| StorageUiMode::ShipPick { index, .. } => {
*index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
}
StorageUiMode::Menu
| StorageUiMode::StoreAmount { .. }
| StorageUiMode::TakeAmount { .. }
| StorageUiMode::ShipAmount { .. } => {}
}
}
pub fn storage_ui_back(&mut self) {
self.state.storage_ui_mode = match &self.state.storage_ui_mode {
StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
index: *pick_index,
},
StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
index: *pick_index,
},
StorageUiMode::ShipAmount {
dest_building_id,
dest_label,
pick_index,
..
} => StorageUiMode::ShipPick {
dest_building_id: dest_building_id.clone(),
dest_label: dest_label.clone(),
index: *pick_index,
},
StorageUiMode::StorePick { .. }
| StorageUiMode::TakePick { .. }
| StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
StorageUiMode::Menu => StorageUiMode::Menu,
};
}
pub fn storage_amount_append_char(&mut self, c: char) {
match &mut self.state.storage_ui_mode {
StorageUiMode::StoreAmount { input, .. }
| StorageUiMode::TakeAmount { input, .. }
| StorageUiMode::ShipAmount { input, .. } => {
if c.is_ascii_digit() && input.len() < 8 {
input.push(c);
}
}
_ => {}
}
}
pub fn storage_amount_backspace(&mut self) {
match &mut self.state.storage_ui_mode {
StorageUiMode::StoreAmount { input, .. }
| StorageUiMode::TakeAmount { input, .. }
| StorageUiMode::ShipAmount { input, .. } => {
input.pop();
}
_ => {}
}
}
pub fn storage_ui_typing(&self) -> bool {
matches!(
self.state.storage_ui_mode,
StorageUiMode::StoreAmount { .. }
| StorageUiMode::TakeAmount { .. }
| StorageUiMode::ShipAmount { .. }
)
}
pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
match self.state.storage_ui_mode.clone() {
StorageUiMode::Menu => {
let index = self.state.storage_menu_index;
match index {
0 => {
let opts = self.state.storage_store_options();
if opts.is_empty() {
self.state.push_log("Nothing loose to store.");
return Ok(());
}
self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
}
1 => {
let opts = self.state.storage_vault_options();
if opts.is_empty() {
self.state.push_log("Vault is empty.");
return Ok(());
}
self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
}
n => {
let dest = self
.state
.storage_panel
.as_ref()
.and_then(|p| p.ship_destinations.get(n - 2))
.cloned();
let Some(dest) = dest else {
return Ok(());
};
let opts = self.state.storage_vault_options();
if opts.is_empty() {
self.state
.push_log("Vault is empty — nothing to ship.");
return Ok(());
}
self.state.storage_ui_mode = StorageUiMode::ShipPick {
dest_building_id: dest.building_id,
dest_label: dest.label,
index: 0,
};
}
}
}
StorageUiMode::StorePick { index } => {
let opts = self.state.storage_store_options();
let Some(opt) = opts.get(index) else {
self.state.push_log("Nothing loose to store.");
self.state.storage_ui_mode = StorageUiMode::Menu;
return Ok(());
};
self.state.storage_ui_mode = StorageUiMode::StoreAmount {
pick_index: index,
item_instance_id: opt.item_instance_id,
label: opt.label.clone(),
max_qty: opt.quantity.max(1),
input: String::new(),
};
}
StorageUiMode::TakePick { index } => {
let opts = self.state.storage_vault_options();
let Some(opt) = opts.get(index) else {
self.state.push_log("Vault is empty.");
self.state.storage_ui_mode = StorageUiMode::Menu;
return Ok(());
};
self.state.storage_ui_mode = StorageUiMode::TakeAmount {
pick_index: index,
item_instance_id: opt.item_instance_id,
label: opt.label.clone(),
max_qty: opt.quantity.max(1),
input: String::new(),
};
}
StorageUiMode::ShipPick {
dest_building_id,
dest_label,
index,
} => {
let opts = self.state.storage_vault_options();
let Some(opt) = opts.get(index) else {
self.state
.push_log("Vault is empty — nothing to ship.");
self.state.storage_ui_mode = StorageUiMode::Menu;
return Ok(());
};
self.state.storage_ui_mode = StorageUiMode::ShipAmount {
dest_building_id,
dest_label,
pick_index: index,
item_instance_id: opt.item_instance_id,
label: opt.label.clone(),
max_qty: opt.quantity.max(1),
input: String::new(),
};
}
StorageUiMode::StoreAmount {
item_instance_id,
max_qty,
input,
..
} => {
let Some(qty) = parse_storage_quantity(&input) else {
self.state
.push_log("Enter a quantity (blank or 0 = all).");
return Ok(());
};
let qty = qty.map(|n| n.min(max_qty).max(1));
self.storage_store(item_instance_id, qty).await?;
self.state.storage_ui_mode = StorageUiMode::Menu;
}
StorageUiMode::TakeAmount {
item_instance_id,
max_qty,
input,
..
} => {
let Some(qty) = parse_storage_quantity(&input) else {
self.state
.push_log("Enter a quantity (blank or 0 = all).");
return Ok(());
};
let qty = qty.map(|n| n.min(max_qty).max(1));
self.storage_take(item_instance_id, qty).await?;
self.state.storage_ui_mode = StorageUiMode::Menu;
}
StorageUiMode::ShipAmount {
dest_building_id,
item_instance_id,
max_qty,
input,
..
} => {
let Some(qty) = parse_storage_quantity(&input) else {
self.state
.push_log("Enter a quantity (blank or 0 = all).");
return Ok(());
};
let qty = qty.map(|n| n.min(max_qty).max(1));
self.storage_ship(dest_building_id, item_instance_id, qty)
.await?;
self.state.storage_ui_mode = StorageUiMode::Menu;
}
}
Ok(())
}
pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
match self.state.bank_ui_mode.clone() {
BankUiMode::Menu => {
let choice = self
.state
.bank_menu_options()
.get(self.state.bank_menu_index)
.copied()
.unwrap_or("Deposit…");
match choice {
"Withdraw…" => {
self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
input: String::new(),
};
}
"Deposit all" => self.bank_deposit(0).await?,
"Withdraw all" => self.bank_withdraw(0).await?,
"Transfer…" => {
self.state.bank_ui_mode = BankUiMode::TransferName {
input: String::new(),
};
}
_ => {
self.state.bank_ui_mode = BankUiMode::DepositAmount {
input: String::new(),
};
}
}
}
BankUiMode::DepositAmount { input } => {
let Some(amount) = parse_bank_copper_amount(&input) else {
self.state
.push_log("Enter a copper amount (blank or 0 = everything on person).");
return Ok(());
};
self.bank_deposit(amount).await?;
self.state.bank_ui_mode = BankUiMode::Menu;
}
BankUiMode::WithdrawAmount { input } => {
let Some(amount) = parse_bank_copper_amount(&input) else {
self.state
.push_log("Enter a copper amount (blank or 0 = full ledger).");
return Ok(());
};
self.bank_withdraw(amount).await?;
self.state.bank_ui_mode = BankUiMode::Menu;
}
BankUiMode::TransferName { input } => {
let name = input.trim().to_string();
if name.is_empty() {
self.state.push_log("Enter the recipient character name.");
return Ok(());
}
self.state.bank_ui_mode = BankUiMode::TransferAmount {
to_name: name,
input: String::new(),
};
}
BankUiMode::TransferAmount { to_name, input } => {
let amount: u64 = match input.trim().parse() {
Ok(v) if v > 0 => v,
_ => {
self.state
.push_log("Enter a positive copper amount to transfer.");
return Ok(());
}
};
self.bank_transfer(None, to_name, amount).await?;
self.state.bank_ui_mode = BankUiMode::Menu;
}
}
Ok(())
}
pub fn bank_transfer_back(&mut self) {
match &self.state.bank_ui_mode {
BankUiMode::TransferAmount { to_name, .. } => {
self.state.bank_ui_mode = BankUiMode::TransferName {
input: to_name.clone(),
};
}
BankUiMode::TransferName { .. }
| BankUiMode::DepositAmount { .. }
| BankUiMode::WithdrawAmount { .. } => {
self.state.bank_ui_mode = BankUiMode::Menu;
}
BankUiMode::Menu => {}
}
}
pub fn bank_transfer_append_char(&mut self, c: char) {
match &mut self.state.bank_ui_mode {
BankUiMode::TransferName { input } => {
if input.len() < 32 && !c.is_control() {
input.push(c);
}
}
BankUiMode::DepositAmount { input }
| BankUiMode::WithdrawAmount { input }
| BankUiMode::TransferAmount { input, .. } => {
if c.is_ascii_digit() && input.len() < 12 {
input.push(c);
}
}
BankUiMode::Menu => {}
}
}
pub fn bank_transfer_backspace(&mut self) {
match &mut self.state.bank_ui_mode {
BankUiMode::TransferName { input }
| BankUiMode::DepositAmount { input }
| BankUiMode::WithdrawAmount { input }
| BankUiMode::TransferAmount { input, .. } => {
input.pop();
}
BankUiMode::Menu => {}
}
}
pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
let npc_id = self
.state
.bank_panel
.as_ref()
.map(|p| p.npc_id.clone());
self.state.clear_bank_panel();
if let Some(npc_id) = npc_id {
self.seq += 1;
self.session
.submit_intent(Intent::BankClose {
entity_id: self.state.entity_id,
npc_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
}
Ok(())
}
pub async fn storage_store(
&mut self,
item_instance_id: uuid::Uuid,
quantity: Option<u32>,
) -> anyhow::Result<()> {
let Some(panel) = self.state.storage_panel.clone() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::StorageStore {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
item_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn storage_take(
&mut self,
item_instance_id: uuid::Uuid,
quantity: Option<u32>,
) -> anyhow::Result<()> {
let Some(panel) = self.state.storage_panel.clone() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::StorageTake {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
item_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn storage_ship(
&mut self,
dest_building_id: String,
item_instance_id: uuid::Uuid,
quantity: Option<u32>,
) -> anyhow::Result<()> {
let Some(panel) = self.state.storage_panel.clone() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::StorageShip {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
dest_building_id,
item_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
let npc_id = self
.state
.storage_panel
.as_ref()
.map(|p| p.npc_id.clone());
self.state.clear_storage_panel();
if let Some(npc_id) = npc_id {
self.seq += 1;
self.session
.submit_intent(Intent::StorageClose {
entity_id: self.state.entity_id,
npc_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
}
Ok(())
}
pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
let npc_id = self
.state
.market_panel
.as_ref()
.map(|p| p.npc_id.clone());
self.state.clear_market_panel();
if let Some(npc_id) = npc_id {
self.seq += 1;
self.session
.submit_intent(Intent::MarketClose {
entity_id: self.state.entity_id,
npc_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
}
Ok(())
}
pub fn market_move_selection(&mut self, delta: i32) {
let indices = self.state.market_filtered_listing_indices();
let n = indices.len();
if n == 0 {
self.state.market_menu_index = 0;
return;
}
let cur = self.state.market_menu_index as i32;
self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
}
pub fn market_page_selection(&mut self, pages: i32) {
let indices = self.state.market_filtered_listing_indices();
let n = indices.len();
if n == 0 {
self.state.market_menu_index = 0;
return;
}
self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
}
pub fn market_list_page(&mut self, pages: i32) {
match &self.state.market_ui_mode {
MarketUiMode::ListSource { index } => {
let n = self.state.market_list_source_options().len();
if n == 0 {
return;
}
let next = page_list_index(*index, pages, n);
self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
}
MarketUiMode::ListPick { source, index } => {
let opts = self.state.market_list_item_options(source);
let n = opts.len();
if n == 0 {
return;
}
let next = page_list_index(*index, pages, n);
self.state.market_ui_mode = MarketUiMode::ListPick {
source: source.clone(),
index: next,
};
}
_ => {}
}
}
pub fn market_cycle_category(&mut self, delta: i32) {
let groups = self.state.market_available_category_groups();
let mut labels: Vec<Option<&'static str>> = vec![None];
labels.extend(groups.into_iter().map(Some));
let n = labels.len() as i32;
let cur = labels
.iter()
.position(|g| *g == self.state.market_category_filter)
.unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(n) as usize;
self.state.market_category_filter = labels[next];
self.state.market_menu_index = 0;
if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
let source = source.clone();
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
}
}
pub fn focus_market_filter(&mut self) {
self.state.market_filter_focused = true;
}
pub fn append_market_filter_char(&mut self, ch: char) {
if !self.state.market_filter_focused {
return;
}
if ch.is_control() {
return;
}
if self.state.market_filter.len() < 48 {
self.state.market_filter.push(ch);
self.state.market_menu_index = 0;
if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
let source = source.clone();
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
}
}
}
pub fn market_filter_backspace(&mut self) {
if !self.state.market_filter_focused {
return;
}
self.state.market_filter.pop();
self.state.market_menu_index = 0;
if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
let source = source.clone();
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
}
}
pub fn clear_or_blur_market_filter(&mut self) -> bool {
if self.state.market_filter_focused {
if !self.state.market_filter.is_empty() {
self.state.market_filter.clear();
self.state.market_menu_index = 0;
if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
let source = source.clone();
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
}
return true;
}
self.state.market_filter_focused = false;
return true;
}
if !self.state.market_filter.is_empty() {
self.state.market_filter.clear();
self.state.market_menu_index = 0;
if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
let source = source.clone();
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
}
return true;
}
false
}
pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
return self.market_confirm_buy(listing_id, qty).await;
}
let Some(panel) = self.state.market_panel.clone() else {
return Ok(());
};
let indices = self.state.market_filtered_listing_indices();
let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
return Ok(());
};
let Some(listing) = panel.listings.get(raw_idx) else {
return Ok(());
};
if listing.mine {
self.seq += 1;
self.session
.submit_intent(Intent::MarketDelist {
entity_id: self.state.entity_id,
npc_id: panel.npc_id.clone(),
listing_id: listing.listing_id,
dest: flatland_protocol::GoodsLocation::Person,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
return Ok(());
}
let qty = 1u32.min(listing.quantity).max(1);
let line = listing.unit_price_copper.saturating_mul(qty as u64);
self.state.market_buy_confirm = Some((
listing.listing_id,
qty,
listing.unit_price_copper,
line,
listing.display_name.clone(),
));
Ok(())
}
pub async fn market_confirm_buy(
&mut self,
listing_id: uuid::Uuid,
quantity: u32,
) -> anyhow::Result<()> {
let Some(panel) = self.state.market_panel.clone() else {
self.state.market_buy_confirm = None;
return Ok(());
};
self.state.market_buy_confirm = None;
self.seq += 1;
self.session
.submit_intent(Intent::MarketBuy {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
listing_id,
quantity,
dest: flatland_protocol::GoodsLocation::Person,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn market_begin_list(&mut self) {
if self.state.market_panel.is_none() {
return;
}
let sources = self.state.market_list_source_options();
if sources.is_empty() {
self.state.push_log("Nothing to list from.");
return;
}
if sources.len() == 1 {
let (source, _) = sources[0].clone();
let opts = self.state.market_list_item_options(&source);
if opts.is_empty() {
self.state.push_log("Nothing loose to list.");
return;
}
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
self.state.market_buy_confirm = None;
return;
}
self.state.market_buy_confirm = None;
self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
}
pub fn market_ui_back(&mut self) {
self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
MarketUiMode::Browse => MarketUiMode::Browse,
MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
MarketUiMode::ListPick { .. } => {
if self.state.market_list_source_options().len() <= 1 {
MarketUiMode::Browse
} else {
MarketUiMode::ListSource { index: 0 }
}
}
MarketUiMode::ListAmount {
source,
pick_index,
..
} => MarketUiMode::ListPick {
source,
index: pick_index,
},
MarketUiMode::ListPrice {
source,
item_instance_id,
label,
max_qty,
quantity,
..
} => {
let input = quantity
.map(|q| q.to_string())
.unwrap_or_default();
MarketUiMode::ListAmount {
source,
pick_index: 0,
item_instance_id,
label,
max_qty,
input,
}
}
};
}
pub fn market_list_move(&mut self, delta: i32) {
match &self.state.market_ui_mode {
MarketUiMode::ListSource { index } => {
let n = self.state.market_list_source_options().len();
if n == 0 {
return;
}
let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
}
MarketUiMode::ListPick { source, index } => {
let opts = self.state.market_list_item_options(source);
let n = opts.len();
if n == 0 {
return;
}
let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
self.state.market_ui_mode = MarketUiMode::ListPick {
source: source.clone(),
index: next,
};
}
_ => {}
}
}
pub fn market_list_amount_append_char(&mut self, c: char) {
if !c.is_ascii_digit() {
return;
}
match &mut self.state.market_ui_mode {
MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
if input.len() < 12 {
input.push(c);
}
}
_ => {}
}
}
pub fn market_list_amount_backspace(&mut self) {
match &mut self.state.market_ui_mode {
MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
input.pop();
}
_ => {}
}
}
pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
match self.state.market_ui_mode.clone() {
MarketUiMode::Browse => Ok(()),
MarketUiMode::ListSource { index } => {
let sources = self.state.market_list_source_options();
let Some((source, _)) = sources.get(index).cloned() else {
return Ok(());
};
let opts = self.state.market_list_item_options(&source);
if opts.is_empty() {
self.state.push_log("Nothing to list from that source.");
return Ok(());
}
self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
Ok(())
}
MarketUiMode::ListPick { source, index } => {
let opts = self.state.market_list_item_options(&source);
let Some(opt) = opts.get(index) else {
self.state.push_log("Nothing to list.");
self.state.market_ui_mode = MarketUiMode::Browse;
return Ok(());
};
self.state.market_ui_mode = MarketUiMode::ListAmount {
source,
pick_index: index,
item_instance_id: opt.item_instance_id,
label: opt.label.clone(),
max_qty: opt.quantity.max(1),
input: String::new(),
};
Ok(())
}
MarketUiMode::ListAmount {
source,
item_instance_id,
label,
max_qty,
input,
..
} => {
let Some(qty_opt) = parse_storage_quantity(&input) else {
self.state.push_log("Enter a quantity (blank = all).");
return Ok(());
};
if let Some(q) = qty_opt {
if q > max_qty {
self.state
.push_log(format!("Only {max_qty} available."));
return Ok(());
}
}
self.state.market_ui_mode = MarketUiMode::ListPrice {
source,
item_instance_id,
label,
quantity: qty_opt,
max_qty,
input: String::new(),
};
Ok(())
}
MarketUiMode::ListPrice {
source,
item_instance_id,
label,
quantity,
input,
..
} => {
let price = input.trim().parse::<u64>().unwrap_or(0);
if price == 0 {
self.state.push_log("Enter a unit price of at least 1 copper.");
return Ok(());
}
let Some(panel) = self.state.market_panel.clone() else {
self.state.market_ui_mode = MarketUiMode::Browse;
return Ok(());
};
let goods = match source {
MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
MarketListSourceKind::TownStorage { building_id } => {
flatland_protocol::GoodsLocation::TownStorage { building_id }
}
};
self.seq += 1;
self.session
.submit_intent(Intent::MarketList {
entity_id: self.state.entity_id,
npc_id: panel.npc_id,
source: goods,
item_instance_id,
quantity,
unit_price_copper: price,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.push_log(format!("Listing {label} @ {price} cp…"));
self.state.market_ui_mode = MarketUiMode::Browse;
Ok(())
}
}
}
pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
let return_to_verbs = self.state.npc_verb_target.is_some();
self.close_shop_menu().await?;
if return_to_verbs {
self.state.show_npc_verb_menu = true;
}
Ok(())
}
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;
if self.state.shop_tab == ShopTab::Sell {
self.state.shop_quantity_set_max();
}
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 fn toggle_quest_menu(&mut self) {
self.state.show_quest_menu = !self.state.show_quest_menu;
if self.state.show_quest_menu {
self.state.quest_menu_index = 0;
self.state.quest_withdraw_confirm = false;
self.state.show_workers_menu = false;
}
}
pub fn toggle_workers_menu(&mut self) {
if self.state.show_workers_menu {
self.close_workers_menu_ui();
} else {
self.state.show_workers_menu = true;
self.state.workers_menu_index = 0;
self.state.show_quest_menu = false;
self.close_worker_give_picker();
self.close_worker_give_target_picker();
self.close_worker_take_picker();
self.close_worker_teach_picker();
self.cancel_worker_rename();
}
}
pub fn close_workers_menu_ui(&mut self) {
self.state.show_workers_menu = false;
self.close_worker_give_picker();
self.close_worker_give_target_picker();
self.close_worker_take_picker();
self.close_worker_teach_picker();
self.cancel_worker_rename();
}
pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
let Some(idx) = self
.state
.hired_workers
.iter()
.position(|w| w.instance_id == instance_id)
else {
anyhow::bail!("worker not found");
};
let label = self.state.hired_workers[idx].label.clone();
self.state.show_workers_menu = true;
self.state.workers_menu_index = idx;
self.state.show_quest_menu = false;
self.close_worker_give_picker();
self.close_worker_give_target_picker();
self.close_worker_take_picker();
self.close_worker_teach_picker();
self.cancel_worker_rename();
self.set_worker_attending(instance_id, true).await?;
self.state
.push_log(format!("Managing {label} — job paused while menu is open"));
Ok(())
}
pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
self.close_workers_menu_ui();
self.release_worker_attend().await
}
async fn set_worker_attending(
&mut self,
instance_id: &str,
attending: bool,
) -> anyhow::Result<()> {
if attending {
if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
return Ok(());
}
if let Some(prev) = self.state.attending_worker_instance_id.clone() {
if prev != instance_id {
self.send_attend_hired_worker(&prev, false).await?;
}
}
self.send_attend_hired_worker(instance_id, true).await?;
self.state.attending_worker_instance_id = Some(instance_id.to_string());
} else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
self.send_attend_hired_worker(instance_id, false).await?;
self.state.attending_worker_instance_id = None;
}
Ok(())
}
pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
let Some(id) = self.state.attending_worker_instance_id.take() else {
return Ok(());
};
self.send_attend_hired_worker(&id, false).await
}
async fn send_attend_hired_worker(
&mut self,
worker_instance_id: &str,
attending: bool,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::AttendHiredWorker {
entity_id: self.state.entity_id,
worker_instance_id: worker_instance_id.to_string(),
attending,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn workers_menu_move(&mut self, delta: i32) {
let n = self.state.hired_workers.len();
if n == 0 {
return;
}
let idx = self.state.workers_menu_index as i32;
self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub fn toggle_workers_menu_compact(&mut self) {
self.state.workers_menu_compact = !self.state.workers_menu_compact;
let mut cfg = crate::client_config::ClientConfig::load();
let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
}
pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
let Some(worker) = self
.state
.hired_workers
.get(self.state.workers_menu_index)
.cloned()
else {
anyhow::bail!("no worker selected");
};
self.seq += 1;
self.session
.submit_intent(Intent::DismissWorker {
entity_id: self.state.entity_id,
worker_instance_id: worker.instance_id.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.hired_workers
.retain(|w| w.instance_id != worker.instance_id);
if self.state.workers_menu_index >= self.state.hired_workers.len() {
self.state.workers_menu_index = self
.state
.hired_workers
.len()
.saturating_sub(1);
}
self.state.push_log(format!("Dismissed {}", worker.label));
Ok(())
}
pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
let Some(worker) = self
.state
.hired_workers
.get(self.state.workers_menu_index)
.cloned()
else {
anyhow::bail!("no worker selected");
};
let mode = match worker.mode {
flatland_protocol::WorkerModeView::Companion => "job_loop",
flatland_protocol::WorkerModeView::JobLoop => "idle",
flatland_protocol::WorkerModeView::Idle => "companion",
};
self.seq += 1;
self.session
.submit_intent(Intent::SetWorkerMode {
entity_id: self.state.entity_id,
worker_instance_id: worker.instance_id,
mode: mode.into(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
if self.state.hired_workers.is_empty() {
return self.hire_worker_laborer().await;
}
self.workers_toggle_mode_selected().await
}
pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
let row = self
.state
.inventory_selected_row()
.ok_or_else(|| anyhow::anyhow!("inventory empty"))?
.clone();
if row.from != flatland_protocol::InventoryLocation::Root {
anyhow::bail!("select a carried item to give");
}
let Some(instance_id) = row.stack.item_instance_id else {
anyhow::bail!("that stack can't be given");
};
let options = self.nearby_worker_give_targets();
if options.is_empty() {
anyhow::bail!(
"no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
);
}
let item_label = row
.stack
.display_name
.as_deref()
.unwrap_or(&row.stack.template_id)
.to_string();
self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
item_instance_id: instance_id,
item_label,
quantity: None,
options,
});
self.state.worker_give_target_picker_index = 0;
self.state.show_worker_give_target_picker = true;
self.state.show_inventory_menu = false;
Ok(())
}
pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
let (px, py, _) = self.state.player_position_with_z();
let mut options: Vec<WorkerGiveTargetOption> = self
.state
.hired_workers
.iter()
.filter_map(|w| {
let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
return None;
}
Some(WorkerGiveTargetOption {
instance_id: w.instance_id.clone(),
label: w.label.clone(),
distance_m: dist,
})
})
.collect();
options.sort_by(|a, b| {
a.distance_m
.partial_cmp(&b.distance_m)
.unwrap_or(std::cmp::Ordering::Equal)
});
options
}
pub fn close_worker_give_target_picker(&mut self) {
self.state.show_worker_give_target_picker = false;
self.state.worker_give_target_picker = None;
self.state.worker_give_target_picker_index = 0;
}
pub fn worker_give_target_picker_move(&mut self, delta: i32) {
let Some(picker) = &self.state.worker_give_target_picker else {
return;
};
let n = picker.options.len();
if n == 0 {
return;
}
let idx = self.state.worker_give_target_picker_index as i32;
self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.worker_give_target_picker.clone() else {
anyhow::bail!("give target picker not open");
};
let Some(opt) = picker
.options
.get(self.state.worker_give_target_picker_index)
.cloned()
else {
anyhow::bail!("no worker selected");
};
let Some(worker) = self
.state
.hired_workers
.iter()
.find(|w| w.instance_id == opt.instance_id)
.cloned()
else {
self.close_worker_give_target_picker();
anyhow::bail!("worker no longer hired");
};
self.give_item_to_worker(
&worker.instance_id,
&worker.label,
worker.x,
worker.y,
picker.item_instance_id,
&picker.item_label,
picker.quantity,
)
.await?;
self.close_worker_give_target_picker();
Ok(())
}
pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
self.open_worker_give_target_picker()
}
pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
let Some(worker) = self
.state
.hired_workers
.get(self.state.workers_menu_index)
.cloned()
else {
anyhow::bail!("select a hired worker first");
};
let (px, py, _) = self.state.player_position_with_z();
let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
anyhow::bail!(
"stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
worker.label
);
}
let options = self.state.giveable_inventory_options();
if options.is_empty() {
anyhow::bail!("nothing in inventory to give");
}
self.state.worker_give_picker = Some(WorkerGivePicker {
worker_instance_id: worker.instance_id,
worker_label: worker.label,
options,
});
self.state.worker_give_picker_index = 0;
self.state.show_worker_give_picker = true;
Ok(())
}
pub fn close_worker_give_picker(&mut self) {
self.state.show_worker_give_picker = false;
self.state.worker_give_picker = None;
self.state.worker_give_picker_index = 0;
}
pub fn worker_give_picker_move(&mut self, delta: i32) {
let Some(picker) = &self.state.worker_give_picker else {
return;
};
let n = picker.options.len();
if n == 0 {
return;
}
let idx = self.state.worker_give_picker_index as i32;
self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.worker_give_picker.clone() else {
anyhow::bail!("give picker not open");
};
let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
anyhow::bail!("no item selected");
};
let Some(worker) = self
.state
.hired_workers
.iter()
.find(|w| w.instance_id == picker.worker_instance_id)
.cloned()
else {
self.close_worker_give_picker();
anyhow::bail!("worker no longer hired");
};
self.give_item_to_worker(
&worker.instance_id,
&worker.label,
worker.x,
worker.y,
opt.item_instance_id,
&opt.label,
None,
)
.await?;
let options = self.state.giveable_inventory_options();
if options.is_empty() {
self.close_worker_give_picker();
} else {
self.state.worker_give_picker = Some(WorkerGivePicker {
worker_instance_id: picker.worker_instance_id,
worker_label: picker.worker_label,
options,
});
if self.state.worker_give_picker_index
>= self
.state
.worker_give_picker
.as_ref()
.map(|p| p.options.len())
.unwrap_or(0)
{
self.state.worker_give_picker_index = self
.state
.worker_give_picker
.as_ref()
.map(|p| p.options.len().saturating_sub(1))
.unwrap_or(0);
}
}
Ok(())
}
pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
let Some(worker) = self
.state
.hired_workers
.get(self.state.workers_menu_index)
.cloned()
else {
anyhow::bail!("select a hired worker first");
};
let (px, py, _) = self.state.player_position_with_z();
let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
anyhow::bail!(
"stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
worker.label
);
}
let options = self.state.teachable_blueprint_options(&worker);
if options.is_empty() {
anyhow::bail!("no recipes you know that {} still needs", worker.label);
}
self.state.worker_teach_picker = Some(WorkerTeachPicker {
worker_instance_id: worker.instance_id,
worker_label: worker.label,
worker_level: worker.level,
options,
});
self.state.worker_teach_picker_index = 0;
self.state.show_worker_teach_picker = true;
Ok(())
}
pub fn close_worker_teach_picker(&mut self) {
self.state.show_worker_teach_picker = false;
self.state.worker_teach_picker = None;
self.state.worker_teach_picker_index = 0;
}
pub fn worker_teach_picker_move(&mut self, delta: i32) {
let Some(picker) = &self.state.worker_teach_picker else {
return;
};
let n = picker.options.len();
if n == 0 {
return;
}
let idx = self.state.worker_teach_picker_index as i32;
self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.worker_teach_picker.clone() else {
anyhow::bail!("teach picker not open");
};
let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
anyhow::bail!("nothing selected");
};
if !opt.level_ok {
anyhow::bail!(
"{} needs level {} (is level {})",
picker.worker_label,
opt.min_level,
opt.worker_level
);
}
if !opt.can_afford {
anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
}
let Some(worker) = self
.state
.hired_workers
.iter()
.find(|w| w.instance_id == picker.worker_instance_id)
.cloned()
else {
anyhow::bail!("worker gone");
};
let (px, py, _) = self.state.player_position_with_z();
let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
anyhow::bail!("worker {} too far — stand next to them", worker.label);
}
self.seq += 1;
self.session
.submit_intent(Intent::TeachWorkerBlueprint {
entity_id: self.state.entity_id,
worker_instance_id: picker.worker_instance_id.clone(),
blueprint_id: opt.blueprint_id.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.push_log(format!(
"Teaching {} to {} ({} cp)",
opt.label, picker.worker_label, opt.cost_copper
));
self.close_worker_teach_picker();
Ok(())
}
async fn give_item_to_worker(
&mut self,
worker_instance_id: &str,
worker_label: &str,
worker_x: f32,
worker_y: f32,
item_instance_id: uuid::Uuid,
item_label: &str,
quantity: Option<u32>,
) -> anyhow::Result<()> {
let (px, py, _) = self.state.player_position_with_z();
let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
anyhow::bail!("worker {worker_label} too far — stand next to them");
}
self.seq += 1;
self.session
.submit_intent(Intent::GiveWorkerItem {
entity_id: self.state.entity_id,
worker_instance_id: worker_instance_id.to_string(),
item_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.push_log(format!("Gave {item_label} to {worker_label}"));
Ok(())
}
pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
let Some(worker) = self
.state
.hired_workers
.get(self.state.workers_menu_index)
.cloned()
else {
anyhow::bail!("select a hired worker first");
};
let (px, py, _) = self.state.player_position_with_z();
let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
anyhow::bail!(
"stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
worker.label
);
}
let options = Self::worker_inventory_options(&worker);
if options.is_empty() {
anyhow::bail!("{} isn't carrying anything", worker.label);
}
let initial_qty = options
.first()
.map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
.unwrap_or(1);
self.state.worker_take_picker = Some(WorkerTakePicker {
worker_instance_id: worker.instance_id,
worker_label: worker.label,
options,
quantity: initial_qty,
});
self.state.worker_take_picker_index = 0;
self.state.show_worker_take_picker = true;
Ok(())
}
fn worker_inventory_options(
worker: &flatland_protocol::HiredWorkerView,
) -> Vec<WorkerGiveOption> {
worker
.inventory
.iter()
.filter_map(|stack| {
let item_instance_id = stack.item_instance_id?;
let label = stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone());
let label = if stack.quantity > 1 {
format!("{label} ×{}", stack.quantity)
} else {
label
};
Some(WorkerGiveOption {
item_instance_id,
label,
quantity: stack.quantity,
template_id: stack.template_id.clone(),
})
})
.collect()
}
pub fn close_worker_take_picker(&mut self) {
self.state.show_worker_take_picker = false;
self.state.worker_take_picker = None;
self.state.worker_take_picker_index = 0;
}
pub fn worker_take_picker_move(&mut self, delta: i32) {
let Some(picker) = &self.state.worker_take_picker else {
return;
};
let n = picker.options.len();
if n == 0 {
return;
}
let idx = self.state.worker_take_picker_index as i32;
self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
self.clamp_worker_take_quantity();
}
pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
let Some(picker) = &mut self.state.worker_take_picker else {
return;
};
let max = picker
.options
.get(self.state.worker_take_picker_index)
.map(|o| o.quantity.max(1))
.unwrap_or(1);
let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
picker.quantity = next as u32;
}
pub fn worker_take_picker_set_quantity_max(&mut self) {
let Some(picker) = &mut self.state.worker_take_picker else {
return;
};
let max = picker
.options
.get(self.state.worker_take_picker_index)
.map(|o| o.quantity.max(1))
.unwrap_or(1);
picker.quantity = max;
}
fn clamp_worker_take_quantity(&mut self) {
let Some(picker) = &mut self.state.worker_take_picker else {
return;
};
let max = picker
.options
.get(self.state.worker_take_picker_index)
.map(|o| o.quantity.max(1))
.unwrap_or(1);
if picker.quantity == 0 || picker.quantity > max {
picker.quantity = if max > 1 { 1 } else { max };
}
}
pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.state.worker_take_picker.clone() else {
anyhow::bail!("take picker not open");
};
let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
anyhow::bail!("no item selected");
};
let Some(worker) = self
.state
.hired_workers
.iter()
.find(|w| w.instance_id == picker.worker_instance_id)
.cloned()
else {
self.close_worker_take_picker();
anyhow::bail!("worker no longer hired");
};
let qty = picker.quantity.clamp(1, opt.quantity.max(1));
let intent_qty = if qty >= opt.quantity {
None
} else {
Some(qty)
};
self.take_item_from_worker(
&worker.instance_id,
&worker.label,
worker.x,
worker.y,
opt.item_instance_id,
&opt.label,
intent_qty,
)
.await?;
Ok(())
}
async fn take_item_from_worker(
&mut self,
worker_instance_id: &str,
worker_label: &str,
worker_x: f32,
worker_y: f32,
item_instance_id: uuid::Uuid,
item_label: &str,
quantity: Option<u32>,
) -> anyhow::Result<()> {
let (px, py, _) = self.state.player_position_with_z();
let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
if dist > WORKER_GIVE_RANGE_M {
anyhow::bail!("worker {worker_label} too far — stand next to them");
}
self.seq += 1;
self.session
.submit_intent(Intent::TakeWorkerItem {
entity_id: self.state.entity_id,
worker_instance_id: worker_instance_id.to_string(),
item_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
let qty_note = quantity
.map(|q| format!(" ×{q}"))
.unwrap_or_default();
self.state
.push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
Ok(())
}
pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
if !self.state.has_worker_lodging() {
anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
}
self.seq += 1;
self.session
.submit_intent(Intent::HireWorker {
entity_id: self.state.entity_id,
def_id: "worker_laborer".into(),
wage_copper_per_interval: 8,
lodging_container_id: None,
job_yaml: None,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
let Some(worker) = self
.state
.hired_workers
.get(self.state.workers_menu_index)
.cloned()
else {
anyhow::bail!("select a hired worker first");
};
let lodging = worker.lodging_container_id.clone().or_else(|| {
crate::worker_route_editor::owned_lodging_container_ids(
&self.state.placed_containers,
self.state.character_id,
)
.into_iter()
.next()
.map(|(id, _)| id)
});
let label = worker.label.clone();
let editor = if let Some(route) = &worker.route {
crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
worker.instance_id,
worker.label,
route,
lodging,
)
} else {
crate::worker_route_editor::WorkerRouteEditorState::new(
worker.instance_id,
worker.label,
lodging,
)
};
self.state.worker_route_editor = Some(editor);
if let Some(ed) = self.state.worker_route_editor.as_mut() {
if let Some(collapsed) =
crate::client_config::ClientConfig::load().worker_route_panel_collapsed
{
ed.panel_collapsed = collapsed;
}
}
self.state.show_workers_menu = false;
self.state.push_log(format!(
"Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
));
Ok(())
}
pub fn close_worker_route_editor(&mut self) {
self.state.worker_route_editor = None;
}
pub fn worker_route_editor_toggle_panel(&mut self) {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.toggle_panel_collapsed();
let collapsed = ed.panel_collapsed;
let mut cfg = crate::client_config::ClientConfig::load();
let _ = cfg.save_worker_route_panel_collapsed(collapsed);
}
}
pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
let n = {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
ed.append_waypoint(x, y, z);
ed.stop_count()
};
self.state
.push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
}
fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
let (px, py, _) = self.state.player_position_with_z();
crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
&self.state.placed_containers,
&self.state.buildings,
self.state.character_id,
px,
py,
&self.state.hired_workers,
)
}
fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
self.state.route_editor_node_candidates()
}
fn re_open_harvest_picker(
&mut self,
index: usize,
picked: std::collections::BTreeSet<String>,
) {
use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
let nodes = self.state.route_editor_node_candidates();
let index = if nodes.is_empty() {
ROUTE_PICKER_DONE_ROW
} else {
index.max(1).min(nodes.len())
};
self.re_open_sheet(S::HarvestPicker {
index,
picked,
nodes,
});
}
fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
let (px, py, _) = self.state.player_position_with_z();
crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
}
fn re_template_candidates(&self) -> Vec<String> {
let mut extra = Vec::new();
if let Some(ed) = self.state.worker_route_editor.as_ref() {
for stop in &ed.stops {
match stop {
crate::worker_route_editor::WorkerRouteStop::DepositAt {
filter: Some(filter),
..
} => extra.extend(filter.iter().cloned()),
crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
extra.push(template.clone());
}
crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
extra.push(bp.output.clone());
for input in &bp.inputs {
extra.push(input.template_id.clone());
}
}
}
crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
for it in items {
extra.push(it.template.clone());
}
}
_ => {}
}
}
if let Some(worker) = self
.state
.hired_workers
.iter()
.find(|w| w.instance_id == ed.worker_instance_id)
{
for recipe in &worker.known_blueprint_ids {
if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
extra.push(bp.output.clone());
}
}
}
}
crate::worker_route_editor::route_item_template_candidates(
&self.state.placed_containers,
self.state.character_id,
&self.state.inventory,
&self.state.blueprints,
&self.state.resource_nodes,
&extra,
)
}
fn re_blueprint_ids(&self) -> Vec<String> {
let worker_known: Option<&[String]> = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| {
self.state
.hired_workers
.iter()
.find(|w| w.instance_id == ed.worker_instance_id)
})
.map(|w| w.known_blueprint_ids.as_slice());
crate::worker_route_editor::worker_craft_blueprint_ids(
&self.state.blueprints,
worker_known,
)
}
fn re_bed_candidates(&self) -> Vec<(String, String)> {
crate::worker_route_editor::owned_lodging_container_ids(
&self.state.placed_containers,
self.state.character_id,
)
}
fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
self.state
.placed_containers
.iter()
.find(|c| c.id == container_id)
.map(|c| c.contents.clone())
.unwrap_or_default()
}
fn re_sheet_supports_filter(&self) -> bool {
use crate::worker_route_editor::RouteEditorSheet as S;
self.state
.worker_route_editor
.as_ref()
.is_some_and(|ed| {
matches!(
ed.sheet,
S::HarvestPicker { .. }
| S::SellItem { .. }
| S::DepositFilter { .. }
| S::WithdrawItems { .. }
| S::WithdrawContainers { .. }
| S::DepositContainers { .. }
| S::SellNpcs { .. }
| S::CraftBlueprint { .. }
| S::BedPicker { .. }
)
})
}
pub fn re_sheet_row_visible(&self, row: usize) -> bool {
use crate::worker_route_editor::{
harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
};
let Some(ed) = self.state.worker_route_editor.as_ref() else {
return false;
};
let filter = &ed.sheet_filter;
match &ed.sheet {
S::HarvestPicker { nodes, .. } => {
harvest_picker_row_matches(nodes, row, filter)
}
S::SellItem { templates, .. } => {
if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
return true;
}
let slot = row.saturating_sub(2);
templates.get(slot).is_some_and(|t| {
let label = self.state.template_display_name(t);
list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
})
}
S::DepositFilter { rows, .. } => {
if row >= rows.len() {
return true;
}
rows.get(row).is_some_and(|(t, _)| {
let label = self.state.template_display_name(t);
list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
})
}
S::WithdrawItems { lines, .. } => {
if row >= lines.len() {
return true;
}
lines.get(row).is_some_and(|l| {
let label = self.state.template_display_name(&l.template);
list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
})
}
S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
.re_container_candidates()
.get(row)
.is_some_and(|c| {
list_filter_row_matches(
filter,
Some(c.dist),
&[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
)
}),
S::SellNpcs { .. } => {
if row == 0 {
return true;
}
self.re_npc_candidates().get(row - 1).is_some_and(|n| {
list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
})
}
S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
let label = self
.state
.blueprints
.iter()
.find(|b| &b.id == id)
.map(|b| {
if b.label.is_empty() {
id.as_str()
} else {
b.label.as_str()
}
})
.unwrap_or(id.as_str());
list_filter_row_matches(filter, None, &[id.as_str(), label])
}),
S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
}),
_ => true,
}
}
fn re_sheet_clamp_index(&mut self) {
let count = self.re_sheet_row_count();
if count == 0 {
return;
}
let cur = self.re_sheet_index();
if self.re_sheet_row_visible(cur) {
return;
}
for offset in 1..count {
if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
self.re_sheet_set_index(cur + offset);
return;
}
if cur >= offset && self.re_sheet_row_visible(cur - offset) {
self.re_sheet_set_index(cur - offset);
return;
}
}
}
fn re_sheet_set_index(&mut self, index: usize) {
use crate::worker_route_editor::RouteEditorSheet as S;
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
match &mut ed.sheet {
S::AddMenu { index: slot }
| S::WaypointMenu { index: slot }
| S::HarvestPicker { index: slot, .. }
| S::WithdrawContainers { index: slot }
| S::DepositContainers { index: slot }
| S::SellNpcs { index: slot }
| S::CraftBlueprint { index: slot }
| S::BedPicker { index: slot }
| S::FarmPlotPicker { index: slot, .. }
| S::FarmPlantSeed { index: slot, .. }
| S::WithdrawItems { index: slot, .. }
| S::DepositFilter { index: slot, .. }
| S::SellItem { index: slot, .. } => *slot = index,
_ => {}
}
}
pub fn re_focus_sheet_filter(&mut self) {
if !self.re_sheet_supports_filter() {
return;
}
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.sheet_filter_focused = true;
}
}
pub fn re_blur_sheet_filter_keep_text(&mut self) {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
if !ed.sheet_filter_focused {
return;
}
ed.sheet_filter_focused = false;
self.re_sheet_clamp_index();
}
pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return false;
};
if ed.sheet_filter_focused {
ed.sheet_filter_focused = false;
self.re_sheet_clamp_index();
return true;
}
if !ed.sheet_filter.is_empty() {
ed.sheet_filter.clear();
self.re_sheet_clamp_index();
return true;
}
false
}
pub fn re_append_sheet_filter_char(&mut self, ch: char) {
if ch.is_control() {
return;
}
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
if !ed.sheet_filter_focused {
return;
}
ed.sheet_filter.push(ch);
self.re_sheet_set_index(0);
self.re_sheet_clamp_index();
}
pub fn re_sheet_filter_backspace(&mut self) {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
if !ed.sheet_filter_focused {
return;
}
ed.sheet_filter.pop();
self.re_sheet_set_index(0);
self.re_sheet_clamp_index();
}
pub fn re_sheet_row_count(&self) -> usize {
use crate::worker_route_editor::{
harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
};
let Some(ed) = self.state.worker_route_editor.as_ref() else {
return 0;
};
match &ed.sheet {
S::Stops => ed.stops.len(),
S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
S::WaypointMapPick => 0,
S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
self.re_container_candidates().len()
}
S::WithdrawItems { lines, .. } => lines.len() + 1, S::DepositFilter { rows, .. } => rows.len() + 1, S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
S::WaitEntry { .. } => 1,
S::BedPicker { .. } => self.re_bed_candidates().len(),
S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
S::FarmPlantSeed { seeds, .. } => seeds.len(),
}
}
pub fn re_sheet_index(&self) -> usize {
use crate::worker_route_editor::RouteEditorSheet as S;
let Some(ed) = self.state.worker_route_editor.as_ref() else {
return 0;
};
match &ed.sheet {
S::AddMenu { index }
| S::WaypointMenu { index }
| S::HarvestPicker { index, .. }
| S::WithdrawContainers { index }
| S::DepositContainers { index }
| S::SellNpcs { index }
| S::CraftBlueprint { index }
| S::BedPicker { index }
| S::FarmPlotPicker { index, .. }
| S::FarmPlantSeed { index, .. }
| S::WithdrawItems { index, .. }
| S::DepositFilter { index, .. }
| S::SellItem { index, .. } => *index,
_ => 0,
}
}
pub fn re_sheet_move(&mut self, delta: i32) {
let count = self.re_sheet_row_count();
if count == 0 {
return;
}
let cur = self.re_sheet_index();
let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
self.re_sheet_set_index(next);
}
pub fn re_sheet_page(&mut self, pages: i32) {
let count = self.re_sheet_row_count();
if count == 0 {
return;
}
let cur = self.re_sheet_index();
let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
self.re_sheet_set_index(next);
}
pub fn re_sheet_adjust(&mut self, delta: i32) {
use crate::worker_route_editor::RouteEditorSheet as S;
let index = self.re_sheet_index();
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
match &mut ed.sheet {
S::WithdrawItems { lines, .. } => {
if let Some(line) = lines.get_mut(index) {
line.adjust_qty(delta);
}
}
S::WaitEntry { ticks } => {
*ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
}
_ => {}
}
}
pub fn re_sheet_back(&mut self) {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
use crate::worker_route_editor::RouteEditorSheet as S;
let was_editing = ed.editing_index.is_some();
let from_top_picker = matches!(
ed.sheet,
S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
);
ed.sheet_back();
if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
self.state
.push_log("Route: left edit sheet — press s to save current stops".to_string());
}
}
pub fn re_at_root_sheet(&self) -> bool {
self.state
.worker_route_editor
.as_ref()
.is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
}
pub fn re_open_add_menu(&mut self) {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.open_add_menu();
}
}
pub fn re_open_bed_picker(&mut self) {
let beds = self.re_bed_candidates();
if beds.is_empty() {
self.state
.push_log("Route: place a camp bed first".to_string());
return;
}
let current = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| ed.lodging_container_id.clone());
let index = current
.and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
.unwrap_or(0);
self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
}
fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.open_sheet(sheet);
}
}
fn re_confirm_stop(
&mut self,
stop: crate::worker_route_editor::WorkerRouteStop,
what: String,
) {
let appended = self
.state
.worker_route_editor
.as_mut()
.is_some_and(|ed| ed.confirm_stop(stop));
if appended {
self.state.push_log(format!("Route: + {what}"));
} else {
self.state
.push_log(format!("Route: {what} already in route — selected it"));
}
}
fn re_open_withdraw_items(&mut self, container_id: String) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
let contents = self.re_container_contents(&container_id);
let existing = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
.and_then(|stop| match stop {
WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
_ => None,
})
.unwrap_or_default();
let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
if let Some(ed) = self.state.worker_route_editor.as_mut() {
let _ = ed.retarget_withdraw_container(container_id.clone());
}
self.re_open_sheet(S::WithdrawItems {
container_id,
lines,
index: 0,
});
}
fn re_withdraw_items_activate(&mut self, index: usize) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
enum Outcome {
Cycled,
Confirmed(String),
Empty,
}
let outcome = {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
let S::WithdrawItems {
container_id,
lines,
index: sheet_index,
} = &mut ed.sheet
else {
return;
};
*sheet_index = index;
if index < lines.len() {
lines[index].cycle();
Outcome::Cycled
} else {
let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
if items.is_empty() {
Outcome::Empty
} else {
let stop = WorkerRouteStop::WithdrawFrom {
container_id: container_id.clone(),
items,
};
let summary = stop.summary();
ed.confirm_stop(stop);
Outcome::Confirmed(summary)
}
}
};
match outcome {
Outcome::Cycled => {}
Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
Outcome::Empty => self
.state
.push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
}
}
fn re_open_deposit_filter(&mut self, container_id: String) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
let existing_filter = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
.and_then(|stop| match stop {
WorkerRouteStop::DepositAt { filter, .. } => {
Some(filter.clone().unwrap_or_default())
}
_ => None,
});
let mut candidates = self.re_template_candidates();
if let Some(ref chosen) = existing_filter {
for t in chosen {
if !candidates.iter().any(|c| c == t) {
candidates.push(t.clone());
}
}
candidates.sort();
candidates.dedup();
}
let rows: Vec<(String, bool)> = match existing_filter {
Some(chosen) => candidates
.iter()
.map(|t| (t.clone(), chosen.contains(t)))
.collect(),
None => candidates.into_iter().map(|t| (t, false)).collect(),
};
if let Some(ed) = self.state.worker_route_editor.as_mut() {
let _ = ed.retarget_deposit_container(container_id.clone());
}
self.re_open_sheet(S::DepositFilter {
container_id,
rows,
index: 0,
});
}
fn re_deposit_filter_activate(&mut self, index: usize) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
let mut confirmed: Option<String> = None;
{
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
let S::DepositFilter {
container_id,
rows,
index: sheet_index,
} = &mut ed.sheet
else {
return;
};
*sheet_index = index;
if index < rows.len() {
rows[index].1 = !rows[index].1;
} else {
let chosen: Vec<String> = rows
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.clone())
.collect();
let filter = if chosen.is_empty() { None } else { Some(chosen) };
let stop = WorkerRouteStop::DepositAt {
container_id: container_id.clone(),
filter,
};
confirmed = Some(stop.summary());
ed.confirm_stop(stop);
}
}
if let Some(what) = confirmed {
self.state.push_log(format!("Route: + {what}"));
}
}
fn re_open_sell_item(&mut self, npc_id: Option<String>) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
let templates = self.re_template_candidates();
if templates.is_empty() {
self.state.push_log(
"Route: no item templates available — learn a craft recipe or place a harvest node first"
.to_string(),
);
return;
}
let (pre_npc, pre_template, pre_all) = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
.and_then(|stop| match stop {
WorkerRouteStop::TradeWith {
npc_id,
template,
sell_all,
} => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
_ => None,
})
.unwrap_or((None, None, true));
let npc_id = npc_id.or(pre_npc);
let mut picked = std::collections::BTreeSet::new();
if let Some(t) = pre_template {
picked.insert(t);
}
self.re_open_sheet(S::SellItem {
npc_id,
templates,
index: if picked.is_empty() {
crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
} else {
2
},
sell_all: pre_all,
picked,
});
}
fn re_sell_item_activate(&mut self, index: usize) {
use crate::worker_route_editor::{
RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
};
let mut batch_log: Option<String> = None;
{
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
let S::SellItem {
npc_id,
templates,
index: sheet_index,
sell_all,
picked,
} = &mut ed.sheet
else {
return;
};
*sheet_index = index;
if index == ROUTE_PICKER_DONE_ROW {
if picked.is_empty() {
batch_log = Some(
"Route: pick at least one item (Space toggles, Done confirms)".into(),
);
} else {
let picks: Vec<String> = picked.iter().cloned().collect();
let npc = npc_id.clone();
let all = *sell_all;
let added = ed.confirm_trade_picks(npc, &picks, all);
batch_log = Some(format!("Route: + {added} sell stop(s)"));
}
} else if index == SELL_ITEM_TOGGLE_ROW {
*sell_all = !*sell_all;
} else if let Some(template) = templates.get(index.saturating_sub(2)) {
if picked.contains(template) {
picked.remove(template);
} else {
picked.insert(template.clone());
}
}
}
if let Some(msg) = batch_log {
self.state.push_log(msg);
}
}
pub fn re_edit_selected_stop(&mut self) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
let Some(stop) = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
else {
self.state
.push_log("Route: no stop selected — press a to add one".to_string());
return;
};
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.begin_edit_selected();
}
match stop {
WorkerRouteStop::Waypoint { .. } => {
self.re_open_sheet(S::WaypointMenu { index: 0 });
}
WorkerRouteStop::HarvestNode { node_id } => {
let nodes = self.state.route_editor_node_candidates();
if nodes.is_empty() {
self.re_cancel_edit();
self.state
.push_log("Route: no harvestable nodes visible to retarget".to_string());
} else {
let mut picked = std::collections::BTreeSet::new();
picked.insert(node_id.clone());
let index = nodes
.iter()
.position(|n| n.id == node_id)
.map(|i| i + 1)
.unwrap_or(1);
self.re_open_harvest_picker(index, picked);
}
}
WorkerRouteStop::WithdrawFrom { container_id, .. } => {
let containers = self.re_container_candidates();
if containers.is_empty() {
self.re_cancel_edit();
self.state
.push_log("Route: place a storage chest first".to_string());
} else {
let index = containers
.iter()
.position(|c| c.id == container_id)
.unwrap_or(0);
self.re_open_sheet(S::WithdrawContainers { index });
}
}
WorkerRouteStop::DepositAt { container_id, .. } => {
let containers = self.re_container_candidates();
if containers.is_empty() {
self.re_cancel_edit();
self.state
.push_log("Route: place a storage chest first".to_string());
} else {
let index = containers
.iter()
.position(|c| c.id == container_id)
.unwrap_or(0);
self.re_open_sheet(S::DepositContainers { index });
}
}
WorkerRouteStop::TradeWith { npc_id, .. } => {
let npcs = self.re_npc_candidates();
let index = npc_id
.as_ref()
.and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
.unwrap_or(0);
self.re_open_sheet(S::SellNpcs { index });
}
WorkerRouteStop::CraftAt { blueprint, .. } => {
let bps = self.re_blueprint_ids();
let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
if bps.is_empty() {
self.re_cancel_edit();
self.state
.push_log("Route: no known blueprints to retarget".to_string());
} else {
self.re_open_sheet(S::CraftBlueprint { index });
}
}
WorkerRouteStop::CultivatePlot { .. } => {
self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
}
WorkerRouteStop::PlantPlot { .. } => {
self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
}
WorkerRouteStop::HarvestPlot { .. } => {
self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
}
WorkerRouteStop::RestIfNeeded => {
self.re_cancel_edit();
self.state
.push_log("Route: rest has no settings (change the bed with l)".to_string());
}
WorkerRouteStop::Wait { wait_ticks } => {
self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
}
}
}
fn re_cancel_edit(&mut self) {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.editing_index = None;
}
}
pub fn worker_route_editor_ui_click(
&mut self,
click: crate::worker_route_editor::RouteEditorClick,
) {
use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
match click {
RouteEditorClick::SelectStop(i) => {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.sheet = S::Stops;
ed.select_stop(i);
}
}
RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
}
}
pub fn re_sheet_row_activate(&mut self, row: usize) {
use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
let Some(sheet) = self
.state
.worker_route_editor
.as_ref()
.map(|ed| ed.sheet.clone())
else {
return;
};
match sheet {
S::Stops => {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.select_stop(row);
}
}
S::AddMenu { .. } => match row {
0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
1 => {
if self.re_node_candidates().is_empty() {
self.state
.push_log("Route: no harvestable nodes visible in this region".to_string());
} else {
self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
}
}
2 | 3 => {
if self.re_container_candidates().is_empty() {
self.state
.push_log("Route: place a storage chest first".to_string());
} else if row == 2 {
self.re_open_sheet(S::WithdrawContainers { index: 0 });
} else {
self.re_open_sheet(S::DepositContainers { index: 0 });
}
}
4 => {
if self.re_template_candidates().is_empty() {
self.state.push_log(
"Route: no item templates available — learn a craft recipe or place a harvest node first"
.to_string(),
);
} else {
self.re_open_sheet(S::SellNpcs { index: 0 });
}
}
5 => {
if self.re_blueprint_ids().is_empty() {
self.state.push_log(
"Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
.to_string(),
);
} else {
self.re_open_sheet(S::CraftBlueprint { index: 0 });
}
}
6 => self.re_confirm_stop(
WorkerRouteStop::RestIfNeeded,
"rest at lodging (if needed)".into(),
),
7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
_ => {}
},
S::WaypointMenu { .. } => match row {
0 => {
let (x, y, z) = self.state.player_position_with_z();
let stop = WorkerRouteStop::Waypoint { x, y, z };
self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
}
1 => {
self.re_open_sheet(S::WaypointMapPick);
self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
}
_ => {}
},
S::HarvestPicker { .. } => {
let mut log: Option<String> = None;
if let Some(ed) = self.state.worker_route_editor.as_mut() {
let S::HarvestPicker {
index: sheet_index,
picked,
nodes,
} = &mut ed.sheet
else {
return;
};
*sheet_index = row;
if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
if picked.is_empty() {
log = Some(
"Route: pick at least one node (Space toggles, Done confirms)"
.into(),
);
} else {
let ids: Vec<String> = picked.iter().cloned().collect();
let added = ed.confirm_harvest_picks(&ids);
log = Some(format!("Route: + {added} harvest stop(s)"));
}
} else if let Some(n) = nodes.get(row.saturating_sub(1)) {
if picked.contains(&n.id) {
picked.remove(&n.id);
} else {
picked.insert(n.id.clone());
}
}
}
if let Some(msg) = log {
self.state.push_log(msg);
}
}
S::WithdrawContainers { .. } => {
let containers = self.re_container_candidates();
if let Some(c) = containers.get(row) {
let id = c.id.clone();
self.re_open_withdraw_items(id);
}
}
S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
S::DepositContainers { .. } => {
let containers = self.re_container_candidates();
if let Some(c) = containers.get(row) {
let id = c.id.clone();
self.re_open_deposit_filter(id);
}
}
S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
S::SellNpcs { .. } => {
let npcs = self.re_npc_candidates();
let npc_id = if row == 0 {
None
} else {
npcs.get(row - 1).map(|n| n.id.clone())
};
if row == 0 || npc_id.is_some() {
self.re_open_sell_item(npc_id);
}
}
S::SellItem { .. } => self.re_sell_item_activate(row),
S::CraftBlueprint { .. } => {
let bps = self.re_blueprint_ids();
if let Some(bp) = bps.get(row) {
let stop = WorkerRouteStop::CraftAt {
device: "hand".into(),
blueprint: bp.clone(),
qty: None,
};
self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
}
}
S::WaitEntry { ticks } => {
let stop = WorkerRouteStop::Wait {
wait_ticks: ticks,
};
self.re_confirm_stop(stop, format!("wait {ticks}t"));
}
S::BedPicker { .. } => {
let beds = self.re_bed_candidates();
if let Some((id, name)) = beds.get(row) {
let (id, name) = (id.clone(), name.clone());
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.lodging_container_id = Some(id.clone());
ed.sheet = S::Stops;
}
self.state
.push_log(format!("Route: rest bed set to {name}"));
}
}
S::FarmPlotPicker { action, .. } => {
let plots = self.re_farm_plot_candidates();
let Some(plot) = plots.get(row).cloned() else {
return;
};
match action {
crate::worker_route_editor::FarmPlotAction::Cultivate => {
let label = plot_route_label(&plot);
self.re_confirm_stop(
WorkerRouteStop::CultivatePlot {
plot_id: plot.plot_id,
},
format!("cultivate {label}"),
);
}
crate::worker_route_editor::FarmPlotAction::Harvest => {
let label = plot_route_label(&plot);
self.re_confirm_stop(
WorkerRouteStop::HarvestPlot {
plot_id: plot.plot_id,
},
format!("harvest {label}"),
);
}
crate::worker_route_editor::FarmPlotAction::Plant => {
let seeds = self.re_farm_seed_candidates();
if seeds.is_empty() {
self.state.push_log(
"Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
);
return;
}
self.re_open_sheet(S::FarmPlantSeed {
plot_id: plot.plot_id,
seeds,
index: 0,
});
}
}
}
S::FarmPlantSeed { plot_id, seeds, .. } => {
if let Some(seed) = seeds.get(row).cloned() {
self.re_confirm_stop(
WorkerRouteStop::PlantPlot {
plot_id,
seed_template: seed.clone(),
},
format!("plant {seed}"),
);
}
}
S::WaypointMapPick => {}
}
}
fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
use crate::worker_route_editor::RouteEditorSheet as S;
if self.re_farm_plot_candidates().is_empty() {
self.state.push_log(
"Route: no farmable plots visible — claim land or get farm access first",
);
return;
}
self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
}
fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
self.state
.property_plots
.iter()
.filter(|p| p.is_mine || p.may_farm)
.cloned()
.collect()
}
fn re_farm_seed_candidates(&self) -> Vec<String> {
let mut set = std::collections::BTreeSet::new();
let looks_like_seed = |id: &str| {
id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
};
for (id, _, _) in self.state.farm_seed_entries() {
set.insert(id);
}
for c in &self.state.placed_containers {
let mine = match (self.state.character_id, c.owner_character_id) {
(Some(a), Some(b)) => a == b,
_ => false,
};
if !mine {
continue;
}
for s in &c.contents {
if s.quantity > 0
&& (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
{
set.insert(s.template_id.clone());
}
}
}
if let Some(ed) = self.state.worker_route_editor.as_ref() {
for stop in &ed.stops {
if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
{
for it in items {
if looks_like_seed(&it.template) {
set.insert(it.template.clone());
}
}
}
if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
seed_template, ..
} = stop
{
if !seed_template.is_empty() {
set.insert(seed_template.clone());
}
}
}
}
for id in self.state.inventory_hints.keys() {
if looks_like_seed(id) {
set.insert(id.clone());
}
}
for id in ["potato_seed", "carrot_seed"] {
set.insert(id.to_string());
}
set.into_iter().collect()
}
pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
use crate::worker_route_editor as wre;
use wre::RouteEditorSheet as S;
if self.state.worker_route_editor.is_none() {
return;
}
let sheet = self
.state
.worker_route_editor
.as_ref()
.map(|ed| ed.sheet.clone())
.unwrap_or(S::Stops);
match sheet {
S::WaypointMapPick => {
let (_, _, z) = self.state.player_position_with_z();
let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
let editing = self
.state
.worker_route_editor
.as_ref()
.is_some_and(|ed| ed.editing_index.is_some());
if !editing {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.sheet = S::WaypointMapPick;
}
}
}
S::HarvestPicker { .. } => {
if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
let mut log: Option<String> = None;
if let Some(ed) = self.state.worker_route_editor.as_mut() {
let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
return;
};
let selected = if picked.contains(&node.id) {
picked.remove(&node.id);
false
} else {
picked.insert(node.id.clone());
true
};
log = Some(format!(
"Route: {} {}",
if selected { "selected" } else { "deselected" },
node.label
));
}
if let Some(msg) = log {
self.state.push_log(msg);
}
}
}
S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
if let Some(cid) = wre::pick_storage_container_at(
&self.state.placed_containers,
self.state.character_id,
x,
y,
) {
self.re_open_withdraw_items(cid);
}
}
S::DepositContainers { .. } | S::DepositFilter { .. } => {
if let Some(cid) = wre::pick_storage_container_at(
&self.state.placed_containers,
self.state.character_id,
x,
y,
) {
self.re_open_deposit_filter(cid);
}
}
S::SellNpcs { .. } => {
if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
self.re_open_sell_item(Some(npc_id));
}
}
S::SellItem { .. } => {
if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
*slot = Some(npc_id.clone());
}
}
self.state
.push_log(format!("Route: sell NPC → {label} ({npc_id})"));
}
}
_ => self.worker_route_editor_quick_add_click(x, y),
}
}
fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
use crate::worker_route_editor as wre;
let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
let dx = ax - bx;
let dy = ay - by;
(dx * dx + dy * dy).sqrt()
};
let selected_stop_kind = self
.state
.worker_route_editor
.as_ref()
.and_then(|ed| ed.stops.get(ed.selected_stop_index))
.map(|s| match s {
wre::WorkerRouteStop::TradeWith { .. } => 1,
wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
_ => 0,
})
.unwrap_or(0);
if selected_stop_kind == 1 {
if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.set_selected_trade_npc(npc_id.clone());
}
self.state
.push_log(format!("Route: sell NPC → {label} ({npc_id})"));
return;
}
}
if selected_stop_kind == 2 {
if let Some(cid) = wre::pick_storage_container_at(
&self.state.placed_containers,
self.state.character_id,
x,
y,
) {
let name = self
.state
.placed_containers
.iter()
.find(|c| c.id == cid)
.map(|c| c.display_name.clone())
.unwrap_or_else(|| "container".into());
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.set_selected_withdraw_container(cid.clone());
}
self.state
.push_log(format!("Route: withdraw source → {name}"));
return;
}
}
enum Target {
Bed(String),
Container(String),
Npc(String, String),
Node(String, String),
}
let mut best: Option<(f32, u8, Target)> = None;
let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
let better = match best {
None => true,
Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
};
if better {
*best = Some((d, rank, t));
}
};
if let Some(bed_id) = wre::pick_lodging_container_at(
&self.state.placed_containers,
self.state.character_id,
x,
y,
) {
if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
let already_bed = self
.state
.worker_route_editor
.as_ref()
.is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
if already_bed {
consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
} else {
consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
}
}
}
if let Some(cid) = wre::pick_storage_container_at(
&self.state.placed_containers,
self.state.character_id,
x,
y,
) {
if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
}
}
if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
consider(
dist(x, y, n.x, n.y),
2,
Target::Npc(npc_id, label),
&mut best,
);
}
}
if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
let d = dist(x, y, node.x, node.y);
consider(
d,
3,
Target::Node(node.id.clone(), node.label.clone()),
&mut best,
);
}
match best.map(|(_, _, t)| t) {
Some(Target::Bed(bed_id)) => {
let name = self
.state
.placed_containers
.iter()
.find(|c| c.id == bed_id)
.map(|c| c.display_name.clone())
.unwrap_or_else(|| "camp bed".into());
if let Some(ed) = self.state.worker_route_editor.as_mut() {
ed.lodging_container_id = Some(bed_id.clone());
}
self.state
.push_log(format!("Route: rest bed set to {name} ({bed_id})"));
}
Some(Target::Container(cid)) => {
let name = self
.state
.placed_containers
.iter()
.find(|c| c.id == cid)
.map(|c| c.display_name.clone())
.unwrap_or_else(|| "container".into());
let added = self
.state
.worker_route_editor
.as_mut()
.is_some_and(|ed| ed.append_deposit_at(&cid));
if added {
self.state
.push_log(format!("Route: + deposit at {name} ({cid})"));
} else {
self.state.push_log(format!(
"Route: {name} already in route — selected it (d to remove)"
));
}
}
Some(Target::Npc(npc_id, label)) => {
let template = self.re_template_candidates().into_iter().next();
let Some(template) = template else {
self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
return;
};
let added = self
.state
.worker_route_editor
.as_mut()
.is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
if added {
self.state
.push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
} else {
self.state.push_log(format!(
"Route: {label} already sells {template} — selected it (d to remove)"
));
}
}
Some(Target::Node(id, label)) => {
let added = self
.state
.worker_route_editor
.as_mut()
.is_some_and(|ed| ed.append_harvest_node(&id));
if added {
self.state
.push_log(format!("Route: + harvest node {label} ({id})"));
} else {
self.state.push_log(format!(
"Route: {label} already in route — selected it (d to remove)"
));
}
}
None => {}
}
}
pub fn worker_route_editor_select(&mut self, delta: i32) {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
if ed.stops.is_empty() {
return;
}
let n = ed.stops.len() as i32;
let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
ed.selected_stop_index = next;
}
pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
if delta < 0 {
ed.move_selected_up();
} else if delta > 0 {
ed.move_selected_down();
}
}
pub fn worker_route_editor_delete_selected(&mut self) {
let removed = self
.state
.worker_route_editor
.as_mut()
.is_some_and(|ed| {
let before = ed.stop_count();
ed.remove_selected_stop();
ed.stop_count() < before
});
if removed {
self.state.push_log("Route: removed selected stop");
}
}
pub fn worker_route_editor_clear_stops(&mut self) {
let Some(ed) = self.state.worker_route_editor.as_mut() else {
return;
};
if ed.stops.is_empty() {
self.state.push_log("Route: already empty — s saves an idle worker".to_string());
return;
}
ed.stops.clear();
ed.selected_stop_index = 0;
self.state
.push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
}
pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
if self.state.pending_worker_job_ack.is_some() {
anyhow::bail!("route save still pending — wait for server ack");
}
let Some(ed) = self.state.worker_route_editor.clone() else {
anyhow::bail!("route editor not open");
};
let (job_yaml, idle) = if ed.stops.is_empty() {
(ed.build_idle_job_yaml(), true)
} else {
(ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
};
let worker_id = ed.worker_instance_id.clone();
let route_view = if idle {
None
} else {
Some(ed.to_route_view())
};
let mode = if idle {
flatland_protocol::WorkerModeView::Idle
} else {
flatland_protocol::WorkerModeView::JobLoop
};
let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
.state
.hired_workers
.iter()
.find(|w| w.instance_id == worker_id)
.map(|w| {
(
w.route.clone(),
w.mode,
w.step_label.clone(),
w.last_error.clone(),
)
})
.unwrap_or((
None,
flatland_protocol::WorkerModeView::Idle,
String::new(),
None,
));
self.seq += 1;
let seq = self.seq;
self.session
.submit_intent(Intent::SetWorkerJob {
entity_id: self.state.entity_id,
worker_instance_id: worker_id.clone(),
job_yaml,
seq,
})
.await?;
self.state.intents_sent += 1;
if let Some(w) = self
.state
.hired_workers
.iter_mut()
.find(|w| w.instance_id == worker_id)
{
w.route = route_view;
w.mode = mode;
w.last_error = None;
if idle {
w.step_label.clear();
w.route_stop_index = None;
}
}
self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
seq,
worker_instance_id: worker_id,
worker_label: ed.worker_label.clone(),
idle,
stop_count: ed.stops.len(),
prev_route,
prev_mode,
prev_step_label,
prev_last_error,
});
self.state.push_log(format!(
"Route: saving for {}… (waiting for server)",
ed.worker_label
));
Ok(())
}
pub fn quest_menu_move(&mut self, delta: i32) {
let n = self.state.active_quest_entries().len();
if n == 0 {
return;
}
let idx = self.state.quest_menu_index as i32;
self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
}
pub fn quest_menu_page(&mut self, pages: i32) {
let n = self.state.active_quest_entries().len();
self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
}
pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
let Some(offer) = self.state.pending_quest_offer.clone() else {
anyhow::bail!("no quest offer");
};
self.seq += 1;
let seq = self.seq;
self.session
.submit_intent(Intent::AcceptQuest {
entity_id: self.state.entity_id,
quest_id: offer.quest_id,
seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn quest_offer_decline(&mut self) {
self.state.show_quest_offer = false;
self.state.pending_quest_offer = None;
if !self.state.show_npc_chat
&& !self.state.show_shop_menu
&& self.state.npc_verb_target.is_some()
{
self.state.show_npc_verb_menu = true;
}
}
pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
if !self.state.show_quest_menu {
return Ok(());
}
let active: Vec<_> = self
.state
.active_quest_entries()
.into_iter()
.cloned()
.collect();
let Some(entry) = active.get(self.state.quest_menu_index) else {
return Ok(());
};
if self.state.quest_withdraw_confirm {
if !entry.can_withdraw {
anyhow::bail!("quest cannot be withdrawn");
}
self.seq += 1;
let seq = self.seq;
self.session
.submit_intent(Intent::WithdrawQuest {
entity_id: self.state.entity_id,
quest_id: entry.quest_id.clone(),
seq,
})
.await?;
self.state.intents_sent += 1;
self.state.quest_withdraw_confirm = false;
return Ok(());
}
self.seq += 1;
let seq = self.seq;
self.session
.submit_intent(Intent::TrackQuest {
entity_id: self.state.entity_id,
quest_id: entry.quest_id.clone(),
seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn quest_request_withdraw(&mut self) {
if self.state.show_quest_menu {
self.state.quest_withdraw_confirm = true;
}
}
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");
};
if line.quantity == 0 {
anyhow::bail!("you have no {}", line.label);
}
let quantity = self.state.shop_quantity.min(line.quantity).max(1);
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,
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 f 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 => {
anyhow::bail!("nothing to interact with nearby");
}
};
if self.state.npcs.iter().any(|n| n.id == target_id) {
self.state.show_npc_verb_menu = true;
self.state.npc_verb_target = Some(target_id);
self.state.npc_verb_index = 0;
return Ok(());
}
if self
.state
.hired_workers
.iter()
.any(|w| w.instance_id == target_id)
{
return self.open_workers_menu_for(&target_id).await;
}
if let Ok(peer_id) = target_id.parse::<EntityId>() {
if self
.state
.hired_workers
.iter()
.any(|w| w.entity_id == peer_id)
{
if let Some(w) = self
.state
.hired_workers
.iter()
.find(|w| w.entity_id == peer_id)
{
let id = w.instance_id.clone();
return self.open_workers_menu_for(&id).await;
}
}
if let Some(entity) = self
.state
.entities
.iter()
.find(|e| e.id == peer_id && e.id != self.state.entity_id)
{
self.state
.player_verbs
.open_for(peer_id, &entity.label);
return Ok(());
}
}
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 use_nearest(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
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;
}
if self
.state
.placed_containers
.iter()
.any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
{
return self.pickup_nearest_container().await;
}
if let Some(plot) = self.state.my_plot_under_player().cloned() {
const SELL_WINDOW: Duration = Duration::from_millis(1200);
let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
&& self
.state
.sell_plot_armed_at
.is_some_and(|t| t.elapsed() <= SELL_WINDOW);
if sell_armed {
return self.confirm_sell_plot_to_crown(plot.plot_id).await;
}
self.state.sell_plot_confirm = None;
self.state.sell_plot_armed_at = None;
let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
self.state.npcs.iter().any(|n| n.id == id)
|| self.state.hired_workers.iter().any(|w| w.instance_id == id)
|| self.state.doors.iter().any(|d| d.id == id)
|| self.state.interactables.iter().any(|i| {
i.id == id
&& matches!(
i.kind.as_str(),
"quest_board" | "well" | "exit" | "enter"
)
})
|| id.parse::<EntityId>().is_ok_and(|eid| {
self.state
.entities
.iter()
.any(|e| e.id == eid && e.id != self.state.entity_id)
})
});
if !blocking_interact {
match self.harvest_nearest().await {
Ok(()) => return Ok(()),
Err(err) => {
let msg = err.to_string();
if !(msg.contains("no harvestable")
|| msg.contains("press p")
|| msg.contains("press f")
|| msg.contains("nothing"))
{
return Err(err);
}
}
}
return Ok(());
}
}
if self.state.nearest_interact_target().is_some() {
return self.interact_nearest().await;
}
if let Some((label, dist)) = self.state.nearest_quest_board() {
if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
anyhow::bail!(
"too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
);
}
}
match self.harvest_nearest().await {
Ok(()) => Ok(()),
Err(err) => {
let msg = err.to_string();
if msg.contains("no harvestable")
|| msg.contains("press p")
|| msg.contains("press f")
{
anyhow::bail!(
"nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
);
}
Err(err)
}
}
}
pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
if self.state.claim_mode.is_some() {
anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
}
let zone = self
.state
.free_property_zone_under_player()
.ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
let zone_id = zone.id.clone();
let label = zone
.label
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(zone.id.as_str())
.to_string();
self.enter_claim_mode(&zone_id);
self.state
.push_log(format!(
"Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
));
Ok(())
}
pub fn enter_claim_mode(&mut self, zone_id: &str) {
let Some(zone) = self
.state
.property_zones
.iter()
.find(|z| z.id == zone_id)
.cloned()
else {
self.state.push_log("unknown property zone");
return;
};
self.state.sell_plot_confirm = None;
self.state.sell_plot_armed_at = None;
let min_area = self
.state
.property_plot_settings
.as_ref()
.map(|s| s.min_plot_area_m2)
.unwrap_or(4.0)
.max(1.0);
let min_side = min_area.sqrt().ceil().max(1.0) as u32;
let side = 4u32.max(min_side);
let (px, py) = self.state.player_position();
let anchor_x = px.floor();
let anchor_y = py.floor();
self.state.claim_mode = Some(ClaimModeState {
zone_id: zone.id.clone(),
width_m: side,
height_m: side,
anchor_x,
anchor_y,
});
let label = zone
.label
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(zone.id.as_str());
self.state.push_log(format!(
"Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
));
}
pub fn cancel_claim_mode(&mut self) {
if self.state.claim_mode.take().is_some() {
self.state.push_log("Claim cancelled");
}
}
pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
if self.state.relocate_mode.is_some() {
anyhow::bail!("already relocating — Enter confirm, Esc cancel");
}
if self.state.claim_mode.is_some() {
anyhow::bail!("finish or cancel claim mode first");
}
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.locked && !chest.accessible {
anyhow::bail!(
"need the matching key for {} before moving it",
chest.display_name
);
}
let label = if chest.display_name.trim().is_empty() {
chest.template_id.clone()
} else {
chest.display_name.clone()
};
self.state.relocate_mode = Some(RelocateModeState {
container_id: chest.id.clone(),
label: label.clone(),
cursor_x: chest.x.floor() + 0.5,
cursor_y: chest.y.floor() + 0.5,
});
self.state.push_log(format!(
"Relocate {label} — WASD move square · Enter confirm · Esc cancel"
));
Ok(())
}
pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
anyhow::bail!("no chest nearby to relocate");
};
if chest.locked && !chest.accessible {
anyhow::bail!(
"need the matching key for {} before moving it",
chest.display_name
);
}
self.begin_relocate_container(&chest.id)
}
pub fn cancel_relocate_mode(&mut self) {
if self.state.relocate_mode.take().is_some() {
self.state.push_log("Relocate cancelled");
}
}
pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
let Some(mode) = self.state.relocate_mode.as_mut() else {
return;
};
let max_x = self.state.world_width_m.max(1.0);
let max_y = self.state.world_height_m.max(1.0);
let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
mode.cursor_x = nx.floor() + 0.5;
mode.cursor_y = ny.floor() + 0.5;
}
pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
let Some(mode) = self.state.relocate_mode.as_mut() else {
return;
};
let max_x = self.state.world_width_m.max(1.0);
let max_y = self.state.world_height_m.max(1.0);
mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
}
pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let Some(mode) = self.state.relocate_mode.clone() else {
anyhow::bail!("not relocating");
};
let (px, py) = self.state.player_position();
let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
if dist > 8.0 {
anyhow::bail!("destination too far (max 8 m)");
}
self.seq += 1;
self.session
.submit_intent(Intent::MovePlacedContainer {
entity_id: self.state.entity_id,
container_id: mode.container_id.clone(),
x: mode.cursor_x,
y: mode.cursor_y,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.relocate_mode = None;
self.state
.push_log(format!("Moving {}…", mode.label));
Ok(())
}
pub fn claim_set_preset(&mut self, w: u32, h: u32) {
let Some(mode) = self.state.claim_mode.as_mut() else {
return;
};
mode.width_m = w.max(1);
mode.height_m = h.max(1);
}
pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
let Some(mode) = self.state.claim_mode.as_mut() else {
return;
};
let w = (mode.width_m as i32 + dw).max(1) as u32;
let h = (mode.height_m as i32 + dh).max(1) as u32;
mode.width_m = w;
mode.height_m = h;
}
pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
let Some(mode) = self.state.claim_mode.as_mut() else {
return;
};
let max_x = self.state.world_width_m.max(1.0);
let max_y = self.state.world_height_m.max(1.0);
let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
mode.anchor_x = nx.floor();
mode.anchor_y = ny.floor();
}
pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let Some(mode) = self.state.claim_mode.clone() else {
anyhow::bail!("not in claim mode");
};
let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
self.state.claim_quote()
else {
anyhow::bail!("cannot quote claim");
};
if !valid {
anyhow::bail!(reason);
}
if !can_afford {
anyhow::bail!(
"not enough copper (need {})",
crate::currency::format_copper(purchase)
);
}
let (x0, y0, x1, y1) = self
.state
.claim_footprint_rect()
.ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
self.seq += 1;
self.session
.submit_intent(Intent::BuyPlot {
entity_id: self.state.entity_id,
zone_id: mode.zone_id,
x0,
y0,
x1,
y1,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.claim_mode = None;
self.state
.push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
Ok(())
}
pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let zone_id = self
.state
.claim_mode
.as_ref()
.map(|m| m.zone_id.clone())
.or_else(|| {
self.state
.free_property_zone_under_player()
.map(|z| z.id.clone())
})
.ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
self.seq += 1;
self.session
.submit_intent(Intent::BuyPlotAllFree {
entity_id: self.state.entity_id,
zone_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.claim_mode = None;
self.state.push_log("Claiming largest free plot…");
Ok(())
}
pub async fn confirm_sell_plot_to_crown(
&mut self,
plot_id: uuid::Uuid,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::SellPlotToCrown {
entity_id: self.state.entity_id,
plot_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.sell_plot_confirm = None;
self.state.sell_plot_armed_at = None;
self.state.push_log("Selling plot to the crown…");
Ok(())
}
pub async fn set_plot_farm_public(
&mut self,
plot_id: uuid::Uuid,
public: bool,
public_tax_discount_bps: u32,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::SetPlotFarmPublic {
entity_id: self.state.entity_id,
plot_id,
public,
public_tax_discount_bps,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn plot_farm_allow_upsert(
&mut self,
plot_id: uuid::Uuid,
character_id: Option<uuid::Uuid>,
character_name: String,
tax_discount_bps: u32,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::PlotFarmAllowUpsert {
entity_id: self.state.entity_id,
plot_id,
character_id,
character_name,
tax_discount_bps,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn plot_farm_allow_remove(
&mut self,
plot_id: uuid::Uuid,
character_id: uuid::Uuid,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::PlotFarmAllowRemove {
entity_id: self.state.entity_id,
plot_id,
character_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn open_farm_access_panel(&mut self) {
let Some(plot) = self.state.my_plot_under_player() else {
self.state
.push_log("Stand on your deed plot to manage farm access");
return;
};
self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
self.state.farm_access_index = 0;
self.state.show_farm_access = true;
}
pub fn close_farm_access_panel(&mut self) {
self.state.show_farm_access = false;
self.state.farm_access_name_draft.clear();
self.state.farm_access_index = 0;
}
pub fn farm_access_move(&mut self, delta: i32) {
let n = self.farm_access_row_count().max(1);
let idx = self.state.farm_access_index as i32 + delta;
self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
}
pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
let Some(plot) = self.state.my_plot_under_player() else {
return vec![FarmAccessRow::PublicToggle];
};
let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
for g in &plot.farm_allow {
rows.push(FarmAccessRow::AllowRemove {
character_id: g.character_id,
label: if g.character_label.trim().is_empty() {
g.character_id.to_string()[..8].to_string()
} else {
g.character_label.clone()
},
tax_discount_bps: g.tax_discount_bps,
});
}
for e in &self.state.entities {
if e.id == self.state.entity_id || e.label.trim().is_empty() {
continue;
}
if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
continue;
}
if self
.state
.npcs
.iter()
.any(|n| n.id == e.label || n.label == e.label)
{
continue;
}
if plot
.farm_allow
.iter()
.any(|g| !g.character_label.is_empty() && g.character_label == e.label)
{
continue;
}
rows.push(FarmAccessRow::NearbyAdd {
name: e.label.clone(),
});
}
rows
}
pub fn farm_access_row_count(&self) -> usize {
self.farm_access_rows().len().max(1)
}
pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
let Some(plot) = self.state.my_plot_under_player().cloned() else {
self.close_farm_access_panel();
return Ok(());
};
let rows = self.farm_access_rows();
let Some(row) = rows.get(self.state.farm_access_index) else {
return Ok(());
};
match row {
FarmAccessRow::PublicToggle => {
self.set_plot_farm_public(
plot.plot_id,
!plot.farm_public,
plot.public_tax_discount_bps,
)
.await
}
FarmAccessRow::PublicDiscount => Ok(()),
FarmAccessRow::AllowRemove { character_id, .. } => {
self.plot_farm_allow_remove(plot.plot_id, *character_id)
.await
}
FarmAccessRow::NearbyAdd { name } => {
let disc = self
.state
.farm_access_discount_bps
.max(plot.public_tax_discount_bps);
self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
.await
}
}
}
pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
let Some(plot) = self.state.my_plot_under_player().cloned() else {
return Ok(());
};
let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
self.state.farm_access_discount_bps = next;
self.state.farm_access_index = 1;
self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
.await
}
pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
if self.state.farmable_plot_under_player().is_none() {
anyhow::bail!("stand on a farmable plot to cultivate");
}
let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
let (px, py) = self.state.player_position();
if self
.state
.terrain_at(px, py)
.is_some_and(|k| k == TerrainKindView::Tilled)
{
anyhow::bail!("already tilled — stand on bare soil and press c");
}
anyhow::bail!("cannot till this cell — move onto soil on your plot");
};
self.cultivate_at(tx, ty).await
}
pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
if self.state.farmable_plot_under_player().is_none() {
anyhow::bail!("stand on a farmable plot to plant");
}
if !self.state.underfoot_free_tilled_plant_slot() {
anyhow::bail!("stand on empty tilled soil and press p");
}
let seeds = self.state.farm_seed_entries();
if seeds.is_empty() {
anyhow::bail!("no seeds in inventory — buy seeds from Eli");
}
if seeds.len() == 1 {
return self.plant_seeds(seeds[0].0.clone(), 1).await;
}
self.open_plant_menu();
Ok(())
}
pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Cultivate {
entity_id: self.state.entity_id,
x,
y,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn plant_seeds(
&mut self,
seed_template_id: String,
quantity: u32,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::PlantSeeds {
entity_id: self.state.entity_id,
seed_template_id: seed_template_id.clone(),
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.push_log(format!("Planting {quantity}× {seed_template_id}…"));
Ok(())
}
pub fn open_plant_menu(&mut self) {
if self.state.farm_seed_entries().is_empty() {
self.state.push_log("No seeds in inventory to plant");
return;
}
self.state.show_plant_menu = true;
self.state.plant_menu_index = 0;
self.state.plant_quantity = 1;
self.state.clamp_plant_menu();
}
pub fn close_plant_menu(&mut self) {
self.state.show_plant_menu = false;
}
pub fn plant_menu_move(&mut self, delta: i32) {
let n = self.state.farm_seed_entries().len();
if n == 0 {
return;
}
let idx = self.state.plant_menu_index as i32 + delta;
self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
self.state.clamp_plant_menu();
}
pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
let next = self.state.plant_quantity as i32 + delta;
self.state.plant_quantity = next.max(1) as u32;
self.state.clamp_plant_menu();
}
pub fn plant_menu_set_quantity_max(&mut self) {
if let Some((_, max, _)) = self.state.plant_menu_selection() {
self.state.plant_quantity = max;
}
self.state.clamp_plant_menu();
}
pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
self.close_plant_menu();
anyhow::bail!("no seeds to plant");
};
self.close_plant_menu();
self.plant_seeds(seed, qty).await?;
self.state.push_log(format!("Planted {qty}× {label}"));
Ok(())
}
pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let binding = self
.state
.hotbar_ability(slot)
.ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
.to_string();
if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
if qty == 0 {
anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
}
return self.use_item(template_id).await;
}
let ability_id = binding;
let target = if ability_id == "heal_touch" {
Some(
self.state
.target_for_slot(2)
.unwrap_or(self.state.entity_id),
)
} else {
self.state
.target_for_slot(1)
.or_else(|| self.state.target_for_slot(2))
};
let Some(target_id) = target else {
anyhow::bail!("no target — Tab to select, then press the hotbar key");
};
self.cast_ability(&ability_id, Some(target_id)).await
}
pub async fn set_hotbar_slot(
&mut self,
slot: u8,
ability_id: Option<&str>,
) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
if !(1..=9).contains(&slot) {
anyhow::bail!("hotbar slot must be 1–9");
}
let ability_id = ability_id
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string);
self.seq += 1;
self.session
.submit_intent(Intent::SetHotbarSlot {
entity_id: self.state.entity_id,
slot,
ability_id: ability_id.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
let idx = (slot - 1) as usize;
if self.state.hotbar.len() < 9 {
self.state.hotbar.resize(9, None);
}
if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
*slot_mut = ability_id.clone();
}
match ability_id {
Some(id) => {
let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
format!("use {tid}")
} else {
id
};
self.state.push_log(format!("Hotbar {slot} → {label}"))
}
None => self.state.push_log(format!("Hotbar {slot} cleared")),
}
Ok(())
}
pub fn npc_verb_options(&self) -> Vec<&'static str> {
self.state.npc_verb_options()
}
pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
let Some(npc_id) = self.state.npc_verb_target.clone() else {
return Ok(());
};
let options = self.npc_verb_options();
let choice = options
.get(self.state.npc_verb_index)
.copied()
.unwrap_or("Talk");
self.seq += 1;
match choice {
"Trade" | "Bank" | "Storage" | "Market" => {
self.session
.submit_intent(Intent::Interact {
entity_id: self.state.entity_id,
target_id: npc_id,
seq: self.seq,
})
.await?;
}
_ => {
self.session
.submit_intent(Intent::NpcTalkOpen {
entity_id: self.state.entity_id,
npc_id,
seq: self.seq,
})
.await?;
}
}
self.state.intents_sent += 1;
Ok(())
}
pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
let Some(chat) = self.state.npc_chat.clone() else {
return Ok(());
};
let message = chat.input.trim().to_string();
if message.is_empty() || chat.pending {
return Ok(());
}
if let Some(c) = self.state.npc_chat.as_mut() {
c.lines.push(format!("You: {message}"));
c.input.clear();
c.pending = true;
}
self.seq += 1;
self.session
.submit_intent(Intent::NpcTalkSay {
entity_id: self.state.entity_id,
npc_id: chat.npc_id,
message,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
let return_to_verbs = self.state.npc_verb_target.is_some();
let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
self.state.show_npc_chat = false;
if return_to_verbs {
self.state.show_npc_verb_menu = true;
}
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::NpcTalkClose {
entity_id: self.state.entity_id,
npc_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.show_npc_chat = false;
self.state.npc_chat = None;
if return_to_verbs {
self.state.show_npc_verb_menu = true;
}
Ok(())
}
pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
if self.state.show_quest_offer
&& (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
{
self.quest_offer_decline();
return Ok(());
}
if self.state.show_npc_chat {
return self.npc_talk_close().await;
}
if self.state.show_shop_menu {
return self.back_from_shop_menu().await;
}
if self.state.bank_panel.is_some() {
if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
self.bank_transfer_back();
return Ok(());
}
return self.close_bank_panel().await;
}
if self.state.storage_panel.is_some() {
if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
self.storage_ui_back();
return Ok(());
}
return self.close_storage_panel().await;
}
if self.state.market_panel.is_some() {
if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
self.market_ui_back();
return Ok(());
}
if self.state.market_buy_confirm.is_some() {
self.state.market_buy_confirm = None;
return Ok(());
}
return self.close_market_panel().await;
}
if self.state.show_npc_verb_menu {
self.state.show_npc_verb_menu = false;
self.state.npc_verb_target = None;
}
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 f");
}
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 async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::DirectionalJump {
entity_id: self.state.entity_id,
forward,
strafe,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.push_log("Jump!");
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,
instance_id: None,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
let idx = self.state.equip_menu_index;
let slots = equip_paperdoll_rows(&self.state);
let Some(row) = slots.get(idx) else {
return Ok(());
};
match row {
EquipPaperdollRow::Body { slot, filled } => {
if *filled {
self.equip_worn(*slot, None).await
} else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
self.equip_worn(*slot, Some(inst)).await
} else {
self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
Ok(())
}
}
EquipPaperdollRow::Mainhand { filled } => {
if *filled {
self.unequip_mainhand().await
} else if let Some(tid) = first_inventory_weapon(&self.state) {
self.equip_mainhand(Some(tid)).await
} else {
self.state.push_log("No weapon in inventory".to_string());
Ok(())
}
}
EquipPaperdollRow::Offhand { filled, locked } => {
if *locked {
self.state
.push_log("Offhand locked — two-handed weapon equipped".to_string());
Ok(())
} else if *filled {
self.unequip_offhand().await
} else if let Some(tid) = first_inventory_offhand(&self.state) {
self.equip_offhand(Some(tid)).await
} else {
self.state
.push_log("No offhand item in inventory".to_string());
Ok(())
}
}
}
}
pub async fn say(
&mut self,
channel: flatland_protocol::ChatChannel,
text: &str,
) -> anyhow::Result<()> {
self.say_to(channel, text, None).await
}
pub async fn say_to(
&mut self,
channel: flatland_protocol::ChatChannel,
text: &str,
to_entity: Option<EntityId>,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::Say {
entity_id: self.state.entity_id,
channel,
text: text.to_string(),
to_entity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
let Some(peer) = self.state.player_verbs.target_entity else {
return Ok(());
};
let label = self.state.player_verbs.target_label.clone();
let choice = crate::social::PlayerVerbState::options()
.get(self.state.player_verbs.index)
.copied()
.unwrap_or("Whisper");
self.state.player_verbs.close();
match choice {
"Trade" => {
self.seq += 1;
self.session
.submit_intent(Intent::TradeRequest {
entity_id: self.state.entity_id,
peer_entity_id: peer,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state
.social_chat
.push_system(format!("Trade request sent to {label} — waiting for accept"));
}
"Whisper" => self.state.social_chat.focus_whisper(peer, &label),
_ => self.state.social_chat.focus_nearby(),
}
Ok(())
}
pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
let Some(pending) = self.state.social_chat.pending_trade.take() else {
return Ok(());
};
self.seq += 1;
self.session
.submit_intent(Intent::TradeRespond {
entity_id: self.state.entity_id,
peer_entity_id: pending.from_entity,
accept,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
if accept {
self.state
.social_chat
.push_system(format!("Accepted trade with {}", pending.from_name));
} else {
self.state
.social_chat
.push_system(format!("Declined trade with {}", pending.from_name));
}
Ok(())
}
pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
let text = self.state.social_chat.buffer.trim().to_string();
if text.is_empty() {
return Ok(());
}
let thread = self.state.social_chat.thread;
let channel = thread.channel();
let to = thread.to_entity();
self.state.social_chat.buffer.clear();
self.say_to(channel, &text, to).await
}
pub async fn trade_present_selected(
&mut self,
item_instance_id: uuid::Uuid,
) -> anyhow::Result<()> {
self.trade_present_quantity(item_instance_id, None).await
}
pub async fn trade_present_quantity(
&mut self,
item_instance_id: uuid::Uuid,
quantity: Option<u32>,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::TradePresent {
entity_id: self.state.entity_id,
item_instance_id,
quantity,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.trade_ui.qty_entry = None;
self.state.trade_ui.picking_inventory = false;
Ok(())
}
pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
let qty = self.state.trade_ui.present_quantity();
return self
.trade_present_quantity(entry.item_instance_id, qty)
.await;
}
if !self.state.trade_ui.picking_inventory {
return Ok(());
}
let Some(stack) = self
.state
.inventory_stacks
.get(self.state.trade_ui.inventory_index)
.cloned()
else {
return Ok(());
};
let Some(id) = stack.item_instance_id else {
return Ok(());
};
let label = stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone());
if stack.quantity <= 1 {
self.trade_present_quantity(id, Some(1)).await
} else {
self.state
.trade_ui
.begin_qty_entry(id, label, stack.quantity);
Ok(())
}
}
pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::TradeSetReady {
entity_id: self.state.entity_id,
ready,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::TradeCancel {
entity_id: self.state.entity_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
self.state.trade_ui.close();
Ok(())
}
pub async fn destroy_whisper_stone(
&mut self,
item_instance_id: uuid::Uuid,
) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::DestroyWhisperStone {
entity_id: self.state.entity_id,
item_instance_id,
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,
publish_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,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: vec![],
}],
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,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec![],
}],
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_blueprint: Some("broker_hut".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
}],
doors: vec![flatland_protocol::DoorView {
id: "door-1".into(),
building_id: "broker-hut".into(),
x: 148.0,
y: 118.0,
open: false,
portal: Some("front".into()),
}],
interior_map: None,
npcs: vec![],
blueprints: vec![],
world_x0: 0.0,
world_y0: 0.0,
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: Vec::new(),
z_platforms: Vec::new(),
z_transitions: 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,
hud_log_hidden: false,
show_equip_menu: false,
equip_menu_index: 0,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
bank_panel: None,
bank_menu_index: 0,
bank_ui_mode: BankUiMode::Menu,
storage_panel: None,
market_panel: None,
market_menu_index: 0,
market_filter: String::new(),
market_filter_focused: false,
market_category_filter: None,
market_buy_confirm: None,
market_ui_mode: MarketUiMode::Browse,
storage_menu_index: 0,
storage_ui_mode: StorageUiMode::Menu,
shop_tab: ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
shop_trade_log: VecDeque::new(),
show_npc_verb_menu: false,
npc_verb_target: None,
npc_verb_index: 0,
player_verbs: crate::social::PlayerVerbState::default(),
social_chat: crate::social::SocialChatState::default(),
trade_ui: crate::social::TradeUiState::default(),
whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
show_npc_chat: false,
npc_chat: None,
show_inventory_menu: false,
inventory_menu_index: 0,
inventory_tab: InventoryTab::OnPerson,
inventory_filter: String::new(),
inventory_filter_focused: false,
show_move_picker: false,
show_rename_prompt: false,
show_worker_rename: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_grant_picker: false,
grant_picker_index: 0,
grant_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
combat_fx: Vec::new(),
property_zones: Vec::new(),
tax_zones: Vec::new(),
growth_zones: Vec::new(),
biome_zones: Vec::new(),
property_plots: Vec::new(),
property_plot_settings: None,
claim_mode: None,
relocate_mode: None,
sell_plot_confirm: None,
sell_plot_armed_at: None,
show_plant_menu: false,
plant_menu_index: 0,
show_farm_access: false,
farm_access_name_draft: String::new(),
farm_access_discount_bps: 0,
farm_access_index: 0,
plant_quantity: 1,
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,
offhand_template_id: None,
offhand_label: None,
mainhand_hand_slots: 1,
defense: None,
worn: BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
keychain_stacks: Vec::new(),
whisper_pouch_stacks: Vec::new(),
combat_target_detail: None,
statuses: Vec::new(),
cast_progress: None,
timed_channel: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
known_abilities: Vec::new(),
hotbar: vec![None; 9],
max_abilities_per_rotation: 0,
show_loadout_menu: false,
show_keychain_menu: false,
keychain_menu_index: 0,
show_rotation_editor: false,
loadout_menu_index: 0,
loadout_hotbar_slot: 1,
loadout_ability_index: 0,
loadout_focus_presets: false,
rotation_editor: RotationEditorState::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
pending_worker_job_ack: None,
attending_worker_instance_id: None,
quest_log: Vec::new(),
interactables: Vec::new(),
ledger: None,
career: None,
character_sheet_tab: CharacterSheetTab::Character,
ledger_period: LedgerPeriod::Day,
show_quest_offer: false,
pending_quest_offer: None,
show_quest_menu: false,
quest_menu_index: 0,
quest_withdraw_confirm: false,
hired_workers: Vec::new(),
show_workers_menu: false,
workers_menu_index: 0,
workers_menu_compact: false,
worker_step_display: BTreeMap::new(),
worker_error_display: BTreeMap::new(),
show_worker_give_picker: false,
worker_give_picker_index: 0,
worker_give_picker: None,
show_worker_give_target_picker: false,
worker_give_target_picker_index: 0,
worker_give_target_picker: None,
show_worker_take_picker: false,
worker_take_picker_index: 0,
worker_take_picker: None,
show_worker_teach_picker: false,
worker_teach_picker_index: 0,
worker_teach_picker: None,
worker_route_editor: None,
progression_curve: None,
};
state.player = state.entities.first().cloned();
state
}
#[test]
fn whisper_cancels_when_peer_walks_out_of_range() {
let mut state = sample_state();
state.player = state.entities.first().cloned();
let mut peer = state.entities[0].clone();
peer.id = 2;
peer.label = "Ada".into();
peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
state.social_chat.focus_whisper(2, "Ada");
state.refresh_whisper_range();
assert!(matches!(
state.social_chat.thread,
crate::social::ChatThreadKind::Whisper { peer: 2 }
));
peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
state.refresh_whisper_range();
assert_eq!(
state.social_chat.thread,
crate::social::ChatThreadKind::Nearby
);
assert!(!state.social_chat.input_focused);
}
#[test]
fn probe_use_world_hired_worker_manage() {
let mut state = sample_state();
state.hired_workers.push(flatland_protocol::HiredWorkerView {
instance_id: "worker-1".into(),
entity_id: 42,
def_id: "worker_laborer".into(),
label: "Sam".into(),
x: 129.0,
y: 128.0,
z: 0.0,
mode: flatland_protocol::WorkerModeView::JobLoop,
state: flatland_protocol::WorkerStateView::Working,
step_label: "cultivate".into(),
vitals: flatland_protocol::WorkerVitalsSummary {
health_pct: 100.0,
stamina_pct: 100.0,
},
carry_pct: 0.0,
last_error: None,
wage_copper_per_interval: 1,
effective_wage_copper: 1,
wage_meters_walked: 0.0,
lodging_container_id: None,
route: None,
route_stop_index: None,
known_blueprint_ids: Vec::new(),
level: 1,
worker_xp: 0.0,
inventory: Vec::new(),
});
let probe = state.probe_use_world();
let primary = probe.primary.expect("primary");
assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
assert_eq!(primary.id, "worker-1");
assert!(primary.hint_line().contains("Manage"));
assert!(primary.hint_line().contains("Sam"));
assert_eq!(
state.nearest_interact_target().as_deref(),
Some("worker-1")
);
}
#[test]
fn market_clerk_verb_options_include_market() {
let mut state = sample_state();
state.npcs.push(flatland_protocol::NpcView {
id: "mira_market".into(),
label: "Mira".into(),
role: "market_clerk".into(),
x: 129.0,
y: 128.0,
building_id: Some("town_market".into()),
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: false,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
paperdoll_ref: None,
});
state.npc_verb_target = Some("mira_market".into());
assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
}
#[test]
fn market_list_excludes_currency_stacks() {
let mut state = sample_state();
state.inventory_stacks = vec![
flatland_protocol::ItemStack {
template_id: "copper_coin".into(),
quantity: 50,
item_instance_id: Some(uuid::Uuid::from_u128(10)),
display_name: Some("Copper Coin".into()),
..Default::default()
},
flatland_protocol::ItemStack {
template_id: "oak_log".into(),
quantity: 2,
item_instance_id: Some(uuid::Uuid::from_u128(11)),
display_name: Some("Oak Log".into()),
..Default::default()
},
flatland_protocol::ItemStack {
template_id: "whisper_stone".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(12)),
display_name: Some("Whisper Stone".into()),
category: Some("quest".into()),
listable: Some(false),
..Default::default()
},
];
let opts = state.market_list_item_options(&MarketListSourceKind::Person);
assert_eq!(opts.len(), 1);
assert!(opts[0].label.contains("Oak"));
}
#[test]
fn market_browse_filters_by_category_and_search() {
let mut state = sample_state();
state.market_panel = Some(flatland_protocol::MarketPanel {
npc_id: "mira_market".into(),
npc_label: "Mira".into(),
building_id: "town_market".into(),
building_label: "Town Market".into(),
used_volume: 0.0,
max_volume: 100.0,
listings: vec![
flatland_protocol::MarketListingView {
listing_id: uuid::Uuid::from_u128(1),
seller_character_id: uuid::Uuid::from_u128(2),
seller_label: "Ada".into(),
hall_building_id: "town_market".into(),
hall_label: "Town Market".into(),
template_id: "oak_log".into(),
display_name: "Oak Log".into(),
category: "resource".into(),
quantity: 3,
unit_price_copper: 10,
line_total_copper: 30,
mine: false,
},
flatland_protocol::MarketListingView {
listing_id: uuid::Uuid::from_u128(3),
seller_character_id: uuid::Uuid::from_u128(2),
seller_label: "Ada".into(),
hall_building_id: "town_market".into(),
hall_label: "Town Market".into(),
template_id: "short_sword".into(),
display_name: "Short Sword".into(),
category: "weapon".into(),
quantity: 1,
unit_price_copper: 100,
line_total_copper: 100,
mine: false,
},
],
tax_bps: 0,
tax_flat_copper: 0,
list_vaults: vec![],
});
assert_eq!(state.market_filtered_listing_indices().len(), 2);
state.market_category_filter = Some("Weapons");
let weapons = state.market_filtered_listing_indices();
assert_eq!(weapons.len(), 1);
assert_eq!(
state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
"Short Sword"
);
state.market_category_filter = None;
state.market_filter = "oak".into();
let oak = state.market_filtered_listing_indices();
assert_eq!(oak.len(), 1);
assert_eq!(
state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
"Oak Log"
);
}
#[test]
fn market_list_source_includes_person_and_vaults() {
let mut state = sample_state();
let item_id = uuid::Uuid::from_u128(1);
state.inventory_stacks = vec![flatland_protocol::ItemStack {
template_id: "oak_log".into(),
quantity: 2,
item_instance_id: Some(item_id),
display_name: Some("Oak Log".into()),
..Default::default()
}];
state.market_panel = Some(flatland_protocol::MarketPanel {
npc_id: "mira_market".into(),
npc_label: "Mira".into(),
building_id: "town_market".into(),
building_label: "Town Market".into(),
used_volume: 0.0,
max_volume: 100.0,
listings: vec![],
tax_bps: 0,
tax_flat_copper: 0,
list_vaults: vec![flatland_protocol::MarketListVault {
building_id: "town_storage".into(),
building_label: "Town Storage".into(),
contents: vec![flatland_protocol::ItemStack {
template_id: "lumber".into(),
quantity: 1,
item_instance_id: Some(uuid::Uuid::from_u128(2)),
display_name: Some("Lumber".into()),
..Default::default()
}],
}],
});
let sources = state.market_list_source_options();
assert_eq!(sources.len(), 2);
assert!(matches!(sources[0].0, MarketListSourceKind::Person));
assert!(matches!(
sources[1].0,
MarketListSourceKind::TownStorage { .. }
));
assert!(sources[1].1.contains("Town Storage"));
}
#[test]
fn probe_use_world_npc_beats_nearby_loot() {
let mut state = sample_state();
state.npcs.push(flatland_protocol::NpcView {
id: "ada".into(),
label: "Ada".into(),
role: "broker".into(),
x: 129.0,
y: 128.0,
building_id: None,
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: true,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
paperdoll_ref: None,
});
state.ground_drops.push(flatland_protocol::GroundDropView {
id: "d1".into(),
template_id: "lumber".into(),
quantity: 1,
x: 128.5,
y: 128.0,
z: 0.0,
tile_id: None,
display_name: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
});
let probe = state.probe_use_world();
let primary = probe.primary.expect("primary");
assert_eq!(primary.kind, crate::UseWorldKind::Npc);
assert_eq!(primary.id, "ada");
}
#[test]
fn probe_use_world_harvest_when_in_range() {
let state = sample_state(); let probe = state.probe_use_world();
assert!(
probe.primary.is_none(),
"oak is 2m away, out of harvest range"
);
assert!(probe
.candidates
.iter()
.any(|c| c.kind == crate::UseWorldKind::Harvest));
let mut state = sample_state();
state.resource_nodes[0].x = 129.0;
let probe = state.probe_use_world();
let primary = probe.primary.expect("primary");
assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
}
#[test]
fn probe_use_world_door_uses_building_label() {
let mut state = sample_state();
state.doors[0].x = 129.0;
state.doors[0].y = 128.0;
let probe = state.probe_use_world();
let primary = probe.primary.expect("primary");
assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
assert_eq!(primary.label, "Broker");
assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
}
#[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![],
interior_map: None,
npcs: vec![],
inventory: vec![],
blueprints: vec![],
world_clock: flatland_protocol::WorldClock::default(),
combat: None,
quest_log: vec![],
hired_workers: Vec::new(),
interactables: vec![],
ledger: None,
career: None,
combat_fx: Vec::new(),
property_plots: Vec::new(),
terrain_overlays: Vec::new(),
};
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![],
interior_map: None,
npcs: vec![],
inventory: vec![],
blueprints: vec![],
world_clock: flatland_protocol::WorldClock::default(),
combat: None,
quest_log: vec![],
hired_workers: Vec::new(),
interactables: vec![],
ledger: None,
career: None,
combat_fx: Vec::new(),
property_plots: Vec::new(),
terrain_overlays: Vec::new(),
};
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,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec![],
}],
buildings: vec![],
doors: vec![],
interior_map: None,
npcs: vec![],
inventory: vec![],
blueprints: vec![],
world_clock: flatland_protocol::WorldClock::default(),
ground_drops: vec![],
placed_containers: vec![],
combat: None,
quest_log: vec![],
hired_workers: Vec::new(),
interactables: vec![],
ledger: None,
career: None,
combat_fx: Vec::new(),
property_plots: Vec::new(),
terrain_overlays: Vec::new(),
};
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,
publish_rev: 0,
entities: vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(4.5, 2.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: None,
attributes: None,
skills: None,
inside_building: Some("broker_hut".into()),
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: vec![],
}],
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_blueprint: Some("broker_hut".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
}],
doors: vec![flatland_protocol::DoorView {
id: "broker_hut_exit".into(),
building_id: "broker_hut".into(),
x: 4.3,
y: 0.9,
open: true,
portal: Some("front".into()),
}],
interior_map: None,
npcs: vec![flatland_protocol::NpcView {
id: "ada_broker".into(),
label: "Ada".into(),
x: 4.5,
y: 2.0,
building_id: Some("broker_hut".into()),
role: "broker".into(),
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: true,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
paperdoll_ref: None,
}],
blueprints: vec![],
world_x0: 0.0,
world_y0: 0.0,
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: Vec::new(),
z_platforms: Vec::new(),
z_transitions: 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,
hud_log_hidden: false,
show_equip_menu: false,
equip_menu_index: 0,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
bank_panel: None,
bank_menu_index: 0,
bank_ui_mode: BankUiMode::Menu,
storage_panel: None,
market_panel: None,
market_menu_index: 0,
market_filter: String::new(),
market_filter_focused: false,
market_category_filter: None,
market_buy_confirm: None,
market_ui_mode: MarketUiMode::Browse,
storage_menu_index: 0,
storage_ui_mode: StorageUiMode::Menu,
shop_tab: ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
shop_trade_log: VecDeque::new(),
show_npc_verb_menu: false,
npc_verb_target: None,
npc_verb_index: 0,
player_verbs: crate::social::PlayerVerbState::default(),
social_chat: crate::social::SocialChatState::default(),
trade_ui: crate::social::TradeUiState::default(),
whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
show_npc_chat: false,
npc_chat: None,
show_inventory_menu: false,
inventory_menu_index: 0,
inventory_tab: InventoryTab::OnPerson,
inventory_filter: String::new(),
inventory_filter_focused: false,
show_move_picker: false,
show_rename_prompt: false,
show_worker_rename: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_grant_picker: false,
grant_picker_index: 0,
grant_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
combat_fx: Vec::new(),
property_zones: Vec::new(),
tax_zones: Vec::new(),
growth_zones: Vec::new(),
biome_zones: Vec::new(),
property_plots: Vec::new(),
property_plot_settings: None,
claim_mode: None,
relocate_mode: None,
sell_plot_confirm: None,
sell_plot_armed_at: None,
show_plant_menu: false,
plant_menu_index: 0,
show_farm_access: false,
farm_access_name_draft: String::new(),
farm_access_discount_bps: 0,
farm_access_index: 0,
plant_quantity: 1,
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,
offhand_template_id: None,
offhand_label: None,
mainhand_hand_slots: 1,
defense: None,
worn: BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
keychain_stacks: Vec::new(),
whisper_pouch_stacks: Vec::new(),
combat_target_detail: None,
statuses: Vec::new(),
cast_progress: None,
timed_channel: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
known_abilities: Vec::new(),
hotbar: vec![None; 9],
max_abilities_per_rotation: 0,
show_loadout_menu: false,
show_keychain_menu: false,
keychain_menu_index: 0,
show_rotation_editor: false,
loadout_menu_index: 0,
loadout_hotbar_slot: 1,
loadout_ability_index: 0,
loadout_focus_presets: false,
rotation_editor: RotationEditorState::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
pending_worker_job_ack: None,
attending_worker_instance_id: None,
quest_log: Vec::new(),
interactables: Vec::new(),
ledger: None,
career: None,
character_sheet_tab: CharacterSheetTab::Character,
ledger_period: LedgerPeriod::Day,
show_quest_offer: false,
pending_quest_offer: None,
show_quest_menu: false,
quest_menu_index: 0,
quest_withdraw_confirm: false,
hired_workers: Vec::new(),
show_workers_menu: false,
workers_menu_index: 0,
workers_menu_compact: false,
worker_step_display: BTreeMap::new(),
worker_error_display: BTreeMap::new(),
show_worker_give_picker: false,
worker_give_picker_index: 0,
worker_give_picker: None,
show_worker_give_target_picker: false,
worker_give_target_picker_index: 0,
worker_give_target_picker: None,
show_worker_take_picker: false,
worker_take_picker_index: 0,
worker_take_picker: None,
show_worker_teach_picker: false,
worker_teach_picker_index: 0,
worker_teach_picker: None,
worker_route_editor: None,
progression_curve: 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)),
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
},
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)),
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
},
];
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 chest_pickup_destinations_offer_person_and_worn_bag() {
let mut state = sample_state();
let back_id = uuid::Uuid::from_u128(42);
state.worn.insert(
BodySlot::Back,
flatland_protocol::ItemStack {
template_id: "travel_backpack".into(),
quantity: 1,
item_instance_id: Some(back_id),
props: Default::default(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: Some("Travel Backpack".into()),
category: Some("container".into()),
base_mass: Some(2.5),
base_volume: Some(12.0),
capacity_volume: Some(80.0),
stackable: Some(false),
world_placeable: Some(false),
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: None,
},
);
let opts = state.chest_pickup_destinations("chest-1");
assert!(matches!(
opts.first().map(|o| &o.kind),
Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
));
assert!(opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::PickupPlaced {
nest_parent_instance_id: None,
..
}
)));
assert!(opts.iter().any(|o| matches!(
&o.kind,
MoveOptionKind::PickupPlaced {
nest_parent_instance_id: Some(id),
..
} if *id == back_id
)));
assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
}
#[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,
listable: true,
},
);
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,
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
};
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_shows_crop_growth_percent_not_depleted() {
let mut state = sample_state();
state.player = state.entities.first().cloned();
state.resource_nodes[0].label = "Carrot (growing)".into();
state.resource_nodes[0].x = 128.2;
state.resource_nodes[0].y = 128.0;
state.resource_nodes[0].state = ResourceNodeState::Cooldown;
state.resource_nodes[0].growth_progress = Some(0.47);
let lines = state.location_context_lines();
let line = lines
.iter()
.find(|l| l.text.contains("Carrot"))
.map(|l| l.text.as_str())
.unwrap_or("");
assert!(
line.contains("(growing, 47%)"),
"expected growth percent, got: {line}"
);
assert!(
!line.contains("depleted"),
"growing crop should not show depleted: {line}"
);
}
#[test]
fn resource_node_near_action_suffix_prefers_growth() {
let node = ResourceNodeView {
id: "crop".into(),
label: "Wheat".into(),
x: 0.0,
y: 0.0,
z: 0.0,
item_template: "wheat".into(),
state: ResourceNodeState::Cooldown,
blocking: false,
blocking_radius_m: 0.0,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: Some(0.12),
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec![],
};
assert_eq!(
resource_node_near_action_suffix(&node),
" (growing, 12%)"
);
}
#[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 quest_board_usable_within_board_radius() {
let mut state = sample_state();
state.player = state.entities.first().cloned();
state.interactables = vec![flatland_protocol::InteractableView {
id: "board-1".into(),
kind: "quest_board".into(),
label: "Town Quest Board".into(),
x: 130.5,
y: 128.0,
z: 0.0,
board_id: Some("starter_town_board".into()),
}];
assert_eq!(
state.nearest_interact_target().as_deref(),
Some("board-1"),
"quest board should be selectable at ~2.5m"
);
let lines = state.location_context_lines();
assert!(
lines
.iter()
.any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
"HUD should advertise f when board is in range: {:?}",
lines
);
}
#[test]
fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
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(),
status_bindings: Vec::new(),
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,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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)),
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
}];
state.inventory_tab = InventoryTab::OnPerson;
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, ]
);
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");
let lines = state.inventory_browser_lines();
assert!(lines.iter().any(|l| matches!(
l,
InventoryBrowserLine::Section(s) if s.contains("Worn")
)));
assert!(lines.iter().any(|l| matches!(
l,
InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
|| text.contains("backpack")
)));
assert!(!lines.iter().any(|l| matches!(
l,
InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
)));
state.inventory_tab = InventoryTab::Nearby;
let nearby_rows = state.inventory_selectable_rows();
assert_eq!(nearby_rows.len(), 2);
assert!(nearby_rows[0].is_chest_shell);
assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
let nearby_lines = state.inventory_browser_lines();
assert!(nearby_lines.iter().any(|l| matches!(
l,
InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
)));
}
#[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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: None,
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: Some(80.0),
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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)),
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
}];
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(),
status_bindings: Vec::new(),
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,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: None,
category: Some("armor".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: None,
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: Some("Simple Belt".into()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: Some("Cloth Cap".into()),
category: Some("armor".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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(),
status_bindings: Vec::new(),
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
contents: vec![flatland_protocol::ItemStack {
template_id: "dimensional_pouch".into(),
quantity: 1,
item_instance_id: Some(pouch_id),
props: Default::default(),
status_bindings: Vec::new(),
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,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: None,
}],
display_name: Some("Simple Belt".into()),
category: Some("container".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
listable: 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(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: None,
category: None,
base_mass: None,
base_volume: Some(2.0),
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: None,
}],
lock_id: None,
capacity_volume: Some(60.0),
item_instance_id: Some(uuid::Uuid::from_u128(4)),
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
}];
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)),
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
}];
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(),
),
]),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: Some("Container Key".into()),
category: Some("key".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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()),
]),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: None,
category: Some("key".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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,
tile_id: None,
worker_lodging_capacity: None,
blocking: false,
blocking_radius_m: 0.0,
}];
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)]),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: None,
category: Some("key".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: None,
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: 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));
}
#[test]
fn combat_hud_refreshes_progression_xp_when_entity_stale() {
use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
let mut state = sample_state();
let curve = ProgressionCurve::default();
let bootstrap = ProgressionXp::bootstrap_new(
curve.baseline_display,
curve.xp_base,
curve.xp_growth,
);
let mut fresh = bootstrap.clone();
fresh.strength += 0.08;
if let Some(player) = state.player.as_mut() {
player.progression_xp = Some(bootstrap);
}
let combat = CombatHud {
progression_xp: Some(fresh.clone()),
progression_baseline: curve.baseline_display,
progression_xp_base: curve.xp_base,
progression_xp_growth: curve.xp_growth,
attributes: state.player.as_ref().and_then(|p| p.attributes),
skills: state.player.as_ref().and_then(|p| p.skills.clone()),
..CombatHud::default()
};
state.apply_combat_hud(&combat);
let xp = state
.player
.as_ref()
.and_then(|p| p.progression_xp.as_ref())
.expect("xp");
assert!((xp.strength - fresh.strength).abs() < 0.001);
assert!(state.progression_curve.is_some());
}
#[test]
fn combat_hud_syncs_known_abilities_and_hotbar() {
use flatland_protocol::CombatHud;
let mut state = sample_state();
let combat = CombatHud {
known_abilities: vec!["unarmed".into(), "fireball".into()],
hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
max_abilities_per_rotation: 4,
ability_id: "short_sword_slash".into(),
..CombatHud::default()
};
state.apply_combat_hud(&combat);
assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
assert_eq!(state.hotbar_ability(1), Some("fireball"));
assert_eq!(state.hotbar_ability(2), None);
assert_eq!(state.hotbar_ability(3), Some("unarmed"));
assert_eq!(state.max_abilities_per_rotation, 4);
let choices = state.loadout_ability_choices();
assert!(choices.iter().any(|a| a == "short_sword_slash"));
assert!(choices.iter().any(|a| a == "fireball"));
}
#[test]
fn loadout_hotbar_choices_include_inventory_consumables() {
let mut state = sample_state();
state.known_abilities = vec!["unarmed".into()];
state.weapon_ability_id = "unarmed".into();
state.inventory_stacks = vec![flatland_protocol::ItemStack {
template_id: "bottle_of_water".into(),
quantity: 3,
item_instance_id: Some(uuid::Uuid::from_u128(9)),
display_name: Some("Bottle of Water".into()),
category: Some("consumable".into()),
..Default::default()
}];
state.inventory.insert("bottle_of_water".into(), 3);
state.inventory_hints.insert(
"bottle_of_water".into(),
InventoryHint {
display_name: "Bottle of Water".into(),
category: "consumable".into(),
..Default::default()
},
);
let choices = state.loadout_hotbar_choices();
assert!(choices.iter().any(|c| c.binding == "unarmed"));
let water = choices
.iter()
.find(|c| c.binding == "item:bottle_of_water")
.expect("water binding");
assert_eq!(water.meta.as_deref(), Some("use"));
assert!(water.label.contains("Water"));
assert_eq!(
state.hotbar_slot_label(1),
None,
"unbound until set"
);
state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
assert_eq!(
state.hotbar_slot_label(5).as_deref(),
Some("Bottle of Water×3")
);
}
#[test]
fn loose_consumable_move_picker_offers_use_and_storage() {
let mut state = sample_state();
let inst = uuid::Uuid::from_u128(77);
state.inventory_stacks = vec![flatland_protocol::ItemStack {
template_id: "carrot".into(),
quantity: 2,
item_instance_id: Some(inst),
props: Default::default(),
status_bindings: Vec::new(),
contents: Vec::new(),
display_name: Some("Wild Carrot".into()),
category: Some("consumable".into()),
base_mass: None,
base_volume: None,
capacity_volume: None,
stackable: Some(true),
world_placeable: None,
worker_lodging_capacity: None,
equip_slot: None,
armor_physical: None,
resists: vec![],
hand_slots: None,
listable: None,
}];
state.inventory_hints.insert(
"carrot".into(),
InventoryHint {
display_name: "Wild Carrot".into(),
category: "consumable".into(),
base_mass: Some(0.15),
base_volume: Some(0.3),
capacity_volume: None,
stackable: true,
listable: true,
},
);
state.show_inventory_menu = true;
state.inventory_menu_index = 0;
let row = state.inventory_selected_row().expect("carrot row");
let mut options = state.move_destinations_for(
&row.from,
row.from_parent_instance_id,
row.stack.item_instance_id,
&row.stack.template_id,
);
if row.from == flatland_protocol::InventoryLocation::Root
&& state.inventory_item_category(&row.stack.template_id) == Some("consumable")
{
options.insert(
0,
MoveOption {
label: "Use (eat / drink)".into(),
kind: MoveOptionKind::Use,
},
);
}
assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
}
#[test]
fn inventory_category_group_order_is_stable() {
assert_eq!(inventory_category_group("weapon").0, "Weapons");
assert_eq!(inventory_category_group("armor").0, "Armor");
assert_eq!(inventory_category_group("consumable").0, "Consumables");
assert_eq!(inventory_category_group("resource").0, "Resources");
assert_eq!(inventory_category_group("container").0, "Containers");
assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
}
#[test]
fn page_list_index_clamps_without_wrap() {
assert_eq!(page_list_index(0, -1, 25), 0);
assert_eq!(page_list_index(0, 1, 25), 10);
assert_eq!(page_list_index(12, 1, 25), 22);
assert_eq!(page_list_index(22, 1, 25), 24);
assert_eq!(page_list_index(5, 1, 0), 0);
assert_eq!(page_list_index(3, -1, 8), 0);
}
#[test]
fn inventory_filter_hides_non_matching_person_items() {
let mut state = sample_state();
let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
sword.display_name = Some("Iron Sword".into());
sword.category = Some("weapon".into());
let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
herb.display_name = Some("Wild Herb".into());
herb.category = Some("consumable".into());
state.inventory_stacks = vec![sword, herb];
state.inventory_tab = InventoryTab::OnPerson;
state.inventory_filter = "sword".into();
let rows = state.inventory_selectable_rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].stack.template_id, "iron_sword");
let lines = state.inventory_browser_lines();
assert!(lines.iter().any(|l| matches!(
l,
InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
)));
assert!(!lines.iter().any(|l| matches!(
l,
InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
)));
}
#[test]
fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
let mut state = sample_state();
let id_a = uuid::Uuid::from_u128(0xa1);
let id_b = uuid::Uuid::from_u128(0xb2);
let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
sword_a.display_name = Some("Iron Sword".into());
sword_a.category = Some("weapon".into());
sword_a.item_instance_id = Some(id_a);
let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
sword_b.display_name = Some("Iron Sword".into());
sword_b.category = Some("weapon".into());
sword_b.item_instance_id = Some(id_b);
state.inventory_stacks = vec![sword_a, sword_b];
state.inventory_tab = InventoryTab::OnPerson;
let lines = state.inventory_browser_lines();
let items: Vec<_> = lines
.iter()
.filter_map(|l| match l {
InventoryBrowserLine::Item {
title,
instance_tooltip,
..
} => Some((title.clone(), instance_tooltip.clone())),
_ => None,
})
.collect();
assert_eq!(items.len(), 2);
for (title, tip) in &items {
assert!(
!title.contains('#'),
"title should not show instance suffix: {title}"
);
assert!(
tip.is_some(),
"two identical rows should expose instance on hover"
);
}
state.inventory_stacks.pop();
let lines = state.inventory_browser_lines();
let one = lines.iter().find_map(|l| match l {
InventoryBrowserLine::Item {
title,
instance_tooltip,
..
} => Some((title.clone(), instance_tooltip.clone())),
_ => None,
});
let (title, tip) = one.expect("one sword row");
assert!(!title.contains('#'));
assert!(tip.is_none(), "single row should not need instance tooltip");
}
#[test]
fn inventory_person_rows_group_by_category() {
let mut state = sample_state();
let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
sword.category = Some("weapon".into());
sword.display_name = Some("Iron Sword".into());
let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
ore.category = Some("resource".into());
ore.display_name = Some("Iron Ore".into());
let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
potion.category = Some("consumable".into());
potion.display_name = Some("Health Potion".into());
state.inventory_stacks = vec![ore, potion, sword];
state.inventory_tab = InventoryTab::OnPerson;
let lines = state.inventory_browser_lines();
let labels: Vec<&str> = lines
.iter()
.filter_map(|l| match l {
InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(
labels.iter().any(|s| s.contains("Weapons")),
"expected Weapons group: {labels:?}"
);
assert!(labels.iter().any(|s| s.contains("Consumables")));
assert!(labels.iter().any(|s| s.contains("Resources")));
let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
assert!(weapon_pos < consumable_pos);
assert!(consumable_pos < resource_pos);
}
#[test]
fn inventory_tab_cycle_resets_selection() {
let mut state = sample_state();
state.inventory_tab = InventoryTab::OnPerson;
state.inventory_menu_index = 3;
state.inventory_tab = state.inventory_tab.cycle(true);
assert_eq!(state.inventory_tab, InventoryTab::Nearby);
assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
}
#[test]
fn parse_bank_copper_amount_blank_and_zero_mean_all() {
assert_eq!(parse_bank_copper_amount(""), Some(0));
assert_eq!(parse_bank_copper_amount(" "), Some(0));
assert_eq!(parse_bank_copper_amount("0"), Some(0));
assert_eq!(parse_bank_copper_amount("250"), Some(250));
assert_eq!(parse_bank_copper_amount("nope"), None);
}
#[test]
fn parse_storage_quantity_blank_and_zero_mean_all() {
assert_eq!(parse_storage_quantity(""), Some(None));
assert_eq!(parse_storage_quantity(" "), Some(None));
assert_eq!(parse_storage_quantity("0"), Some(None));
assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
assert_eq!(parse_storage_quantity("nope"), None);
}
#[test]
fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
assert!(worker_error_is_hud_noise("path stuck — repathing"));
assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
assert!(!worker_error_is_hud_noise(
"path stuck — no lodging to reset to"
));
}
}