Skip to main content

flatland_client_lib/
game.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5    AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6    CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7    LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8    ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15    #[default]
16    Character,
17    Ledger,
18    Career,
19}
20
21impl CharacterSheetTab {
22    pub fn cycle(self) -> Self {
23        match self {
24            Self::Character => Self::Ledger,
25            Self::Ledger => Self::Career,
26            Self::Career => Self::Character,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33    #[default]
34    Day,
35    Week,
36    Month,
37    Lifetime,
38}
39
40impl LedgerPeriod {
41    pub fn label(self) -> &'static str {
42        match self {
43            Self::Day => "Day",
44            Self::Week => "Week",
45            Self::Month => "Month",
46            Self::Lifetime => "All",
47        }
48    }
49
50    pub fn cycle(self) -> Self {
51        match self {
52            Self::Day => Self::Week,
53            Self::Week => Self::Month,
54            Self::Month => Self::Lifetime,
55            Self::Lifetime => Self::Day,
56        }
57    }
58
59    pub fn from_digit(c: char) -> Option<Self> {
60        match c {
61            '1' => Some(Self::Day),
62            '2' => Some(Self::Week),
63            '3' => Some(Self::Month),
64            '4' => Some(Self::Lifetime),
65            _ => None,
66        }
67    }
68}
69
70/// Matches `flatland_sim::containers` prop keys (client does not depend on sim).
71const KEY_TEMPLATE: &str = "container_key";
72const PROP_LOCK_ID: &str = "lock_id";
73const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
74const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
75const PROP_CUSTOM_NAME: &str = "custom_name";
76const PROP_LOCKED: &str = "locked";
77
78fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
79    stack
80        .props
81        .get(PROP_LOCKED)
82        .is_some_and(|v| v == "true" || v == "1")
83}
84
85const MAX_LOG_LINES: usize = 200;
86const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
87const INTERACTION_RADIUS_M: f32 = 1.5;
88const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
89const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
90const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
91/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
92const CRAFT_STAMINA_COST: f32 = 3.0;
93/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
94const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
95/// Keep the last worker route error visible in the HUD after the server clears it.
96const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
97
98/// Catalog hints synced from server `ItemStack` wire rows.
99#[derive(Debug, Clone, Default)]
100pub struct InventoryHint {
101    pub display_name: String,
102    pub category: String,
103    pub base_mass: Option<f32>,
104    pub base_volume: Option<f32>,
105    pub capacity_volume: Option<f32>,
106    pub stackable: bool,
107}
108
109/// One row in the loadout hotbar picker (ability or inventory consumable).
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct LoadoutHotbarChoice {
112    /// Value sent via [`Intent::SetHotbarSlot`] (`ability_id` or `item:<template>`).
113    pub binding: String,
114    /// Display label (ability id, or "Carrot ×3").
115    pub label: String,
116    /// Optional meta badge (`weapon`, `use`, …).
117    pub meta: Option<String>,
118}
119
120/// Rotation editor overlay mode (`plans/26` §C2.5).
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum RotationEditorMode {
123    #[default]
124    List,
125    EditSequence,
126    PickAbility,
127    EditLabel,
128}
129
130/// Local rotation editor UI state (not persisted).
131#[derive(Debug, Clone, Default)]
132pub struct RotationEditorState {
133    pub mode: RotationEditorMode,
134    pub list_index: usize,
135    pub ability_index: usize,
136    pub picker_index: usize,
137    pub draft: Option<RotationPreset>,
138    pub label_buffer: String,
139}
140
141impl RotationEditorState {
142    pub fn reset(&mut self) {
143        *self = Self::default();
144    }
145}
146
147/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
148/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
149/// client only ever shows chests the server will actually let you use — this is
150/// what makes a chest disappear from the menu as soon as you walk away.
151pub const CONTAINER_RANGE_M: f32 = 3.0;
152
153/// Broad section of the inventory browser a row belongs to (drives the grouped
154/// "Worn" / "On you" / "Nearby chest" headers in the UI).
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum InventorySection {
157    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
158    Worn,
159    /// Loose on your person — not worn, not inside a placed chest.
160    Person,
161    /// Inside a placed chest within reach.
162    Nearby,
163}
164
165/// Top-level inventory browser tab (`b` menu).
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub enum InventoryTab {
168    #[default]
169    OnPerson,
170    Nearby,
171}
172
173impl InventoryTab {
174    pub fn label(self) -> &'static str {
175        match self {
176            Self::OnPerson => "On person",
177            Self::Nearby => "Nearby storage",
178        }
179    }
180
181    pub fn cycle(self, forward: bool) -> Self {
182        match (self, forward) {
183            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
184            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
185        }
186    }
187}
188
189/// Rows jumped by PageUp / PageDown in list UIs.
190pub const LIST_PAGE_SIZE: usize = 10;
191
192/// Case-insensitive substring match for list filters (name / template / label).
193pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
194    if filter.is_empty() {
195        return true;
196    }
197    haystack
198        .to_ascii_lowercase()
199        .contains(&filter.to_ascii_lowercase())
200}
201
202/// Clamp a list selection after a page jump (`pages` is typically ±1).
203pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
204    if len == 0 {
205        return 0;
206    }
207    let page = LIST_PAGE_SIZE as i32;
208    let next = index as i32 + pages * page;
209    next.clamp(0, (len as i32) - 1) as usize
210}
211
212/// Advance `index` by `delta` among indices where `pred` is true (wraps).
213pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
214    if len == 0 {
215        return 0;
216    }
217    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
218    if matching.is_empty() {
219        return index.min(len - 1);
220    }
221    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
222    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
223    matching[next]
224}
225
226/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
227pub fn page_filtered_index(
228    index: usize,
229    pages: i32,
230    len: usize,
231    pred: impl Fn(usize) -> bool,
232) -> usize {
233    if len == 0 {
234        return 0;
235    }
236    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
237    if matching.is_empty() {
238        return index.min(len - 1);
239    }
240    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
241    let next = page_list_index(pos, pages, matching.len());
242    matching[next]
243}
244
245/// Category group for On-person loose items (stable order).
246pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
247    match category {
248        "weapon" | "ammo" => ("Weapons", 0),
249        "armor" | "shield" | "offhand" => ("Armor", 1),
250        "consumable" => ("Consumables", 2),
251        "resource" | "harvest_node" => ("Resources", 3),
252        "container" | "lodging" => ("Containers", 4),
253        "currency" | "key" => ("Currency & keys", 5),
254        "tool" | "misc" | "furniture" | "quest" => ("Gear & misc", 6),
255        _ => ("Other", 7),
256    }
257}
258
259/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
260fn parse_bank_copper_amount(input: &str) -> Option<u64> {
261    let s = input.trim();
262    if s.is_empty() {
263        return Some(0);
264    }
265    s.parse::<u64>().ok()
266}
267
268/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
269fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
270    let s = input.trim();
271    if s.is_empty() || s == "0" {
272        return Some(None);
273    }
274    let n = s.parse::<u32>().ok()?;
275    if n == 0 {
276        return Some(None);
277    }
278    Some(Some(n))
279}
280
281fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
282    let name = stack
283        .display_name
284        .as_deref()
285        .unwrap_or(stack.template_id.as_str());
286    if stack.quantity > 1 {
287        format!("{name} ×{}", stack.quantity)
288    } else {
289        name.to_string()
290    }
291}
292
293/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
294/// browser section headers and the move-destination picker.
295pub fn body_slot_label(slot: BodySlot) -> &'static str {
296    match slot {
297        BodySlot::Head => "Head",
298        BodySlot::Chest => "Chest",
299        BodySlot::Forearms => "Forearms",
300        BodySlot::Legs => "Legs",
301        BodySlot::Feet => "Feet",
302        BodySlot::Cloak => "Cloak",
303        BodySlot::Back => "Back",
304        BodySlot::Waist => "Waist",
305        BodySlot::Earrings => "Earrings",
306        BodySlot::Necklace => "Necklace",
307        BodySlot::Eyeglasses => "Eyeglasses",
308        BodySlot::RingLeft1 => "Ring L1",
309        BodySlot::RingLeft2 => "Ring L2",
310        BodySlot::RingRight1 => "Ring R1",
311        BodySlot::RingRight2 => "Ring R2",
312    }
313}
314
315fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
316    let cat = stack.category.as_deref().unwrap_or("");
317    match mode {
318        "while_equipped" => {
319            stack.equip_slot.is_some()
320                || cat == "weapon"
321                || cat == "shield"
322                || cat == "offhand"
323                || cat == "armor"
324        }
325        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
326    }
327}
328
329fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
330    if grant_tags.is_empty() {
331        return true;
332    }
333    let target_tags: Vec<&str> = stack
334        .props
335        .get("allowed_enchant_tags")
336        .map(|s| {
337            s.split(',')
338                .map(str::trim)
339                .filter(|t| !t.is_empty())
340                .collect()
341        })
342        .unwrap_or_default();
343    if target_tags.is_empty() {
344        return true;
345    }
346    grant_tags.iter().any(|t| target_tags.contains(t))
347}
348
349/// Default sim tick rate when the client has no settings (matches server-settings).
350pub const DEFAULT_TICK_HZ: u32 = 30;
351
352/// Human-readable remaining time for an item status binding.
353pub fn format_binding_ttl(
354    binding: &flatland_protocol::ItemStatusBinding,
355    tick: u64,
356    tick_hz: u32,
357) -> String {
358    let Some(expires) = binding.expires_at_tick else {
359        return "permanent".into();
360    };
361    let hz = tick_hz.max(1) as f32;
362    let remaining = expires.saturating_sub(tick) as f32 / hz;
363    if remaining <= 0.0 {
364        return "expired".into();
365    }
366    if remaining >= 120.0 {
367        format!("{:.0}m left", remaining / 60.0)
368    } else if remaining >= 10.0 {
369        format!("{remaining:.0}s left")
370    } else {
371        format!("{remaining:.1}s left")
372    }
373}
374
375pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
376    match mode {
377        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
378        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
379    }
380}
381
382/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
383pub fn format_status_bindings_suffix(
384    bindings: &[flatland_protocol::ItemStatusBinding],
385    tick: u64,
386    tick_hz: u32,
387) -> String {
388    if bindings.is_empty() {
389        return String::new();
390    }
391    let parts: Vec<String> = bindings
392        .iter()
393        .map(|b| {
394            format!(
395                "{} ({}, {})",
396                b.effect_id,
397                format_binding_mode(b.mode),
398                format_binding_ttl(b, tick, tick_hz)
399            )
400        })
401        .collect();
402    format!(" · {}", parts.join("; "))
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum EquipPaperdollRow {
407    Body { slot: BodySlot, filled: bool },
408    Mainhand { filled: bool },
409    Offhand { filled: bool, locked: bool },
410}
411
412pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
413    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
414        .iter()
415        .map(|slot| EquipPaperdollRow::Body {
416            slot: *slot,
417            filled: state.worn.contains_key(slot),
418        })
419        .collect();
420    let two_hand = state.mainhand_hand_slots >= 2;
421    rows.push(EquipPaperdollRow::Mainhand {
422        filled: state.mainhand_template_id.is_some(),
423    });
424    rows.push(EquipPaperdollRow::Offhand {
425        filled: state.offhand_template_id.is_some(),
426        locked: two_hand,
427    });
428    rows
429}
430
431fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
432    for stack in &state.inventory_stacks {
433        let matches = stack
434            .equip_slot
435            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
436            .unwrap_or(false)
437            || guess_body_slot(&stack.template_id) == Some(slot);
438        if matches {
439            return stack.item_instance_id;
440        }
441    }
442    None
443}
444
445fn is_client_ring(slot: BodySlot) -> bool {
446    matches!(
447        slot,
448        BodySlot::RingLeft1
449            | BodySlot::RingLeft2
450            | BodySlot::RingRight1
451            | BodySlot::RingRight2
452    )
453}
454
455fn first_inventory_weapon(state: &GameState) -> Option<String> {
456    for stack in &state.inventory_stacks {
457        if stack.category.as_deref() == Some("weapon") {
458            return Some(stack.template_id.clone());
459        }
460    }
461    None
462}
463
464fn first_inventory_offhand(state: &GameState) -> Option<String> {
465    for stack in &state.inventory_stacks {
466        let cat = stack.category.as_deref().unwrap_or("");
467        if matches!(cat, "shield" | "offhand") {
468            return Some(stack.template_id.clone());
469        }
470    }
471    None
472}
473
474/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
475/// on the stack when present; otherwise guesses from `template_id`.
476fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
477    if template_id.contains("backpack") {
478        Some(BodySlot::Back)
479    } else if template_id.contains("belt") {
480        Some(BodySlot::Waist)
481    } else if template_id.contains("cloak") || template_id.contains("cape") {
482        Some(BodySlot::Cloak)
483    } else if template_id.contains("cap")
484        || template_id.contains("hat")
485        || template_id.contains("helm")
486    {
487        Some(BodySlot::Head)
488    } else if template_id.contains("shirt")
489        || template_id.contains("robe")
490        || template_id.contains("vest")
491        || template_id.contains("chest")
492        || template_id.contains("jerkin")
493    {
494        Some(BodySlot::Chest)
495    } else if template_id.contains("sleeves")
496        || template_id.contains("gloves")
497        || template_id.contains("gauntlets")
498    {
499        Some(BodySlot::Forearms)
500    } else if template_id.contains("pants") || template_id.contains("leggings") {
501        Some(BodySlot::Legs)
502    } else if template_id.contains("boots") || template_id.contains("shoes") {
503        Some(BodySlot::Feet)
504    } else if template_id.contains("earring") {
505        Some(BodySlot::Earrings)
506    } else if template_id.contains("necklace") || template_id.contains("amulet") {
507        Some(BodySlot::Necklace)
508    } else if template_id.contains("glass")
509        || template_id.contains("spectacles")
510        || template_id.contains("goggles")
511    {
512        Some(BodySlot::Eyeglasses)
513    } else if template_id.contains("ring") {
514        Some(BodySlot::RingLeft1)
515    } else {
516        None
517    }
518}
519
520/// One row in the inventory browser tree.
521#[derive(Debug, Clone)]
522pub struct InventoryRow {
523    pub depth: usize,
524    pub stack: flatland_protocol::ItemStack,
525    /// `MoveItem` source location for this stack.
526    pub from: flatland_protocol::InventoryLocation,
527    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
528    pub from_parent_instance_id: Option<uuid::Uuid>,
529    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
530    pub is_equip_shell: bool,
531    /// Placed world chest shell — lock/unlock via Enter or `l`.
532    pub is_chest_shell: bool,
533    pub section: InventorySection,
534}
535
536/// Formatted inventory row text shared by TUI and gfx browsers.
537#[derive(Debug, Clone)]
538pub struct InventoryRowView {
539    pub depth: usize,
540    /// Dense single-line label (legacy / TUI).
541    pub text: String,
542    /// Primary label for redesigned gfx rows (name, qty, optional #id / slot).
543    pub title: String,
544    pub mass_kg: Option<f32>,
545    pub volume: Option<(f32, f32)>,
546}
547
548/// One line in the sectioned inventory browser (headers are non-selectable).
549#[derive(Debug, Clone)]
550pub enum InventoryBrowserLine {
551    Section(String),
552    SlotLabel(String),
553    Hint(String),
554    Blank,
555    Item {
556        selectable_index: usize,
557        selected: bool,
558        depth: usize,
559        text: String,
560        title: String,
561        mass_kg: Option<f32>,
562        volume: Option<(f32, f32)>,
563    },
564}
565
566/// Bank teller panel focus (action list vs amount / transfer prompts).
567#[derive(Debug, Clone, PartialEq, Eq, Default)]
568pub enum BankUiMode {
569    #[default]
570    Menu,
571    DepositAmount {
572        input: String,
573    },
574    WithdrawAmount {
575        input: String,
576    },
577    TransferName {
578        input: String,
579    },
580    TransferAmount {
581        to_name: String,
582        input: String,
583    },
584}
585
586/// Town storage manager focus (action list vs item pickers vs quantity).
587#[derive(Debug, Clone, PartialEq, Eq, Default)]
588pub enum StorageUiMode {
589    #[default]
590    Menu,
591    /// Pick a loose on-person stack to store.
592    StorePick {
593        index: usize,
594    },
595    /// Quantity for a chosen store stack (blank/0 = all).
596    StoreAmount {
597        pick_index: usize,
598        item_instance_id: uuid::Uuid,
599        label: String,
600        max_qty: u32,
601        input: String,
602    },
603    /// Pick a vault stack to take.
604    TakePick {
605        index: usize,
606    },
607    /// Quantity for a chosen take stack (blank/0 = all).
608    TakeAmount {
609        pick_index: usize,
610        item_instance_id: uuid::Uuid,
611        label: String,
612        max_qty: u32,
613        input: String,
614    },
615    /// Pick a vault stack to ship to `dest_building_id`.
616    ShipPick {
617        dest_building_id: String,
618        dest_label: String,
619        index: usize,
620    },
621    /// Quantity for a chosen ship stack (blank/0 = all).
622    ShipAmount {
623        dest_building_id: String,
624        dest_label: String,
625        pick_index: usize,
626        item_instance_id: uuid::Uuid,
627        label: String,
628        max_qty: u32,
629        input: String,
630    },
631}
632
633/// One selectable stack in a storage store/take/ship picker.
634#[derive(Debug, Clone)]
635pub struct StoragePickOption {
636    pub item_instance_id: uuid::Uuid,
637    pub label: String,
638    pub quantity: u32,
639}
640
641/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
642/// the browser (empty when locked without the matching key).
643#[derive(Debug, Clone)]
644pub struct NearbyContainer {
645    pub view: flatland_protocol::PlacedContainerView,
646    pub distance_m: f32,
647    pub rows: Vec<InventoryRow>,
648}
649
650/// One key row in the keychain overlay (carried vs stowed).
651#[derive(Debug, Clone)]
652pub struct KeychainEntry {
653    pub stack: flatland_protocol::ItemStack,
654    pub stowed: bool,
655}
656
657/// A destination the currently-picked item could be moved to.
658#[derive(Debug, Clone)]
659pub struct MoveOption {
660    pub label: String,
661    pub kind: MoveOptionKind,
662}
663
664#[derive(Debug, Clone, PartialEq)]
665pub enum MoveOptionKind {
666    Move {
667        location: flatland_protocol::InventoryLocation,
668        parent_instance_id: Option<uuid::Uuid>,
669    },
670    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
671    PickupPlaced {
672        container_id: String,
673        nest_location: flatland_protocol::InventoryLocation,
674        nest_parent_instance_id: Option<uuid::Uuid>,
675    },
676    /// Eat/drink a loose consumable (one unit per use).
677    Use,
678    /// Open grant-target picker for `grants_item_status` consumables.
679    GrantApply,
680    Drop,
681    Cancel,
682}
683
684/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
685#[derive(Debug, Clone)]
686pub struct GrantTargetPicker {
687    pub grant_instance_id: uuid::Uuid,
688    pub grant_label: String,
689    pub effect_id: String,
690    pub mode: String,
691    pub options: Vec<GrantTargetOption>,
692    pub filter: String,
693    pub filter_focused: bool,
694}
695
696#[derive(Debug, Clone)]
697pub struct GrantTargetOption {
698    pub label: String,
699    pub target_instance_id: uuid::Uuid,
700}
701
702/// Active "move to…" destination picker state for the selected inventory item.
703#[derive(Debug, Clone)]
704pub struct MovePicker {
705    pub item_instance_id: uuid::Uuid,
706    pub from: flatland_protocol::InventoryLocation,
707    pub item_label: String,
708    pub template_id: String,
709    pub stack_quantity: u32,
710    pub quantity: u32,
711    pub options: Vec<MoveOption>,
712    pub filter: String,
713    pub filter_focused: bool,
714}
715
716/// Active permanent-delete picker for the selected inventory item.
717#[derive(Debug, Clone)]
718pub struct DestroyPicker {
719    pub item_instance_id: uuid::Uuid,
720    pub from: flatland_protocol::InventoryLocation,
721    pub item_label: String,
722    pub stack_quantity: u32,
723    pub quantity: u32,
724}
725
726/// One giveable stack in the workers-menu give picker.
727#[derive(Debug, Clone)]
728pub struct WorkerGiveOption {
729    pub item_instance_id: uuid::Uuid,
730    pub label: String,
731    pub quantity: u32,
732    pub template_id: String,
733}
734
735/// Give an on-person inventory stack to the selected hired worker.
736#[derive(Debug, Clone)]
737pub struct WorkerGivePicker {
738    pub worker_instance_id: String,
739    pub worker_label: String,
740    pub options: Vec<WorkerGiveOption>,
741}
742
743/// Nearby hired worker choice when giving a selected inventory stack (`g`).
744#[derive(Debug, Clone)]
745pub struct WorkerGiveTargetOption {
746    pub instance_id: String,
747    pub label: String,
748    pub distance_m: f32,
749}
750
751/// Pick which nearby worker receives the selected inventory item.
752#[derive(Debug, Clone)]
753pub struct WorkerGiveTargetPicker {
754    pub item_instance_id: uuid::Uuid,
755    pub item_label: String,
756    pub quantity: Option<u32>,
757    pub options: Vec<WorkerGiveTargetOption>,
758}
759
760/// Take an item from a hired worker back into the employer's inventory.
761#[derive(Debug, Clone)]
762pub struct WorkerTakePicker {
763    pub worker_instance_id: String,
764    pub worker_label: String,
765    pub options: Vec<WorkerGiveOption>,
766}
767
768/// Max distance (m) to hand an item to a hired worker.
769pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
770
771/// One teachable blueprint in the workers-menu teach picker.
772#[derive(Debug, Clone)]
773pub struct WorkerTeachOption {
774    pub blueprint_id: String,
775    pub label: String,
776    pub cost_copper: u64,
777    pub min_level: u32,
778    pub worker_level: u32,
779    pub can_afford: bool,
780    pub level_ok: bool,
781}
782
783/// Teach a known blueprint to the selected hired worker.
784#[derive(Debug, Clone)]
785pub struct WorkerTeachPicker {
786    pub worker_instance_id: String,
787    pub worker_label: String,
788    pub worker_level: u32,
789    pub options: Vec<WorkerTeachOption>,
790}
791
792/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
793/// do not thrash the UI.
794#[derive(Debug, Clone, Default)]
795pub struct StickyWorkerStep {
796    shown: String,
797    pending: String,
798    pending_since: Option<Instant>,
799}
800
801impl StickyWorkerStep {
802    fn from_label(label: String) -> Self {
803        Self {
804            shown: label.clone(),
805            pending: label,
806            pending_since: Some(Instant::now()),
807        }
808    }
809
810    fn observe(&mut self, label: &str, now: Instant) {
811        let pending_since = self.pending_since.unwrap_or(now);
812        if label == self.pending {
813            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
814            {
815                self.shown = self.pending.clone();
816            }
817            return;
818        }
819        self.pending = label.to_string();
820        self.pending_since = Some(now);
821        // Empty → first value, or first observation: show immediately.
822        if self.shown.is_empty() {
823            self.shown = self.pending.clone();
824        }
825    }
826}
827
828/// Last non-transient worker error — held so route flicker stays readable.
829/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
830#[derive(Debug, Clone, Default)]
831pub struct StickyWorkerError {
832    message: String,
833    last_seen: Option<Instant>,
834}
835
836impl StickyWorkerError {
837    fn observe(&mut self, err: Option<&str>, now: Instant) {
838        if let Some(e) = err {
839            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
840                self.message = e.to_string();
841                self.last_seen = Some(now);
842            }
843            return;
844        }
845        if let Some(seen) = self.last_seen {
846            if now.duration_since(seen) > WORKER_ERROR_HOLD {
847                self.message.clear();
848                self.last_seen = None;
849            }
850        }
851    }
852
853    pub fn shown(&self, now: Instant) -> Option<&str> {
854        if self.message.is_empty() {
855            return None;
856        }
857        let seen = self.last_seen?;
858        if now.duration_since(seen) > WORKER_ERROR_HOLD {
859            return None;
860        }
861        Some(self.message.as_str())
862    }
863}
864
865/// First non-transient worker issue for the status bar (strike, route error, etc.).
866/// Routine recovery noise (path failures → lodging) stays in the game log only.
867pub fn worker_attention_line(state: &GameState) -> Option<String> {
868    use flatland_protocol::WorkerStateView;
869    let now = Instant::now();
870    for w in &state.hired_workers {
871        if matches!(w.state, WorkerStateView::Strike) {
872            return Some(format!(
873                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
874                w.label
875            ));
876        }
877        if let Some(err) = state
878            .worker_error_display
879            .get(&w.instance_id)
880            .and_then(|s| s.shown(now))
881        {
882            if !worker_error_is_hud_noise(err) {
883                return Some(format!("Worker {}: {err}", w.label));
884            }
885        }
886        if let Some(err) = &w.last_error {
887            if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
888                return Some(format!("Worker {}: {err}", w.label));
889            }
890        }
891    }
892    None
893}
894
895/// True when a worker `last_error` is transient noise (soft-skip chatter).
896pub fn worker_error_is_transient(err: &str) -> bool {
897    let e = err.to_ascii_lowercase();
898    e.contains("continuing route")
899        || e.contains("storage full")
900        || e.starts_with("nothing to withdraw")
901}
902
903/// True for routine worker recoveries that should not paint the status bar.
904/// Still logged when they change (see [`GameState::apply_hired_workers`]).
905pub fn worker_error_is_hud_noise(err: &str) -> bool {
906    let e = err.to_ascii_lowercase();
907    e.contains("returned to lodging after path")
908        || e.contains("path failure")
909        || e.contains("no path to")
910        || e.contains("pathfinding")
911}
912
913/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
914#[derive(Debug, Clone)]
915pub struct PendingWorkerJobAck {
916    pub seq: u32,
917    pub worker_instance_id: String,
918    pub worker_label: String,
919    pub idle: bool,
920    pub stop_count: usize,
921    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
922    pub prev_mode: flatland_protocol::WorkerModeView,
923    pub prev_step_label: String,
924    pub prev_last_error: Option<String>,
925}
926
927fn push_inventory_rows(
928    rows: &mut Vec<InventoryRow>,
929    depth: usize,
930    stack: &flatland_protocol::ItemStack,
931    from: &flatland_protocol::InventoryLocation,
932    from_parent_instance_id: Option<uuid::Uuid>,
933    section: InventorySection,
934) {
935    push_inventory_rows_filtered(
936        rows,
937        depth,
938        stack,
939        from,
940        from_parent_instance_id,
941        section,
942        "",
943    );
944}
945
946fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
947    if filter.is_empty() {
948        return true;
949    }
950    let f = filter.to_ascii_lowercase();
951    let name = stack
952        .display_name
953        .as_deref()
954        .unwrap_or("")
955        .to_ascii_lowercase();
956    let tid = stack.template_id.to_ascii_lowercase();
957    name.contains(&f)
958        || tid.contains(&f)
959        || stack
960            .contents
961            .iter()
962            .any(|c| stack_matches_filter(c, filter))
963}
964
965fn push_inventory_rows_filtered(
966    rows: &mut Vec<InventoryRow>,
967    depth: usize,
968    stack: &flatland_protocol::ItemStack,
969    from: &flatland_protocol::InventoryLocation,
970    from_parent_instance_id: Option<uuid::Uuid>,
971    section: InventorySection,
972    filter: &str,
973) {
974    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
975        return;
976    }
977    let self_hit = filter.is_empty() || {
978        let f = filter.to_ascii_lowercase();
979        let name = stack
980            .display_name
981            .as_deref()
982            .unwrap_or("")
983            .to_ascii_lowercase();
984        let tid = stack.template_id.to_ascii_lowercase();
985        name.contains(&f) || tid.contains(&f)
986    };
987    rows.push(InventoryRow {
988        depth,
989        stack: stack.clone(),
990        from: from.clone(),
991        from_parent_instance_id,
992        is_equip_shell: false,
993        is_chest_shell: false,
994        section,
995    });
996    for child in &stack.contents {
997        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
998            push_inventory_rows_filtered(
999                rows,
1000                depth + 1,
1001                child,
1002                from,
1003                stack.item_instance_id,
1004                section,
1005                if self_hit { "" } else { filter },
1006            );
1007        }
1008    }
1009}
1010
1011#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1012pub enum ShopTab {
1013    #[default]
1014    Buy,
1015    Sell,
1016}
1017
1018#[derive(Debug, Clone)]
1019pub struct NpcChatState {
1020    pub npc_id: String,
1021    pub npc_label: String,
1022    pub lines: Vec<String>,
1023    pub input: String,
1024    pub pending: bool,
1025    pub talk_depth: flatland_protocol::NpcTalkDepth,
1026    pub trade_allowed: bool,
1027    pub banner: Option<String>,
1028}
1029
1030impl Default for NpcChatState {
1031    fn default() -> Self {
1032        Self {
1033            npc_id: String::new(),
1034            npc_label: String::new(),
1035            lines: Vec::new(),
1036            input: String::new(),
1037            pending: false,
1038            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1039            trade_allowed: true,
1040            banner: None,
1041        }
1042    }
1043}
1044
1045#[derive(Debug, Clone)]
1046pub struct GameState {
1047    pub session_id: SessionId,
1048    pub entity_id: EntityId,
1049    /// Logged-in character — used to show owner-only container labels.
1050    pub character_id: Option<uuid::Uuid>,
1051    pub tick: Tick,
1052    pub chunk_rev: u64,
1053    pub content_rev: u64,
1054    pub publish_rev: u64,
1055    pub entities: Vec<EntityState>,
1056    pub player: Option<EntityState>,
1057    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1058    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1059    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1060    pub buildings: Vec<BuildingView>,
1061    pub doors: Vec<DoorView>,
1062    pub interior_map: Option<InteriorMapView>,
1063    pub npcs: Vec<NpcView>,
1064    pub blueprints: Vec<BlueprintView>,
1065    pub world_width_m: f32,
1066    pub world_height_m: f32,
1067    pub terrain_zones: Vec<TerrainZoneView>,
1068    pub z_platforms: Vec<ZPlatformView>,
1069    pub z_transitions: Vec<ZTransitionView>,
1070    pub world_clock: flatland_protocol::WorldClock,
1071    pub inventory: std::collections::HashMap<String, u32>,
1072    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1073    pub logs: VecDeque<String>,
1074    pub intents_sent: u64,
1075    pub ticks_received: u64,
1076    pub connected: bool,
1077    pub disconnect_reason: Option<String>,
1078    pub show_stats: bool,
1079    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1080    pub hud_log_hidden: bool,
1081    pub show_equip_menu: bool,
1082    pub equip_menu_index: usize,
1083    pub show_craft_menu: bool,
1084    pub craft_menu_index: usize,
1085    /// How many timed crafts to queue when confirming the craft menu.
1086    pub craft_batch_quantity: u32,
1087    pub show_shop_menu: bool,
1088    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1089    pub bank_panel: Option<flatland_protocol::BankPanel>,
1090    pub bank_menu_index: usize,
1091    pub bank_ui_mode: BankUiMode,
1092    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1093    pub storage_menu_index: usize,
1094    pub storage_ui_mode: StorageUiMode,
1095    pub shop_tab: ShopTab,
1096    pub shop_menu_index: usize,
1097    pub shop_quantity: u32,
1098    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1099    pub shop_trade_log: VecDeque<String>,
1100    pub show_npc_verb_menu: bool,
1101    pub npc_verb_target: Option<String>,
1102    pub npc_verb_index: usize,
1103    /// Nearby player Speak / Whisper / Trade dock.
1104    pub player_verbs: crate::social::PlayerVerbState,
1105    pub social_chat: crate::social::SocialChatState,
1106    pub trade_ui: crate::social::TradeUiState,
1107    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1108    pub show_npc_chat: bool,
1109    pub npc_chat: Option<NpcChatState>,
1110    pub show_inventory_menu: bool,
1111    pub inventory_menu_index: usize,
1112    pub inventory_tab: InventoryTab,
1113    pub inventory_filter: String,
1114    pub inventory_filter_focused: bool,
1115    pub show_move_picker: bool,
1116    pub move_picker_index: usize,
1117    pub move_picker: Option<MovePicker>,
1118    pub show_grant_picker: bool,
1119    pub grant_picker_index: usize,
1120    pub grant_picker: Option<GrantTargetPicker>,
1121    pub show_destroy_picker: bool,
1122    pub destroy_confirm_pending: bool,
1123    pub destroy_picker: Option<DestroyPicker>,
1124    /// Rename prompt for a selected container (`n` in inventory).
1125    pub show_rename_prompt: bool,
1126    /// Rename prompt for a hired worker (`n` in workers menu).
1127    pub show_worker_rename: bool,
1128    pub rename_buffer: String,
1129    /// Slot-1 combat target (mirrors server after SetTarget).
1130    pub combat_target: Option<EntityId>,
1131    pub combat_target_label: Option<String>,
1132    /// Active combat footprints from the server (`plans/39`).
1133    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1134    pub in_combat: bool,
1135    pub auto_attack: bool,
1136    pub combat_has_los: bool,
1137    pub attack_cd_ticks: u64,
1138    pub gcd_ticks: u64,
1139    pub weapon_ability_id: String,
1140    pub mainhand_template_id: Option<String>,
1141    pub mainhand_label: Option<String>,
1142    pub offhand_template_id: Option<String>,
1143    pub offhand_label: Option<String>,
1144    pub mainhand_hand_slots: u8,
1145    pub defense: Option<flatland_protocol::DefenseHud>,
1146    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1147    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1148    pub carry_mass: f32,
1149    pub carry_mass_max: f32,
1150    pub encumbrance: flatland_protocol::EncumbranceState,
1151    /// Full nested inventory stacks from the server (root only; worn are separate).
1152    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1153    /// Keys stowed on the virtual keychain (zero carry mass).
1154    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1155    /// Whisper stones in the pouch (zero carry mass).
1156    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1157    /// Active status effects on the local player (buff/debuff strip).
1158    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1159    pub combat_target_detail: Option<CombatTargetHud>,
1160    pub cast_progress: Option<CastProgressHud>,
1161    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1162    pub blocking_active: bool,
1163    pub max_target_slots: u8,
1164    pub combat_slots: Vec<CombatSlotHud>,
1165    pub rotation_presets: Vec<RotationPreset>,
1166    /// Learned abilities from combat HUD (usable casts).
1167    pub known_abilities: Vec<String>,
1168    /// Server-persisted hotbar bindings (index 0 = key 1).
1169    pub hotbar: Vec<Option<String>>,
1170    /// Max abilities allowed in one rotation (mind score).
1171    pub max_abilities_per_rotation: u8,
1172    pub show_loadout_menu: bool,
1173    pub show_keychain_menu: bool,
1174    pub keychain_menu_index: usize,
1175    pub show_rotation_editor: bool,
1176    /// Selected rotation preset in the loadout menu.
1177    pub loadout_menu_index: usize,
1178    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1179    pub loadout_hotbar_slot: u8,
1180    /// Selected known-ability row in the loadout menu.
1181    pub loadout_ability_index: usize,
1182    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1183    pub loadout_focus_presets: bool,
1184    pub rotation_editor: RotationEditorState,
1185    /// True after a harvest intent is accepted until result/reject/disconnect.
1186    pub harvest_in_progress: bool,
1187    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1188    pub harvest_started_at: Option<Instant>,
1189    /// Craft log deferred until the server acks the craft intent.
1190    pub pending_craft_ack: Option<(u32, String, u32)>,
1191    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1192    pub interactables: Vec<flatland_protocol::InteractableView>,
1193    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1194    pub career: Option<flatland_protocol::PlayerCareerView>,
1195    pub character_sheet_tab: CharacterSheetTab,
1196    pub ledger_period: LedgerPeriod,
1197    pub show_quest_offer: bool,
1198    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1199    pub show_quest_menu: bool,
1200    pub quest_menu_index: usize,
1201    pub quest_withdraw_confirm: bool,
1202    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1203    pub show_workers_menu: bool,
1204    pub workers_menu_index: usize,
1205    /// Workers panel: one-line rows instead of full cards (more on screen).
1206    pub workers_menu_compact: bool,
1207    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1208    /// Public so out-of-crate tests can construct [`GameState`].
1209    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1210    /// Held worker route errors (readable when server clears `last_error` each tick).
1211    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1212    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1213    pub show_worker_give_picker: bool,
1214    pub worker_give_picker_index: usize,
1215    pub worker_give_picker: Option<WorkerGivePicker>,
1216    /// Inventory `g` — pick which nearby worker receives the selected stack.
1217    pub show_worker_give_target_picker: bool,
1218    pub worker_give_target_picker_index: usize,
1219    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1220    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1221    pub show_worker_take_picker: bool,
1222    pub worker_take_picker_index: usize,
1223    pub worker_take_picker: Option<WorkerTakePicker>,
1224    /// Teach-blueprint sheet opened from the workers menu (`t`).
1225    pub show_worker_teach_picker: bool,
1226    pub worker_teach_picker_index: usize,
1227    pub worker_teach_picker: Option<WorkerTeachPicker>,
1228    /// Active harvest-route editor (`h` → `e` on a worker).
1229    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1230    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1231    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1232    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1233    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1234}
1235
1236impl GameState {
1237    pub fn push_log(&mut self, line: impl Into<String>) {
1238        self.logs.push_back(line.into());
1239        while self.logs.len() > MAX_LOG_LINES {
1240            self.logs.pop_front();
1241        }
1242    }
1243
1244    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1245        self.shop_trade_log.push_back(line.into());
1246        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1247            self.shop_trade_log.pop_front();
1248        }
1249    }
1250
1251    pub fn clear_shop_trade_log(&mut self) {
1252        self.shop_trade_log.clear();
1253    }
1254
1255    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1256        if !self.show_shop_menu {
1257            return;
1258        }
1259        let msg = notice.message.trim();
1260        if msg.is_empty() {
1261            return;
1262        }
1263        if notice.coins_delta != 0
1264            || msg.starts_with("Bought ")
1265            || msg.starts_with("Sold ")
1266            || msg.contains("taught you how to craft")
1267            || msg.starts_with("need ")
1268        {
1269            self.push_shop_trade_log(msg);
1270        }
1271    }
1272
1273    pub fn is_alive(&self) -> bool {
1274        self.player
1275            .as_ref()
1276            .and_then(|p| p.vitals)
1277            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1278            .unwrap_or(true)
1279    }
1280
1281    /// Verb menu entries for the current `npc_verb_target`.
1282    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1283        let Some(ref id) = self.npc_verb_target else {
1284            return vec![];
1285        };
1286        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1287            return vec!["Talk"];
1288        };
1289        let role = npc.role.as_str();
1290        if Self::npc_role_is_bank(role) {
1291            return vec!["Bank", "Talk"];
1292        }
1293        if Self::npc_role_is_storage(role) {
1294            return vec!["Storage", "Talk"];
1295        }
1296        if npc.can_trade || Self::npc_role_can_trade(role) {
1297            vec!["Talk", "Trade"]
1298        } else {
1299            vec!["Talk"]
1300        }
1301    }
1302
1303    fn npc_role_can_trade(role: &str) -> bool {
1304        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1305    }
1306
1307    fn npc_role_is_bank(role: &str) -> bool {
1308        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1309    }
1310
1311    fn npc_role_is_storage(role: &str) -> bool {
1312        role.eq_ignore_ascii_case("storage_manager")
1313    }
1314
1315    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1316        vec![
1317            "Deposit…",
1318            "Withdraw…",
1319            "Deposit all",
1320            "Withdraw all",
1321            "Transfer…",
1322        ]
1323    }
1324
1325    pub fn storage_menu_options(&self) -> Vec<String> {
1326        let mut opts = vec!["Store…".into(), "Take…".into()];
1327        if let Some(panel) = &self.storage_panel {
1328            for dest in &panel.ship_destinations {
1329                opts.push(format!(
1330                    "Ship → {} ({} cp / {} ticks)",
1331                    dest.label, dest.fee_copper, dest.travel_ticks
1332                ));
1333            }
1334        }
1335        opts
1336    }
1337
1338    /// Loose on-person stacks eligible for town-storage store.
1339    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1340        self.person_rows()
1341            .into_iter()
1342            .filter(|r| r.depth == 0)
1343            .filter_map(|r| {
1344                let id = r.stack.item_instance_id?;
1345                Some(StoragePickOption {
1346                    item_instance_id: id,
1347                    label: storage_stack_label(&r.stack),
1348                    quantity: r.stack.quantity,
1349                })
1350            })
1351            .collect()
1352    }
1353
1354    /// Vault stacks eligible for take / ship.
1355    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1356        let Some(panel) = &self.storage_panel else {
1357            return Vec::new();
1358        };
1359        panel
1360            .contents
1361            .iter()
1362            .filter_map(|s| {
1363                let id = s.item_instance_id?;
1364                Some(StoragePickOption {
1365                    item_instance_id: id,
1366                    label: storage_stack_label(s),
1367                    quantity: s.quantity,
1368                })
1369            })
1370            .collect()
1371    }
1372
1373    pub fn clear_harvest_state(&mut self) {
1374        self.harvest_in_progress = false;
1375        self.harvest_started_at = None;
1376    }
1377
1378    fn harvest_state_stale(&self) -> bool {
1379        match self.harvest_started_at {
1380            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1381            None => self.harvest_in_progress,
1382        }
1383    }
1384
1385    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1386        self.player.as_ref().and_then(|p| p.vitals)
1387    }
1388
1389    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1390        let materials_ok = blueprint.inputs.iter().all(|input| {
1391            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1392        });
1393        let tools_ok = blueprint
1394            .required_tools
1395            .iter()
1396            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1397        let station_ok = match blueprint.station.as_deref() {
1398            None | Some("hand") => true,
1399            Some(tag) => self.player_at_station_tag(tag),
1400        };
1401        materials_ok && tools_ok && station_ok
1402    }
1403
1404    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1405        if !self.can_craft_blueprint(blueprint) {
1406            return 0;
1407        }
1408        let mut limit = u32::MAX;
1409        for input in &blueprint.inputs {
1410            if input.quantity == 0 {
1411                continue;
1412            }
1413            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1414            limit = limit.min(have / input.quantity);
1415        }
1416        for tool in &blueprint.required_tools {
1417            if tool.consumed {
1418                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1419                limit = limit.min(have);
1420            }
1421        }
1422        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1423        if CRAFT_STAMINA_COST > 0.0 {
1424            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1425        }
1426        limit
1427    }
1428
1429    pub fn clamp_craft_batch_quantity(&mut self) {
1430        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1431            self.craft_batch_quantity = 1;
1432            return;
1433        };
1434        let max = self.max_craft_batches(bp).max(1);
1435        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1436    }
1437
1438    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1439        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1440            return;
1441        };
1442        let max = self.max_craft_batches(&bp).max(1);
1443        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1444        self.craft_batch_quantity = next as u32;
1445    }
1446
1447    pub fn craft_batch_set_max(&mut self) {
1448        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1449            return;
1450        };
1451        let max = self.max_craft_batches(&bp);
1452        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1453    }
1454
1455    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1456        let preserve_ui = self.show_shop_menu;
1457        let tab = self.shop_tab;
1458        let index = self.shop_menu_index;
1459        let qty = self.shop_quantity;
1460
1461        self.show_shop_menu = true;
1462        self.bank_panel = None;
1463        self.show_craft_menu = false;
1464        self.show_inventory_menu = false;
1465        self.show_stats = false;
1466        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1467            self.npc_verb_target = Some(catalog.npc_id.clone());
1468        }
1469        self.shop_catalog = Some(catalog);
1470
1471        if preserve_ui {
1472            self.shop_tab = tab;
1473            self.shop_menu_index = index;
1474            self.shop_quantity = qty;
1475        } else {
1476            self.shop_tab = ShopTab::Buy;
1477            self.shop_menu_index = 0;
1478            self.shop_quantity = 1;
1479            self.clear_shop_trade_log();
1480        }
1481        self.show_npc_verb_menu = false;
1482        self.clamp_shop_selection();
1483    }
1484
1485    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1486        let same_teller = self
1487            .bank_panel
1488            .as_ref()
1489            .is_some_and(|p| p.npc_id == panel.npc_id);
1490        self.bank_panel = Some(panel);
1491        self.storage_panel = None;
1492        self.shop_catalog = None;
1493        self.show_shop_menu = false;
1494        self.show_craft_menu = false;
1495        self.show_inventory_menu = false;
1496        self.show_stats = false;
1497        self.show_npc_verb_menu = false;
1498        self.show_npc_chat = false;
1499        self.npc_chat = None;
1500        if !same_teller {
1501            self.bank_menu_index = 0;
1502            self.bank_ui_mode = BankUiMode::Menu;
1503        }
1504        if let Some(panel) = &self.bank_panel {
1505            if self.npc_verb_target.is_none() {
1506                self.npc_verb_target = Some(panel.npc_id.clone());
1507            }
1508        }
1509    }
1510
1511    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1512        let same_manager = self
1513            .storage_panel
1514            .as_ref()
1515            .is_some_and(|p| p.npc_id == panel.npc_id);
1516        self.storage_panel = Some(panel);
1517        self.bank_panel = None;
1518        self.bank_ui_mode = BankUiMode::Menu;
1519        self.shop_catalog = None;
1520        self.show_shop_menu = false;
1521        self.show_craft_menu = false;
1522        self.show_inventory_menu = false;
1523        self.show_stats = false;
1524        self.show_npc_verb_menu = false;
1525        self.show_npc_chat = false;
1526        self.npc_chat = None;
1527        if !same_manager {
1528            self.storage_menu_index = 0;
1529            self.storage_ui_mode = StorageUiMode::Menu;
1530        } else {
1531            self.clamp_storage_pick_index();
1532        }
1533        if let Some(panel) = &self.storage_panel {
1534            if self.npc_verb_target.is_none() {
1535                self.npc_verb_target = Some(panel.npc_id.clone());
1536            }
1537        }
1538    }
1539
1540    pub fn clear_bank_panel(&mut self) {
1541        self.bank_panel = None;
1542        self.bank_menu_index = 0;
1543        self.bank_ui_mode = BankUiMode::Menu;
1544    }
1545
1546    pub fn clear_storage_panel(&mut self) {
1547        self.storage_panel = None;
1548        self.storage_menu_index = 0;
1549        self.storage_ui_mode = StorageUiMode::Menu;
1550    }
1551
1552    fn clamp_storage_pick_index(&mut self) {
1553        match &self.storage_ui_mode {
1554            StorageUiMode::StorePick { index } => {
1555                let n = self.storage_store_options().len();
1556                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1557                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1558            }
1559            StorageUiMode::TakePick { index } => {
1560                let n = self.storage_vault_options().len();
1561                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1562                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1563            }
1564            StorageUiMode::ShipPick {
1565                dest_building_id,
1566                dest_label,
1567                index,
1568            } => {
1569                let n = self.storage_vault_options().len();
1570                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1571                self.storage_ui_mode = StorageUiMode::ShipPick {
1572                    dest_building_id: dest_building_id.clone(),
1573                    dest_label: dest_label.clone(),
1574                    index: next,
1575                };
1576            }
1577            StorageUiMode::Menu
1578            | StorageUiMode::StoreAmount { .. }
1579            | StorageUiMode::TakeAmount { .. }
1580            | StorageUiMode::ShipAmount { .. } => {}
1581        }
1582    }
1583
1584    pub fn shop_list_len(&self) -> usize {
1585        let Some(catalog) = &self.shop_catalog else {
1586            return 0;
1587        };
1588        match self.shop_tab {
1589            ShopTab::Buy => catalog.sells.len(),
1590            ShopTab::Sell => catalog.buys.len(),
1591        }
1592    }
1593
1594    pub fn shop_menu_move(&mut self, delta: i32) {
1595        let n = self.shop_list_len();
1596        if n == 0 {
1597            return;
1598        }
1599        let idx = self.shop_menu_index as i32;
1600        let next = (idx + delta).rem_euclid(n as i32);
1601        self.shop_menu_index = next as usize;
1602        self.clamp_shop_quantity();
1603    }
1604
1605    pub fn shop_quantity_adjust(&mut self, delta: i32) {
1606        let max = self.shop_quantity_max();
1607        if max == 0 {
1608            self.shop_quantity = 0;
1609            return;
1610        }
1611        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
1612        self.shop_quantity = next as u32;
1613    }
1614
1615    pub(crate) fn clamp_shop_selection(&mut self) {
1616        let n = self.shop_list_len();
1617        if n == 0 {
1618            self.shop_menu_index = 0;
1619        } else {
1620            self.shop_menu_index = self.shop_menu_index.min(n - 1);
1621        }
1622        self.clamp_shop_quantity();
1623    }
1624
1625    fn shop_quantity_max(&self) -> u32 {
1626        let Some(catalog) = &self.shop_catalog else {
1627            return 1;
1628        };
1629        match self.shop_tab {
1630            ShopTab::Buy => {
1631                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
1632                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
1633                        return 1;
1634                    }
1635                }
1636                99
1637            }
1638            ShopTab::Sell => catalog
1639                .buys
1640                .get(self.shop_menu_index)
1641                .map(|l| l.quantity)
1642                .unwrap_or(0),
1643        }
1644    }
1645
1646    pub fn shop_quantity_set_max(&mut self) {
1647        self.shop_quantity = self.shop_quantity_max();
1648    }
1649
1650    fn clamp_shop_quantity(&mut self) {
1651        let max = self.shop_quantity_max();
1652        if max == 0 {
1653            self.shop_quantity = 0;
1654        } else {
1655            self.shop_quantity = self.shop_quantity.max(1).min(max);
1656        }
1657    }
1658
1659    pub fn player_at_station_tag(&self, tag: &str) -> bool {
1660        let Some(id) = self.effective_inside_building() else {
1661            return false;
1662        };
1663        self.buildings
1664            .iter()
1665            .find(|b| b.id == id)
1666            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
1667    }
1668
1669    /// Short hint for UI when a recipe cannot be started.
1670    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
1671        if self.can_craft_blueprint(blueprint) {
1672            return None;
1673        }
1674        let mut missing = Vec::new();
1675        for input in &blueprint.inputs {
1676            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1677            if have < input.quantity {
1678                missing.push(format!(
1679                    "{}×{} (have {have})",
1680                    input.quantity, input.template_id
1681                ));
1682            }
1683        }
1684        for tool in &blueprint.required_tools {
1685            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1686            if have < 1 {
1687                missing.push(format!("tool: {}", tool.item));
1688            }
1689        }
1690        if let Some(station) = blueprint.station.as_deref() {
1691            if station != "hand" && !self.player_at_station_tag(station) {
1692                missing.push(format!("station: {station} (enter building)"));
1693            }
1694        }
1695        if missing.is_empty() {
1696            None
1697        } else {
1698            Some(missing.join(", "))
1699        }
1700    }
1701
1702    pub fn player_entity(&self) -> Option<&EntityState> {
1703        self.player
1704            .as_ref()
1705            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
1706    }
1707
1708    pub fn player_position(&self) -> (f32, f32) {
1709        let (x, y, _) = self.player_position_with_z();
1710        (x, y)
1711    }
1712
1713    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
1714        if let Some(p) = self.player_entity() {
1715            (
1716                p.transform.position.x,
1717                p.transform.position.y,
1718                p.transform.position.z,
1719            )
1720        } else {
1721            (0.0, 0.0, 0.0)
1722        }
1723    }
1724
1725    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
1726        let mut rows: Vec<(String, u32, String)> = self
1727            .inventory
1728            .iter()
1729            .filter(|(_, q)| **q > 0)
1730            .map(|(id, qty)| {
1731                let label = self
1732                    .inventory_hints
1733                    .get(id)
1734                    .map(|h| h.display_name.clone())
1735                    .unwrap_or_else(|| id.clone());
1736                (id.clone(), *qty, label)
1737            })
1738            .collect();
1739        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
1740        rows
1741    }
1742
1743    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
1744        self.inventory_hints
1745            .get(template_id)
1746            .map(|h| h.category.as_str())
1747            .filter(|c| !c.is_empty())
1748    }
1749
1750    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
1751        stack
1752            .props
1753            .get("grants_item_status_effect")
1754            .map(|s| !s.is_empty())
1755            .unwrap_or(false)
1756    }
1757
1758    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
1759        stack
1760            .props
1761            .get("grants_item_status_effect")
1762            .map(String::as_str)
1763            .filter(|s| !s.is_empty())
1764    }
1765
1766    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
1767        stack
1768            .props
1769            .get("grants_item_status_mode")
1770            .map(String::as_str)
1771            .unwrap_or("on_hit")
1772    }
1773
1774    /// Candidate gear for a grant consumable (inventory + worn).
1775    pub fn grant_target_options(
1776        &self,
1777        grant: &flatland_protocol::ItemStack,
1778    ) -> Vec<GrantTargetOption> {
1779        let mode = Self::grant_mode(grant);
1780        let grant_tags: Vec<&str> = grant
1781            .props
1782            .get("grants_item_status_tags")
1783            .map(|s| {
1784                s.split(',')
1785                    .map(str::trim)
1786                    .filter(|t| !t.is_empty())
1787                    .collect()
1788            })
1789            .unwrap_or_default();
1790        let grant_id = grant.item_instance_id;
1791        let mut out = Vec::new();
1792        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
1793            let Some(iid) = stack.item_instance_id else {
1794                return;
1795            };
1796            if Some(iid) == grant_id {
1797                return;
1798            }
1799            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
1800                return;
1801            }
1802            if !grant_target_matches_mode(stack, mode) {
1803                return;
1804            }
1805            if !grant_tags_match(stack, &grant_tags) {
1806                return;
1807            }
1808            let name = stack
1809                .display_name
1810                .clone()
1811                .unwrap_or_else(|| stack.template_id.clone());
1812            let bindings = if stack.status_bindings.is_empty() {
1813                String::new()
1814            } else {
1815                format!(
1816                    " · {}",
1817                    stack
1818                        .status_bindings
1819                        .iter()
1820                        .map(|b| b.effect_id.as_str())
1821                        .collect::<Vec<_>>()
1822                        .join(", ")
1823                )
1824            };
1825            out.push(GrantTargetOption {
1826                label: format!("{where_label}: {name}{bindings}"),
1827                target_instance_id: iid,
1828            });
1829        };
1830        fn walk(
1831            stacks: &[flatland_protocol::ItemStack],
1832            where_label: &str,
1833            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
1834        ) {
1835            for s in stacks {
1836                push(s, where_label);
1837                if !s.contents.is_empty() {
1838                    let nested = format!(
1839                        "{where_label}/{}",
1840                        s.display_name
1841                            .as_deref()
1842                            .unwrap_or(s.template_id.as_str())
1843                    );
1844                    walk(&s.contents, &nested, push);
1845                }
1846            }
1847        }
1848        walk(&self.inventory_stacks, "Bag", &mut push);
1849        for (slot, stack) in &self.worn {
1850            push(stack, body_slot_label(*slot));
1851            let nest = format!(
1852                "{}/{}",
1853                body_slot_label(*slot),
1854                stack
1855                    .display_name
1856                    .as_deref()
1857                    .unwrap_or(stack.template_id.as_str())
1858            );
1859            walk(&stack.contents, &nest, &mut push);
1860        }
1861        out
1862    }
1863
1864    pub fn item_base_mass(&self, template_id: &str) -> f32 {
1865        self.inventory_hints
1866            .get(template_id)
1867            .and_then(|h| h.base_mass)
1868            .unwrap_or(0.5)
1869    }
1870
1871    pub fn item_base_volume(&self, template_id: &str) -> f32 {
1872        self.inventory_hints
1873            .get(template_id)
1874            .and_then(|h| h.base_volume)
1875            .unwrap_or(1.0)
1876    }
1877
1878    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
1879        let unit = stack
1880            .base_mass
1881            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
1882        unit * stack.quantity as f32
1883    }
1884
1885    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
1886        let unit = stack.base_volume.unwrap_or(1.0);
1887        unit * stack.quantity as f32
1888            + stack
1889                .contents
1890                .iter()
1891                .map(Self::stack_tree_volume)
1892                .sum::<f32>()
1893    }
1894
1895    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
1896        contents.iter().map(Self::stack_tree_volume).sum()
1897    }
1898
1899    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
1900        self.inventory_hints
1901            .get(template_id)
1902            .and_then(|h| h.capacity_volume)
1903            .filter(|c| *c > 0.0)
1904    }
1905
1906    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
1907        stack
1908            .capacity_volume
1909            .filter(|c| *c > 0.0)
1910            .or_else(|| self.template_capacity_volume(&stack.template_id))
1911    }
1912
1913    /// Volume used / capacity / free space label for storage containers in the inventory UI.
1914    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
1915        let Some((used, cap)) = self.container_volume_stats(row) else {
1916            return String::new();
1917        };
1918        let free = (cap - used).max(0.0);
1919        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
1920    }
1921
1922    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
1923        if row.is_chest_shell {
1924            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
1925                return None;
1926            };
1927            let chest = self
1928                .placed_containers
1929                .iter()
1930                .find(|c| c.id == *container_id)?;
1931            let cap = self
1932                .stack_capacity_volume(&row.stack)
1933                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
1934            let used = if chest.accessible {
1935                Self::contents_used_volume(&chest.contents)
1936            } else {
1937                0.0
1938            };
1939            return Some((used, cap));
1940        }
1941
1942        let cap = self.stack_capacity_volume(&row.stack)?;
1943        let used = Self::contents_used_volume(&row.stack.contents);
1944        Some((used, cap))
1945    }
1946
1947    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1948        if row.is_chest_shell {
1949            return true;
1950        }
1951        if row.is_equip_shell {
1952            return self.inventory_item_category(&row.stack.template_id) == Some("container");
1953        }
1954        self.inventory_item_category(&row.stack.template_id) == Some("container")
1955            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1956    }
1957
1958    fn container_stack_for(
1959        &self,
1960        location: &flatland_protocol::InventoryLocation,
1961        parent_instance_id: Option<uuid::Uuid>,
1962    ) -> Option<flatland_protocol::ItemStack> {
1963        match location {
1964            flatland_protocol::InventoryLocation::Root => {
1965                let pid = parent_instance_id?;
1966                self.find_stack_by_instance(&self.inventory_stacks, pid)
1967            }
1968            flatland_protocol::InventoryLocation::Worn { slot } => {
1969                let worn = self.worn.get(slot)?;
1970                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1971                    Some(worn.clone())
1972                } else {
1973                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1974                }
1975            }
1976            flatland_protocol::InventoryLocation::Placed { container_id } => {
1977                let chest = self
1978                    .placed_containers
1979                    .iter()
1980                    .find(|c| c.id == *container_id)?;
1981                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1982                    Some(flatland_protocol::ItemStack {
1983                        template_id: chest.template_id.clone(),
1984                        quantity: 1,
1985                        item_instance_id: chest.item_instance_id,
1986                        props: Default::default(),
1987                        status_bindings: Vec::new(),
1988                        contents: chest.contents.clone(),
1989                        display_name: Some(chest.display_name.clone()),
1990                        category: Some("container".into()),
1991                        capacity_volume: self
1992                            .inventory_hints
1993                            .get(&chest.template_id)
1994                            .and_then(|h| h.capacity_volume),
1995                        worker_lodging_capacity: chest.worker_lodging_capacity,
1996                        ..Default::default()
1997                    })
1998                } else {
1999                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2000                }
2001            }
2002            flatland_protocol::InventoryLocation::Keychain => None,
2003            flatland_protocol::InventoryLocation::WhisperPouch => None,
2004        }
2005    }
2006
2007    fn find_stack_by_instance(
2008        &self,
2009        stacks: &[flatland_protocol::ItemStack],
2010        instance_id: uuid::Uuid,
2011    ) -> Option<flatland_protocol::ItemStack> {
2012        for stack in stacks {
2013            if stack.item_instance_id == Some(instance_id) {
2014                return Some(stack.clone());
2015            }
2016            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2017                return Some(found);
2018            }
2019        }
2020        None
2021    }
2022
2023    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2024    pub fn max_movable_to(
2025        &self,
2026        template_id: &str,
2027        stack_qty: u32,
2028        from: &flatland_protocol::InventoryLocation,
2029        to: &flatland_protocol::InventoryLocation,
2030        parent_instance_id: Option<uuid::Uuid>,
2031    ) -> u32 {
2032        let unit_vol = self.item_base_volume(template_id);
2033        let unit_mass = self.item_base_mass(template_id);
2034        let mut limit = stack_qty;
2035
2036        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2037            let cap = parent
2038                .capacity_volume
2039                .or_else(|| {
2040                    self.inventory_hints
2041                        .get(&parent.template_id)
2042                        .and_then(|h| h.capacity_volume)
2043                })
2044                .unwrap_or(0.0);
2045            if cap > 0.0 && unit_vol > 0.0 {
2046                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2047                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2048            }
2049        }
2050
2051        let to_person = matches!(
2052            to,
2053            flatland_protocol::InventoryLocation::Root
2054                | flatland_protocol::InventoryLocation::Worn { .. }
2055        );
2056        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2057        if to_person && from_placed && unit_mass > 0.0 {
2058            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2059            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2060                limit = 0;
2061            } else {
2062                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2063            }
2064        }
2065
2066        limit.max(0).min(stack_qty)
2067    }
2068
2069    pub fn move_picker_max_at_selection(&self) -> u32 {
2070        let Some(picker) = &self.move_picker else {
2071            return 1;
2072        };
2073        let Some(opt) = picker.options.get(self.move_picker_index) else {
2074            return picker.stack_quantity;
2075        };
2076        match &opt.kind {
2077            MoveOptionKind::Cancel
2078            | MoveOptionKind::Drop
2079            | MoveOptionKind::Use
2080            | MoveOptionKind::GrantApply
2081            | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
2082            MoveOptionKind::Move {
2083                location,
2084                parent_instance_id,
2085            } => self.max_movable_to(
2086                &picker.template_id,
2087                picker.stack_quantity,
2088                &picker.from,
2089                location,
2090                *parent_instance_id,
2091            ),
2092        }
2093    }
2094
2095    pub fn clamp_move_picker_quantity(&mut self) {
2096        let max = self.move_picker_max_at_selection();
2097        if let Some(picker) = &mut self.move_picker {
2098            if max == 0 {
2099                picker.quantity = 1;
2100            } else {
2101                picker.quantity = picker.quantity.clamp(1, max);
2102            }
2103        }
2104    }
2105
2106    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2107        let max = self.move_picker_max_at_selection().max(1);
2108        if let Some(picker) = &mut self.move_picker {
2109            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2110            picker.quantity = next as u32;
2111        }
2112    }
2113
2114    pub fn move_picker_set_quantity_max(&mut self) {
2115        let max = self.move_picker_max_at_selection();
2116        if let Some(picker) = &mut self.move_picker {
2117            picker.quantity = if max == 0 {
2118                1
2119            } else {
2120                max.min(picker.stack_quantity)
2121            };
2122        }
2123    }
2124
2125    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2126        if let Some(picker) = &mut self.destroy_picker {
2127            let max = picker.stack_quantity.max(1);
2128            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2129            picker.quantity = next as u32;
2130        }
2131    }
2132
2133    pub fn destroy_picker_set_quantity_max(&mut self) {
2134        if let Some(picker) = &mut self.destroy_picker {
2135            picker.quantity = picker.stack_quantity.max(1);
2136        }
2137    }
2138
2139    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2140        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2141        (have, have >= need)
2142    }
2143
2144    pub fn currency_display(&self) -> String {
2145        crate::currency::currency_line(&self.inventory)
2146    }
2147
2148    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2149    pub fn in_shallow_water(&self) -> bool {
2150        let (px, py) = self.player_position();
2151        self.terrain_at(px, py)
2152            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2153    }
2154
2155    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2156        self.terrain_zone_at(x, y).map(|z| z.kind)
2157    }
2158
2159    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2160    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2161        self.terrain_zones
2162            .iter()
2163            .enumerate()
2164            .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2165            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2166            .map(|(_, z)| z)
2167    }
2168
2169    /// Ground elevation from terrain zones (m).
2170    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2171        self.terrain_zone_at(x, y)
2172            .map(|z| z.elevation)
2173            .unwrap_or(0.0)
2174    }
2175
2176    /// Walkable z levels at a map column (terrain + platforms).
2177    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2178        const TOL: f32 = 0.35;
2179        let mut levels = vec![self.elevation_at(x, y)];
2180        for p in &self.z_platforms {
2181            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2182                levels.push(p.z);
2183            }
2184        }
2185        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2186        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2187        levels
2188    }
2189
2190    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2191        const TOL: f32 = 0.35;
2192        self.walkable_levels_at(x, y)
2193            .iter()
2194            .any(|&l| (l - z).abs() <= TOL)
2195    }
2196
2197    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2198        let mut top = self.elevation_at(x, y);
2199        for p in &self.z_platforms {
2200            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2201                top = top.max(p.z);
2202            }
2203        }
2204        top
2205    }
2206
2207    /// Authoritative interior context from the server (`inside_building` flag).
2208    pub fn effective_inside_building(&self) -> Option<String> {
2209        self.player_entity().and_then(|p| p.inside_building.clone())
2210    }
2211
2212    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2213        self.inventory_stacks = stacks.to_vec();
2214        self.inventory.clear();
2215        self.inventory_hints.clear();
2216        fn walk(
2217            stacks: &[flatland_protocol::ItemStack],
2218            inventory: &mut std::collections::HashMap<String, u32>,
2219            hints: &mut std::collections::HashMap<String, InventoryHint>,
2220        ) {
2221            for stack in stacks {
2222                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2223                if stack.display_name.is_some()
2224                    || stack.category.is_some()
2225                    || stack.base_mass.is_some()
2226                    || stack.base_volume.is_some()
2227                {
2228                    hints.insert(
2229                        stack.template_id.clone(),
2230                        InventoryHint {
2231                            display_name: stack
2232                                .display_name
2233                                .clone()
2234                                .unwrap_or_else(|| stack.template_id.clone()),
2235                            category: stack.category.clone().unwrap_or_default(),
2236                            base_mass: stack.base_mass,
2237                            base_volume: stack.base_volume,
2238                            capacity_volume: stack.capacity_volume,
2239                            stackable: stack.stackable.unwrap_or(true),
2240                        },
2241                    );
2242                }
2243                walk(&stack.contents, inventory, hints);
2244            }
2245        }
2246        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2247        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
2248        for item in self.worn.values() {
2249            walk(
2250                std::slice::from_ref(item),
2251                &mut self.inventory,
2252                &mut self.inventory_hints,
2253            );
2254        }
2255    }
2256
2257    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
2258    /// inventory UI updates before the next tick snapshot arrives.
2259    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2260        let subtract_items =
2261            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2262        for stack in &notice.inventory_delta {
2263            if stack.quantity == 0 {
2264                continue;
2265            }
2266            if subtract_items {
2267                crate::currency::drain_template_stacks(
2268                    &mut self.inventory_stacks,
2269                    &stack.template_id,
2270                    stack.quantity,
2271                );
2272                continue;
2273            }
2274            let stackable = self
2275                .inventory_hints
2276                .get(&stack.template_id)
2277                .map(|h| h.stackable)
2278                .or(stack.stackable)
2279                .unwrap_or(true);
2280            if stackable {
2281                if let Some(existing) = self
2282                    .inventory_stacks
2283                    .iter_mut()
2284                    .find(|s| s.template_id == stack.template_id)
2285                {
2286                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
2287                    if stack.display_name.is_some() {
2288                        existing.display_name = stack.display_name.clone();
2289                    }
2290                    if stack.category.is_some() {
2291                        existing.category = stack.category.clone();
2292                    }
2293                    continue;
2294                }
2295            }
2296            self.inventory_stacks.push(stack.clone());
2297        }
2298        if notice.coins_delta != 0 {
2299            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2300        }
2301        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2302            let stacks = self.inventory_stacks.clone();
2303            self.sync_inventory_from_stacks(&stacks);
2304        }
2305        self.record_shop_trade_notice(notice);
2306    }
2307
2308    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
2309    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
2310    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
2311    /// iteration alone gives a stable row order.
2312    pub fn worn_rows(&self) -> Vec<InventoryRow> {
2313        let mut rows = Vec::new();
2314        for (slot, item) in &self.worn {
2315            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2316            rows.push(InventoryRow {
2317                depth: 0,
2318                stack: item.clone(),
2319                from: from.clone(),
2320                from_parent_instance_id: None,
2321                is_equip_shell: true,
2322                is_chest_shell: false,
2323                section: InventorySection::Worn,
2324            });
2325            for child in &item.contents {
2326                push_inventory_rows(
2327                    &mut rows,
2328                    1,
2329                    child,
2330                    &from,
2331                    item.item_instance_id,
2332                    InventorySection::Worn,
2333                );
2334            }
2335        }
2336        rows
2337    }
2338
2339    /// Root on-person stacks that can be handed to a hired worker.
2340    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2341        self.inventory_stacks
2342            .iter()
2343            .filter_map(|stack| {
2344                let item_instance_id = stack.item_instance_id?;
2345                let label = stack
2346                    .display_name
2347                    .clone()
2348                    .unwrap_or_else(|| stack.template_id.clone());
2349                let label = if stack.quantity > 1 {
2350                    format!("{label} ×{}", stack.quantity)
2351                } else {
2352                    label
2353                };
2354                Some(WorkerGiveOption {
2355                    item_instance_id,
2356                    label,
2357                    quantity: stack.quantity,
2358                    template_id: stack.template_id.clone(),
2359                })
2360            })
2361            .collect()
2362    }
2363
2364    /// Employer-known blueprints the selected worker does not yet know.
2365    pub fn teachable_blueprint_options(
2366        &self,
2367        worker: &flatland_protocol::HiredWorkerView,
2368    ) -> Vec<WorkerTeachOption> {
2369        let copper = crate::currency::copper_from_counts(&self.inventory);
2370        let mut options: Vec<WorkerTeachOption> = self
2371            .blueprints
2372            .iter()
2373            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2374            .map(|bp| {
2375                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2376                let cost = bp.worker_train_copper;
2377                WorkerTeachOption {
2378                    blueprint_id: bp.id.clone(),
2379                    label: if bp.label.is_empty() {
2380                        bp.id.clone()
2381                    } else {
2382                        bp.label.clone()
2383                    },
2384                    cost_copper: cost,
2385                    min_level,
2386                    worker_level: worker.level,
2387                    can_afford: copper >= cost,
2388                    level_ok: worker.level >= min_level,
2389                }
2390            })
2391            .collect();
2392        options.sort_by(|a, b| a.label.cmp(&b.label));
2393        options
2394    }
2395
2396    /// Loose on-person inventory (not worn, not inside a placed chest).
2397    /// Root stacks are ordered by category group; nested contents stay under their parent.
2398    pub fn person_rows(&self) -> Vec<InventoryRow> {
2399        self.person_rows_filtered("")
2400    }
2401
2402    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2403        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2404        roots.sort_by(|a, b| {
2405            let ca = a
2406                .category
2407                .as_deref()
2408                .or_else(|| self.inventory_item_category(&a.template_id))
2409                .unwrap_or("");
2410            let cb = b
2411                .category
2412                .as_deref()
2413                .or_else(|| self.inventory_item_category(&b.template_id))
2414                .unwrap_or("");
2415            let ga = inventory_category_group(ca).1;
2416            let gb = inventory_category_group(cb).1;
2417            ga.cmp(&gb).then_with(|| {
2418                let na = a
2419                    .display_name
2420                    .as_deref()
2421                    .unwrap_or(a.template_id.as_str());
2422                let nb = b
2423                    .display_name
2424                    .as_deref()
2425                    .unwrap_or(b.template_id.as_str());
2426                na.cmp(nb)
2427            })
2428        });
2429        let mut rows = Vec::new();
2430        for stack in roots {
2431            push_inventory_rows_filtered(
2432                &mut rows,
2433                0,
2434                stack,
2435                &flatland_protocol::InventoryLocation::Root,
2436                None,
2437                InventorySection::Person,
2438                filter,
2439            );
2440        }
2441        rows
2442    }
2443
2444    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2445        if filter.is_empty() {
2446            return self.worn_rows();
2447        }
2448        let mut rows = Vec::new();
2449        for (slot, item) in &self.worn {
2450            if !stack_matches_filter(item, filter) {
2451                continue;
2452            }
2453            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2454            let self_hit = {
2455                let f = filter.to_ascii_lowercase();
2456                let name = item
2457                    .display_name
2458                    .as_deref()
2459                    .unwrap_or("")
2460                    .to_ascii_lowercase();
2461                let tid = item.template_id.to_ascii_lowercase();
2462                name.contains(&f) || tid.contains(&f)
2463            };
2464            rows.push(InventoryRow {
2465                depth: 0,
2466                stack: item.clone(),
2467                from: from.clone(),
2468                from_parent_instance_id: None,
2469                is_equip_shell: true,
2470                is_chest_shell: false,
2471                section: InventorySection::Worn,
2472            });
2473            for child in &item.contents {
2474                if self_hit || stack_matches_filter(child, filter) {
2475                    push_inventory_rows_filtered(
2476                        &mut rows,
2477                        1,
2478                        child,
2479                        &from,
2480                        item.item_instance_id,
2481                        InventorySection::Worn,
2482                        if self_hit { "" } else { filter },
2483                    );
2484                }
2485            }
2486        }
2487        rows
2488    }
2489
2490    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
2491    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
2492        let mut rows = self.worn_rows();
2493        rows.extend(self.person_rows());
2494        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
2495    }
2496
2497    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
2498    /// populated when `accessible` — this is what makes a chest's contents
2499    /// disappear the moment you walk away or it's locked without your key.
2500    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
2501        let (px, py) = self.player_position();
2502        let mut list: Vec<NearbyContainer> = self
2503            .placed_containers
2504            .iter()
2505            .filter_map(|c| {
2506                let distance_m = (c.x - px).hypot(c.y - py);
2507                if distance_m > CONTAINER_RANGE_M {
2508                    return None;
2509                }
2510                let mut rows = Vec::new();
2511                let from = flatland_protocol::InventoryLocation::Placed {
2512                    container_id: c.id.clone(),
2513                };
2514                rows.push(InventoryRow {
2515                    depth: 0,
2516                    stack: flatland_protocol::ItemStack {
2517                        template_id: c.template_id.clone(),
2518                        quantity: 1,
2519                        item_instance_id: c.item_instance_id,
2520                        props: Default::default(),
2521                        status_bindings: Vec::new(),
2522                        contents: Vec::new(),
2523                        display_name: Some(c.display_name.clone()),
2524                        category: Some("container".into()),
2525                        capacity_volume: c.capacity_volume,
2526                        worker_lodging_capacity: c.worker_lodging_capacity,
2527                        ..Default::default()
2528                    },
2529                    from: from.clone(),
2530                    from_parent_instance_id: None,
2531                    is_equip_shell: false,
2532                    is_chest_shell: true,
2533                    section: InventorySection::Nearby,
2534                });
2535                if c.accessible {
2536                    for child in &c.contents {
2537                        push_inventory_rows(
2538                            &mut rows,
2539                            1,
2540                            child,
2541                            &from,
2542                            c.item_instance_id,
2543                            InventorySection::Nearby,
2544                        );
2545                    }
2546                }
2547                Some(NearbyContainer {
2548                    view: c.clone(),
2549                    distance_m,
2550                    rows,
2551                })
2552            })
2553            .collect();
2554        list.sort_by(|a, b| {
2555            a.distance_m
2556                .partial_cmp(&b.distance_m)
2557                .unwrap_or(std::cmp::Ordering::Equal)
2558        });
2559        list
2560    }
2561
2562    /// Nearest placed chest within `max_dist`, regardless of accessibility.
2563    pub fn nearest_placed_container(
2564        &self,
2565        max_dist: f32,
2566    ) -> Option<flatland_protocol::PlacedContainerView> {
2567        let (px, py) = self.player_position();
2568        self.placed_containers
2569            .iter()
2570            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
2571            .min_by(|a, b| {
2572                let da = (a.x - px).hypot(a.y - py);
2573                let db = (b.x - px).hypot(b.y - py);
2574                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2575            })
2576            .cloned()
2577    }
2578
2579    /// Selectable rows for the **active inventory tab** (and current filter).
2580    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
2581    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
2582        let filter = self.inventory_filter.as_str();
2583        match self.inventory_tab {
2584            InventoryTab::OnPerson => {
2585                let mut rows = self.worn_rows_filtered(filter);
2586                rows.extend(self.person_rows_filtered(filter));
2587                rows
2588            }
2589            InventoryTab::Nearby => {
2590                let mut rows = Vec::new();
2591                for nc in self.nearby_containers() {
2592                    if filter.is_empty() {
2593                        rows.extend(nc.rows);
2594                        continue;
2595                    }
2596                    let shell = nc.rows.first().cloned();
2597                    let contents: Vec<_> = nc
2598                        .rows
2599                        .iter()
2600                        .skip(1)
2601                        .filter(|r| stack_matches_filter(&r.stack, filter))
2602                        .cloned()
2603                        .collect();
2604                    let shell_hit = shell
2605                        .as_ref()
2606                        .map(|s| stack_matches_filter(&s.stack, filter))
2607                        .unwrap_or(false);
2608                    if shell_hit || !contents.is_empty() {
2609                        if let Some(s) = shell {
2610                            rows.push(s);
2611                        }
2612                        if shell_hit {
2613                            rows.extend(nc.rows.into_iter().skip(1));
2614                        } else {
2615                            rows.extend(contents);
2616                        }
2617                    }
2618                }
2619                rows
2620            }
2621        }
2622    }
2623
2624    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
2625        self.inventory_selectable_rows()
2626            .into_iter()
2627            .nth(self.inventory_menu_index)
2628    }
2629
2630    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
2631    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
2632        let cat = self
2633            .inventory_item_category(&row.stack.template_id)
2634            .unwrap_or("");
2635        let label = if cat == "key" {
2636            self.key_inventory_label(&row.stack)
2637        } else {
2638            row.stack
2639                .display_name
2640                .clone()
2641                .unwrap_or_else(|| row.stack.template_id.clone())
2642        };
2643        let hint: String = if row.is_equip_shell {
2644            " [worn — Enter to unequip]".into()
2645        } else if row.is_chest_shell {
2646            let (locked, lodging_note) = match &row.from {
2647                flatland_protocol::InventoryLocation::Placed { container_id } => {
2648                    let locked = self
2649                        .placed_containers
2650                        .iter()
2651                        .find(|c| c.id == *container_id)
2652                        .map(|c| c.locked)
2653                        .unwrap_or(false);
2654                    let lodging_note = self
2655                        .lodging_occupancy_label(container_id)
2656                        .map(|who| format!(" [lodging: {who}]"))
2657                        .unwrap_or_default();
2658                    (locked, lodging_note)
2659                }
2660                _ => (false, String::new()),
2661            };
2662            if locked {
2663                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
2664            } else {
2665                format!(" [Enter pick up · l lock]{lodging_note}")
2666            }
2667        } else if cat == "key" {
2668            self.key_inventory_hint(&row.stack)
2669        } else {
2670            match cat {
2671                "weapon" => " [weapon]".into(),
2672                "container" => " [bag/chest/belt]".into(),
2673                "lodging" => " [worker lodging]".into(),
2674                "armor" => " [armor]".into(),
2675                _ => String::new(),
2676            }
2677        };
2678        let qty = if row.stack.quantity > 1 {
2679            format!(" ×{}", row.stack.quantity)
2680        } else {
2681            String::new()
2682        };
2683        let bindings = format_status_bindings_suffix(
2684            &row.stack.status_bindings,
2685            self.tick,
2686            DEFAULT_TICK_HZ,
2687        );
2688        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
2689            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
2690            let mode = Self::grant_mode(&row.stack);
2691            format!(" [grant {effect} · {mode} — e apply]")
2692        } else {
2693            String::new()
2694        };
2695        let mass = self.stack_mass(&row.stack);
2696        let mass_kg = (mass >= 0.05).then_some(mass);
2697        let mass_str = mass_kg
2698            .map(|m| format!("  {m:.1} kg"))
2699            .unwrap_or_default();
2700        let volume = self.container_volume_stats(row);
2701        let vol_str = self.container_volume_label(row);
2702
2703        let mut title = label.clone();
2704        if let Some(id) = row.stack.item_instance_id {
2705            let hex: String = id
2706                .as_simple()
2707                .to_string()
2708                .chars()
2709                .filter(|c| c.is_ascii_hexdigit())
2710                .collect();
2711            if hex.len() >= 4 {
2712                title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
2713            }
2714        }
2715        title.push_str(&qty);
2716        if row.is_equip_shell {
2717            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2718                title.push_str(&format!(" ({})", body_slot_label(slot)));
2719            }
2720        }
2721
2722        InventoryRowView {
2723            depth: row.depth,
2724            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
2725            title: format!("{title}{grant_hint}{bindings}"),
2726            mass_kg,
2727            volume,
2728        }
2729    }
2730
2731    fn push_browser_item(
2732        &self,
2733        lines: &mut Vec<InventoryBrowserLine>,
2734        row: &InventoryRow,
2735        global_idx: &mut usize,
2736        target: usize,
2737        highlight: bool,
2738    ) {
2739        let view = self.format_inventory_row(row);
2740        lines.push(InventoryBrowserLine::Item {
2741            selectable_index: *global_idx,
2742            selected: highlight && *global_idx == target,
2743            depth: view.depth,
2744            text: view.text,
2745            title: view.title,
2746            mass_kg: view.mass_kg,
2747            volume: view.volume,
2748        });
2749        *global_idx += 1;
2750    }
2751
2752    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
2753    /// `selectable_index` matching `inventory_menu_index`.
2754    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
2755        let mut lines = Vec::new();
2756        let target = self.inventory_menu_index;
2757        let highlight = !self.show_move_picker && !self.show_grant_picker;
2758        let filter = self.inventory_filter.as_str();
2759        let mut global_idx = 0usize;
2760
2761        match self.inventory_tab {
2762            InventoryTab::OnPerson => {
2763                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
2764                let worn = self.worn_rows_filtered(filter);
2765                if worn.is_empty() {
2766                    lines.push(InventoryBrowserLine::Hint(
2767                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
2768                    ));
2769                } else {
2770                    for row in &worn {
2771                        if row.is_equip_shell {
2772                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2773                                lines.push(InventoryBrowserLine::SlotLabel(format!(
2774                                    "  {}:",
2775                                    body_slot_label(slot)
2776                                )));
2777                            }
2778                        }
2779                        self.push_browser_item(
2780                            &mut lines,
2781                            row,
2782                            &mut global_idx,
2783                            target,
2784                            highlight,
2785                        );
2786                    }
2787                }
2788
2789                lines.push(InventoryBrowserLine::Blank);
2790                lines.push(InventoryBrowserLine::Section(
2791                    "— On you (loose, not worn) —".into(),
2792                ));
2793                let person = self.person_rows_filtered(filter);
2794                if person.is_empty() {
2795                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
2796                } else {
2797                    let mut last_group: Option<&'static str> = None;
2798                    for row in &person {
2799                        if row.depth == 0 {
2800                            let cat = row
2801                                .stack
2802                                .category
2803                                .as_deref()
2804                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
2805                                .unwrap_or("");
2806                            let (group, _) = inventory_category_group(cat);
2807                            if last_group != Some(group) {
2808                                lines.push(InventoryBrowserLine::SlotLabel(format!(
2809                                    "  {group}"
2810                                )));
2811                                last_group = Some(group);
2812                            }
2813                        }
2814                        self.push_browser_item(
2815                            &mut lines,
2816                            row,
2817                            &mut global_idx,
2818                            target,
2819                            highlight,
2820                        );
2821                    }
2822                }
2823            }
2824            InventoryTab::Nearby => {
2825                let nearby = self.nearby_containers();
2826                if nearby.is_empty() {
2827                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2828                    lines.push(InventoryBrowserLine::Hint(
2829                        "  (none within reach — walk up to a chest)".into(),
2830                    ));
2831                    lines.push(InventoryBrowserLine::Hint(
2832                        "  Select an on-person item, then m / Enter → move into chest.".into(),
2833                    ));
2834                } else {
2835                    let mut any_visible = false;
2836                    for nc in &nearby {
2837                        let shell = nc.rows.first();
2838                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
2839                            nc.rows.iter().skip(1).collect()
2840                        } else {
2841                            let shell_hit = shell
2842                                .map(|s| {
2843                                    let f = filter.to_ascii_lowercase();
2844                                    let name = s
2845                                        .stack
2846                                        .display_name
2847                                        .as_deref()
2848                                        .unwrap_or("")
2849                                        .to_ascii_lowercase();
2850                                    let tid = s.stack.template_id.to_ascii_lowercase();
2851                                    name.contains(&f) || tid.contains(&f)
2852                                })
2853                                .unwrap_or(false);
2854                            if shell_hit {
2855                                nc.rows.iter().skip(1).collect()
2856                            } else {
2857                                nc.rows
2858                                    .iter()
2859                                    .skip(1)
2860                                    .filter(|r| stack_matches_filter(&r.stack, filter))
2861                                    .collect()
2862                            }
2863                        };
2864                        let shell_visible = filter.is_empty()
2865                            || shell
2866                                .map(|s| stack_matches_filter(&s.stack, filter))
2867                                .unwrap_or(false)
2868                            || !contents.is_empty();
2869                        if !shell_visible && shell.is_some() {
2870                            continue;
2871                        }
2872                        any_visible = true;
2873                        lines.push(InventoryBrowserLine::Blank);
2874                        let lock_note = if nc.view.locked && nc.view.accessible {
2875                            "  unlocked with your key"
2876                        } else if nc.view.locked {
2877                            "  locked"
2878                        } else {
2879                            ""
2880                        };
2881                        lines.push(InventoryBrowserLine::Section(format!(
2882                            "— {} ({:.0}m away){lock_note} —",
2883                            nc.view.display_name, nc.distance_m
2884                        )));
2885                        if !nc.view.accessible {
2886                            lines.push(InventoryBrowserLine::Hint(
2887                                "  locked — need the matching key (l to try)".into(),
2888                            ));
2889                        } else if nc.rows.is_empty() {
2890                            lines.push(InventoryBrowserLine::Hint(
2891                                "  (empty — switch to On person, select an item, m to move in)"
2892                                    .into(),
2893                            ));
2894                        } else if let Some(shell_row) = shell {
2895                            self.push_browser_item(
2896                                &mut lines,
2897                                shell_row,
2898                                &mut global_idx,
2899                                target,
2900                                highlight,
2901                            );
2902                            for row in contents {
2903                                self.push_browser_item(
2904                                    &mut lines,
2905                                    row,
2906                                    &mut global_idx,
2907                                    target,
2908                                    highlight,
2909                                );
2910                            }
2911                        }
2912                    }
2913                    if !any_visible {
2914                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2915                        lines.push(InventoryBrowserLine::Hint(
2916                            "  (no matching items — clear filter with Esc)".into(),
2917                        ));
2918                    }
2919                }
2920            }
2921        }
2922        lines
2923    }
2924
2925    /// Destinations for picking up a placed chest/crate into inventory.
2926    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
2927        let mut opts = Vec::new();
2928        opts.push(MoveOption {
2929            label: "On your person (loose)".into(),
2930            kind: MoveOptionKind::PickupPlaced {
2931                container_id: container_id.to_string(),
2932                nest_location: flatland_protocol::InventoryLocation::Root,
2933                nest_parent_instance_id: None,
2934            },
2935        });
2936        for (slot, item) in &self.worn {
2937            if item.category.as_deref() != Some("container") {
2938                continue;
2939            }
2940            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
2941                continue;
2942            }
2943            let Some(parent_id) = item.item_instance_id else {
2944                continue;
2945            };
2946            let shell_name = item
2947                .display_name
2948                .clone()
2949                .unwrap_or_else(|| item.template_id.clone());
2950            opts.push(MoveOption {
2951                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
2952                kind: MoveOptionKind::PickupPlaced {
2953                    container_id: container_id.to_string(),
2954                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
2955                    nest_parent_instance_id: Some(parent_id),
2956                },
2957            });
2958            // Nested pouches inside the worn bag.
2959            Self::append_chest_pickup_nested(
2960                &mut opts,
2961                container_id,
2962                flatland_protocol::InventoryLocation::Worn { slot: *slot },
2963                item,
2964                &format!("in {shell_name}"),
2965            );
2966        }
2967        opts.push(MoveOption {
2968            label: "Cancel".into(),
2969            kind: MoveOptionKind::Cancel,
2970        });
2971        opts
2972    }
2973
2974    fn append_chest_pickup_nested(
2975        opts: &mut Vec<MoveOption>,
2976        container_id: &str,
2977        location: flatland_protocol::InventoryLocation,
2978        parent: &flatland_protocol::ItemStack,
2979        context: &str,
2980    ) {
2981        for child in &parent.contents {
2982            if child.category.as_deref() != Some("container") {
2983                continue;
2984            }
2985            if !Self::is_volume_container_stack(child) {
2986                continue;
2987            }
2988            // Skip placeable nested chests — relocating into another chest is not useful.
2989            if child.world_placeable == Some(true) {
2990                continue;
2991            }
2992            let Some(child_id) = child.item_instance_id else {
2993                continue;
2994            };
2995            let name = child
2996                .display_name
2997                .clone()
2998                .unwrap_or_else(|| child.template_id.clone());
2999            opts.push(MoveOption {
3000                label: format!("{name} ({context})"),
3001                kind: MoveOptionKind::PickupPlaced {
3002                    container_id: container_id.to_string(),
3003                    nest_location: location.clone(),
3004                    nest_parent_instance_id: Some(child_id),
3005                },
3006            });
3007            Self::append_chest_pickup_nested(
3008                opts,
3009                container_id,
3010                location.clone(),
3011                child,
3012                &format!("in {name}"),
3013            );
3014        }
3015    }
3016
3017    /// Build the "move to…" destination list for an item currently at `from`.
3018    pub fn move_destinations_for(
3019        &self,
3020        from: &flatland_protocol::InventoryLocation,
3021        from_parent_instance_id: Option<uuid::Uuid>,
3022        moving_instance_id: Option<uuid::Uuid>,
3023        moving_template_id: &str,
3024    ) -> Vec<MoveOption> {
3025        let mut opts = Vec::new();
3026        if *from != flatland_protocol::InventoryLocation::Root {
3027            opts.push(MoveOption {
3028                label: "On your person (loose)".into(),
3029                kind: MoveOptionKind::Move {
3030                    location: flatland_protocol::InventoryLocation::Root,
3031                    parent_instance_id: None,
3032                },
3033            });
3034        }
3035        for (slot, item) in &self.worn {
3036            if item.category.as_deref() != Some("container") {
3037                continue;
3038            }
3039            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3040            let shell_name = item
3041                .display_name
3042                .clone()
3043                .unwrap_or_else(|| item.template_id.clone());
3044
3045            // Backpack and other worn volume containers — store directly inside the shell.
3046            if *slot != BodySlot::Waist
3047                && item.item_instance_id != moving_instance_id
3048                && Self::is_volume_container_stack(item)
3049            {
3050                Self::push_move_destination(
3051                    &mut opts,
3052                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3053                    location.clone(),
3054                    item.item_instance_id,
3055                    from,
3056                    from_parent_instance_id,
3057                );
3058            }
3059
3060            // Belt loops only accept pouch attachments — not loose materials.
3061            if *slot == BodySlot::Waist
3062                && Self::attaches_to_belt_loop(moving_template_id)
3063                && item.item_instance_id != moving_instance_id
3064            {
3065                Self::push_move_destination(
3066                    &mut opts,
3067                    format!("{shell_name} (belt loop)"),
3068                    location.clone(),
3069                    item.item_instance_id,
3070                    from,
3071                    from_parent_instance_id,
3072                );
3073            }
3074
3075            let context = if *slot == BodySlot::Waist {
3076                format!("on {shell_name}")
3077            } else {
3078                format!("in {shell_name}")
3079            };
3080            Self::append_nested_container_destinations(
3081                &mut opts,
3082                location,
3083                item,
3084                &context,
3085                from,
3086                from_parent_instance_id,
3087                moving_instance_id,
3088            );
3089        }
3090        for nc in self.nearby_containers() {
3091            if !nc.view.accessible {
3092                continue;
3093            }
3094            let location = flatland_protocol::InventoryLocation::Placed {
3095                container_id: nc.view.id.clone(),
3096            };
3097            Self::push_move_destination(
3098                &mut opts,
3099                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3100                location,
3101                nc.view.item_instance_id,
3102                from,
3103                from_parent_instance_id,
3104            );
3105        }
3106        let allow_drop = moving_instance_id
3107            .and_then(|id| self.stack_for_instance(id))
3108            .map(|stack| !self.key_drop_blocked(&stack))
3109            .unwrap_or(moving_template_id != KEY_TEMPLATE);
3110        if allow_drop {
3111            opts.push(MoveOption {
3112                label: "Drop on the ground".into(),
3113                kind: MoveOptionKind::Drop,
3114            });
3115        }
3116        opts.push(MoveOption {
3117            label: "Cancel".into(),
3118            kind: MoveOptionKind::Cancel,
3119        });
3120        opts
3121    }
3122
3123    fn is_same_container_dest(
3124        dest_location: &flatland_protocol::InventoryLocation,
3125        dest_parent: Option<uuid::Uuid>,
3126        from: &flatland_protocol::InventoryLocation,
3127        from_parent: Option<uuid::Uuid>,
3128    ) -> bool {
3129        dest_location == from && dest_parent == from_parent
3130    }
3131
3132    fn push_move_destination(
3133        opts: &mut Vec<MoveOption>,
3134        label: String,
3135        location: flatland_protocol::InventoryLocation,
3136        parent_instance_id: Option<uuid::Uuid>,
3137        from: &flatland_protocol::InventoryLocation,
3138        from_parent_instance_id: Option<uuid::Uuid>,
3139    ) {
3140        if Self::is_same_container_dest(
3141            &location,
3142            parent_instance_id,
3143            from,
3144            from_parent_instance_id,
3145        ) {
3146            return;
3147        }
3148        opts.push(MoveOption {
3149            label,
3150            kind: MoveOptionKind::Move {
3151                location,
3152                parent_instance_id,
3153            },
3154        });
3155    }
3156
3157    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3158        stack.capacity_volume.is_some_and(|c| c > 0.0)
3159    }
3160
3161    fn attaches_to_belt_loop(template_id: &str) -> bool {
3162        matches!(template_id, "leather_pouch" | "dimensional_pouch")
3163    }
3164
3165    fn append_nested_container_destinations(
3166        opts: &mut Vec<MoveOption>,
3167        location: flatland_protocol::InventoryLocation,
3168        container: &flatland_protocol::ItemStack,
3169        context: &str,
3170        from: &flatland_protocol::InventoryLocation,
3171        from_parent_instance_id: Option<uuid::Uuid>,
3172        moving_instance_id: Option<uuid::Uuid>,
3173    ) {
3174        for child in &container.contents {
3175            if Self::is_volume_container_stack(child)
3176                && child.item_instance_id != moving_instance_id
3177            {
3178                let name = child
3179                    .display_name
3180                    .clone()
3181                    .unwrap_or_else(|| child.template_id.clone());
3182                Self::push_move_destination(
3183                    opts,
3184                    format!("{name} ({context})"),
3185                    location.clone(),
3186                    child.item_instance_id,
3187                    from,
3188                    from_parent_instance_id,
3189                );
3190            }
3191            let nested_context = format!(
3192                "in {}",
3193                child.display_name.as_deref().unwrap_or(&child.template_id)
3194            );
3195            Self::append_nested_container_destinations(
3196                opts,
3197                location.clone(),
3198                child,
3199                &nested_context,
3200                from,
3201                from_parent_instance_id,
3202                moving_instance_id,
3203            );
3204        }
3205    }
3206
3207    fn clamp_inventory_indices(&mut self) {
3208        let n = self.inventory_selectable_rows().len();
3209        self.inventory_menu_index = if n == 0 {
3210            0
3211        } else {
3212            self.inventory_menu_index.min(n - 1)
3213        };
3214        if let Some(picker) = &self.move_picker {
3215            let pn = picker.options.len();
3216            self.move_picker_index = if pn == 0 {
3217                0
3218            } else {
3219                self.move_picker_index.min(pn - 1)
3220            };
3221        }
3222    }
3223
3224    /// Drop interior map layers whenever the player is outdoors.
3225    fn sync_interior_map_context(&mut self) {
3226        if self.effective_inside_building().is_none() {
3227            self.interior_map = None;
3228        }
3229    }
3230
3231    fn apply_snapshot_fields(
3232        &mut self,
3233        snapshot: &flatland_protocol::Snapshot,
3234        entity_id: EntityId,
3235    ) {
3236        self.tick = snapshot.tick;
3237        self.chunk_rev = snapshot.chunk_rev;
3238        self.content_rev = snapshot.content_rev;
3239        self.publish_rev = snapshot.publish_rev;
3240        self.resource_nodes = snapshot.resource_nodes.clone();
3241        self.ground_drops = snapshot.ground_drops.clone();
3242        self.placed_containers = snapshot.placed_containers.clone();
3243        self.world_width_m = snapshot.world_width_m;
3244        self.world_height_m = snapshot.world_height_m;
3245        self.world_clock = snapshot.world_clock;
3246        self.terrain_zones = snapshot.terrain_zones.clone();
3247        self.z_platforms = snapshot.z_platforms.clone();
3248        self.z_transitions = snapshot.z_transitions.clone();
3249        self.buildings = snapshot.buildings.clone();
3250        self.doors = snapshot.doors.clone();
3251        self.interior_map = snapshot.interior_map.clone();
3252        self.npcs = snapshot.npcs.clone();
3253        self.blueprints = snapshot.blueprints.clone();
3254        self.sync_inventory_from_stacks(&snapshot.inventory);
3255        self.player = snapshot
3256            .entities
3257            .iter()
3258            .find(|e| e.id == entity_id)
3259            .cloned();
3260        self.entities = snapshot.entities.clone();
3261        self.quest_log = snapshot.quest_log.clone();
3262        self.apply_hired_workers(snapshot.hired_workers.clone());
3263        self.interactables = snapshot.interactables.clone();
3264        self.ledger = snapshot.ledger.clone();
3265        self.career = snapshot.career.clone();
3266        self.combat_fx = snapshot.combat_fx.clone();
3267        self.sync_interior_map_context();
3268        self.refresh_whisper_range();
3269    }
3270
3271    /// Re-derive inventory selection state after a snapshot/tick — chests that
3272    /// went out of range or got locked simply vanish from the row list, and the
3273    /// move picker (if any) closes once its item is no longer reachable.
3274    fn refresh_inventory_ui(&mut self) {
3275        if let Some(picker) = &self.move_picker {
3276            let instance_id = picker.item_instance_id;
3277            let still_exists = self
3278                .inventory_selectable_rows()
3279                .iter()
3280                .any(|r| r.stack.item_instance_id == Some(instance_id));
3281            if !still_exists {
3282                self.move_picker = None;
3283                self.show_move_picker = false;
3284            }
3285        }
3286        if let Some(picker) = &self.destroy_picker {
3287            let instance_id = picker.item_instance_id;
3288            let still_exists = self
3289                .inventory_selectable_rows()
3290                .iter()
3291                .any(|r| r.stack.item_instance_id == Some(instance_id));
3292            if !still_exists {
3293                self.destroy_picker = None;
3294                self.show_destroy_picker = false;
3295                self.destroy_confirm_pending = false;
3296            }
3297        }
3298        self.clamp_inventory_indices();
3299    }
3300
3301    /// Replace the hired-worker list from a snapshot/delta.
3302    ///
3303    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
3304    /// selected worker (and its step line) does not jump when the server rebuilds
3305    /// the list.
3306    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3307        let selected_id = self
3308            .hired_workers
3309            .get(self.workers_menu_index)
3310            .map(|w| w.instance_id.clone());
3311        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3312        let now = Instant::now();
3313        for w in &workers {
3314            let prev_err = self
3315                .hired_workers
3316                .iter()
3317                .find(|p| p.instance_id == w.instance_id)
3318                .and_then(|p| p.last_error.as_deref());
3319            let new_err = w.last_error.as_deref();
3320            if new_err != prev_err {
3321                if let Some(err) = new_err {
3322                    if !worker_error_is_transient(err) {
3323                        self.push_log(format!("Worker {}: {err}", w.label));
3324                    }
3325                }
3326            }
3327        }
3328        let mut next_display = BTreeMap::new();
3329        let mut next_errors = BTreeMap::new();
3330        for w in &workers {
3331            let mut sticky = self
3332                .worker_step_display
3333                .remove(&w.instance_id)
3334                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3335            sticky.observe(&w.step_label, now);
3336            next_display.insert(w.instance_id.clone(), sticky);
3337
3338            let mut err_sticky = self
3339                .worker_error_display
3340                .remove(&w.instance_id)
3341                .unwrap_or_default();
3342            err_sticky.observe(w.last_error.as_deref(), now);
3343            if err_sticky.shown(now).is_some() {
3344                next_errors.insert(w.instance_id.clone(), err_sticky);
3345            }
3346        }
3347        self.worker_step_display = next_display;
3348        self.worker_error_display = next_errors;
3349        self.hired_workers = workers;
3350        if let Some(id) = selected_id {
3351            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
3352                self.workers_menu_index = idx;
3353                return;
3354            }
3355        }
3356        if self.workers_menu_index >= self.hired_workers.len() {
3357            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
3358        }
3359    }
3360
3361    /// Held coarse step label for the workers menu (`step:` line).
3362    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
3363        self.worker_step_display
3364            .get(worker_instance_id)
3365            .map(|s| s.shown.as_str())
3366            .or_else(|| {
3367                self.hired_workers
3368                    .iter()
3369                    .find(|w| w.instance_id == worker_instance_id)
3370                    .map(|w| w.step_label.as_str())
3371            })
3372            .unwrap_or("")
3373    }
3374
3375    /// Held error line for workers UI (detail + compact).
3376    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
3377        let now = Instant::now();
3378        self.worker_error_display
3379            .get(worker_instance_id)
3380            .and_then(|s| s.shown(now))
3381            .or_else(|| {
3382                self.hired_workers
3383                    .iter()
3384                    .find(|w| w.instance_id == worker_instance_id)
3385                    .and_then(|w| w.last_error.as_deref())
3386                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
3387            })
3388            .filter(|e| !worker_error_is_hud_noise(e))
3389    }
3390
3391    fn apply_combat_hud(&mut self, combat: &CombatHud) {
3392        self.in_combat = combat.in_combat;
3393        self.auto_attack = combat.auto_attack;
3394        self.combat_has_los = combat.has_los;
3395        self.attack_cd_ticks = combat.attack_cd_ticks;
3396        self.gcd_ticks = combat.gcd_ticks;
3397        self.weapon_ability_id = combat.ability_id.clone();
3398        self.mainhand_template_id = combat.mainhand_template_id.clone();
3399        self.mainhand_label = combat.mainhand_label.clone();
3400        self.offhand_template_id = combat.offhand_template_id.clone();
3401        self.offhand_label = combat.offhand_label.clone();
3402        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
3403            1
3404        } else {
3405            combat.mainhand_hand_slots
3406        };
3407        self.defense = combat.defense.clone();
3408        self.worn = combat.worn.iter().cloned().collect();
3409        self.carry_mass = combat.carry_mass;
3410        self.carry_mass_max = combat.carry_mass_max;
3411        self.encumbrance = combat.encumbrance;
3412        self.cast_progress = combat.cast.clone();
3413        self.ability_cooldowns = combat.ability_cooldowns.clone();
3414        self.blocking_active = combat.blocking_active;
3415        self.max_target_slots = combat.max_target_slots.max(1);
3416        self.combat_slots = combat.slots.clone();
3417        self.rotation_presets = combat.rotation_presets.clone();
3418        self.known_abilities = combat.known_abilities.clone();
3419        self.hotbar = combat.hotbar.clone();
3420        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
3421        self.keychain_stacks = combat.keychain.clone();
3422        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
3423        self.combat_target_detail = combat.target.clone();
3424        self.statuses = combat.statuses.clone();
3425        self.combat_target = combat.target_entity_id;
3426        if combat.progression_xp_base > 0.0 {
3427            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
3428                baseline_display: combat.progression_baseline,
3429                xp_base: combat.progression_xp_base,
3430                xp_growth: combat.progression_xp_growth,
3431            });
3432        }
3433        if let Some(xp) = &combat.progression_xp {
3434            if let Some(player) = &mut self.player {
3435                player.progression_xp = Some(xp.clone());
3436                if let Some(attrs) = combat.attributes {
3437                    player.attributes = Some(attrs);
3438                }
3439                if let Some(skills) = &combat.skills {
3440                    player.skills = Some(skills.clone());
3441                }
3442            }
3443        }
3444        if let Some(label) = &combat.target_label {
3445            self.combat_target_label = Some(label.clone());
3446        } else if let Some(id) = combat.target_entity_id {
3447            self.combat_target_label = self
3448                .entities
3449                .iter()
3450                .find(|e| e.id == id)
3451                .map(|e| e.label.clone())
3452                .or_else(|| self.combat_target_label.clone());
3453        }
3454        self.refresh_inventory_ui();
3455    }
3456
3457    /// Entity id assigned to a combat slot (from server HUD).
3458    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
3459        self.combat_slots
3460            .iter()
3461            .find(|s| s.slot_index == slot)
3462            .and_then(|s| s.target_entity_id)
3463            .or_else(|| if slot == 1 { self.combat_target } else { None })
3464    }
3465
3466    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
3467    /// May be an ability id or `item:<template_id>` consumable binding.
3468    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
3469        if !(1..=9).contains(&slot_1_to_9) {
3470            return None;
3471        }
3472        self.hotbar
3473            .get((slot_1_to_9 - 1) as usize)
3474            .and_then(|a| a.as_deref())
3475            .filter(|id| !id.is_empty())
3476    }
3477
3478    /// Short label for a hotbar slot (ability id, or consumable name × qty).
3479    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
3480        let binding = self.hotbar_ability(slot_1_to_9)?;
3481        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
3482            let name = self
3483                .inventory_hints
3484                .get(template_id)
3485                .map(|h| h.display_name.as_str())
3486                .unwrap_or(template_id);
3487            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
3488            Some(format!("{name}×{qty}"))
3489        } else {
3490            Some(binding.to_string())
3491        }
3492    }
3493
3494    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
3495    pub fn loadout_ability_choices(&self) -> Vec<String> {
3496        let mut out = self.known_abilities.clone();
3497        let weapon = self.weapon_ability_id.trim();
3498        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
3499            out.push(weapon.to_string());
3500        }
3501        out
3502    }
3503
3504    /// Hotbar bind candidates: learned abilities, then on-person consumables.
3505    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
3506        let mut out = Vec::new();
3507        for ability in self.loadout_ability_choices() {
3508            let meta = if ability == self.weapon_ability_id {
3509                Some("weapon".into())
3510            } else {
3511                None
3512            };
3513            out.push(LoadoutHotbarChoice {
3514                binding: ability.clone(),
3515                label: ability,
3516                meta,
3517            });
3518        }
3519        let mut consumables: Vec<(String, String, u32)> = Vec::new();
3520        for stack in &self.inventory_stacks {
3521            if Self::stack_is_item_grant(stack) {
3522                continue;
3523            }
3524            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
3525                continue;
3526            }
3527            let qty = stack.quantity.max(1);
3528            if let Some((_, _, existing)) = consumables
3529                .iter_mut()
3530                .find(|(id, _, _)| id == &stack.template_id)
3531            {
3532                *existing = existing.saturating_add(qty);
3533            } else {
3534                let label = stack
3535                    .display_name
3536                    .clone()
3537                    .or_else(|| {
3538                        self.inventory_hints
3539                            .get(&stack.template_id)
3540                            .map(|h| h.display_name.clone())
3541                    })
3542                    .unwrap_or_else(|| stack.template_id.clone());
3543                consumables.push((stack.template_id.clone(), label, qty));
3544            }
3545        }
3546        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
3547        for (template_id, label, qty) in consumables {
3548            out.push(LoadoutHotbarChoice {
3549                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
3550                label: format!("{label} ×{qty}"),
3551                meta: Some("use".into()),
3552            });
3553        }
3554        out
3555    }
3556
3557    /// Hostile wildlife / monsters for T1 (`Tab`).
3558    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
3559        self.combat_candidates()
3560    }
3561
3562    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
3563    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
3564        let (px, py) = self.player_position();
3565        let dist = |id: EntityId| {
3566            self.entities
3567                .iter()
3568                .find(|e| e.id == id)
3569                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3570                .unwrap_or(f32::MAX)
3571        };
3572
3573        let mut allies = Vec::new();
3574        // Self first — heal_touch on T2.
3575        if let Some(me) = self.player.as_ref() {
3576            let alive = me
3577                .vitals
3578                .as_ref()
3579                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3580                .unwrap_or(true);
3581            if alive {
3582                allies.push((self.entity_id, "Yourself".into()));
3583            }
3584        }
3585        for entity in &self.entities {
3586            if entity.id == self.entity_id {
3587                continue;
3588            }
3589            if entity.vitals.is_some() {
3590                let alive = entity
3591                    .vitals
3592                    .as_ref()
3593                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3594                    .unwrap_or(true);
3595                if alive {
3596                    allies.push((entity.id, entity.label.clone()));
3597                }
3598            }
3599        }
3600        allies.sort_by(|(a, _), (b, _)| {
3601            if *a == self.entity_id {
3602                return std::cmp::Ordering::Less;
3603            }
3604            if *b == self.entity_id {
3605                return std::cmp::Ordering::Greater;
3606            }
3607            dist(*a)
3608                .partial_cmp(&dist(*b))
3609                .unwrap_or(std::cmp::Ordering::Equal)
3610        });
3611
3612        let mut monsters = self.combat_candidates();
3613        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
3614        allies.into_iter().chain(monsters).collect()
3615    }
3616
3617    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
3618        match slot_index {
3619            2 => self.t2_candidates(),
3620            _ => self.t1_candidates(),
3621        }
3622    }
3623
3624    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
3625    pub fn pick_combat_target_at(
3626        &self,
3627        wx: f32,
3628        wy: f32,
3629        slot_index: u8,
3630        radius_m: f32,
3631    ) -> Option<(EntityId, String)> {
3632        let mut best: Option<(f32, EntityId, String)> = None;
3633        for (id, label) in self.candidates_for_slot(slot_index) {
3634            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
3635                // NPCs may only appear in NpcView — fall back to npc list coords.
3636                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
3637                    let d = distance(wx, wy, npc.x, npc.y);
3638                    if d <= radius_m {
3639                        best = match best {
3640                            Some((bd, _, _)) if bd <= d => best,
3641                            _ => Some((d, id, label)),
3642                        };
3643                    }
3644                }
3645                continue;
3646            };
3647            let d = distance(
3648                wx,
3649                wy,
3650                entity.transform.position.x,
3651                entity.transform.position.y,
3652            );
3653            if d <= radius_m {
3654                best = match best {
3655                    Some((bd, _, _)) if bd <= d => best,
3656                    _ => Some((d, id, label)),
3657                };
3658            }
3659        }
3660        best.map(|(_, id, label)| (id, label))
3661    }
3662
3663    /// Full client reset after Welcome (initial connect or reconnect).
3664    pub(crate) fn restore_from_welcome(
3665        &mut self,
3666        session_id: SessionId,
3667        entity_id: EntityId,
3668        snapshot: &flatland_protocol::Snapshot,
3669    ) {
3670        self.clear_harvest_state();
3671        self.disconnect_reason = None;
3672        self.show_stats = false;
3673        self.show_craft_menu = false;
3674        self.show_shop_menu = false;
3675        self.shop_catalog = None;
3676        self.show_inventory_menu = false;
3677        self.session_id = session_id;
3678        self.entity_id = entity_id;
3679        self.connected = true;
3680        self.apply_snapshot_fields(snapshot, entity_id);
3681        if let Some(combat) = &snapshot.combat {
3682            self.apply_combat_hud(combat);
3683            let stacks = self.inventory_stacks.clone();
3684            self.sync_inventory_from_stacks(&stacks);
3685        }
3686    }
3687
3688    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
3689        self.tick = delta.tick;
3690        self.world_clock = delta.world_clock;
3691
3692        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
3693        if delta.entities.is_empty() {
3694            self.combat_fx = delta.combat_fx.clone();
3695            if let Some(combat) = &delta.combat {
3696                self.apply_combat_hud(combat);
3697                let stacks = self.inventory_stacks.clone();
3698                self.sync_inventory_from_stacks(&stacks);
3699            }
3700            // Peer vanished from AOI — drop directed whisper.
3701            self.refresh_whisper_range();
3702            return;
3703        }
3704        if !delta.buildings.is_empty() {
3705            self.buildings = delta.buildings.clone();
3706        }
3707        if !delta.blueprints.is_empty() {
3708            self.blueprints = delta.blueprints.clone();
3709        }
3710        self.sync_inventory_from_stacks(&delta.inventory);
3711
3712        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
3713            self.player = Some(updated.clone());
3714        }
3715        self.entities = delta.entities.clone();
3716        if self.player.is_none() {
3717            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
3718        }
3719
3720        self.sync_interior_map_context();
3721
3722        // Static world layers: ticks often omit these (indoors, or unchanged).
3723        // Never wipe the welcome snapshot with an empty vec — except when indoors,
3724        // where empty means "no nodes in this building" (clear outdoor leftovers).
3725        if !delta.resource_nodes.is_empty() {
3726            self.resource_nodes = delta.resource_nodes.clone();
3727        } else if delta.interior_map.is_some()
3728            || self.effective_inside_building().is_some()
3729        {
3730            self.resource_nodes = delta.resource_nodes.clone();
3731        }
3732        if self
3733            .player
3734            .as_ref()
3735            .is_none_or(|p| p.inside_building.is_none())
3736        {
3737            self.ground_drops = delta.ground_drops.clone();
3738            self.placed_containers = delta.placed_containers.clone();
3739        }
3740        if !delta.doors.is_empty() {
3741            self.doors = delta.doors.clone();
3742        }
3743        if self.effective_inside_building().is_some() {
3744            if let Some(map) = &delta.interior_map {
3745                self.interior_map = Some(map.clone());
3746            }
3747        } else {
3748            self.interior_map = None;
3749        }
3750        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
3751        self.npcs = delta.npcs.clone();
3752        if !delta.quest_log.is_empty() {
3753            self.quest_log = delta.quest_log.clone();
3754        }
3755        self.apply_hired_workers(delta.hired_workers.clone());
3756        if !delta.interactables.is_empty() {
3757            self.interactables = delta.interactables.clone();
3758        }
3759        if delta.ledger.is_some() {
3760            self.ledger = delta.ledger.clone();
3761        }
3762        if delta.career.is_some() {
3763            self.career = delta.career.clone();
3764        }
3765        self.combat_fx = delta.combat_fx.clone();
3766        if let Some(combat) = &delta.combat {
3767            self.apply_combat_hud(combat);
3768            let stacks = self.inventory_stacks.clone();
3769            self.sync_inventory_from_stacks(&stacks);
3770        } else {
3771            self.refresh_inventory_ui();
3772        }
3773        self.refresh_whisper_range();
3774    }
3775
3776    /// End directed whisper when the peer is farther than interact range (or missing).
3777    /// Each client runs this locally so both players drop whisper mode when either walks away.
3778    fn refresh_whisper_range(&mut self) {
3779        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
3780            return;
3781        };
3782        let (px, py) = self.player_position();
3783        let in_range = self.entities.iter().any(|e| {
3784            e.id == peer
3785                && distance(
3786                    px,
3787                    py,
3788                    e.transform.position.x,
3789                    e.transform.position.y,
3790                ) <= INTERACTION_RADIUS_M
3791        });
3792        if !in_range {
3793            self.social_chat.cancel_whisper_out_of_range();
3794        }
3795    }
3796
3797    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
3798    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
3799        let (px, py) = self.player_position();
3800        let mut out = Vec::new();
3801        for npc in &self.npcs {
3802            let Some(eid) = npc.entity_id else {
3803                continue;
3804            };
3805            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
3806            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
3807            if alive && has_hp {
3808                out.push((eid, npc.label.clone()));
3809            }
3810        }
3811        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
3812            let dist = |id: EntityId| {
3813                self.entities
3814                    .iter()
3815                    .find(|e| e.id == id)
3816                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3817                    .unwrap_or(f32::MAX)
3818            };
3819            dist(*a_id)
3820                .partial_cmp(&dist(*b_id))
3821                .unwrap_or(std::cmp::Ordering::Equal)
3822                .then_with(|| a_label.cmp(b_label))
3823                .then_with(|| a_id.cmp(b_id))
3824        });
3825        out
3826    }
3827
3828    pub fn refresh_combat_target_label(&mut self) {
3829        let Some(id) = self.combat_target else {
3830            return;
3831        };
3832        if let Some((_, label)) = self
3833            .combat_candidates()
3834            .into_iter()
3835            .find(|(eid, _)| *eid == id)
3836        {
3837            self.combat_target_label = Some(label);
3838        } else if let Some(label) = self
3839            .entities
3840            .iter()
3841            .find(|e| e.id == id)
3842            .map(|e| e.label.clone())
3843        {
3844            self.combat_target_label = Some(label);
3845        }
3846    }
3847
3848    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
3849        self.quest_log
3850            .iter()
3851            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
3852            .collect()
3853    }
3854
3855    /// True when the observer has at least one free lodging slot (max workers = beds).
3856    pub fn has_worker_lodging(&self) -> bool {
3857        self.free_worker_lodging_slots() > 0
3858    }
3859
3860    /// Owned lodging capacity minus currently hired workers.
3861    pub fn free_worker_lodging_slots(&self) -> i64 {
3862        let slots: u32 = self
3863            .placed_containers
3864            .iter()
3865            .filter(|c| match (self.character_id, c.owner_character_id) {
3866                (Some(me), Some(owner)) => me == owner,
3867                (Some(_), None) => false,
3868                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
3869            })
3870            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
3871            .sum();
3872        let used = self.hired_workers.len() as u32;
3873        slots as i64 - used as i64
3874    }
3875
3876    /// Display names of hired workers assigned to this lodging container.
3877    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
3878        let mut names: Vec<String> = self
3879            .hired_workers
3880            .iter()
3881            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
3882            .map(|w| w.label.clone())
3883            .collect();
3884        names.sort();
3885        names
3886    }
3887
3888    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
3889    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
3890        let is_lodging = self
3891            .placed_containers
3892            .iter()
3893            .find(|c| c.id == container_id)
3894            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
3895        if !is_lodging {
3896            return None;
3897        }
3898        let names = self.lodging_occupant_labels(container_id);
3899        Some(if names.is_empty() {
3900            "vacant".into()
3901        } else {
3902            names.join(", ")
3903        })
3904    }
3905
3906    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
3907        self.quest_log
3908            .iter()
3909            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
3910            .or_else(|| {
3911                self.quest_log
3912                    .iter()
3913                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
3914            })
3915    }
3916
3917    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
3918    pub fn nearest_interact_target(&self) -> Option<String> {
3919        let (px, py) = self.player_position();
3920        let inside = self.effective_inside_building();
3921
3922        #[derive(Clone, Copy, PartialEq, Eq)]
3923        enum Kind {
3924            Player,
3925            Npc,
3926            QuestBoard,
3927            ExitDoor,
3928            EnterDoor,
3929            Well,
3930            Water,
3931        }
3932
3933        fn kind_priority(kind: Kind) -> u8 {
3934            match kind {
3935                Kind::Player => 0,
3936                Kind::Npc => 0,
3937                Kind::QuestBoard => 1,
3938                Kind::ExitDoor => 2,
3939                Kind::EnterDoor => 3,
3940                Kind::Well => 4,
3941                Kind::Water => 5,
3942            }
3943        }
3944
3945        let mut best: Option<(f32, Kind, String)> = None;
3946
3947        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
3948            if dist > max {
3949                return;
3950            }
3951            let replace = match best {
3952                None => true,
3953                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
3954                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
3955                    kind_priority(kind) < kind_priority(bk)
3956                }
3957                _ => false,
3958            };
3959            if replace {
3960                best = Some((dist, kind, id));
3961            }
3962        };
3963
3964        for npc in &self.npcs {
3965            consider(
3966                distance(px, py, npc.x, npc.y),
3967                INTERACTION_RADIUS_M,
3968                Kind::Npc,
3969                npc.id.clone(),
3970            );
3971        }
3972
3973        for entity in &self.entities {
3974            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
3975            {
3976                continue;
3977            }
3978            consider(
3979                distance(
3980                    px,
3981                    py,
3982                    entity.transform.position.x,
3983                    entity.transform.position.y,
3984                ),
3985                INTERACTION_RADIUS_M,
3986                Kind::Player,
3987                entity.id.to_string(),
3988            );
3989        }
3990
3991        for door in &self.doors {
3992            if let Some(ref bid) = inside {
3993                if door.building_id != *bid {
3994                    continue;
3995                }
3996                let is_exit = door.portal.is_some();
3997                let max = if is_exit {
3998                    INTERACTION_RADIUS_M
3999                } else {
4000                    DOOR_INTERACTION_RADIUS_M
4001                };
4002                let kind = if is_exit {
4003                    Kind::ExitDoor
4004                } else {
4005                    Kind::EnterDoor
4006                };
4007                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4008                continue;
4009            }
4010            consider(
4011                distance(px, py, door.x, door.y),
4012                DOOR_INTERACTION_RADIUS_M,
4013                Kind::EnterDoor,
4014                door.id.clone(),
4015            );
4016        }
4017
4018        if inside.is_none() {
4019            for inter in &self.interactables {
4020                if inter.kind == "quest_board" {
4021                    consider(
4022                        distance(px, py, inter.x, inter.y),
4023                        QUEST_BOARD_INTERACTION_RADIUS_M,
4024                        Kind::QuestBoard,
4025                        inter.id.clone(),
4026                    );
4027                }
4028            }
4029            for building in &self.buildings {
4030                if !building.tags.iter().any(|t| t == "well") {
4031                    continue;
4032                }
4033                consider(
4034                    distance(px, py, building.x, building.y),
4035                    INTERACTION_RADIUS_M,
4036                    Kind::Well,
4037                    building.id.clone(),
4038                );
4039            }
4040            if self.in_shallow_water() {
4041                consider(
4042                    0.0,
4043                    INTERACTION_RADIUS_M,
4044                    Kind::Water,
4045                    "water_source".into(),
4046                );
4047            }
4048        }
4049
4050        best.map(|(_, _, id)| id)
4051    }
4052
4053    /// Nearest quest board and distance (any distance), for out-of-range feedback.
4054    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4055        if self.effective_inside_building().is_some() {
4056            return None;
4057        }
4058        let (px, py) = self.player_position();
4059        self.interactables
4060            .iter()
4061            .filter(|i| i.kind == "quest_board")
4062            .map(|i| {
4063                let label = if i.label.is_empty() {
4064                    "Quest board".to_string()
4065                } else {
4066                    i.label.clone()
4067                };
4068                (label, distance(px, py, i.x, i.y))
4069            })
4070            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4071    }
4072
4073    /// Human-readable template name from synced catalog hints.
4074    pub fn template_display_name(&self, template_id: &str) -> String {
4075        self.inventory_hints
4076            .get(template_id)
4077            .map(|h| h.display_name.clone())
4078            .filter(|n| !n.is_empty())
4079            .unwrap_or_else(|| humanize_template_id(template_id))
4080    }
4081
4082    /// Chest label for the location panel — generic type unless this player owns it.
4083    pub fn placed_container_public_label(
4084        &self,
4085        c: &flatland_protocol::PlacedContainerView,
4086    ) -> String {
4087        let is_owner = match (self.character_id, c.owner_character_id) {
4088            (Some(me), Some(owner)) => me == owner,
4089            _ => false,
4090        };
4091        if is_owner {
4092            c.display_name.clone()
4093        } else {
4094            self.template_display_name(&c.template_id)
4095        }
4096    }
4097
4098    /// Keys on person and stowed on the keychain for the keychain overlay.
4099    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4100        let mut out = Vec::new();
4101        for stack in &self.inventory_stacks {
4102            if stack.template_id == KEY_TEMPLATE {
4103                out.push(KeychainEntry {
4104                    stack: stack.clone(),
4105                    stowed: false,
4106                });
4107            }
4108        }
4109        for stack in &self.keychain_stacks {
4110            if stack.template_id == KEY_TEMPLATE {
4111                out.push(KeychainEntry {
4112                    stack: stack.clone(),
4113                    stowed: true,
4114                });
4115            }
4116        }
4117        out
4118    }
4119
4120    /// Display name of the chest a `container_key` opens, when known on the client.
4121    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4122        if stack.template_id != KEY_TEMPLATE {
4123            return None;
4124        }
4125        if let Some(name) = stack
4126            .props
4127            .get(PROP_OPENS_CONTAINER_NAME)
4128            .filter(|n| !n.is_empty())
4129        {
4130            return Some(name.clone());
4131        }
4132        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4133        self.container_name_for_lock_id(opens)
4134    }
4135
4136    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
4137    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4138        if stack.template_id == KEY_TEMPLATE {
4139            self.template_display_name(KEY_TEMPLATE)
4140        } else {
4141            stack
4142                .display_name
4143                .clone()
4144                .unwrap_or_else(|| stack.template_id.clone())
4145        }
4146    }
4147
4148    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
4149    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4150        if stack.template_id != KEY_TEMPLATE {
4151            return String::new();
4152        }
4153        match self.key_pair_chest_label(stack) {
4154            Some(chest) if self.key_drop_blocked(stack) => {
4155                format!(" [key for {chest} — can't drop while locked]")
4156            }
4157            Some(chest) => format!(" [key for {chest}]"),
4158            None => " [key — unpaired]".into(),
4159        }
4160    }
4161
4162    /// Resolve a lock id to a container label (placed chest or one still on your person).
4163    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4164        for c in &self.placed_containers {
4165            if c.lock_id.as_deref() == Some(lock) {
4166                return Some(c.display_name.clone());
4167            }
4168        }
4169        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4170            self.worn
4171                .values()
4172                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4173        })
4174    }
4175
4176    /// Keys cannot be dropped while their paired chest is locked.
4177    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4178        if stack.template_id != KEY_TEMPLATE {
4179            return false;
4180        }
4181        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4182            return false;
4183        };
4184        for c in &self.placed_containers {
4185            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4186                return true;
4187            }
4188        }
4189        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4190            return true;
4191        }
4192        self.worn
4193            .values()
4194            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4195    }
4196
4197    fn container_name_in_stacks(
4198        stacks: &[flatland_protocol::ItemStack],
4199        lock: &str,
4200    ) -> Option<String> {
4201        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
4202            for s in stacks {
4203                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
4204                    return Some(GameState::stack_container_label(s));
4205                }
4206                if let Some(name) = walk(&s.contents, lock) {
4207                    return Some(name);
4208                }
4209            }
4210            None
4211        }
4212        walk(stacks, lock)
4213    }
4214
4215    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
4216        stack
4217            .props
4218            .get(PROP_CUSTOM_NAME)
4219            .cloned()
4220            .or_else(|| stack.display_name.clone())
4221            .unwrap_or_else(|| stack.template_id.clone())
4222    }
4223
4224    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4225        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4226            for s in stacks {
4227                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
4228                    return true;
4229                }
4230                if walk(&s.contents, lock) {
4231                    return true;
4232                }
4233            }
4234            false
4235        }
4236        walk(stacks, lock)
4237    }
4238
4239    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
4240        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
4241            return Some(stack.clone());
4242        }
4243        for worn in self.worn.values() {
4244            if worn.item_instance_id == Some(instance_id) {
4245                return Some(worn.clone());
4246            }
4247            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
4248                return Some(stack.clone());
4249            }
4250        }
4251        None
4252    }
4253
4254    /// Terrain, interactables, and map objects near the player for the HUD location panel.
4255    pub fn location_context_lines(&self) -> Vec<ContextLine> {
4256        let (px, py) = self.player_position();
4257        let inside = self.effective_inside_building();
4258        let mut lines = Vec::new();
4259
4260        if let Some(kind) = self.terrain_at(px, py) {
4261            lines.push(ContextLine {
4262                on_top: true,
4263                text: format!("Terrain: {}", terrain_kind_label(kind)),
4264            });
4265        }
4266
4267        if let Some(id) = inside.as_ref() {
4268            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
4269                lines.push(ContextLine {
4270                    on_top: true,
4271                    text: format!("Inside: {}", b.label),
4272                });
4273            }
4274        }
4275
4276        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
4277
4278        for node in &self.resource_nodes {
4279            let dist = distance(px, py, node.x, node.y);
4280            if dist > NEARBY_SCAN_M {
4281                continue;
4282            }
4283            let on_top = dist <= ON_TOP_RADIUS_M;
4284            let state = match node.state {
4285                flatland_protocol::ResourceNodeState::Available => " — f harvest",
4286                flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
4287                flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
4288            };
4289            let prefix = if on_top { "On" } else { "Near" };
4290            nearby.push((
4291                dist,
4292                ContextLine {
4293                    on_top,
4294                    text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
4295                },
4296            ));
4297        }
4298
4299        for drop in &self.ground_drops {
4300            let dist = distance(px, py, drop.x, drop.y);
4301            if dist > INTERACTION_RADIUS_M {
4302                continue;
4303            }
4304            let on_top = dist <= ON_TOP_RADIUS_M;
4305            let name = self.template_display_name(&drop.template_id);
4306            let prefix = if on_top { "On" } else { "Near" };
4307            let qty = if drop.quantity > 1 {
4308                format!(" ×{}", drop.quantity)
4309            } else {
4310                String::new()
4311            };
4312            nearby.push((
4313                dist,
4314                ContextLine {
4315                    on_top,
4316                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
4317                },
4318            ));
4319        }
4320
4321        for c in &self.placed_containers {
4322            let dist = distance(px, py, c.x, c.y);
4323            if dist > CONTAINER_RANGE_M {
4324                continue;
4325            }
4326            let on_top = dist <= ON_TOP_RADIUS_M;
4327            let name = self.placed_container_public_label(c);
4328            let lock = if c.locked { " [locked]" } else { "" };
4329            let prefix = if on_top { "On" } else { "Near" };
4330            nearby.push((
4331                dist,
4332                ContextLine {
4333                    on_top,
4334                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
4335                },
4336            ));
4337        }
4338
4339        for npc in &self.npcs {
4340            let dist = distance(px, py, npc.x, npc.y);
4341            if dist > NEARBY_SCAN_M {
4342                continue;
4343            }
4344            let on_top = dist <= ON_TOP_RADIUS_M;
4345            let prefix = if on_top { "On" } else { "Near" };
4346            nearby.push((
4347                dist,
4348                ContextLine {
4349                    on_top,
4350                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
4351                },
4352            ));
4353        }
4354
4355        for door in &self.doors {
4356            let dist = distance(px, py, door.x, door.y);
4357            if dist > DOOR_INTERACTION_RADIUS_M {
4358                continue;
4359            }
4360            let building = self
4361                .buildings
4362                .iter()
4363                .find(|b| b.id == door.building_id)
4364                .map(|b| b.label.as_str())
4365                .unwrap_or(door.building_id.as_str());
4366            let action = if inside.is_some() && door.portal.is_some() {
4367                "exit"
4368            } else {
4369                "enter"
4370            };
4371            nearby.push((
4372                dist,
4373                ContextLine {
4374                    on_top: dist <= ON_TOP_RADIUS_M,
4375                    text: format!("{building} door ({dist:.1}m) — f {action}"),
4376                },
4377            ));
4378        }
4379
4380        if inside.is_none() {
4381            for inter in &self.interactables {
4382                if inter.kind != "quest_board" {
4383                    continue;
4384                }
4385                let dist = distance(px, py, inter.x, inter.y);
4386                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
4387                    continue;
4388                }
4389                let on_top = dist <= ON_TOP_RADIUS_M;
4390                let prefix = if on_top { "On" } else { "Near" };
4391                let label = if inter.label.is_empty() {
4392                    "Quest board".to_string()
4393                } else {
4394                    inter.label.clone()
4395                };
4396                nearby.push((
4397                    dist,
4398                    ContextLine {
4399                        on_top,
4400                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
4401                    },
4402                ));
4403            }
4404        }
4405
4406        if self.in_shallow_water() {
4407            let already = self
4408                .terrain_at(px, py)
4409                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
4410            if !already {
4411                nearby.push((
4412                    0.0,
4413                    ContextLine {
4414                        on_top: true,
4415                        text: "Shallow water — f fill bottle".into(),
4416                    },
4417                ));
4418            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
4419                line.text.push_str(" — f fill bottle");
4420            }
4421        }
4422
4423        for entity in &self.entities {
4424            if entity.id == self.entity_id {
4425                continue;
4426            }
4427            let dist = distance(
4428                px,
4429                py,
4430                entity.transform.position.x,
4431                entity.transform.position.y,
4432            );
4433            if dist > NEARBY_SCAN_M {
4434                continue;
4435            }
4436            let label = if entity.label.is_empty() {
4437                format!("entity {}", entity.id)
4438            } else {
4439                entity.label.clone()
4440            };
4441            nearby.push((
4442                dist,
4443                ContextLine {
4444                    on_top: dist <= ON_TOP_RADIUS_M,
4445                    text: format!("Near: {label} ({dist:.1}m)"),
4446                },
4447            ));
4448        }
4449
4450        nearby.sort_by(|a, b| {
4451            a.0.partial_cmp(&b.0)
4452                .unwrap_or(std::cmp::Ordering::Equal)
4453                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
4454        });
4455        lines.extend(nearby.into_iter().map(|(_, l)| l));
4456
4457        if lines.is_empty() {
4458            lines.push(ContextLine {
4459                on_top: false,
4460                text: "(nothing notable nearby)".into(),
4461            });
4462        }
4463
4464        lines
4465    }
4466}
4467
4468/// HUD line for the location / nearby panel.
4469#[derive(Debug, Clone)]
4470pub struct ContextLine {
4471    pub on_top: bool,
4472    pub text: String,
4473}
4474
4475const ON_TOP_RADIUS_M: f32 = 0.65;
4476const NEARBY_SCAN_M: f32 = 5.0;
4477
4478fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
4479    use flatland_protocol::TerrainKindView;
4480    match kind {
4481        TerrainKindView::Grass => "Grass",
4482        TerrainKindView::Dirt => "Dirt",
4483        TerrainKindView::Tilled => "Tilled",
4484        TerrainKindView::Desert => "Desert",
4485        TerrainKindView::Hill => "Hills",
4486        TerrainKindView::Bog => "Bog",
4487        TerrainKindView::ShallowWater => "Shallow water",
4488        TerrainKindView::DeepWater => "Deep water",
4489        TerrainKindView::Trail => "Trail",
4490        TerrainKindView::Road => "Road",
4491        TerrainKindView::Rock => "Rock",
4492    }
4493}
4494
4495fn humanize_template_id(template_id: &str) -> String {
4496    template_id
4497        .split('_')
4498        .map(|word| {
4499            let mut chars = word.chars();
4500            match chars.next() {
4501                None => String::new(),
4502                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4503            }
4504        })
4505        .collect::<Vec<_>>()
4506        .join(" ")
4507}
4508
4509/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
4510const HARVEST_RANGE_M: f32 = 1.5;
4511
4512pub struct GameClient<S: PlayConnection> {
4513    session: S,
4514    seq: Seq,
4515    pub state: GameState,
4516    last_move_forward: f32,
4517    last_move_strafe: f32,
4518}
4519
4520impl<S: PlayConnection> GameClient<S> {
4521    pub fn new(session: S) -> Self {
4522        let session_id = session.session_id();
4523        let entity_id = session.entity_id();
4524        Self {
4525            session,
4526            seq: 0,
4527            last_move_forward: 0.0,
4528            last_move_strafe: 0.0,
4529            state: GameState {
4530                session_id,
4531                entity_id,
4532                character_id: None,
4533                tick: 0,
4534                chunk_rev: 0,
4535                content_rev: 0,
4536                publish_rev: 0,
4537                entities: Vec::new(),
4538                player: None,
4539                resource_nodes: Vec::new(),
4540                ground_drops: Vec::new(),
4541                placed_containers: Vec::new(),
4542                buildings: Vec::new(),
4543                doors: Vec::new(),
4544                interior_map: None,
4545                npcs: Vec::new(),
4546                blueprints: Vec::new(),
4547                world_width_m: 0.0,
4548                world_height_m: 0.0,
4549                terrain_zones: Vec::new(),
4550                z_platforms: Vec::new(),
4551                z_transitions: Vec::new(),
4552                world_clock: flatland_protocol::WorldClock::default(),
4553                inventory: std::collections::HashMap::new(),
4554                inventory_hints: std::collections::HashMap::new(),
4555                logs: VecDeque::new(),
4556                intents_sent: 0,
4557                ticks_received: 0,
4558                connected: false,
4559                disconnect_reason: None,
4560                show_stats: false,
4561            hud_log_hidden: false,
4562                show_equip_menu: false,
4563                equip_menu_index: 0,
4564                show_craft_menu: false,
4565                craft_menu_index: 0,
4566                craft_batch_quantity: 1,
4567                show_shop_menu: false,
4568                shop_catalog: None,
4569                bank_panel: None,
4570                bank_menu_index: 0,
4571                bank_ui_mode: BankUiMode::Menu,
4572                storage_panel: None,
4573                storage_menu_index: 0,
4574                storage_ui_mode: StorageUiMode::Menu,
4575                shop_tab: ShopTab::default(),
4576                shop_menu_index: 0,
4577                shop_quantity: 1,
4578                shop_trade_log: VecDeque::new(),
4579                show_npc_verb_menu: false,
4580                npc_verb_target: None,
4581                npc_verb_index: 0,
4582                player_verbs: crate::social::PlayerVerbState::default(),
4583                social_chat: crate::social::SocialChatState::default(),
4584                trade_ui: crate::social::TradeUiState::default(),
4585                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
4586                show_npc_chat: false,
4587                npc_chat: None,
4588                show_inventory_menu: false,
4589                inventory_menu_index: 0,
4590                inventory_tab: InventoryTab::OnPerson,
4591                inventory_filter: String::new(),
4592                inventory_filter_focused: false,
4593                show_move_picker: false,
4594                show_rename_prompt: false,
4595                show_worker_rename: false,
4596                rename_buffer: String::new(),
4597                move_picker_index: 0,
4598                move_picker: None,
4599                show_grant_picker: false,
4600                grant_picker_index: 0,
4601                grant_picker: None,
4602                show_destroy_picker: false,
4603                destroy_confirm_pending: false,
4604                destroy_picker: None,
4605                combat_target: None,
4606                combat_target_label: None,
4607                combat_fx: Vec::new(),
4608                in_combat: false,
4609                auto_attack: true,
4610                combat_has_los: false,
4611                attack_cd_ticks: 0,
4612                gcd_ticks: 0,
4613                weapon_ability_id: "unarmed".into(),
4614                mainhand_template_id: None,
4615                mainhand_label: None,
4616                offhand_template_id: None,
4617                offhand_label: None,
4618                mainhand_hand_slots: 1,
4619                defense: None,
4620                worn: BTreeMap::new(),
4621                carry_mass: 0.0,
4622                carry_mass_max: 0.0,
4623                encumbrance: flatland_protocol::EncumbranceState::Light,
4624                inventory_stacks: Vec::new(),
4625                keychain_stacks: Vec::new(),
4626            whisper_pouch_stacks: Vec::new(),
4627                combat_target_detail: None,
4628                statuses: Vec::new(),
4629                cast_progress: None,
4630                ability_cooldowns: Vec::new(),
4631                blocking_active: false,
4632                max_target_slots: 1,
4633                combat_slots: Vec::new(),
4634                rotation_presets: Vec::new(),
4635                known_abilities: Vec::new(),
4636                hotbar: vec![None; 9],
4637                max_abilities_per_rotation: 0,
4638                show_loadout_menu: false,
4639                show_keychain_menu: false,
4640                keychain_menu_index: 0,
4641                show_rotation_editor: false,
4642                loadout_menu_index: 0,
4643                loadout_hotbar_slot: 1,
4644                loadout_ability_index: 0,
4645                loadout_focus_presets: false,
4646                rotation_editor: RotationEditorState::default(),
4647                harvest_in_progress: false,
4648                harvest_started_at: None,
4649                pending_craft_ack: None,
4650                pending_worker_job_ack: None,
4651                quest_log: Vec::new(),
4652                interactables: Vec::new(),
4653                ledger: None,
4654                career: None,
4655                character_sheet_tab: CharacterSheetTab::Character,
4656                ledger_period: LedgerPeriod::Day,
4657                show_quest_offer: false,
4658                pending_quest_offer: None,
4659                show_quest_menu: false,
4660                quest_menu_index: 0,
4661                quest_withdraw_confirm: false,
4662                hired_workers: Vec::new(),
4663                show_workers_menu: false,
4664                workers_menu_index: 0,
4665                workers_menu_compact: false,
4666                worker_step_display: BTreeMap::new(),
4667                worker_error_display: BTreeMap::new(),
4668                show_worker_give_picker: false,
4669                worker_give_picker_index: 0,
4670                worker_give_picker: None,
4671                show_worker_give_target_picker: false,
4672                worker_give_target_picker_index: 0,
4673                worker_give_target_picker: None,
4674                show_worker_take_picker: false,
4675                worker_take_picker_index: 0,
4676                worker_take_picker: None,
4677                show_worker_teach_picker: false,
4678                worker_teach_picker_index: 0,
4679                worker_teach_picker: None,
4680                worker_route_editor: None,
4681                progression_curve: None,
4682            },
4683        }
4684    }
4685
4686    pub fn entity_id(&self) -> EntityId {
4687        self.state.entity_id
4688    }
4689
4690    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
4691        if self.state.connected {
4692            return Ok(());
4693        }
4694
4695        loop {
4696            match self.session.next_event().await {
4697                Some(SessionEvent::Welcome {
4698                    session_id,
4699                    entity_id,
4700                    snapshot,
4701                }) => {
4702                    self.state
4703                        .restore_from_welcome(session_id, entity_id, &snapshot);
4704                    self.state.push_log(format!(
4705                        "Connected — session {session_id}, entity {entity_id}"
4706                    ));
4707                    return Ok(());
4708                }
4709                Some(SessionEvent::Disconnected { .. }) => {
4710                    anyhow::bail!("disconnected before welcome");
4711                }
4712                Some(_) => continue,
4713                None => anyhow::bail!("session closed before welcome"),
4714            }
4715        }
4716    }
4717
4718    /// Drain all pending server events (non-blocking).
4719    pub fn drain_events(&mut self) {
4720        while let Some(event) = self.session.try_next_event() {
4721            if self.handle_event_sync(event).is_err() {
4722                break;
4723            }
4724        }
4725    }
4726
4727    /// Wait for the next server event.
4728    pub async fn next_event(&mut self) -> Option<SessionEvent> {
4729        self.session.next_event().await
4730    }
4731
4732    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4733        self.handle_event_sync(event)
4734    }
4735
4736    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4737        match event {
4738            SessionEvent::Welcome {
4739                session_id,
4740                entity_id,
4741                snapshot,
4742            } => {
4743                let resumed = self.state.connected;
4744                self.state
4745                    .restore_from_welcome(session_id, entity_id, &snapshot);
4746                if resumed {
4747                    self.state.push_log(format!(
4748                        "Session restored — session {session_id}, entity {entity_id}"
4749                    ));
4750                }
4751            }
4752            SessionEvent::ContentUpdated { snapshot } => {
4753                self.state
4754                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
4755                self.state.push_log(format!(
4756                    "World updated (content rev {})",
4757                    snapshot.content_rev
4758                ));
4759            }
4760            SessionEvent::Tick(delta) => {
4761                self.state.apply_tick_fields(&delta, self.state.entity_id);
4762                self.state.ticks_received += 1;
4763            }
4764            SessionEvent::IntentAck {
4765                entity_id,
4766                seq,
4767                tick,
4768            } => {
4769                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
4770                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
4771                    if *craft_seq == seq {
4772                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
4773                        if batches > 1 {
4774                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
4775                        } else {
4776                            self.state.push_log(format!("Crafting {label}…"));
4777                        }
4778                    }
4779                }
4780                if self
4781                    .state
4782                    .pending_worker_job_ack
4783                    .as_ref()
4784                    .is_some_and(|p| p.seq == seq)
4785                {
4786                    let pending = self.state.pending_worker_job_ack.take().unwrap();
4787                    if pending.idle {
4788                        self.state.push_log(format!(
4789                            "Route cleared for {} — worker idle",
4790                            pending.worker_label
4791                        ));
4792                    } else {
4793                        self.state.push_log(format!(
4794                            "Route saved for {} — {} stop(s), job loop active",
4795                            pending.worker_label, pending.stop_count
4796                        ));
4797                    }
4798                    if self
4799                        .state
4800                        .worker_route_editor
4801                        .as_ref()
4802                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
4803                    {
4804                        self.close_worker_route_editor();
4805                    }
4806                }
4807            }
4808            SessionEvent::Chat(msg) => {
4809                let label = match msg.channel {
4810                    flatland_protocol::ChatChannel::Nearby => "nearby",
4811                    flatland_protocol::ChatChannel::Direct => "speak",
4812                    flatland_protocol::ChatChannel::Whisper => "whisper",
4813                    flatland_protocol::ChatChannel::WhisperStone => "stone",
4814                };
4815                let clarity = match msg.clarity {
4816                    flatland_protocol::ChatClarity::Clear => "",
4817                    flatland_protocol::ChatClarity::Partial => "~",
4818                    flatland_protocol::ChatClarity::Heavy => "…",
4819                };
4820                self.state.push_log(format!(
4821                    "[{label}{clarity}] {}: {}",
4822                    msg.from_name, msg.text
4823                ));
4824                let now_ms = std::time::SystemTime::now()
4825                    .duration_since(std::time::UNIX_EPOCH)
4826                    .map(|d| d.as_millis() as u64)
4827                    .unwrap_or(0);
4828                self.state
4829                    .social_chat
4830                    .note_speech(&msg, self.state.entity_id, now_ms);
4831                self.state
4832                    .social_chat
4833                    .push(crate::social::ChatLogEntry::from_message(
4834                        msg,
4835                        self.state.entity_id,
4836                    ));
4837            }
4838            SessionEvent::TradeOpened(panel) => {
4839                self.state.social_chat.pending_trade = None;
4840                let peer = panel.peer_name.clone();
4841                self.state.trade_ui.open(panel);
4842                self.state
4843                    .social_chat
4844                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
4845                self.state
4846                    .social_chat
4847                    .push_cue(crate::social::AudioCue::TradeOpened);
4848            }
4849            SessionEvent::TradeClosed { reason } => {
4850                self.state.push_log(reason.clone());
4851                self.state.social_chat.push_system(reason);
4852                self.state.trade_ui.close();
4853            }
4854            SessionEvent::HarvestResult(result) => {
4855                self.state.clear_harvest_state();
4856                crate::harvest_trace!(
4857                    entity_id = self.state.entity_id,
4858                    node_id = %result.node_id,
4859                    template = %result.item_template,
4860                    quantity = result.quantity,
4861                    client_tick = self.state.tick,
4862                    "client applied harvest result"
4863                );
4864                self.state.push_log(format!(
4865                    "Harvested {} x{} (on the ground — press P to pick up)",
4866                    result.item_template, result.quantity
4867                ));
4868            }
4869            SessionEvent::CraftResult(result) => {
4870                for stack in &result.consumed {
4871                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
4872                        *qty = qty.saturating_sub(stack.quantity);
4873                        if *qty == 0 {
4874                            self.state.inventory.remove(&stack.template_id);
4875                        }
4876                    }
4877                }
4878                for stack in &result.outputs {
4879                    *self
4880                        .state
4881                        .inventory
4882                        .entry(stack.template_id.clone())
4883                        .or_insert(0) += stack.quantity;
4884                }
4885                if let Some(output) = result.outputs.first() {
4886                    if result.batch_total > 1 {
4887                        self.state.push_log(format!(
4888                            "Crafted {} x{} ({}/{})",
4889                            output.template_id,
4890                            output.quantity,
4891                            result.batch_index,
4892                            result.batch_total
4893                        ));
4894                    } else {
4895                        self.state.push_log(format!(
4896                            "Crafted {} x{}",
4897                            output.template_id, output.quantity
4898                        ));
4899                    }
4900                } else {
4901                    self.state
4902                        .push_log(format!("Craft finished: {}", result.blueprint_id));
4903                }
4904            }
4905            SessionEvent::Death(notice) => {
4906                self.state.clear_harvest_state();
4907                self.state.push_log(notice.message.clone());
4908                self.state.push_log(format!(
4909                    "Respawned at ({:.1}, {:.1})",
4910                    notice.respawn_x, notice.respawn_y
4911                ));
4912            }
4913            SessionEvent::Interaction(notice) => {
4914                if notice.message.starts_with("Harvest failed:") {
4915                    self.state.clear_harvest_state();
4916                }
4917                if notice.message.starts_with("Can't do that:") {
4918                    self.state.pending_craft_ack = None;
4919                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
4920                        if let Some(w) = self
4921                            .state
4922                            .hired_workers
4923                            .iter_mut()
4924                            .find(|w| w.instance_id == pending.worker_instance_id)
4925                        {
4926                            w.route = pending.prev_route;
4927                            w.mode = pending.prev_mode;
4928                            w.step_label = pending.prev_step_label;
4929                            w.last_error = pending.prev_last_error;
4930                        }
4931                        let reason = notice
4932                            .message
4933                            .strip_prefix("Can't do that:")
4934                            .unwrap_or(&notice.message)
4935                            .trim();
4936                        self.state.push_log(format!(
4937                            "Route save failed for {}: {reason}",
4938                            pending.worker_label
4939                        ));
4940                    }
4941                }
4942                if notice.message.starts_with("Cast failed:") {
4943                    self.state.cast_progress = None;
4944                }
4945                if notice.message.contains("slain the") {
4946                    self.state.combat_target = None;
4947                    self.state.combat_target_label = None;
4948                }
4949                // Inbound trade request → inline Y/N in the CHAT column (no popup).
4950                if notice.message.contains("wants to trade") {
4951                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
4952                        let from_name = notice
4953                            .message
4954                            .split(" wants to trade")
4955                            .next()
4956                            .unwrap_or("Player")
4957                            .to_string();
4958                        self.state.social_chat.pending_trade =
4959                            Some(crate::social::PendingTradeRequest {
4960                                from_entity,
4961                                from_name: from_name.clone(),
4962                            });
4963                        self.state.social_chat.push_system(format!(
4964                            "{from_name} wants to trade — [Y] accept · [N] decline"
4965                        ));
4966                        self.state
4967                            .social_chat
4968                            .push_cue(crate::social::AudioCue::TradeOffer);
4969                    }
4970                }
4971                if notice.message.starts_with("trade request declined") {
4972                    self.state
4973                        .social_chat
4974                        .push_system(notice.message.clone());
4975                    self.state
4976                        .social_chat
4977                        .push_cue(crate::social::AudioCue::TradeDeclined);
4978                }
4979                self.state.apply_interaction_notice(&notice);
4980                self.state.push_log(notice.message.clone());
4981            }
4982            SessionEvent::ShopOpened(catalog) => {
4983                self.state.apply_shop_catalog(catalog);
4984            }
4985            SessionEvent::BankOpened(panel) => {
4986                self.state.apply_bank_panel(panel);
4987            }
4988            SessionEvent::StorageOpened(panel) => {
4989                self.state.apply_storage_panel(panel);
4990            }
4991            SessionEvent::NpcTalkOpened(opened) => {
4992                self.state.show_npc_verb_menu = false;
4993                if self.state.npc_verb_target.is_none() {
4994                    self.state.npc_verb_target = Some(opened.npc_id.clone());
4995                }
4996                let label = opened.npc_label.clone();
4997                let banner = if !opened.trade_allowed {
4998                    Some("Trade is unavailable right now.".to_string())
4999                } else {
5000                    None
5001                };
5002                self.state.show_npc_chat = true;
5003                self.state.npc_chat = Some(NpcChatState {
5004                    npc_id: opened.npc_id,
5005                    npc_label: opened.npc_label,
5006                    lines: if opened.greeting.is_empty() {
5007                        vec![]
5008                    } else {
5009                        vec![format!("{label}: {}", opened.greeting)]
5010                    },
5011                    input: String::new(),
5012                    pending: opened.greeting.is_empty(),
5013                    talk_depth: opened.talk_depth,
5014                    trade_allowed: opened.trade_allowed,
5015                    banner,
5016                });
5017            }
5018            SessionEvent::NpcTalkPending(_) => {
5019                if let Some(chat) = self.state.npc_chat.as_mut() {
5020                    chat.pending = true;
5021                }
5022            }
5023            SessionEvent::NpcTalkReply(reply) => {
5024                if let Some(chat) = self.state.npc_chat.as_mut() {
5025                    if chat.npc_id == reply.npc_id {
5026                        chat.pending = false;
5027                        if reply.trade_disabled {
5028                            chat.trade_allowed = false;
5029                            chat.banner = Some("Trade is unavailable right now.".to_string());
5030                        }
5031                        if reply.wind_down {
5032                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
5033                            if chat.banner.is_none() {
5034                                chat.banner =
5035                                    Some("They're wrapping up — keep it brief.".to_string());
5036                            }
5037                        }
5038                        chat.lines
5039                            .push(format!("{}: {}", chat.npc_label, reply.line));
5040                    }
5041                }
5042            }
5043            SessionEvent::NpcTalkClosed(closed) => {
5044                if self
5045                    .state
5046                    .npc_chat
5047                    .as_ref()
5048                    .is_some_and(|c| c.npc_id == closed.npc_id)
5049                {
5050                    self.state.show_npc_chat = false;
5051                    self.state.npc_chat = None;
5052                }
5053            }
5054            SessionEvent::NpcTalkError(err) => {
5055                self.state.push_log(format!("Talk failed: {}", err.reason));
5056                if let Some(chat) = self.state.npc_chat.as_mut() {
5057                    chat.pending = false;
5058                }
5059            }
5060            SessionEvent::UseResult(result) => {
5061                // Inventory count hint only — the Interaction notice already logs
5062                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
5063                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
5064                    *qty = qty.saturating_sub(1);
5065                    if *qty == 0 {
5066                        self.state.inventory.remove(&result.template_id);
5067                    }
5068                }
5069            }
5070            SessionEvent::QuestOffer(offer) => {
5071                self.state.pending_quest_offer = Some(offer.clone());
5072                self.state.show_quest_offer = true;
5073                self.state
5074                    .push_log(format!("Quest offered: {}", offer.title));
5075            }
5076            SessionEvent::QuestAccepted(notice) => {
5077                self.state.show_quest_offer = false;
5078                self.state.pending_quest_offer = None;
5079                self.state.push_log(notice.message);
5080            }
5081            SessionEvent::QuestWithdrawn(notice) => {
5082                self.state.show_quest_menu = false;
5083                self.state.quest_withdraw_confirm = false;
5084                self.state.push_log(notice.message);
5085            }
5086            SessionEvent::QuestStepCompleted(notice) => {
5087                self.state.push_log(notice.message);
5088            }
5089            SessionEvent::QuestCompleted(notice) => {
5090                self.state.push_log(notice.message);
5091            }
5092            SessionEvent::Disconnected { reason } => {
5093                self.state.clear_harvest_state();
5094                self.state.connected = false;
5095                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
5096                if let Some(r) = &self.state.disconnect_reason {
5097                    self.state.push_log(format!("Disconnected: {r}"));
5098                } else {
5099                    self.state.push_log("Disconnected from server");
5100                }
5101            }
5102        }
5103        Ok(())
5104    }
5105
5106    pub fn is_connected(&self) -> bool {
5107        self.state.connected
5108    }
5109
5110    pub fn close_overlays(&mut self) {
5111        self.state.show_stats = false;
5112        self.state.show_craft_menu = false;
5113        self.state.show_shop_menu = false;
5114        self.state.shop_catalog = None;
5115        self.state.show_npc_verb_menu = false;
5116        self.state.npc_verb_target = None;
5117        self.state.show_npc_chat = false;
5118        self.state.npc_chat = None;
5119        self.state.show_inventory_menu = false;
5120        self.state.show_loadout_menu = false;
5121        self.state.show_rotation_editor = false;
5122        self.state.rotation_editor.reset();
5123        self.state.show_rename_prompt = false;
5124        self.state.show_worker_rename = false;
5125        self.state.rename_buffer.clear();
5126        self.state.show_move_picker = false;
5127        self.state.move_picker = None;
5128        self.state.show_destroy_picker = false;
5129        self.state.destroy_confirm_pending = false;
5130        self.state.destroy_picker = None;
5131        self.state.show_quest_offer = false;
5132        self.state.pending_quest_offer = None;
5133        self.state.show_quest_menu = false;
5134        self.state.quest_withdraw_confirm = false;
5135        self.state.show_workers_menu = false;
5136        self.close_worker_give_picker();
5137        self.close_worker_give_target_picker();
5138        self.close_worker_take_picker();
5139        self.close_worker_teach_picker();
5140        self.state.worker_route_editor = None;
5141    }
5142
5143    /// Esc / back — pop one UI layer instead of closing every overlay at once.
5144    pub fn back_on_esc(&mut self) -> bool {
5145        if self.state.social_chat.composer_open() {
5146            self.state.social_chat.close_composer();
5147            return true;
5148        }
5149        if self.state.player_verbs.open {
5150            self.state.player_verbs.close();
5151            return true;
5152        }
5153        if self.state.whisper_pouch_ui.open {
5154            self.state.whisper_pouch_ui.open = false;
5155            return true;
5156        }
5157        if self.state.trade_ui.panel.is_some() {
5158            // async cancel — caller should prefer trade_cancel; close UI optimistically
5159            self.state.trade_ui.close();
5160            return true;
5161        }
5162        if self.state.show_rename_prompt {
5163            self.cancel_rename_prompt();
5164            return true;
5165        }
5166        if self.state.show_worker_rename {
5167            self.cancel_worker_rename();
5168            return true;
5169        }
5170        if self.state.show_destroy_picker {
5171            if self.state.destroy_confirm_pending {
5172                self.cancel_destroy_confirm();
5173            } else {
5174                self.close_destroy_picker();
5175            }
5176            return true;
5177        }
5178        if self.state.show_move_picker {
5179            self.close_move_picker();
5180            return true;
5181        }
5182        if self.state.show_rotation_editor {
5183            match self.state.rotation_editor.mode {
5184                RotationEditorMode::List => {
5185                    self.state.show_rotation_editor = false;
5186                    self.state.rotation_editor.reset();
5187                }
5188                RotationEditorMode::EditLabel => {
5189                    self.state.rotation_editor.label_buffer.clear();
5190                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
5191                }
5192                RotationEditorMode::PickAbility => {
5193                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
5194                }
5195                RotationEditorMode::EditSequence => {
5196                    self.state.rotation_editor.draft = None;
5197                    self.state.rotation_editor.mode = RotationEditorMode::List;
5198                }
5199            }
5200            return true;
5201        }
5202        if self.state.show_inventory_menu {
5203            self.close_inventory_menu();
5204            return true;
5205        }
5206        if self.state.show_craft_menu {
5207            self.close_craft_menu();
5208            return true;
5209        }
5210        if self.state.show_quest_offer {
5211            self.quest_offer_decline();
5212            return true;
5213        }
5214        if self.state.show_shop_menu {
5215            // Caller must await shop close (npc_interaction_back / play-loop).
5216            return false;
5217        }
5218        if self.state.bank_panel.is_some() {
5219            return false;
5220        }
5221        if self.state.storage_panel.is_some() {
5222            return false;
5223        }
5224        if self.state.show_npc_chat {
5225            // play.rs calls npc_interaction_back().await on Esc
5226            return false;
5227        }
5228        if self.state.show_npc_verb_menu {
5229            self.state.show_npc_verb_menu = false;
5230            self.state.npc_verb_target = None;
5231            return true;
5232        }
5233        if self.state.show_quest_menu {
5234            if self.state.quest_withdraw_confirm {
5235                self.state.quest_withdraw_confirm = false;
5236            } else {
5237                self.state.show_quest_menu = false;
5238            }
5239            return true;
5240        }
5241        if self.state.worker_route_editor.is_some() {
5242            // Pop one sheet level; only close the editor at the root stop list.
5243            if self.re_at_root_sheet() {
5244                self.close_worker_route_editor();
5245            } else {
5246                self.re_sheet_back();
5247            }
5248            return true;
5249        }
5250        if self.state.show_worker_give_picker {
5251            self.close_worker_give_picker();
5252            return true;
5253        }
5254        if self.state.show_worker_give_target_picker {
5255            self.close_worker_give_target_picker();
5256            return true;
5257        }
5258        if self.state.show_worker_take_picker {
5259            self.close_worker_take_picker();
5260            return true;
5261        }
5262        if self.state.show_worker_teach_picker {
5263            self.close_worker_teach_picker();
5264            return true;
5265        }
5266        if self.state.show_workers_menu {
5267            self.state.show_workers_menu = false;
5268            return true;
5269        }
5270        if self.state.show_loadout_menu {
5271            self.state.show_loadout_menu = false;
5272            return true;
5273        }
5274        if self.state.show_stats {
5275            self.state.show_stats = false;
5276            return true;
5277        }
5278        if self.state.show_equip_menu {
5279            self.state.show_equip_menu = false;
5280            return true;
5281        }
5282        false
5283    }
5284
5285    pub fn toggle_stats(&mut self) {
5286        self.state.show_stats = !self.state.show_stats;
5287        if self.state.show_stats {
5288            self.state.character_sheet_tab = CharacterSheetTab::Character;
5289            self.state.show_craft_menu = false;
5290            self.state.show_shop_menu = false;
5291            self.state.shop_catalog = None;
5292            self.state.show_inventory_menu = false;
5293            self.state.show_equip_menu = false;
5294        }
5295    }
5296
5297    pub fn toggle_equip_menu(&mut self) {
5298        self.state.show_equip_menu = !self.state.show_equip_menu;
5299        if self.state.show_equip_menu {
5300            self.state.show_stats = false;
5301            self.state.show_craft_menu = false;
5302            self.state.show_shop_menu = false;
5303            self.state.shop_catalog = None;
5304            self.state.show_inventory_menu = false;
5305            self.state.show_loadout_menu = false;
5306        }
5307    }
5308
5309    pub fn cycle_character_sheet_tab(&mut self) {
5310        if self.state.show_stats {
5311            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
5312        }
5313    }
5314
5315    pub fn set_ledger_period_digit(&mut self, c: char) {
5316        if self.state.show_stats {
5317            if let Some(p) = LedgerPeriod::from_digit(c) {
5318                self.state.ledger_period = p;
5319                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
5320            }
5321        }
5322    }
5323
5324    pub fn cycle_ledger_period(&mut self) {
5325        if self.state.show_stats
5326            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
5327        {
5328            self.state.ledger_period = self.state.ledger_period.cycle();
5329        }
5330    }
5331
5332    pub fn open_inventory_menu(&mut self) {
5333        self.state.show_inventory_menu = true;
5334        self.state.show_craft_menu = false;
5335        self.state.show_shop_menu = false;
5336        self.state.shop_catalog = None;
5337        self.state.show_stats = false;
5338        self.state.show_move_picker = false;
5339        self.state.move_picker = None;
5340        self.state.show_destroy_picker = false;
5341        self.state.destroy_confirm_pending = false;
5342        self.state.destroy_picker = None;
5343        self.state.show_rename_prompt = false;
5344        self.state.rename_buffer.clear();
5345        self.state.inventory_filter_focused = false;
5346        self.state.clamp_inventory_indices();
5347    }
5348
5349    pub fn close_inventory_menu(&mut self) {
5350        self.state.show_inventory_menu = false;
5351        self.state.show_move_picker = false;
5352        self.state.move_picker = None;
5353        self.close_grant_picker();
5354        self.state.show_destroy_picker = false;
5355        self.state.destroy_confirm_pending = false;
5356        self.state.destroy_picker = None;
5357        self.state.show_rename_prompt = false;
5358        self.state.rename_buffer.clear();
5359        self.state.inventory_filter_focused = false;
5360    }
5361
5362    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
5363        let Some(row) = self.state.inventory_selected_row() else {
5364            anyhow::bail!("inventory empty");
5365        };
5366        if !self.state.row_is_renameable_container(&row) {
5367            anyhow::bail!("only storage containers can be renamed");
5368        }
5369        let current = row
5370            .stack
5371            .display_name
5372            .clone()
5373            .unwrap_or_else(|| row.stack.template_id.clone());
5374        self.state.rename_buffer = current;
5375        self.state.show_rename_prompt = true;
5376        self.state.show_worker_rename = false;
5377        self.state.show_move_picker = false;
5378        self.state.show_destroy_picker = false;
5379        self.state.destroy_confirm_pending = false;
5380        Ok(())
5381    }
5382
5383    pub fn cancel_rename_prompt(&mut self) {
5384        self.state.show_rename_prompt = false;
5385        self.state.rename_buffer.clear();
5386    }
5387
5388    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
5389        let name = self.state.rename_buffer.trim().to_string();
5390        if name.is_empty() {
5391            anyhow::bail!("name cannot be empty");
5392        }
5393        let Some(row) = self.state.inventory_selected_row() else {
5394            anyhow::bail!("inventory empty");
5395        };
5396        let Some(instance_id) = row.stack.item_instance_id else {
5397            anyhow::bail!("item has no instance id");
5398        };
5399        self.seq += 1;
5400        self.session
5401            .submit_intent(Intent::RenameContainer {
5402                entity_id: self.state.entity_id,
5403                item_instance_id: instance_id,
5404                location: row.from.clone(),
5405                name,
5406                seq: self.seq,
5407            })
5408            .await?;
5409        self.state.intents_sent += 1;
5410        self.state.show_rename_prompt = false;
5411        self.state.rename_buffer.clear();
5412        Ok(())
5413    }
5414
5415    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
5416        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5417            anyhow::bail!("no worker selected");
5418        };
5419        self.state.rename_buffer = worker.label.clone();
5420        self.state.show_worker_rename = true;
5421        self.state.show_rename_prompt = false;
5422        Ok(())
5423    }
5424
5425    pub fn cancel_worker_rename(&mut self) {
5426        self.state.show_worker_rename = false;
5427        self.state.rename_buffer.clear();
5428    }
5429
5430    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
5431        let name = self.state.rename_buffer.trim().to_string();
5432        if name.is_empty() {
5433            anyhow::bail!("name cannot be empty");
5434        }
5435        if name.chars().count() > 32 {
5436            anyhow::bail!("name must be 1–32 characters");
5437        }
5438        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5439            anyhow::bail!("no worker selected");
5440        };
5441        let worker_instance_id = worker.instance_id.clone();
5442        self.seq += 1;
5443        self.session
5444            .submit_intent(Intent::RenameHiredWorker {
5445                entity_id: self.state.entity_id,
5446                worker_instance_id: worker_instance_id.clone(),
5447                name: name.clone(),
5448                seq: self.seq,
5449            })
5450            .await?;
5451        self.state.intents_sent += 1;
5452        if let Some(w) = self
5453            .state
5454            .hired_workers
5455            .iter_mut()
5456            .find(|w| w.instance_id == worker_instance_id)
5457        {
5458            w.label = name.clone();
5459        }
5460        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5461            if ed.worker_instance_id == worker_instance_id {
5462                ed.worker_label = name.clone();
5463            }
5464        }
5465        self.state.show_worker_rename = false;
5466        self.state.rename_buffer.clear();
5467        self.state.push_log(format!("Renamed worker to \"{name}\""));
5468        Ok(())
5469    }
5470
5471    pub fn toggle_inventory_menu(&mut self) {
5472        if self.state.show_inventory_menu {
5473            self.close_inventory_menu();
5474        } else {
5475            self.open_inventory_menu();
5476        }
5477    }
5478
5479    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
5480    pub fn inventory_menu_move(&mut self, delta: i32) {
5481        if self.state.show_grant_picker {
5482            let Some(picker) = self.state.grant_picker.as_ref() else {
5483                return;
5484            };
5485            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5486            let filter = picker.filter.clone();
5487            let n = labels.len();
5488            if n == 0 {
5489                return;
5490            }
5491            self.state.grant_picker_index = step_filtered_index(
5492                self.state.grant_picker_index,
5493                delta,
5494                n,
5495                |i| list_label_matches(&labels[i], &filter),
5496            );
5497            return;
5498        }
5499        if self.state.show_move_picker {
5500            let Some(picker) = self.state.move_picker.as_ref() else {
5501                return;
5502            };
5503            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5504            let filter = picker.filter.clone();
5505            let n = labels.len();
5506            if n == 0 {
5507                return;
5508            }
5509            self.state.move_picker_index = step_filtered_index(
5510                self.state.move_picker_index,
5511                delta,
5512                n,
5513                |i| list_label_matches(&labels[i], &filter),
5514            );
5515            self.state.clamp_move_picker_quantity();
5516            return;
5517        }
5518        let n = self.state.inventory_selectable_rows().len();
5519        if n == 0 {
5520            return;
5521        }
5522        let idx = self.state.inventory_menu_index as i32;
5523        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5524    }
5525
5526    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
5527    pub fn inventory_menu_page(&mut self, pages: i32) {
5528        if self.state.show_grant_picker {
5529            let Some(picker) = self.state.grant_picker.as_ref() else {
5530                return;
5531            };
5532            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5533            let filter = picker.filter.clone();
5534            let n = labels.len();
5535            self.state.grant_picker_index = page_filtered_index(
5536                self.state.grant_picker_index,
5537                pages,
5538                n,
5539                |i| list_label_matches(&labels[i], &filter),
5540            );
5541            return;
5542        }
5543        if self.state.show_move_picker {
5544            let Some(picker) = self.state.move_picker.as_ref() else {
5545                return;
5546            };
5547            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5548            let filter = picker.filter.clone();
5549            let n = labels.len();
5550            self.state.move_picker_index = page_filtered_index(
5551                self.state.move_picker_index,
5552                pages,
5553                n,
5554                |i| list_label_matches(&labels[i], &filter),
5555            );
5556            self.state.clamp_move_picker_quantity();
5557            return;
5558        }
5559        let n = self.state.inventory_selectable_rows().len();
5560        self.state.inventory_menu_index =
5561            page_list_index(self.state.inventory_menu_index, pages, n);
5562    }
5563
5564    pub fn cycle_inventory_tab(&mut self, forward: bool) {
5565        if self.state.show_move_picker
5566            || self.state.show_grant_picker
5567            || self.state.show_destroy_picker
5568            || self.state.show_rename_prompt
5569            || self.state.inventory_filter_focused
5570        {
5571            return;
5572        }
5573        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
5574        self.state.inventory_menu_index = 0;
5575        self.state.clamp_inventory_indices();
5576    }
5577
5578    pub fn focus_inventory_filter(&mut self) {
5579        if self.state.show_grant_picker {
5580            if let Some(p) = self.state.grant_picker.as_mut() {
5581                p.filter_focused = true;
5582            }
5583            return;
5584        }
5585        if self.state.show_move_picker {
5586            if let Some(p) = self.state.move_picker.as_mut() {
5587                p.filter_focused = true;
5588            }
5589            return;
5590        }
5591        self.state.inventory_filter_focused = true;
5592    }
5593
5594    pub fn set_inventory_filter(&mut self, filter: String) {
5595        self.state.inventory_filter = filter;
5596        self.state.inventory_menu_index = 0;
5597        self.state.clamp_inventory_indices();
5598    }
5599
5600    pub fn append_inventory_filter_char(&mut self, ch: char) {
5601        if ch.is_control() {
5602            return;
5603        }
5604        if self.state.show_grant_picker {
5605            if let Some(p) = self.state.grant_picker.as_mut() {
5606                if p.filter_focused {
5607                    p.filter.push(ch);
5608                    self.state.grant_picker_index = 0;
5609                }
5610            }
5611            return;
5612        }
5613        if self.state.show_move_picker {
5614            if let Some(p) = self.state.move_picker.as_mut() {
5615                if p.filter_focused {
5616                    p.filter.push(ch);
5617                    self.state.move_picker_index = 0;
5618                    self.state.clamp_move_picker_quantity();
5619                }
5620            }
5621            return;
5622        }
5623        if !self.state.inventory_filter_focused {
5624            return;
5625        }
5626        self.state.inventory_filter.push(ch);
5627        self.state.inventory_menu_index = 0;
5628        self.state.clamp_inventory_indices();
5629    }
5630
5631    pub fn inventory_filter_backspace(&mut self) {
5632        if self.state.show_grant_picker {
5633            if let Some(p) = self.state.grant_picker.as_mut() {
5634                if p.filter_focused {
5635                    p.filter.pop();
5636                    self.state.grant_picker_index = 0;
5637                }
5638            }
5639            return;
5640        }
5641        if self.state.show_move_picker {
5642            if let Some(p) = self.state.move_picker.as_mut() {
5643                if p.filter_focused {
5644                    p.filter.pop();
5645                    self.state.move_picker_index = 0;
5646                    self.state.clamp_move_picker_quantity();
5647                }
5648            }
5649            return;
5650        }
5651        if !self.state.inventory_filter_focused {
5652            return;
5653        }
5654        self.state.inventory_filter.pop();
5655        self.state.inventory_menu_index = 0;
5656        self.state.clamp_inventory_indices();
5657    }
5658
5659    /// Esc while filter focused: clear filter then blur. Returns true if handled.
5660    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
5661        if self.state.show_grant_picker {
5662            if let Some(p) = self.state.grant_picker.as_mut() {
5663                if p.filter_focused {
5664                    if !p.filter.is_empty() {
5665                        p.filter.clear();
5666                        self.state.grant_picker_index = 0;
5667                    } else {
5668                        p.filter_focused = false;
5669                    }
5670                    return true;
5671                }
5672                if !p.filter.is_empty() {
5673                    p.filter.clear();
5674                    self.state.grant_picker_index = 0;
5675                    return true;
5676                }
5677            }
5678            return false;
5679        }
5680        if self.state.show_move_picker {
5681            if let Some(p) = self.state.move_picker.as_mut() {
5682                if p.filter_focused {
5683                    if !p.filter.is_empty() {
5684                        p.filter.clear();
5685                        self.state.move_picker_index = 0;
5686                        self.state.clamp_move_picker_quantity();
5687                    } else {
5688                        p.filter_focused = false;
5689                    }
5690                    return true;
5691                }
5692                if !p.filter.is_empty() {
5693                    p.filter.clear();
5694                    self.state.move_picker_index = 0;
5695                    self.state.clamp_move_picker_quantity();
5696                    return true;
5697                }
5698            }
5699            return false;
5700        }
5701        if self.state.inventory_filter_focused {
5702            if !self.state.inventory_filter.is_empty() {
5703                self.state.inventory_filter.clear();
5704                self.state.inventory_menu_index = 0;
5705                self.state.clamp_inventory_indices();
5706            } else {
5707                self.state.inventory_filter_focused = false;
5708            }
5709            return true;
5710        }
5711        if !self.state.inventory_filter.is_empty() {
5712            self.state.inventory_filter.clear();
5713            self.state.inventory_menu_index = 0;
5714            self.state.clamp_inventory_indices();
5715            return true;
5716        }
5717        false
5718    }
5719
5720    pub fn craft_menu_page(&mut self, pages: i32) {
5721        let n = self.state.blueprints.len();
5722        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
5723        self.state.clamp_craft_batch_quantity();
5724    }
5725
5726    pub fn shop_menu_page(&mut self, pages: i32) {
5727        let n = self.state.shop_list_len();
5728        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
5729        self.state.clamp_shop_quantity();
5730    }
5731
5732    pub fn workers_menu_page(&mut self, pages: i32) {
5733        let n = self.state.hired_workers.len();
5734        self.state.workers_menu_index =
5735            page_list_index(self.state.workers_menu_index, pages, n);
5736    }
5737
5738    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
5739    /// chest — or fall back to the "move to…" destination picker (including
5740    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
5741    /// Placed chest shells open a pick-up destination picker (`l` still locks).
5742    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
5743        if self.state.show_destroy_picker {
5744            if self.state.destroy_confirm_pending {
5745                return self.confirm_destroy_item().await;
5746            }
5747            return self.request_destroy_confirm();
5748        }
5749        if self.state.show_grant_picker {
5750            return self.confirm_grant_picker().await;
5751        }
5752        if self.state.show_move_picker {
5753            return self.confirm_move_picker().await;
5754        }
5755        let Some(row) = self.state.inventory_selected_row() else {
5756            anyhow::bail!("inventory empty");
5757        };
5758        if row.is_equip_shell {
5759            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
5760                anyhow::bail!("not a worn item");
5761            };
5762            return self.equip_worn(slot, None).await;
5763        }
5764        if row.is_chest_shell {
5765            return self.open_chest_pickup_picker();
5766        }
5767        let template_id = row.stack.template_id.clone();
5768        let instance_id = row.stack.item_instance_id;
5769        let category = self.state.inventory_item_category(&template_id);
5770        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5771
5772        if category == Some("weapon") {
5773            return self.equip_mainhand(Some(template_id)).await;
5774        }
5775        if category == Some("lodging") && on_person {
5776            if let Some(inst) = instance_id {
5777                return self.place_container(inst).await;
5778            }
5779        }
5780        if (category == Some("container") || category == Some("armor")) && on_person {
5781            if let Some(inst) = instance_id {
5782                let world_placeable = row.stack.world_placeable == Some(true)
5783                    || template_id.contains("chest");
5784                if world_placeable {
5785                    return self.place_container(inst).await;
5786                }
5787                // Pouches no longer equip directly — they clip onto a worn belt's loops
5788                // instead, so fall through to the move picker (offers "belt loop" when a
5789                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
5790                if let Some(slot) = guess_body_slot(&template_id) {
5791                    return self.equip_worn(slot, Some(inst)).await;
5792                }
5793            }
5794        }
5795        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
5796        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
5797        // instead of guessing.
5798        self.open_move_picker()
5799    }
5800
5801    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
5802    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
5803        let Some(row) = self.state.inventory_selected_row() else {
5804            anyhow::bail!("inventory empty");
5805        };
5806        if row.from != flatland_protocol::InventoryLocation::Root {
5807            anyhow::bail!("select a consumable on your person");
5808        }
5809        if GameState::stack_is_item_grant(&row.stack) {
5810            return self.open_grant_target_picker();
5811        }
5812        let category = self
5813            .state
5814            .inventory_item_category(&row.stack.template_id);
5815        if category != Some("consumable") {
5816            anyhow::bail!("selected item is not consumable");
5817        }
5818        self.use_item(&row.stack.template_id).await
5819    }
5820
5821    /// Open picker: apply selected grant item onto inventory / worn gear.
5822    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
5823        let Some(row) = self.state.inventory_selected_row() else {
5824            anyhow::bail!("inventory empty");
5825        };
5826        if row.from != flatland_protocol::InventoryLocation::Root {
5827            anyhow::bail!("select a grant item on your person");
5828        }
5829        if !GameState::stack_is_item_grant(&row.stack) {
5830            anyhow::bail!("selected item does not grant onto gear");
5831        }
5832        let Some(grant_instance_id) = row.stack.item_instance_id else {
5833            anyhow::bail!("grant has no instance id");
5834        };
5835        let effect_id = GameState::grant_effect_id(&row.stack)
5836            .unwrap_or("?")
5837            .to_string();
5838        let mode = GameState::grant_mode(&row.stack).to_string();
5839        let options = self.state.grant_target_options(&row.stack);
5840        if options.is_empty() {
5841            anyhow::bail!("no valid gear to apply {effect_id} to");
5842        }
5843        let grant_label = row
5844            .stack
5845            .display_name
5846            .clone()
5847            .unwrap_or_else(|| row.stack.template_id.clone());
5848        self.state.show_grant_picker = true;
5849        self.state.grant_picker_index = 0;
5850        self.state.grant_picker = Some(GrantTargetPicker {
5851            grant_instance_id,
5852            grant_label,
5853            effect_id,
5854            mode,
5855            options,
5856            filter: String::new(),
5857            filter_focused: false,
5858        });
5859        Ok(())
5860    }
5861
5862    pub fn close_grant_picker(&mut self) {
5863        self.state.show_grant_picker = false;
5864        self.state.grant_picker = None;
5865        self.state.grant_picker_index = 0;
5866    }
5867
5868    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
5869        let Some(picker) = self.state.grant_picker.clone() else {
5870            self.close_grant_picker();
5871            return Ok(());
5872        };
5873        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
5874            self.close_grant_picker();
5875            return Ok(());
5876        };
5877        self.close_grant_picker();
5878        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
5879            .await?;
5880        self.state.push_log(format!(
5881            "Applying {} onto {}…",
5882            picker.effect_id, opt.label
5883        ));
5884        Ok(())
5885    }
5886
5887    /// `m`: always open the "move to…" picker for the selected item, even for
5888    /// weapons/wearables that Enter would otherwise equip/wear directly.
5889    /// Placed chests open the pick-up destination picker instead.
5890    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
5891        let Some(row) = self.state.inventory_selected_row() else {
5892            anyhow::bail!("inventory empty");
5893        };
5894        if row.is_equip_shell {
5895            anyhow::bail!("this is a worn bag — press Enter to unequip it");
5896        }
5897        if row.is_chest_shell {
5898            return self.open_chest_pickup_picker();
5899        }
5900        let Some(instance_id) = row.stack.item_instance_id else {
5901            anyhow::bail!("item has no instance id");
5902        };
5903        let mut options = self.state.move_destinations_for(
5904            &row.from,
5905            row.from_parent_instance_id,
5906            row.stack.item_instance_id,
5907            &row.stack.template_id,
5908        );
5909        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5910        let category = self.state.inventory_item_category(&row.stack.template_id);
5911        if on_person && category == Some("consumable") {
5912            if GameState::stack_is_item_grant(&row.stack) {
5913                options.insert(
5914                    0,
5915                    MoveOption {
5916                        label: "Apply onto gear…".into(),
5917                        kind: MoveOptionKind::GrantApply,
5918                    },
5919                );
5920            } else {
5921                options.insert(
5922                    0,
5923                    MoveOption {
5924                        label: "Use (eat / drink)".into(),
5925                        kind: MoveOptionKind::Use,
5926                    },
5927                );
5928            }
5929        }
5930        let item_label = row
5931            .stack
5932            .display_name
5933            .clone()
5934            .unwrap_or_else(|| row.stack.template_id.clone());
5935        // Default to 1 so withdrawing from storage is partial unless the player
5936        // presses `a` for max fit (full stack when the destination allows it).
5937        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
5938        self.state.move_picker = Some(MovePicker {
5939            item_instance_id: instance_id,
5940            from: row.from,
5941            item_label,
5942            template_id: row.stack.template_id.clone(),
5943            stack_quantity: row.stack.quantity,
5944            quantity: initial_qty.max(1),
5945            options,
5946            filter: String::new(),
5947            filter_focused: false,
5948        });
5949        self.state.move_picker_index = 0;
5950        self.state.show_move_picker = true;
5951        self.state.show_destroy_picker = false;
5952        self.state.destroy_confirm_pending = false;
5953        self.state.destroy_picker = None;
5954        self.state.clamp_move_picker_quantity();
5955        Ok(())
5956    }
5957
5958    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
5959    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
5960        let Some(row) = self.state.inventory_selected_row() else {
5961            anyhow::bail!("inventory empty");
5962        };
5963        if !row.is_chest_shell {
5964            anyhow::bail!("not a placed chest");
5965        }
5966        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
5967            anyhow::bail!("not a placed chest");
5968        };
5969        let Some(instance_id) = row.stack.item_instance_id else {
5970            anyhow::bail!("chest has no instance id");
5971        };
5972        let chest = self
5973            .state
5974            .placed_containers
5975            .iter()
5976            .find(|c| c.id == *container_id)
5977            .cloned()
5978            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
5979        let (px, py) = self.state.player_position();
5980        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
5981            anyhow::bail!("too far from {}", chest.display_name);
5982        }
5983        if chest.locked && !chest.accessible {
5984            anyhow::bail!(
5985                "need the matching key for {} before picking it up",
5986                chest.display_name
5987            );
5988        }
5989        let options = self.state.chest_pickup_destinations(container_id);
5990        let item_label = row
5991            .stack
5992            .display_name
5993            .clone()
5994            .unwrap_or_else(|| row.stack.template_id.clone());
5995        self.state.move_picker = Some(MovePicker {
5996            item_instance_id: instance_id,
5997            from: row.from.clone(),
5998            item_label,
5999            template_id: row.stack.template_id.clone(),
6000            stack_quantity: 1,
6001            quantity: 1,
6002            options,
6003            filter: String::new(),
6004            filter_focused: false,
6005        });
6006        self.state.move_picker_index = 0;
6007        self.state.show_move_picker = true;
6008        self.state.show_destroy_picker = false;
6009        self.state.destroy_confirm_pending = false;
6010        self.state.destroy_picker = None;
6011        Ok(())
6012    }
6013
6014    pub fn close_move_picker(&mut self) {
6015        self.state.show_move_picker = false;
6016        self.state.move_picker = None;
6017    }
6018
6019    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
6020        self.state.move_picker_adjust_quantity(delta);
6021    }
6022
6023    pub fn move_picker_set_quantity_max(&mut self) {
6024        self.state.move_picker_set_quantity_max();
6025    }
6026
6027    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
6028        self.state.destroy_picker_adjust_quantity(delta);
6029    }
6030
6031    pub fn destroy_picker_set_quantity_max(&mut self) {
6032        self.state.destroy_picker_set_quantity_max();
6033    }
6034
6035    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
6036        let Some(picker) = self.state.move_picker.clone() else {
6037            self.close_move_picker();
6038            return Ok(());
6039        };
6040        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
6041            self.close_move_picker();
6042            return Ok(());
6043        };
6044        match option.kind {
6045            MoveOptionKind::Cancel => {
6046                self.close_move_picker();
6047            }
6048            MoveOptionKind::Use => {
6049                self.close_move_picker();
6050                self.use_item(&picker.template_id).await?;
6051            }
6052            MoveOptionKind::GrantApply => {
6053                self.close_move_picker();
6054                self.open_grant_target_picker()?;
6055            }
6056            MoveOptionKind::Drop => {
6057                self.close_move_picker();
6058                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
6059                    if self.state.key_drop_blocked(&stack) {
6060                        anyhow::bail!("cannot drop the key while its chest is locked");
6061                    }
6062                }
6063                self.drop_item(picker.item_instance_id, picker.from).await?;
6064                self.state
6065                    .push_log(format!("Dropped {}", picker.item_label));
6066            }
6067            MoveOptionKind::PickupPlaced {
6068                container_id,
6069                nest_location,
6070                nest_parent_instance_id,
6071            } => {
6072                self.close_move_picker();
6073                self.pickup_container(container_id.clone()).await?;
6074                let nest_into_bag = nest_parent_instance_id.is_some()
6075                    || !matches!(
6076                        nest_location,
6077                        flatland_protocol::InventoryLocation::Root
6078                    );
6079                if nest_into_bag {
6080                    self.move_item(
6081                        picker.item_instance_id,
6082                        flatland_protocol::InventoryLocation::Root,
6083                        nest_location,
6084                        nest_parent_instance_id,
6085                        None,
6086                    )
6087                    .await?;
6088                    self.state
6089                        .push_log(format!("Picked up {} into bag", picker.item_label));
6090                } else {
6091                    self.state
6092                        .push_log(format!("Picked up {}", picker.item_label));
6093                }
6094            }
6095            MoveOptionKind::Move {
6096                location,
6097                parent_instance_id,
6098            } => {
6099                self.close_move_picker();
6100                let qty = if picker.quantity >= picker.stack_quantity {
6101                    None
6102                } else {
6103                    Some(picker.quantity)
6104                };
6105                self.move_item(
6106                    picker.item_instance_id,
6107                    picker.from,
6108                    location,
6109                    parent_instance_id,
6110                    qty,
6111                )
6112                .await?;
6113                let moved = qty.unwrap_or(picker.stack_quantity);
6114                if moved >= picker.stack_quantity {
6115                    self.state.push_log(format!("Moved {}", picker.item_label));
6116                } else {
6117                    self.state.push_log(format!(
6118                        "Moved {} ×{} of {}",
6119                        picker.item_label, moved, picker.stack_quantity
6120                    ));
6121                }
6122            }
6123        }
6124        Ok(())
6125    }
6126
6127    /// `d`: drop the selected item on the ground immediately (no picker).
6128    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
6129        let Some(row) = self.state.inventory_selected_row() else {
6130            anyhow::bail!("inventory empty");
6131        };
6132        if row.is_equip_shell {
6133            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
6134        }
6135        if row.is_chest_shell {
6136            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
6137        }
6138        let Some(inst) = row.stack.item_instance_id else {
6139            anyhow::bail!("item has no instance id");
6140        };
6141        if self.state.key_drop_blocked(&row.stack) {
6142            anyhow::bail!("cannot drop the key while its chest is locked");
6143        }
6144        let label = row
6145            .stack
6146            .display_name
6147            .clone()
6148            .unwrap_or_else(|| row.stack.template_id.clone());
6149        self.drop_item(inst, row.from).await?;
6150        self.state.push_log(format!("Dropped {label}"));
6151        Ok(())
6152    }
6153
6154    pub async fn drop_item(
6155        &mut self,
6156        item_instance_id: uuid::Uuid,
6157        from: flatland_protocol::InventoryLocation,
6158    ) -> anyhow::Result<()> {
6159        self.seq += 1;
6160        self.session
6161            .submit_intent(Intent::DropItem {
6162                entity_id: self.state.entity_id,
6163                item_instance_id,
6164                from,
6165                seq: self.seq,
6166            })
6167            .await?;
6168        self.state.intents_sent += 1;
6169        Ok(())
6170    }
6171
6172    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
6173    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
6174        let Some(row) = self.state.inventory_selected_row() else {
6175            anyhow::bail!("inventory empty");
6176        };
6177        if row.is_equip_shell {
6178            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
6179        }
6180        if row.is_chest_shell {
6181            anyhow::bail!("can't destroy a placed chest from the inventory list");
6182        }
6183        let Some(instance_id) = row.stack.item_instance_id else {
6184            anyhow::bail!("item has no instance id");
6185        };
6186        if self.state.key_drop_blocked(&row.stack) {
6187            anyhow::bail!("cannot destroy the key while its chest is locked");
6188        }
6189        let item_label = row
6190            .stack
6191            .display_name
6192            .clone()
6193            .unwrap_or_else(|| row.stack.template_id.clone());
6194        self.state.destroy_picker = Some(DestroyPicker {
6195            item_instance_id: instance_id,
6196            from: row.from,
6197            item_label,
6198            stack_quantity: row.stack.quantity,
6199            quantity: row.stack.quantity,
6200        });
6201        self.state.destroy_confirm_pending = false;
6202        self.state.show_destroy_picker = true;
6203        self.state.show_move_picker = false;
6204        self.state.move_picker = None;
6205        Ok(())
6206    }
6207
6208    pub fn close_destroy_picker(&mut self) {
6209        self.state.show_destroy_picker = false;
6210        self.state.destroy_confirm_pending = false;
6211        self.state.destroy_picker = None;
6212    }
6213
6214    pub fn cancel_destroy_confirm(&mut self) {
6215        self.state.destroy_confirm_pending = false;
6216    }
6217
6218    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
6219        if self.state.destroy_picker.is_none() {
6220            self.close_destroy_picker();
6221            return Ok(());
6222        }
6223        self.state.destroy_confirm_pending = true;
6224        Ok(())
6225    }
6226
6227    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
6228        let Some(picker) = self.state.destroy_picker.clone() else {
6229            self.close_destroy_picker();
6230            return Ok(());
6231        };
6232        let qty = if picker.quantity >= picker.stack_quantity {
6233            None
6234        } else {
6235            Some(picker.quantity)
6236        };
6237        self.destroy_item(picker.item_instance_id, picker.from, qty)
6238            .await?;
6239        let destroyed = qty.unwrap_or(picker.stack_quantity);
6240        if destroyed >= picker.stack_quantity {
6241            self.state
6242                .push_log(format!("Destroyed {}", picker.item_label));
6243        } else {
6244            self.state.push_log(format!(
6245                "Destroyed {} ×{} of {}",
6246                picker.item_label, destroyed, picker.stack_quantity
6247            ));
6248        }
6249        self.close_destroy_picker();
6250        Ok(())
6251    }
6252
6253    pub async fn destroy_item(
6254        &mut self,
6255        item_instance_id: uuid::Uuid,
6256        from: flatland_protocol::InventoryLocation,
6257        quantity: Option<u32>,
6258    ) -> anyhow::Result<()> {
6259        self.seq += 1;
6260        self.session
6261            .submit_intent(Intent::DestroyItem {
6262                entity_id: self.state.entity_id,
6263                item_instance_id,
6264                from,
6265                quantity,
6266                seq: self.seq,
6267            })
6268            .await?;
6269        self.state.intents_sent += 1;
6270        Ok(())
6271    }
6272
6273    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
6274    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
6275        if let Some(row) = self.state.inventory_selected_row() {
6276            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
6277                return self.toggle_placed_chest_lock(container_id).await;
6278            }
6279        }
6280        self.toggle_nearby_chest_lock().await
6281    }
6282
6283    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
6284        let chest = self
6285            .state
6286            .placed_containers
6287            .iter()
6288            .find(|c| c.id == container_id)
6289            .cloned()
6290            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
6291        let (px, py) = self.state.player_position();
6292        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
6293            anyhow::bail!("too far from {}", chest.display_name);
6294        }
6295        if !chest.accessible && chest.locked {
6296            anyhow::bail!(
6297                "need the matching key for {} (each crafted chest has its own key)",
6298                chest.display_name
6299            );
6300        }
6301        let lock = !chest.locked;
6302        self.set_container_locked(
6303            flatland_protocol::InventoryLocation::Placed {
6304                container_id: chest.id.clone(),
6305            },
6306            lock,
6307        )
6308        .await?;
6309        self.state.push_log(if lock {
6310            format!("Locked {}", chest.display_name)
6311        } else {
6312            format!("Unlocked {}", chest.display_name)
6313        });
6314        Ok(())
6315    }
6316
6317    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
6318    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
6319        let chest = self
6320            .state
6321            .nearest_placed_container(CONTAINER_RANGE_M)
6322            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
6323        self.toggle_placed_chest_lock(&chest.id).await
6324    }
6325
6326    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
6327        self.equip_mainhand(None).await
6328    }
6329
6330    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
6331        if !self.state.is_alive() {
6332            anyhow::bail!("you are dead");
6333        }
6334        self.seq += 1;
6335        self.session
6336            .submit_intent(Intent::EquipOffhand {
6337                entity_id: self.state.entity_id,
6338                template_id,
6339                instance_id: None,
6340                seq: self.seq,
6341            })
6342            .await?;
6343        self.state.intents_sent += 1;
6344        Ok(())
6345    }
6346
6347    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
6348        self.equip_offhand(None).await
6349    }
6350
6351    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
6352        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
6353        for slot in slots {
6354            self.equip_worn(slot, None).await?;
6355        }
6356        Ok(())
6357    }
6358
6359    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
6360        let (px, py) = self.state.player_position();
6361        let nearest = self
6362            .state
6363            .placed_containers
6364            .iter()
6365            .min_by(|a, b| {
6366                let da = (a.x - px).hypot(a.y - py);
6367                let db = (b.x - px).hypot(b.y - py);
6368                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6369            })
6370            .cloned();
6371        let Some(chest) = nearest else {
6372            anyhow::bail!("no chest nearby");
6373        };
6374        if (chest.x - px).hypot(chest.y - py) > 2.0 {
6375            anyhow::bail!("too far from chest");
6376        }
6377        self.pickup_container(chest.id).await
6378    }
6379
6380    pub async fn equip_worn(
6381        &mut self,
6382        slot: BodySlot,
6383        instance_id: Option<uuid::Uuid>,
6384    ) -> anyhow::Result<()> {
6385        self.seq += 1;
6386        self.session
6387            .submit_intent(Intent::EquipWorn {
6388                entity_id: self.state.entity_id,
6389                slot,
6390                instance_id,
6391                seq: self.seq,
6392            })
6393            .await?;
6394        self.state.intents_sent += 1;
6395        Ok(())
6396    }
6397
6398    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
6399        self.seq += 1;
6400        self.session
6401            .submit_intent(Intent::PlaceContainer {
6402                entity_id: self.state.entity_id,
6403                item_instance_id,
6404                seq: self.seq,
6405            })
6406            .await?;
6407        self.state.intents_sent += 1;
6408        Ok(())
6409    }
6410
6411    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
6412        self.seq += 1;
6413        self.session
6414            .submit_intent(Intent::PickupContainer {
6415                entity_id: self.state.entity_id,
6416                container_id,
6417                seq: self.seq,
6418            })
6419            .await?;
6420        self.state.intents_sent += 1;
6421        Ok(())
6422    }
6423
6424    pub async fn move_item(
6425        &mut self,
6426        item_instance_id: uuid::Uuid,
6427        from: flatland_protocol::InventoryLocation,
6428        to: flatland_protocol::InventoryLocation,
6429        to_parent_instance_id: Option<uuid::Uuid>,
6430        quantity: Option<u32>,
6431    ) -> anyhow::Result<()> {
6432        self.seq += 1;
6433        self.session
6434            .submit_intent(Intent::MoveItem {
6435                entity_id: self.state.entity_id,
6436                item_instance_id,
6437                from,
6438                to,
6439                to_parent_instance_id,
6440                quantity,
6441                seq: self.seq,
6442            })
6443            .await?;
6444        self.state.intents_sent += 1;
6445        Ok(())
6446    }
6447
6448    pub async fn set_container_locked(
6449        &mut self,
6450        location: flatland_protocol::InventoryLocation,
6451        locked: bool,
6452    ) -> anyhow::Result<()> {
6453        self.seq += 1;
6454        self.session
6455            .submit_intent(Intent::SetContainerLocked {
6456                entity_id: self.state.entity_id,
6457                location,
6458                locked,
6459                seq: self.seq,
6460            })
6461            .await?;
6462        self.state.intents_sent += 1;
6463        Ok(())
6464    }
6465
6466    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
6467        if !self.state.is_alive() {
6468            anyhow::bail!("you are dead");
6469        }
6470        self.seq += 1;
6471        self.session
6472            .submit_intent(Intent::Use {
6473                entity_id: self.state.entity_id,
6474                template_id: template_id.to_string(),
6475                seq: self.seq,
6476            })
6477            .await?;
6478        self.state.intents_sent += 1;
6479        Ok(())
6480    }
6481
6482    /// Bind a grant consumable onto a specific item instance (unique gear status).
6483    pub async fn use_grant(
6484        &mut self,
6485        grant_instance_id: uuid::Uuid,
6486        target_instance_id: uuid::Uuid,
6487    ) -> anyhow::Result<()> {
6488        if !self.state.is_alive() {
6489            anyhow::bail!("you are dead");
6490        }
6491        self.seq += 1;
6492        self.session
6493            .submit_intent(Intent::UseGrant {
6494                entity_id: self.state.entity_id,
6495                grant_instance_id,
6496                target_instance_id,
6497                seq: self.seq,
6498            })
6499            .await?;
6500        self.state.intents_sent += 1;
6501        Ok(())
6502    }
6503
6504    pub fn open_craft_menu(&mut self) {
6505        self.state.show_craft_menu = true;
6506        self.state.show_shop_menu = false;
6507        self.state.shop_catalog = None;
6508        self.state.show_stats = false;
6509        self.state.show_inventory_menu = false;
6510        if self.state.blueprints.is_empty() {
6511            self.state.craft_menu_index = 0;
6512            self.state.craft_batch_quantity = 1;
6513            return;
6514        }
6515        self.state.craft_menu_index = self
6516            .state
6517            .craft_menu_index
6518            .min(self.state.blueprints.len() - 1);
6519        if let Some(idx) = self
6520            .state
6521            .blueprints
6522            .iter()
6523            .position(|bp| self.state.can_craft_blueprint(bp))
6524        {
6525            self.state.craft_menu_index = idx;
6526        }
6527        self.state.clamp_craft_batch_quantity();
6528    }
6529
6530    pub fn close_craft_menu(&mut self) {
6531        self.state.show_craft_menu = false;
6532    }
6533
6534    pub fn toggle_keychain_menu(&mut self) {
6535        if self.state.show_keychain_menu {
6536            self.close_keychain_menu();
6537        } else {
6538            self.state.show_keychain_menu = true;
6539            self.state.show_craft_menu = false;
6540            self.state.show_shop_menu = false;
6541            self.state.show_inventory_menu = false;
6542            let n = self.state.keychain_entries().len();
6543            if n == 0 {
6544                self.state.keychain_menu_index = 0;
6545            } else {
6546                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
6547            }
6548        }
6549    }
6550
6551    pub fn close_keychain_menu(&mut self) {
6552        self.state.show_keychain_menu = false;
6553    }
6554
6555    pub fn keychain_menu_move(&mut self, delta: i32) {
6556        let n = self.state.keychain_entries().len();
6557        if n == 0 {
6558            self.state.keychain_menu_index = 0;
6559            return;
6560        }
6561        let idx = self.state.keychain_menu_index as i32 + delta;
6562        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
6563    }
6564
6565    pub fn keychain_menu_page(&mut self, pages: i32) {
6566        let n = self.state.keychain_entries().len();
6567        self.state.keychain_menu_index =
6568            page_list_index(self.state.keychain_menu_index, pages, n);
6569    }
6570
6571    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
6572        if !self.state.is_alive() {
6573            anyhow::bail!("you are dead");
6574        }
6575        let entries = self.state.keychain_entries();
6576        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
6577            anyhow::bail!("nothing selected");
6578        };
6579        let Some(instance_id) = entry.stack.item_instance_id else {
6580            anyhow::bail!("key has no instance id");
6581        };
6582        if entry.stowed {
6583            self.move_item(
6584                instance_id,
6585                flatland_protocol::InventoryLocation::Keychain,
6586                flatland_protocol::InventoryLocation::Root,
6587                None,
6588                Some(1),
6589            )
6590            .await
6591        } else {
6592            self.move_item(
6593                instance_id,
6594                flatland_protocol::InventoryLocation::Root,
6595                flatland_protocol::InventoryLocation::Keychain,
6596                None,
6597                Some(1),
6598            )
6599            .await
6600        }
6601    }
6602
6603    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
6604        let npc_id = self
6605            .state
6606            .shop_catalog
6607            .as_ref()
6608            .map(|c| c.npc_id.clone());
6609        self.state.show_shop_menu = false;
6610        self.state.shop_catalog = None;
6611        self.state.clear_shop_trade_log();
6612        if let Some(npc_id) = npc_id {
6613            self.seq += 1;
6614            self.session
6615                .submit_intent(Intent::ShopClose {
6616                    entity_id: self.state.entity_id,
6617                    npc_id,
6618                    seq: self.seq,
6619                })
6620                .await?;
6621            self.state.intents_sent += 1;
6622        }
6623        Ok(())
6624    }
6625
6626    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6627        let Some(panel) = self.state.bank_panel.clone() else {
6628            return Ok(());
6629        };
6630        self.seq += 1;
6631        self.session
6632            .submit_intent(Intent::BankDeposit {
6633                entity_id: self.state.entity_id,
6634                npc_id: panel.npc_id,
6635                amount_copper,
6636                seq: self.seq,
6637            })
6638            .await?;
6639        self.state.intents_sent += 1;
6640        Ok(())
6641    }
6642
6643    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6644        let Some(panel) = self.state.bank_panel.clone() else {
6645            return Ok(());
6646        };
6647        self.seq += 1;
6648        self.session
6649            .submit_intent(Intent::BankWithdraw {
6650                entity_id: self.state.entity_id,
6651                npc_id: panel.npc_id,
6652                amount_copper,
6653                seq: self.seq,
6654            })
6655            .await?;
6656        self.state.intents_sent += 1;
6657        Ok(())
6658    }
6659
6660    pub async fn bank_transfer(
6661        &mut self,
6662        to_character_id: Option<uuid::Uuid>,
6663        to_name: String,
6664        amount_copper: u64,
6665    ) -> anyhow::Result<()> {
6666        let Some(panel) = self.state.bank_panel.clone() else {
6667            return Ok(());
6668        };
6669        self.seq += 1;
6670        self.session
6671            .submit_intent(Intent::BankTransfer {
6672                entity_id: self.state.entity_id,
6673                npc_id: panel.npc_id,
6674                to_character_id,
6675                to_name,
6676                amount_copper,
6677                seq: self.seq,
6678            })
6679            .await?;
6680        self.state.intents_sent += 1;
6681        Ok(())
6682    }
6683
6684    pub fn bank_menu_move(&mut self, delta: i32) {
6685        let n = self.state.bank_menu_options().len();
6686        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
6687            return;
6688        }
6689        let idx = self.state.bank_menu_index as i32;
6690        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6691    }
6692
6693    pub fn storage_menu_move(&mut self, delta: i32) {
6694        let n = self.state.storage_menu_options().len();
6695        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
6696            return;
6697        }
6698        let idx = self.state.storage_menu_index as i32;
6699        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6700    }
6701
6702    pub fn storage_pick_move(&mut self, delta: i32) {
6703        let n = match &self.state.storage_ui_mode {
6704            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
6705            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
6706                self.state.storage_vault_options().len()
6707            }
6708            StorageUiMode::Menu
6709            | StorageUiMode::StoreAmount { .. }
6710            | StorageUiMode::TakeAmount { .. }
6711            | StorageUiMode::ShipAmount { .. } => 0,
6712        };
6713        if n == 0 {
6714            return;
6715        }
6716        match &mut self.state.storage_ui_mode {
6717            StorageUiMode::StorePick { index }
6718            | StorageUiMode::TakePick { index }
6719            | StorageUiMode::ShipPick { index, .. } => {
6720                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
6721            }
6722            StorageUiMode::Menu
6723            | StorageUiMode::StoreAmount { .. }
6724            | StorageUiMode::TakeAmount { .. }
6725            | StorageUiMode::ShipAmount { .. } => {}
6726        }
6727    }
6728
6729    pub fn storage_ui_back(&mut self) {
6730        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
6731            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
6732                index: *pick_index,
6733            },
6734            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
6735                index: *pick_index,
6736            },
6737            StorageUiMode::ShipAmount {
6738                dest_building_id,
6739                dest_label,
6740                pick_index,
6741                ..
6742            } => StorageUiMode::ShipPick {
6743                dest_building_id: dest_building_id.clone(),
6744                dest_label: dest_label.clone(),
6745                index: *pick_index,
6746            },
6747            StorageUiMode::StorePick { .. }
6748            | StorageUiMode::TakePick { .. }
6749            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
6750            StorageUiMode::Menu => StorageUiMode::Menu,
6751        };
6752    }
6753
6754    pub fn storage_amount_append_char(&mut self, c: char) {
6755        match &mut self.state.storage_ui_mode {
6756            StorageUiMode::StoreAmount { input, .. }
6757            | StorageUiMode::TakeAmount { input, .. }
6758            | StorageUiMode::ShipAmount { input, .. } => {
6759                if c.is_ascii_digit() && input.len() < 8 {
6760                    input.push(c);
6761                }
6762            }
6763            _ => {}
6764        }
6765    }
6766
6767    pub fn storage_amount_backspace(&mut self) {
6768        match &mut self.state.storage_ui_mode {
6769            StorageUiMode::StoreAmount { input, .. }
6770            | StorageUiMode::TakeAmount { input, .. }
6771            | StorageUiMode::ShipAmount { input, .. } => {
6772                input.pop();
6773            }
6774            _ => {}
6775        }
6776    }
6777
6778    pub fn storage_ui_typing(&self) -> bool {
6779        matches!(
6780            self.state.storage_ui_mode,
6781            StorageUiMode::StoreAmount { .. }
6782                | StorageUiMode::TakeAmount { .. }
6783                | StorageUiMode::ShipAmount { .. }
6784        )
6785    }
6786
6787    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
6788        match self.state.storage_ui_mode.clone() {
6789            StorageUiMode::Menu => {
6790                let index = self.state.storage_menu_index;
6791                match index {
6792                    0 => {
6793                        let opts = self.state.storage_store_options();
6794                        if opts.is_empty() {
6795                            self.state.push_log("Nothing loose to store.");
6796                            return Ok(());
6797                        }
6798                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
6799                    }
6800                    1 => {
6801                        let opts = self.state.storage_vault_options();
6802                        if opts.is_empty() {
6803                            self.state.push_log("Vault is empty.");
6804                            return Ok(());
6805                        }
6806                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
6807                    }
6808                    n => {
6809                        let dest = self
6810                            .state
6811                            .storage_panel
6812                            .as_ref()
6813                            .and_then(|p| p.ship_destinations.get(n - 2))
6814                            .cloned();
6815                        let Some(dest) = dest else {
6816                            return Ok(());
6817                        };
6818                        let opts = self.state.storage_vault_options();
6819                        if opts.is_empty() {
6820                            self.state
6821                                .push_log("Vault is empty — nothing to ship.");
6822                            return Ok(());
6823                        }
6824                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
6825                            dest_building_id: dest.building_id,
6826                            dest_label: dest.label,
6827                            index: 0,
6828                        };
6829                    }
6830                }
6831            }
6832            StorageUiMode::StorePick { index } => {
6833                let opts = self.state.storage_store_options();
6834                let Some(opt) = opts.get(index) else {
6835                    self.state.push_log("Nothing loose to store.");
6836                    self.state.storage_ui_mode = StorageUiMode::Menu;
6837                    return Ok(());
6838                };
6839                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
6840                    pick_index: index,
6841                    item_instance_id: opt.item_instance_id,
6842                    label: opt.label.clone(),
6843                    max_qty: opt.quantity.max(1),
6844                    input: String::new(),
6845                };
6846            }
6847            StorageUiMode::TakePick { index } => {
6848                let opts = self.state.storage_vault_options();
6849                let Some(opt) = opts.get(index) else {
6850                    self.state.push_log("Vault is empty.");
6851                    self.state.storage_ui_mode = StorageUiMode::Menu;
6852                    return Ok(());
6853                };
6854                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
6855                    pick_index: index,
6856                    item_instance_id: opt.item_instance_id,
6857                    label: opt.label.clone(),
6858                    max_qty: opt.quantity.max(1),
6859                    input: String::new(),
6860                };
6861            }
6862            StorageUiMode::ShipPick {
6863                dest_building_id,
6864                dest_label,
6865                index,
6866            } => {
6867                let opts = self.state.storage_vault_options();
6868                let Some(opt) = opts.get(index) else {
6869                    self.state
6870                        .push_log("Vault is empty — nothing to ship.");
6871                    self.state.storage_ui_mode = StorageUiMode::Menu;
6872                    return Ok(());
6873                };
6874                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
6875                    dest_building_id,
6876                    dest_label,
6877                    pick_index: index,
6878                    item_instance_id: opt.item_instance_id,
6879                    label: opt.label.clone(),
6880                    max_qty: opt.quantity.max(1),
6881                    input: String::new(),
6882                };
6883            }
6884            StorageUiMode::StoreAmount {
6885                item_instance_id,
6886                max_qty,
6887                input,
6888                ..
6889            } => {
6890                let Some(qty) = parse_storage_quantity(&input) else {
6891                    self.state
6892                        .push_log("Enter a quantity (blank or 0 = all).");
6893                    return Ok(());
6894                };
6895                let qty = qty.map(|n| n.min(max_qty).max(1));
6896                self.storage_store(item_instance_id, qty).await?;
6897                self.state.storage_ui_mode = StorageUiMode::Menu;
6898            }
6899            StorageUiMode::TakeAmount {
6900                item_instance_id,
6901                max_qty,
6902                input,
6903                ..
6904            } => {
6905                let Some(qty) = parse_storage_quantity(&input) else {
6906                    self.state
6907                        .push_log("Enter a quantity (blank or 0 = all).");
6908                    return Ok(());
6909                };
6910                let qty = qty.map(|n| n.min(max_qty).max(1));
6911                self.storage_take(item_instance_id, qty).await?;
6912                self.state.storage_ui_mode = StorageUiMode::Menu;
6913            }
6914            StorageUiMode::ShipAmount {
6915                dest_building_id,
6916                item_instance_id,
6917                max_qty,
6918                input,
6919                ..
6920            } => {
6921                let Some(qty) = parse_storage_quantity(&input) else {
6922                    self.state
6923                        .push_log("Enter a quantity (blank or 0 = all).");
6924                    return Ok(());
6925                };
6926                let qty = qty.map(|n| n.min(max_qty).max(1));
6927                self.storage_ship(dest_building_id, item_instance_id, qty)
6928                    .await?;
6929                self.state.storage_ui_mode = StorageUiMode::Menu;
6930            }
6931        }
6932        Ok(())
6933    }
6934
6935    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
6936        match self.state.bank_ui_mode.clone() {
6937            BankUiMode::Menu => {
6938                let choice = self
6939                    .state
6940                    .bank_menu_options()
6941                    .get(self.state.bank_menu_index)
6942                    .copied()
6943                    .unwrap_or("Deposit…");
6944                match choice {
6945                    "Withdraw…" => {
6946                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
6947                            input: String::new(),
6948                        };
6949                    }
6950                    "Deposit all" => self.bank_deposit(0).await?,
6951                    "Withdraw all" => self.bank_withdraw(0).await?,
6952                    "Transfer…" => {
6953                        self.state.bank_ui_mode = BankUiMode::TransferName {
6954                            input: String::new(),
6955                        };
6956                    }
6957                    _ => {
6958                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
6959                            input: String::new(),
6960                        };
6961                    }
6962                }
6963            }
6964            BankUiMode::DepositAmount { input } => {
6965                let Some(amount) = parse_bank_copper_amount(&input) else {
6966                    self.state
6967                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
6968                    return Ok(());
6969                };
6970                self.bank_deposit(amount).await?;
6971                self.state.bank_ui_mode = BankUiMode::Menu;
6972            }
6973            BankUiMode::WithdrawAmount { input } => {
6974                let Some(amount) = parse_bank_copper_amount(&input) else {
6975                    self.state
6976                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
6977                    return Ok(());
6978                };
6979                self.bank_withdraw(amount).await?;
6980                self.state.bank_ui_mode = BankUiMode::Menu;
6981            }
6982            BankUiMode::TransferName { input } => {
6983                let name = input.trim().to_string();
6984                if name.is_empty() {
6985                    self.state.push_log("Enter the recipient character name.");
6986                    return Ok(());
6987                }
6988                self.state.bank_ui_mode = BankUiMode::TransferAmount {
6989                    to_name: name,
6990                    input: String::new(),
6991                };
6992            }
6993            BankUiMode::TransferAmount { to_name, input } => {
6994                let amount: u64 = match input.trim().parse() {
6995                    Ok(v) if v > 0 => v,
6996                    _ => {
6997                        self.state
6998                            .push_log("Enter a positive copper amount to transfer.");
6999                        return Ok(());
7000                    }
7001                };
7002                self.bank_transfer(None, to_name, amount).await?;
7003                self.state.bank_ui_mode = BankUiMode::Menu;
7004            }
7005        }
7006        Ok(())
7007    }
7008
7009    pub fn bank_transfer_back(&mut self) {
7010        match &self.state.bank_ui_mode {
7011            BankUiMode::TransferAmount { to_name, .. } => {
7012                self.state.bank_ui_mode = BankUiMode::TransferName {
7013                    input: to_name.clone(),
7014                };
7015            }
7016            BankUiMode::TransferName { .. }
7017            | BankUiMode::DepositAmount { .. }
7018            | BankUiMode::WithdrawAmount { .. } => {
7019                self.state.bank_ui_mode = BankUiMode::Menu;
7020            }
7021            BankUiMode::Menu => {}
7022        }
7023    }
7024
7025    pub fn bank_transfer_append_char(&mut self, c: char) {
7026        match &mut self.state.bank_ui_mode {
7027            BankUiMode::TransferName { input } => {
7028                if input.len() < 32 && !c.is_control() {
7029                    input.push(c);
7030                }
7031            }
7032            BankUiMode::DepositAmount { input }
7033            | BankUiMode::WithdrawAmount { input }
7034            | BankUiMode::TransferAmount { input, .. } => {
7035                if c.is_ascii_digit() && input.len() < 12 {
7036                    input.push(c);
7037                }
7038            }
7039            BankUiMode::Menu => {}
7040        }
7041    }
7042
7043    pub fn bank_transfer_backspace(&mut self) {
7044        match &mut self.state.bank_ui_mode {
7045            BankUiMode::TransferName { input }
7046            | BankUiMode::DepositAmount { input }
7047            | BankUiMode::WithdrawAmount { input }
7048            | BankUiMode::TransferAmount { input, .. } => {
7049                input.pop();
7050            }
7051            BankUiMode::Menu => {}
7052        }
7053    }
7054
7055    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
7056        let npc_id = self
7057            .state
7058            .bank_panel
7059            .as_ref()
7060            .map(|p| p.npc_id.clone());
7061        self.state.clear_bank_panel();
7062        if let Some(npc_id) = npc_id {
7063            self.seq += 1;
7064            self.session
7065                .submit_intent(Intent::BankClose {
7066                    entity_id: self.state.entity_id,
7067                    npc_id,
7068                    seq: self.seq,
7069                })
7070                .await?;
7071            self.state.intents_sent += 1;
7072        }
7073        Ok(())
7074    }
7075
7076    pub async fn storage_store(
7077        &mut self,
7078        item_instance_id: uuid::Uuid,
7079        quantity: Option<u32>,
7080    ) -> anyhow::Result<()> {
7081        let Some(panel) = self.state.storage_panel.clone() else {
7082            return Ok(());
7083        };
7084        self.seq += 1;
7085        self.session
7086            .submit_intent(Intent::StorageStore {
7087                entity_id: self.state.entity_id,
7088                npc_id: panel.npc_id,
7089                item_instance_id,
7090                quantity,
7091                seq: self.seq,
7092            })
7093            .await?;
7094        self.state.intents_sent += 1;
7095        Ok(())
7096    }
7097
7098    pub async fn storage_take(
7099        &mut self,
7100        item_instance_id: uuid::Uuid,
7101        quantity: Option<u32>,
7102    ) -> anyhow::Result<()> {
7103        let Some(panel) = self.state.storage_panel.clone() else {
7104            return Ok(());
7105        };
7106        self.seq += 1;
7107        self.session
7108            .submit_intent(Intent::StorageTake {
7109                entity_id: self.state.entity_id,
7110                npc_id: panel.npc_id,
7111                item_instance_id,
7112                quantity,
7113                seq: self.seq,
7114            })
7115            .await?;
7116        self.state.intents_sent += 1;
7117        Ok(())
7118    }
7119
7120    pub async fn storage_ship(
7121        &mut self,
7122        dest_building_id: String,
7123        item_instance_id: uuid::Uuid,
7124        quantity: Option<u32>,
7125    ) -> anyhow::Result<()> {
7126        let Some(panel) = self.state.storage_panel.clone() else {
7127            return Ok(());
7128        };
7129        self.seq += 1;
7130        self.session
7131            .submit_intent(Intent::StorageShip {
7132                entity_id: self.state.entity_id,
7133                npc_id: panel.npc_id,
7134                dest_building_id,
7135                item_instance_id,
7136                quantity,
7137                seq: self.seq,
7138            })
7139            .await?;
7140        self.state.intents_sent += 1;
7141        Ok(())
7142    }
7143
7144    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
7145        let npc_id = self
7146            .state
7147            .storage_panel
7148            .as_ref()
7149            .map(|p| p.npc_id.clone());
7150        self.state.clear_storage_panel();
7151        if let Some(npc_id) = npc_id {
7152            self.seq += 1;
7153            self.session
7154                .submit_intent(Intent::StorageClose {
7155                    entity_id: self.state.entity_id,
7156                    npc_id,
7157                    seq: self.seq,
7158                })
7159                .await?;
7160            self.state.intents_sent += 1;
7161        }
7162        Ok(())
7163    }
7164
7165    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
7166    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
7167        let return_to_verbs = self.state.npc_verb_target.is_some();
7168        self.close_shop_menu().await?;
7169        if return_to_verbs {
7170            self.state.show_npc_verb_menu = true;
7171        }
7172        Ok(())
7173    }
7174
7175    pub fn shop_tab_toggle(&mut self) {
7176        self.state.shop_tab = match self.state.shop_tab {
7177            ShopTab::Buy => ShopTab::Sell,
7178            ShopTab::Sell => ShopTab::Buy,
7179        };
7180        self.state.shop_menu_index = 0;
7181        if self.state.shop_tab == ShopTab::Sell {
7182            self.state.shop_quantity_set_max();
7183        }
7184        self.state.clamp_shop_selection();
7185    }
7186
7187    pub fn shop_menu_move(&mut self, delta: i32) {
7188        self.state.shop_menu_move(delta);
7189    }
7190
7191    pub fn shop_quantity_adjust(&mut self, delta: i32) {
7192        self.state.shop_quantity_adjust(delta);
7193    }
7194
7195    pub fn shop_quantity_set_max(&mut self) {
7196        self.state.shop_quantity_set_max();
7197    }
7198
7199    pub fn toggle_quest_menu(&mut self) {
7200        self.state.show_quest_menu = !self.state.show_quest_menu;
7201        if self.state.show_quest_menu {
7202            self.state.quest_menu_index = 0;
7203            self.state.quest_withdraw_confirm = false;
7204            self.state.show_workers_menu = false;
7205        }
7206    }
7207
7208    pub fn toggle_workers_menu(&mut self) {
7209        self.state.show_workers_menu = !self.state.show_workers_menu;
7210        if self.state.show_workers_menu {
7211            self.state.workers_menu_index = 0;
7212            self.state.show_quest_menu = false;
7213            self.close_worker_give_picker();
7214            self.close_worker_give_target_picker();
7215            self.close_worker_take_picker();
7216            self.close_worker_teach_picker();
7217            self.cancel_worker_rename();
7218        } else {
7219            self.close_worker_give_picker();
7220            self.close_worker_give_target_picker();
7221            self.close_worker_take_picker();
7222            self.close_worker_teach_picker();
7223            self.cancel_worker_rename();
7224        }
7225    }
7226
7227    pub fn workers_menu_move(&mut self, delta: i32) {
7228        let n = self.state.hired_workers.len();
7229        if n == 0 {
7230            return;
7231        }
7232        let idx = self.state.workers_menu_index as i32;
7233        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7234    }
7235
7236    pub fn toggle_workers_menu_compact(&mut self) {
7237        self.state.workers_menu_compact = !self.state.workers_menu_compact;
7238    }
7239
7240    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
7241        let Some(worker) = self
7242            .state
7243            .hired_workers
7244            .get(self.state.workers_menu_index)
7245            .cloned()
7246        else {
7247            anyhow::bail!("no worker selected");
7248        };
7249        self.seq += 1;
7250        self.session
7251            .submit_intent(Intent::DismissWorker {
7252                entity_id: self.state.entity_id,
7253                worker_instance_id: worker.instance_id.clone(),
7254                seq: self.seq,
7255            })
7256            .await?;
7257        self.state.intents_sent += 1;
7258        self.state
7259            .hired_workers
7260            .retain(|w| w.instance_id != worker.instance_id);
7261        if self.state.workers_menu_index >= self.state.hired_workers.len() {
7262            self.state.workers_menu_index = self
7263                .state
7264                .hired_workers
7265                .len()
7266                .saturating_sub(1);
7267        }
7268        self.state.push_log(format!("Dismissed {}", worker.label));
7269        Ok(())
7270    }
7271
7272    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
7273        let Some(worker) = self
7274            .state
7275            .hired_workers
7276            .get(self.state.workers_menu_index)
7277            .cloned()
7278        else {
7279            anyhow::bail!("no worker selected");
7280        };
7281        let mode = match worker.mode {
7282            flatland_protocol::WorkerModeView::Companion => "job_loop",
7283            flatland_protocol::WorkerModeView::JobLoop => "idle",
7284            flatland_protocol::WorkerModeView::Idle => "companion",
7285        };
7286        self.seq += 1;
7287        self.session
7288            .submit_intent(Intent::SetWorkerMode {
7289                entity_id: self.state.entity_id,
7290                worker_instance_id: worker.instance_id,
7291                mode: mode.into(),
7292                seq: self.seq,
7293            })
7294            .await?;
7295        self.state.intents_sent += 1;
7296        Ok(())
7297    }
7298
7299    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
7300        if self.state.hired_workers.is_empty() {
7301            return self.hire_worker_laborer().await;
7302        }
7303        self.workers_toggle_mode_selected().await
7304    }
7305
7306    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
7307    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
7308    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7309        let row = self
7310            .state
7311            .inventory_selected_row()
7312            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
7313            .clone();
7314        if row.from != flatland_protocol::InventoryLocation::Root {
7315            anyhow::bail!("select a carried item to give");
7316        }
7317        let Some(instance_id) = row.stack.item_instance_id else {
7318            anyhow::bail!("that stack can't be given");
7319        };
7320        let options = self.nearby_worker_give_targets();
7321        if options.is_empty() {
7322            anyhow::bail!(
7323                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
7324            );
7325        }
7326        let item_label = row
7327            .stack
7328            .display_name
7329            .as_deref()
7330            .unwrap_or(&row.stack.template_id)
7331            .to_string();
7332        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
7333            item_instance_id: instance_id,
7334            item_label,
7335            quantity: None,
7336            options,
7337        });
7338        self.state.worker_give_target_picker_index = 0;
7339        self.state.show_worker_give_target_picker = true;
7340        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
7341        self.state.show_inventory_menu = false;
7342        Ok(())
7343    }
7344
7345    /// Hired workers within give/take range, nearest first.
7346    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
7347        let (px, py, _) = self.state.player_position_with_z();
7348        let mut options: Vec<WorkerGiveTargetOption> = self
7349            .state
7350            .hired_workers
7351            .iter()
7352            .filter_map(|w| {
7353                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
7354                if dist > WORKER_GIVE_RANGE_M {
7355                    return None;
7356                }
7357                Some(WorkerGiveTargetOption {
7358                    instance_id: w.instance_id.clone(),
7359                    label: w.label.clone(),
7360                    distance_m: dist,
7361                })
7362            })
7363            .collect();
7364        options.sort_by(|a, b| {
7365            a.distance_m
7366                .partial_cmp(&b.distance_m)
7367                .unwrap_or(std::cmp::Ordering::Equal)
7368        });
7369        options
7370    }
7371
7372    pub fn close_worker_give_target_picker(&mut self) {
7373        self.state.show_worker_give_target_picker = false;
7374        self.state.worker_give_target_picker = None;
7375        self.state.worker_give_target_picker_index = 0;
7376    }
7377
7378    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
7379        let Some(picker) = &self.state.worker_give_target_picker else {
7380            return;
7381        };
7382        let n = picker.options.len();
7383        if n == 0 {
7384            return;
7385        }
7386        let idx = self.state.worker_give_target_picker_index as i32;
7387        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7388    }
7389
7390    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7391        let Some(picker) = self.state.worker_give_target_picker.clone() else {
7392            anyhow::bail!("give target picker not open");
7393        };
7394        let Some(opt) = picker
7395            .options
7396            .get(self.state.worker_give_target_picker_index)
7397            .cloned()
7398        else {
7399            anyhow::bail!("no worker selected");
7400        };
7401        let Some(worker) = self
7402            .state
7403            .hired_workers
7404            .iter()
7405            .find(|w| w.instance_id == opt.instance_id)
7406            .cloned()
7407        else {
7408            self.close_worker_give_target_picker();
7409            anyhow::bail!("worker no longer hired");
7410        };
7411        self.give_item_to_worker(
7412            &worker.instance_id,
7413            &worker.label,
7414            worker.x,
7415            worker.y,
7416            picker.item_instance_id,
7417            &picker.item_label,
7418            picker.quantity,
7419        )
7420        .await?;
7421        self.close_worker_give_target_picker();
7422        Ok(())
7423    }
7424
7425    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
7426    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
7427        self.open_worker_give_target_picker()
7428    }
7429
7430    /// Open the workers-menu give picker for the selected worker (must be in range).
7431    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
7432        let Some(worker) = self
7433            .state
7434            .hired_workers
7435            .get(self.state.workers_menu_index)
7436            .cloned()
7437        else {
7438            anyhow::bail!("select a hired worker first");
7439        };
7440        let (px, py, _) = self.state.player_position_with_z();
7441        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7442        if dist > WORKER_GIVE_RANGE_M {
7443            anyhow::bail!(
7444                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
7445                worker.label
7446            );
7447        }
7448        let options = self.state.giveable_inventory_options();
7449        if options.is_empty() {
7450            anyhow::bail!("nothing in inventory to give");
7451        }
7452        self.state.worker_give_picker = Some(WorkerGivePicker {
7453            worker_instance_id: worker.instance_id,
7454            worker_label: worker.label,
7455            options,
7456        });
7457        self.state.worker_give_picker_index = 0;
7458        self.state.show_worker_give_picker = true;
7459        Ok(())
7460    }
7461
7462    pub fn close_worker_give_picker(&mut self) {
7463        self.state.show_worker_give_picker = false;
7464        self.state.worker_give_picker = None;
7465        self.state.worker_give_picker_index = 0;
7466    }
7467
7468    pub fn worker_give_picker_move(&mut self, delta: i32) {
7469        let Some(picker) = &self.state.worker_give_picker else {
7470            return;
7471        };
7472        let n = picker.options.len();
7473        if n == 0 {
7474            return;
7475        }
7476        let idx = self.state.worker_give_picker_index as i32;
7477        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7478    }
7479
7480    /// Confirm the selected row in the workers-menu give picker.
7481    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
7482        let Some(picker) = self.state.worker_give_picker.clone() else {
7483            anyhow::bail!("give picker not open");
7484        };
7485        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
7486            anyhow::bail!("no item selected");
7487        };
7488        let Some(worker) = self
7489            .state
7490            .hired_workers
7491            .iter()
7492            .find(|w| w.instance_id == picker.worker_instance_id)
7493            .cloned()
7494        else {
7495            self.close_worker_give_picker();
7496            anyhow::bail!("worker no longer hired");
7497        };
7498        self.give_item_to_worker(
7499            &worker.instance_id,
7500            &worker.label,
7501            worker.x,
7502            worker.y,
7503            opt.item_instance_id,
7504            &opt.label,
7505            None,
7506        )
7507        .await?;
7508        // Refresh options (stack may be gone / reduced) or close when empty.
7509        let options = self.state.giveable_inventory_options();
7510        if options.is_empty() {
7511            self.close_worker_give_picker();
7512        } else {
7513            self.state.worker_give_picker = Some(WorkerGivePicker {
7514                worker_instance_id: picker.worker_instance_id,
7515                worker_label: picker.worker_label,
7516                options,
7517            });
7518            if self.state.worker_give_picker_index
7519                >= self
7520                    .state
7521                    .worker_give_picker
7522                    .as_ref()
7523                    .map(|p| p.options.len())
7524                    .unwrap_or(0)
7525            {
7526                self.state.worker_give_picker_index = self
7527                    .state
7528                    .worker_give_picker
7529                    .as_ref()
7530                    .map(|p| p.options.len().saturating_sub(1))
7531                    .unwrap_or(0);
7532            }
7533        }
7534        Ok(())
7535    }
7536
7537    /// Open the workers-menu teach picker for the selected worker (must be in range).
7538    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7539        let Some(worker) = self
7540            .state
7541            .hired_workers
7542            .get(self.state.workers_menu_index)
7543            .cloned()
7544        else {
7545            anyhow::bail!("select a hired worker first");
7546        };
7547        let (px, py, _) = self.state.player_position_with_z();
7548        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7549        if dist > WORKER_GIVE_RANGE_M {
7550            anyhow::bail!(
7551                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
7552                worker.label
7553            );
7554        }
7555        let options = self.state.teachable_blueprint_options(&worker);
7556        if options.is_empty() {
7557            anyhow::bail!("no recipes you know that {} still needs", worker.label);
7558        }
7559        self.state.worker_teach_picker = Some(WorkerTeachPicker {
7560            worker_instance_id: worker.instance_id,
7561            worker_label: worker.label,
7562            worker_level: worker.level,
7563            options,
7564        });
7565        self.state.worker_teach_picker_index = 0;
7566        self.state.show_worker_teach_picker = true;
7567        Ok(())
7568    }
7569
7570    pub fn close_worker_teach_picker(&mut self) {
7571        self.state.show_worker_teach_picker = false;
7572        self.state.worker_teach_picker = None;
7573        self.state.worker_teach_picker_index = 0;
7574    }
7575
7576    pub fn worker_teach_picker_move(&mut self, delta: i32) {
7577        let Some(picker) = &self.state.worker_teach_picker else {
7578            return;
7579        };
7580        let n = picker.options.len();
7581        if n == 0 {
7582            return;
7583        }
7584        let idx = self.state.worker_teach_picker_index as i32;
7585        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7586    }
7587
7588    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7589        let Some(picker) = self.state.worker_teach_picker.clone() else {
7590            anyhow::bail!("teach picker not open");
7591        };
7592        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
7593            anyhow::bail!("nothing selected");
7594        };
7595        if !opt.level_ok {
7596            anyhow::bail!(
7597                "{} needs level {} (is level {})",
7598                picker.worker_label,
7599                opt.min_level,
7600                opt.worker_level
7601            );
7602        }
7603        if !opt.can_afford {
7604            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
7605        }
7606        let Some(worker) = self
7607            .state
7608            .hired_workers
7609            .iter()
7610            .find(|w| w.instance_id == picker.worker_instance_id)
7611            .cloned()
7612        else {
7613            anyhow::bail!("worker gone");
7614        };
7615        let (px, py, _) = self.state.player_position_with_z();
7616        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7617        if dist > WORKER_GIVE_RANGE_M {
7618            anyhow::bail!("worker {} too far — stand next to them", worker.label);
7619        }
7620        self.seq += 1;
7621        self.session
7622            .submit_intent(Intent::TeachWorkerBlueprint {
7623                entity_id: self.state.entity_id,
7624                worker_instance_id: picker.worker_instance_id.clone(),
7625                blueprint_id: opt.blueprint_id.clone(),
7626                seq: self.seq,
7627            })
7628            .await?;
7629        self.state.intents_sent += 1;
7630        self.state.push_log(format!(
7631            "Teaching {} to {} ({} cp)",
7632            opt.label, picker.worker_label, opt.cost_copper
7633        ));
7634        self.close_worker_teach_picker();
7635        Ok(())
7636    }
7637
7638    async fn give_item_to_worker(
7639        &mut self,
7640        worker_instance_id: &str,
7641        worker_label: &str,
7642        worker_x: f32,
7643        worker_y: f32,
7644        item_instance_id: uuid::Uuid,
7645        item_label: &str,
7646        quantity: Option<u32>,
7647    ) -> anyhow::Result<()> {
7648        let (px, py, _) = self.state.player_position_with_z();
7649        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7650        if dist > WORKER_GIVE_RANGE_M {
7651            anyhow::bail!("worker {worker_label} too far — stand next to them");
7652        }
7653        self.seq += 1;
7654        self.session
7655            .submit_intent(Intent::GiveWorkerItem {
7656                entity_id: self.state.entity_id,
7657                worker_instance_id: worker_instance_id.to_string(),
7658                item_instance_id,
7659                quantity,
7660                seq: self.seq,
7661            })
7662            .await?;
7663        self.state.intents_sent += 1;
7664        self.state
7665            .push_log(format!("Gave {item_label} to {worker_label}"));
7666        Ok(())
7667    }
7668
7669    /// Open the workers-menu take picker for the selected worker (must be in range).
7670    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
7671        let Some(worker) = self
7672            .state
7673            .hired_workers
7674            .get(self.state.workers_menu_index)
7675            .cloned()
7676        else {
7677            anyhow::bail!("select a hired worker first");
7678        };
7679        let (px, py, _) = self.state.player_position_with_z();
7680        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7681        if dist > WORKER_GIVE_RANGE_M {
7682            anyhow::bail!(
7683                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
7684                worker.label
7685            );
7686        }
7687        let options = Self::worker_inventory_options(&worker);
7688        if options.is_empty() {
7689            anyhow::bail!("{} isn't carrying anything", worker.label);
7690        }
7691        self.state.worker_take_picker = Some(WorkerTakePicker {
7692            worker_instance_id: worker.instance_id,
7693            worker_label: worker.label,
7694            options,
7695        });
7696        self.state.worker_take_picker_index = 0;
7697        self.state.show_worker_take_picker = true;
7698        Ok(())
7699    }
7700
7701    fn worker_inventory_options(
7702        worker: &flatland_protocol::HiredWorkerView,
7703    ) -> Vec<WorkerGiveOption> {
7704        worker
7705            .inventory
7706            .iter()
7707            .filter_map(|stack| {
7708                let item_instance_id = stack.item_instance_id?;
7709                let label = stack
7710                    .display_name
7711                    .clone()
7712                    .unwrap_or_else(|| stack.template_id.clone());
7713                let label = if stack.quantity > 1 {
7714                    format!("{label} ×{}", stack.quantity)
7715                } else {
7716                    label
7717                };
7718                Some(WorkerGiveOption {
7719                    item_instance_id,
7720                    label,
7721                    quantity: stack.quantity,
7722                    template_id: stack.template_id.clone(),
7723                })
7724            })
7725            .collect()
7726    }
7727
7728    pub fn close_worker_take_picker(&mut self) {
7729        self.state.show_worker_take_picker = false;
7730        self.state.worker_take_picker = None;
7731        self.state.worker_take_picker_index = 0;
7732    }
7733
7734    pub fn worker_take_picker_move(&mut self, delta: i32) {
7735        let Some(picker) = &self.state.worker_take_picker else {
7736            return;
7737        };
7738        let n = picker.options.len();
7739        if n == 0 {
7740            return;
7741        }
7742        let idx = self.state.worker_take_picker_index as i32;
7743        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7744    }
7745
7746    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
7747        let Some(picker) = self.state.worker_take_picker.clone() else {
7748            anyhow::bail!("take picker not open");
7749        };
7750        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
7751            anyhow::bail!("no item selected");
7752        };
7753        let Some(worker) = self
7754            .state
7755            .hired_workers
7756            .iter()
7757            .find(|w| w.instance_id == picker.worker_instance_id)
7758            .cloned()
7759        else {
7760            self.close_worker_take_picker();
7761            anyhow::bail!("worker no longer hired");
7762        };
7763        self.take_item_from_worker(
7764            &worker.instance_id,
7765            &worker.label,
7766            worker.x,
7767            worker.y,
7768            opt.item_instance_id,
7769            &opt.label,
7770            None,
7771        )
7772        .await?;
7773        // Refresh from live view after local optimistic wait — use last known
7774        // worker inventory minus the taken stack for immediate UI; next tick
7775        // will reconcile.
7776        if let Some(w) = self
7777            .state
7778            .hired_workers
7779            .iter()
7780            .find(|w| w.instance_id == picker.worker_instance_id)
7781        {
7782            let options = Self::worker_inventory_options(w)
7783                .into_iter()
7784                .filter(|o| o.item_instance_id != opt.item_instance_id)
7785                .collect::<Vec<_>>();
7786            if options.is_empty() {
7787                self.close_worker_take_picker();
7788            } else {
7789                self.state.worker_take_picker = Some(WorkerTakePicker {
7790                    worker_instance_id: picker.worker_instance_id,
7791                    worker_label: picker.worker_label,
7792                    options,
7793                });
7794                self.state.worker_take_picker_index = self
7795                    .state
7796                    .worker_take_picker_index
7797                    .min(
7798                        self.state
7799                            .worker_take_picker
7800                            .as_ref()
7801                            .map(|p| p.options.len().saturating_sub(1))
7802                            .unwrap_or(0),
7803                    );
7804            }
7805        } else {
7806            self.close_worker_take_picker();
7807        }
7808        Ok(())
7809    }
7810
7811    async fn take_item_from_worker(
7812        &mut self,
7813        worker_instance_id: &str,
7814        worker_label: &str,
7815        worker_x: f32,
7816        worker_y: f32,
7817        item_instance_id: uuid::Uuid,
7818        item_label: &str,
7819        quantity: Option<u32>,
7820    ) -> anyhow::Result<()> {
7821        let (px, py, _) = self.state.player_position_with_z();
7822        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7823        if dist > WORKER_GIVE_RANGE_M {
7824            anyhow::bail!("worker {worker_label} too far — stand next to them");
7825        }
7826        self.seq += 1;
7827        self.session
7828            .submit_intent(Intent::TakeWorkerItem {
7829                entity_id: self.state.entity_id,
7830                worker_instance_id: worker_instance_id.to_string(),
7831                item_instance_id,
7832                quantity,
7833                seq: self.seq,
7834            })
7835            .await?;
7836        self.state.intents_sent += 1;
7837        self.state
7838            .push_log(format!("Took {item_label} from {worker_label}"));
7839        Ok(())
7840    }
7841
7842    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
7843        if !self.state.has_worker_lodging() {
7844            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
7845        }
7846        self.seq += 1;
7847        self.session
7848            .submit_intent(Intent::HireWorker {
7849                entity_id: self.state.entity_id,
7850                def_id: "worker_laborer".into(),
7851                wage_copper_per_interval: 8,
7852                lodging_container_id: None,
7853                job_yaml: None,
7854                seq: self.seq,
7855            })
7856            .await?;
7857        self.state.intents_sent += 1;
7858        Ok(())
7859    }
7860
7861    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
7862        let Some(worker) = self
7863            .state
7864            .hired_workers
7865            .get(self.state.workers_menu_index)
7866            .cloned()
7867        else {
7868            anyhow::bail!("select a hired worker first");
7869        };
7870        let lodging = worker.lodging_container_id.clone().or_else(|| {
7871            crate::worker_route_editor::owned_lodging_container_ids(
7872                &self.state.placed_containers,
7873                self.state.character_id,
7874            )
7875            .into_iter()
7876            .next()
7877            .map(|(id, _)| id)
7878        });
7879        let label = worker.label.clone();
7880        let editor = if let Some(route) = &worker.route {
7881            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
7882                worker.instance_id,
7883                worker.label,
7884                route,
7885                lodging,
7886            )
7887        } else {
7888            crate::worker_route_editor::WorkerRouteEditorState::new(
7889                worker.instance_id,
7890                worker.label,
7891                lodging,
7892            )
7893        };
7894        self.state.worker_route_editor = Some(editor);
7895        self.state.show_workers_menu = false;
7896        self.state.push_log(format!(
7897            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
7898        ));
7899        Ok(())
7900    }
7901
7902    pub fn close_worker_route_editor(&mut self) {
7903        self.state.worker_route_editor = None;
7904    }
7905
7906    pub fn worker_route_editor_toggle_panel(&mut self) {
7907        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7908            ed.toggle_panel_collapsed();
7909        }
7910    }
7911
7912    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
7913        let n = {
7914            let Some(ed) = self.state.worker_route_editor.as_mut() else {
7915                return;
7916            };
7917            ed.append_waypoint(x, y, z);
7918            ed.stop_count()
7919        };
7920        self.state
7921            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
7922    }
7923
7924    // ---- picker candidate snapshots ----------------------------------
7925
7926    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
7927        let (px, py, _) = self.state.player_position_with_z();
7928        crate::worker_route_editor::owned_container_candidates(
7929            &self.state.placed_containers,
7930            self.state.character_id,
7931            px,
7932            py,
7933        )
7934    }
7935
7936    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
7937        let (px, py, _) = self.state.player_position_with_z();
7938        crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
7939    }
7940
7941    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
7942        let (px, py, _) = self.state.player_position_with_z();
7943        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
7944    }
7945
7946    fn re_template_candidates(&self) -> Vec<String> {
7947        let mut extra = Vec::new();
7948        if let Some(ed) = self.state.worker_route_editor.as_ref() {
7949            for stop in &ed.stops {
7950                match stop {
7951                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
7952                        filter: Some(filter),
7953                        ..
7954                    } => extra.extend(filter.iter().cloned()),
7955                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
7956                        extra.push(template.clone());
7957                    }
7958                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
7959                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
7960                            extra.push(bp.output.clone());
7961                            for input in &bp.inputs {
7962                                extra.push(input.template_id.clone());
7963                            }
7964                        }
7965                    }
7966                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
7967                        for it in items {
7968                            extra.push(it.template.clone());
7969                        }
7970                    }
7971                    _ => {}
7972                }
7973            }
7974            // Worker-known recipe outputs even if the player hasn't learned them yet.
7975            if let Some(worker) = self
7976                .state
7977                .hired_workers
7978                .iter()
7979                .find(|w| w.instance_id == ed.worker_instance_id)
7980            {
7981                for recipe in &worker.known_blueprint_ids {
7982                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
7983                        extra.push(bp.output.clone());
7984                    }
7985                }
7986            }
7987        }
7988        crate::worker_route_editor::route_item_template_candidates(
7989            &self.state.placed_containers,
7990            self.state.character_id,
7991            &self.state.inventory,
7992            &self.state.blueprints,
7993            &self.state.resource_nodes,
7994            &extra,
7995        )
7996    }
7997
7998    fn re_blueprint_ids(&self) -> Vec<String> {
7999        let worker_known: Option<&[String]> = self
8000            .state
8001            .worker_route_editor
8002            .as_ref()
8003            .and_then(|ed| {
8004                self.state
8005                    .hired_workers
8006                    .iter()
8007                    .find(|w| w.instance_id == ed.worker_instance_id)
8008            })
8009            .map(|w| w.known_blueprint_ids.as_slice());
8010        crate::worker_route_editor::worker_craft_blueprint_ids(
8011            &self.state.blueprints,
8012            worker_known,
8013        )
8014    }
8015
8016    fn re_bed_candidates(&self) -> Vec<(String, String)> {
8017        crate::worker_route_editor::owned_lodging_container_ids(
8018            &self.state.placed_containers,
8019            self.state.character_id,
8020        )
8021    }
8022
8023    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
8024        self.state
8025            .placed_containers
8026            .iter()
8027            .find(|c| c.id == container_id)
8028            .map(|c| c.contents.clone())
8029            .unwrap_or_default()
8030    }
8031
8032    // ---- sheet navigation (`plans/33`) --------------------------------
8033
8034    /// Row count of the current sheet (for cursor wrapping).
8035    pub fn re_sheet_row_count(&self) -> usize {
8036        use crate::worker_route_editor::RouteEditorSheet as S;
8037        let Some(ed) = self.state.worker_route_editor.as_ref() else {
8038            return 0;
8039        };
8040        match &ed.sheet {
8041            S::Stops => ed.stops.len(),
8042            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
8043            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
8044            S::WaypointMapPick => 0,
8045            S::HarvestPicker { .. } => self.re_node_candidates().len(),
8046            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
8047                self.re_container_candidates().len()
8048            }
8049            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
8050            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
8051            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
8052            S::SellItem { templates, .. } => templates.len() + 1, // + sell-all toggle
8053            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
8054            S::WaitEntry { .. } => 1,
8055            S::BedPicker { .. } => self.re_bed_candidates().len(),
8056        }
8057    }
8058
8059    /// Current sheet cursor index (0 for sheets without one).
8060    pub fn re_sheet_index(&self) -> usize {
8061        use crate::worker_route_editor::RouteEditorSheet as S;
8062        let Some(ed) = self.state.worker_route_editor.as_ref() else {
8063            return 0;
8064        };
8065        match &ed.sheet {
8066            S::AddMenu { index }
8067            | S::WaypointMenu { index }
8068            | S::HarvestPicker { index }
8069            | S::WithdrawContainers { index }
8070            | S::DepositContainers { index }
8071            | S::SellNpcs { index }
8072            | S::CraftBlueprint { index }
8073            | S::BedPicker { index }
8074            | S::WithdrawItems { index, .. }
8075            | S::DepositFilter { index, .. }
8076            | S::SellItem { index, .. } => *index,
8077            _ => 0,
8078        }
8079    }
8080
8081    /// Move the current sheet's cursor, wrapping within its rows.
8082    pub fn re_sheet_move(&mut self, delta: i32) {
8083        use crate::worker_route_editor::RouteEditorSheet as S;
8084        let count = self.re_sheet_row_count();
8085        if count == 0 {
8086            return;
8087        }
8088        let cur = self.re_sheet_index() as i32;
8089        let next = (cur + delta).rem_euclid(count as i32) as usize;
8090        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8091            return;
8092        };
8093        match &mut ed.sheet {
8094            S::AddMenu { index }
8095            | S::WaypointMenu { index }
8096            | S::HarvestPicker { index }
8097            | S::WithdrawContainers { index }
8098            | S::DepositContainers { index }
8099            | S::SellNpcs { index }
8100            | S::CraftBlueprint { index }
8101            | S::BedPicker { index }
8102            | S::WithdrawItems { index, .. }
8103            | S::DepositFilter { index, .. }
8104            | S::SellItem { index, .. } => *index = next,
8105            _ => {}
8106        }
8107    }
8108
8109    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
8110    pub fn re_sheet_adjust(&mut self, delta: i32) {
8111        use crate::worker_route_editor::RouteEditorSheet as S;
8112        let index = self.re_sheet_index();
8113        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8114            return;
8115        };
8116        match &mut ed.sheet {
8117            S::WithdrawItems { lines, .. } => {
8118                if let Some(line) = lines.get_mut(index) {
8119                    line.adjust_qty(delta);
8120                }
8121            }
8122            S::WaitEntry { ticks } => {
8123                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
8124            }
8125            _ => {}
8126        }
8127    }
8128
8129    pub fn re_sheet_back(&mut self) {
8130        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8131            return;
8132        };
8133        use crate::worker_route_editor::RouteEditorSheet as S;
8134        let was_editing = ed.editing_index.is_some();
8135        let from_top_picker = matches!(
8136            ed.sheet,
8137            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
8138        );
8139        ed.sheet_back();
8140        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
8141            // Chest retarget commits on pick; Esc here just ends the edit sheet.
8142            self.state
8143                .push_log("Route: left edit sheet — press s to save current stops".to_string());
8144        }
8145    }
8146
8147    /// True when the editor is at its root sheet (Esc should close it).
8148    pub fn re_at_root_sheet(&self) -> bool {
8149        self.state
8150            .worker_route_editor
8151            .as_ref()
8152            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
8153    }
8154
8155    pub fn re_open_add_menu(&mut self) {
8156        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8157            ed.open_add_menu();
8158        }
8159    }
8160
8161    pub fn re_open_bed_picker(&mut self) {
8162        let beds = self.re_bed_candidates();
8163        if beds.is_empty() {
8164            self.state
8165                .push_log("Route: place a camp bed first".to_string());
8166            return;
8167        }
8168        let current = self
8169            .state
8170            .worker_route_editor
8171            .as_ref()
8172            .and_then(|ed| ed.lodging_container_id.clone());
8173        let index = current
8174            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
8175            .unwrap_or(0);
8176        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
8177    }
8178
8179    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
8180        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8181            ed.open_sheet(sheet);
8182        }
8183    }
8184
8185    /// Confirm a sheet's stop and log the outcome.
8186    fn re_confirm_stop(
8187        &mut self,
8188        stop: crate::worker_route_editor::WorkerRouteStop,
8189        what: String,
8190    ) {
8191        let appended = self
8192            .state
8193            .worker_route_editor
8194            .as_mut()
8195            .is_some_and(|ed| ed.confirm_stop(stop));
8196        if appended {
8197            self.state.push_log(format!("Route: + {what}"));
8198        } else {
8199            self.state
8200                .push_log(format!("Route: {what} already in route — selected it"));
8201        }
8202    }
8203
8204    fn re_open_withdraw_items(&mut self, container_id: String) {
8205        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8206        let contents = self.re_container_contents(&container_id);
8207        // When editing, keep the stop's item picks even if the player retargets
8208        // to a different (possibly empty) chest — otherwise Done has nothing to
8209        // confirm and the edit appears to "revert".
8210        let existing = self
8211            .state
8212            .worker_route_editor
8213            .as_ref()
8214            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8215            .and_then(|stop| match stop {
8216                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
8217                _ => None,
8218            })
8219            .unwrap_or_default();
8220        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
8221        // Commit the new chest immediately while editing so Esc-before-Done still
8222        // keeps the retarget (items update only when Done is pressed).
8223        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8224            let _ = ed.retarget_withdraw_container(container_id.clone());
8225        }
8226        self.re_open_sheet(S::WithdrawItems {
8227            container_id,
8228            lines,
8229            index: 0,
8230        });
8231    }
8232
8233    fn re_withdraw_items_activate(&mut self, index: usize) {
8234        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8235        enum Outcome {
8236            Cycled,
8237            Confirmed(String),
8238            Empty,
8239        }
8240        let outcome = {
8241            let Some(ed) = self.state.worker_route_editor.as_mut() else {
8242                return;
8243            };
8244            let S::WithdrawItems {
8245                container_id,
8246                lines,
8247                index: sheet_index,
8248            } = &mut ed.sheet
8249            else {
8250                return;
8251            };
8252            *sheet_index = index;
8253            if index < lines.len() {
8254                lines[index].cycle();
8255                Outcome::Cycled
8256            } else {
8257                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
8258                if items.is_empty() {
8259                    Outcome::Empty
8260                } else {
8261                    let stop = WorkerRouteStop::WithdrawFrom {
8262                        container_id: container_id.clone(),
8263                        items,
8264                    };
8265                    let summary = stop.summary();
8266                    ed.confirm_stop(stop);
8267                    Outcome::Confirmed(summary)
8268                }
8269            }
8270        };
8271        match outcome {
8272            Outcome::Cycled => {}
8273            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
8274            Outcome::Empty => self
8275                .state
8276                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
8277        }
8278    }
8279
8280    fn re_open_deposit_filter(&mut self, container_id: String) {
8281        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8282        // Preserve filter when retargeting the deposit chest while editing.
8283        let existing_filter = self
8284            .state
8285            .worker_route_editor
8286            .as_ref()
8287            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8288            .and_then(|stop| match stop {
8289                WorkerRouteStop::DepositAt { filter, .. } => {
8290                    Some(filter.clone().unwrap_or_default())
8291                }
8292                _ => None,
8293            });
8294        let mut candidates = self.re_template_candidates();
8295        if let Some(ref chosen) = existing_filter {
8296            for t in chosen {
8297                if !candidates.iter().any(|c| c == t) {
8298                    candidates.push(t.clone());
8299                }
8300            }
8301            candidates.sort();
8302            candidates.dedup();
8303        }
8304        let rows: Vec<(String, bool)> = match existing_filter {
8305            Some(chosen) => candidates
8306                .iter()
8307                .map(|t| (t.clone(), chosen.contains(t)))
8308                .collect(),
8309            None => candidates.into_iter().map(|t| (t, false)).collect(),
8310        };
8311        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8312            let _ = ed.retarget_deposit_container(container_id.clone());
8313        }
8314        self.re_open_sheet(S::DepositFilter {
8315            container_id,
8316            rows,
8317            index: 0,
8318        });
8319    }
8320
8321    fn re_deposit_filter_activate(&mut self, index: usize) {
8322        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8323        let mut confirmed: Option<String> = None;
8324        {
8325            let Some(ed) = self.state.worker_route_editor.as_mut() else {
8326                return;
8327            };
8328            let S::DepositFilter {
8329                container_id,
8330                rows,
8331                index: sheet_index,
8332            } = &mut ed.sheet
8333            else {
8334                return;
8335            };
8336            *sheet_index = index;
8337            if index < rows.len() {
8338                rows[index].1 = !rows[index].1;
8339            } else {
8340                // "Done" row.
8341                let chosen: Vec<String> = rows
8342                    .iter()
8343                    .filter(|(_, on)| *on)
8344                    .map(|(t, _)| t.clone())
8345                    .collect();
8346                let filter = if chosen.is_empty() { None } else { Some(chosen) };
8347                let stop = WorkerRouteStop::DepositAt {
8348                    container_id: container_id.clone(),
8349                    filter,
8350                };
8351                confirmed = Some(stop.summary());
8352                ed.confirm_stop(stop);
8353            }
8354        }
8355        if let Some(what) = confirmed {
8356            self.state.push_log(format!("Route: + {what}"));
8357        }
8358    }
8359
8360    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
8361        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8362        let templates = self.re_template_candidates();
8363        if templates.is_empty() {
8364            self.state.push_log(
8365                "Route: no item templates available — learn a craft recipe or place a harvest node first"
8366                    .to_string(),
8367            );
8368            return;
8369        }
8370        // Prefill when editing an existing sell stop.
8371        let (pre_npc, pre_template, pre_all) = self
8372            .state
8373            .worker_route_editor
8374            .as_ref()
8375            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8376            .and_then(|stop| match stop {
8377                WorkerRouteStop::TradeWith {
8378                    npc_id,
8379                    template,
8380                    sell_all,
8381                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
8382                _ => None,
8383            })
8384            .unwrap_or((None, None, true));
8385        let npc_id = npc_id.or(pre_npc);
8386        let index = pre_template
8387            .and_then(|t| templates.iter().position(|x| x == &t))
8388            .map(|i| i + 1) // +1 for the sell-all toggle row
8389            .unwrap_or(1);
8390        self.re_open_sheet(S::SellItem {
8391            npc_id,
8392            templates,
8393            index,
8394            sell_all: pre_all,
8395        });
8396    }
8397
8398    fn re_sell_item_activate(&mut self, index: usize) {
8399        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8400        let mut confirmed: Option<String> = None;
8401        {
8402            let Some(ed) = self.state.worker_route_editor.as_mut() else {
8403                return;
8404            };
8405            let S::SellItem {
8406                npc_id,
8407                templates,
8408                index: sheet_index,
8409                sell_all,
8410            } = &mut ed.sheet
8411            else {
8412                return;
8413            };
8414            *sheet_index = index;
8415            if index == 0 {
8416                *sell_all = !*sell_all;
8417            } else if let Some(template) = templates.get(index - 1).cloned() {
8418                let stop = WorkerRouteStop::TradeWith {
8419                    npc_id: npc_id.clone(),
8420                    template,
8421                    sell_all: *sell_all,
8422                };
8423                confirmed = Some(stop.summary());
8424                ed.confirm_stop(stop);
8425            }
8426        }
8427        if let Some(what) = confirmed {
8428            self.state.push_log(format!("Route: + {what}"));
8429        }
8430    }
8431
8432    /// Enter on the stop list: open the selected stop's sheet, prefilled.
8433    pub fn re_edit_selected_stop(&mut self) {
8434        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8435        let Some(stop) = self
8436            .state
8437            .worker_route_editor
8438            .as_ref()
8439            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
8440        else {
8441            self.state
8442                .push_log("Route: no stop selected — press a to add one".to_string());
8443            return;
8444        };
8445        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8446            ed.begin_edit_selected();
8447        }
8448        match stop {
8449            WorkerRouteStop::Waypoint { .. } => {
8450                self.re_open_sheet(S::WaypointMenu { index: 0 });
8451            }
8452            WorkerRouteStop::HarvestNode { node_id } => {
8453                let nodes = self.re_node_candidates();
8454                let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
8455                if nodes.is_empty() {
8456                    self.re_cancel_edit();
8457                    self.state
8458                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
8459                } else {
8460                    self.re_open_sheet(S::HarvestPicker { index });
8461                }
8462            }
8463            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
8464                // Open the container picker so the player can retarget storage
8465                // (previously jumped straight to items on the same chest).
8466                let containers = self.re_container_candidates();
8467                if containers.is_empty() {
8468                    self.re_cancel_edit();
8469                    self.state
8470                        .push_log("Route: place a storage chest first".to_string());
8471                } else {
8472                    let index = containers
8473                        .iter()
8474                        .position(|c| c.id == container_id)
8475                        .unwrap_or(0);
8476                    self.re_open_sheet(S::WithdrawContainers { index });
8477                }
8478            }
8479            WorkerRouteStop::DepositAt { container_id, .. } => {
8480                let containers = self.re_container_candidates();
8481                if containers.is_empty() {
8482                    self.re_cancel_edit();
8483                    self.state
8484                        .push_log("Route: place a storage chest first".to_string());
8485                } else {
8486                    let index = containers
8487                        .iter()
8488                        .position(|c| c.id == container_id)
8489                        .unwrap_or(0);
8490                    self.re_open_sheet(S::DepositContainers { index });
8491                }
8492            }
8493            WorkerRouteStop::TradeWith { npc_id, .. } => {
8494                let npcs = self.re_npc_candidates();
8495                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
8496                let index = npc_id
8497                    .as_ref()
8498                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
8499                    .unwrap_or(0);
8500                self.re_open_sheet(S::SellNpcs { index });
8501            }
8502            WorkerRouteStop::CraftAt { blueprint, .. } => {
8503                let bps = self.re_blueprint_ids();
8504                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
8505                if bps.is_empty() {
8506                    self.re_cancel_edit();
8507                    self.state
8508                        .push_log("Route: no known blueprints to retarget".to_string());
8509                } else {
8510                    self.re_open_sheet(S::CraftBlueprint { index });
8511                }
8512            }
8513            WorkerRouteStop::RestIfNeeded => {
8514                self.re_cancel_edit();
8515                self.state
8516                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
8517            }
8518            WorkerRouteStop::Wait { wait_ticks } => {
8519                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
8520            }
8521        }
8522    }
8523
8524    fn re_cancel_edit(&mut self) {
8525        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8526            ed.editing_index = None;
8527        }
8528    }
8529
8530    // ---- mouse clicks inside the overlay ------------------------------
8531
8532    pub fn worker_route_editor_ui_click(
8533        &mut self,
8534        click: crate::worker_route_editor::RouteEditorClick,
8535    ) {
8536        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
8537        match click {
8538            RouteEditorClick::SelectStop(i) => {
8539                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8540                    ed.sheet = S::Stops;
8541                    ed.select_stop(i);
8542                }
8543            }
8544            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
8545            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
8546            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
8547        }
8548    }
8549
8550    /// Activate (Enter / click) a row of the current sheet.
8551    pub fn re_sheet_row_activate(&mut self, row: usize) {
8552        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8553        let Some(sheet) = self
8554            .state
8555            .worker_route_editor
8556            .as_ref()
8557            .map(|ed| ed.sheet.clone())
8558        else {
8559            return;
8560        };
8561        match sheet {
8562            S::Stops => {
8563                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8564                    ed.select_stop(row);
8565                }
8566            }
8567            S::AddMenu { .. } => match row {
8568                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
8569                1 => {
8570                    if self.re_node_candidates().is_empty() {
8571                        self.state
8572                            .push_log("Route: no harvestable nodes visible in this region".to_string());
8573                    } else {
8574                        self.re_open_sheet(S::HarvestPicker { index: 0 });
8575                    }
8576                }
8577                2 | 3 => {
8578                    if self.re_container_candidates().is_empty() {
8579                        self.state
8580                            .push_log("Route: place a storage chest first".to_string());
8581                    } else if row == 2 {
8582                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
8583                    } else {
8584                        self.re_open_sheet(S::DepositContainers { index: 0 });
8585                    }
8586                }
8587                4 => {
8588                    if self.re_template_candidates().is_empty() {
8589                        self.state.push_log(
8590                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
8591                                .to_string(),
8592                        );
8593                    } else {
8594                        self.re_open_sheet(S::SellNpcs { index: 0 });
8595                    }
8596                }
8597                5 => {
8598                    if self.re_blueprint_ids().is_empty() {
8599                        self.state.push_log(
8600                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
8601                                .to_string(),
8602                        );
8603                    } else {
8604                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
8605                    }
8606                }
8607                6 => self.re_confirm_stop(
8608                    WorkerRouteStop::RestIfNeeded,
8609                    "rest if needed".into(),
8610                ),
8611                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
8612                _ => {}
8613            },
8614            S::WaypointMenu { .. } => match row {
8615                0 => {
8616                    let (x, y, z) = self.state.player_position_with_z();
8617                    let stop = WorkerRouteStop::Waypoint { x, y, z };
8618                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8619                }
8620                1 => {
8621                    self.re_open_sheet(S::WaypointMapPick);
8622                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
8623                }
8624                _ => {}
8625            },
8626            S::HarvestPicker { .. } => {
8627                let nodes = self.re_node_candidates();
8628                if let Some(n) = nodes.get(row) {
8629                    let stop = WorkerRouteStop::HarvestNode {
8630                        node_id: n.id.clone(),
8631                    };
8632                    let label = n.label.clone();
8633                    self.re_confirm_stop(stop, format!("harvest {label}"));
8634                }
8635            }
8636            S::WithdrawContainers { .. } => {
8637                let containers = self.re_container_candidates();
8638                if let Some(c) = containers.get(row) {
8639                    let id = c.id.clone();
8640                    self.re_open_withdraw_items(id);
8641                }
8642            }
8643            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
8644            S::DepositContainers { .. } => {
8645                let containers = self.re_container_candidates();
8646                if let Some(c) = containers.get(row) {
8647                    let id = c.id.clone();
8648                    self.re_open_deposit_filter(id);
8649                }
8650            }
8651            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
8652            S::SellNpcs { .. } => {
8653                let npcs = self.re_npc_candidates();
8654                let npc_id = if row == 0 {
8655                    None
8656                } else {
8657                    npcs.get(row - 1).map(|n| n.id.clone())
8658                };
8659                if row == 0 || npc_id.is_some() {
8660                    self.re_open_sell_item(npc_id);
8661                }
8662            }
8663            S::SellItem { .. } => self.re_sell_item_activate(row),
8664            S::CraftBlueprint { .. } => {
8665                let bps = self.re_blueprint_ids();
8666                if let Some(bp) = bps.get(row) {
8667                    let stop = WorkerRouteStop::CraftAt {
8668                        device: "hand".into(),
8669                        blueprint: bp.clone(),
8670                        qty: None,
8671                    };
8672                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
8673                }
8674            }
8675            S::WaitEntry { ticks } => {
8676                let stop = WorkerRouteStop::Wait {
8677                    wait_ticks: ticks,
8678                };
8679                self.re_confirm_stop(stop, format!("wait {ticks}t"));
8680            }
8681            S::BedPicker { .. } => {
8682                let beds = self.re_bed_candidates();
8683                if let Some((id, name)) = beds.get(row) {
8684                    let (id, name) = (id.clone(), name.clone());
8685                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
8686                        ed.lodging_container_id = Some(id.clone());
8687                        ed.sheet = S::Stops;
8688                    }
8689                    self.state
8690                        .push_log(format!("Route: rest bed set to {name}"));
8691                }
8692            }
8693            S::WaypointMapPick => {}
8694        }
8695    }
8696
8697    // ---- map clicks (sheet-scoped accelerators) ------------------------
8698
8699    /// Map click while the route editor is open. When a picker sheet is open
8700    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
8701    /// container, NPC clicks the merchant, node clicks the harvest target);
8702    /// otherwise the click quick-adds the nearest target (context-aware).
8703    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
8704        use crate::worker_route_editor as wre;
8705        use wre::RouteEditorSheet as S;
8706        if self.state.worker_route_editor.is_none() {
8707            return;
8708        }
8709        let sheet = self
8710            .state
8711            .worker_route_editor
8712            .as_ref()
8713            .map(|ed| ed.sheet.clone())
8714            .unwrap_or(S::Stops);
8715        match sheet {
8716            S::WaypointMapPick => {
8717                let (_, _, z) = self.state.player_position_with_z();
8718                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
8719                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8720                // Stay in map-pick mode when adding (not editing) for fast pathing.
8721                let editing = self
8722                    .state
8723                    .worker_route_editor
8724                    .as_ref()
8725                    .is_some_and(|ed| ed.editing_index.is_some());
8726                if !editing {
8727                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
8728                        ed.sheet = S::WaypointMapPick;
8729                    }
8730                }
8731            }
8732            S::HarvestPicker { .. } => {
8733                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8734                    let stop = wre::WorkerRouteStop::HarvestNode {
8735                        node_id: node.id.clone(),
8736                    };
8737                    let label = node.label.clone();
8738                    self.re_confirm_stop(stop, format!("harvest {label}"));
8739                }
8740            }
8741            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
8742                // Chest click picks (or switches) the withdraw source.
8743                if let Some(cid) = wre::pick_storage_container_at(
8744                    &self.state.placed_containers,
8745                    self.state.character_id,
8746                    x,
8747                    y,
8748                ) {
8749                    self.re_open_withdraw_items(cid);
8750                }
8751            }
8752            S::DepositContainers { .. } | S::DepositFilter { .. } => {
8753                if let Some(cid) = wre::pick_storage_container_at(
8754                    &self.state.placed_containers,
8755                    self.state.character_id,
8756                    x,
8757                    y,
8758                ) {
8759                    self.re_open_deposit_filter(cid);
8760                }
8761            }
8762            S::SellNpcs { .. } => {
8763                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8764                    self.re_open_sell_item(Some(npc_id));
8765                }
8766            }
8767            S::SellItem { .. } => {
8768                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8769                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
8770                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
8771                            *slot = Some(npc_id.clone());
8772                        }
8773                    }
8774                    self.state
8775                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8776                }
8777            }
8778            // Stop list / other sheets: context-aware quick add.
8779            _ => self.worker_route_editor_quick_add_click(x, y),
8780        }
8781    }
8782
8783    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
8784    /// node wins; duplicates select the existing stop; a click on a merchant
8785    /// while a sell stop is selected pins it.
8786    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
8787        use crate::worker_route_editor as wre;
8788        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
8789            let dx = ax - bx;
8790            let dy = ay - by;
8791            (dx * dx + dy * dy).sqrt()
8792        };
8793
8794        // 1. Retarget the selected stop when the click names its target:
8795        //    sell stop → pin the merchant; withdraw stop → set the source chest.
8796        let selected_stop_kind = self
8797            .state
8798            .worker_route_editor
8799            .as_ref()
8800            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
8801            .map(|s| match s {
8802                wre::WorkerRouteStop::TradeWith { .. } => 1,
8803                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
8804                _ => 0,
8805            })
8806            .unwrap_or(0);
8807        if selected_stop_kind == 1 {
8808            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8809                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8810                    ed.set_selected_trade_npc(npc_id.clone());
8811                }
8812                self.state
8813                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8814                return;
8815            }
8816        }
8817        if selected_stop_kind == 2 {
8818            if let Some(cid) = wre::pick_storage_container_at(
8819                &self.state.placed_containers,
8820                self.state.character_id,
8821                x,
8822                y,
8823            ) {
8824                let name = self
8825                    .state
8826                    .placed_containers
8827                    .iter()
8828                    .find(|c| c.id == cid)
8829                    .map(|c| c.display_name.clone())
8830                    .unwrap_or_else(|| "container".into());
8831                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8832                    ed.set_selected_withdraw_container(cid.clone());
8833                }
8834                self.state
8835                    .push_log(format!("Route: withdraw source → {name}"));
8836                return;
8837            }
8838        }
8839
8840        // 2. Nearest candidate of any kind wins. Category order is the
8841        //    tie-break for exact overlaps (bed over chest over merchant over node).
8842        enum Target {
8843            Bed(String),
8844            Container(String),
8845            Npc(String, String),
8846            Node(String, String),
8847        }
8848        let mut best: Option<(f32, u8, Target)> = None;
8849        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
8850            let better = match best {
8851                None => true,
8852                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
8853            };
8854            if better {
8855                *best = Some((d, rank, t));
8856            }
8857        };
8858        if let Some(bed_id) = wre::pick_lodging_container_at(
8859            &self.state.placed_containers,
8860            self.state.character_id,
8861            x,
8862            y,
8863        ) {
8864            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
8865                // A bed that's already the rest bed doubles as a storage chest —
8866                // a second click on it means "deposit here", not "set bed again".
8867                let already_bed = self
8868                    .state
8869                    .worker_route_editor
8870                    .as_ref()
8871                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
8872                if already_bed {
8873                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
8874                } else {
8875                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
8876                }
8877            }
8878        }
8879        if let Some(cid) = wre::pick_storage_container_at(
8880            &self.state.placed_containers,
8881            self.state.character_id,
8882            x,
8883            y,
8884        ) {
8885            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
8886                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
8887            }
8888        }
8889        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8890            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
8891                consider(
8892                    dist(x, y, n.x, n.y),
8893                    2,
8894                    Target::Npc(npc_id, label),
8895                    &mut best,
8896                );
8897            }
8898        }
8899        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8900            let d = dist(x, y, node.x, node.y);
8901            consider(
8902                d,
8903                3,
8904                Target::Node(node.id.clone(), node.label.clone()),
8905                &mut best,
8906            );
8907        }
8908
8909        match best.map(|(_, _, t)| t) {
8910            Some(Target::Bed(bed_id)) => {
8911                let name = self
8912                    .state
8913                    .placed_containers
8914                    .iter()
8915                    .find(|c| c.id == bed_id)
8916                    .map(|c| c.display_name.clone())
8917                    .unwrap_or_else(|| "camp bed".into());
8918                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8919                    ed.lodging_container_id = Some(bed_id.clone());
8920                }
8921                self.state
8922                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
8923            }
8924            Some(Target::Container(cid)) => {
8925                let name = self
8926                    .state
8927                    .placed_containers
8928                    .iter()
8929                    .find(|c| c.id == cid)
8930                    .map(|c| c.display_name.clone())
8931                    .unwrap_or_else(|| "container".into());
8932                let added = self
8933                    .state
8934                    .worker_route_editor
8935                    .as_mut()
8936                    .is_some_and(|ed| ed.append_deposit_at(&cid));
8937                if added {
8938                    self.state
8939                        .push_log(format!("Route: + deposit at {name} ({cid})"));
8940                } else {
8941                    self.state.push_log(format!(
8942                        "Route: {name} already in route — selected it (d to remove)"
8943                    ));
8944                }
8945            }
8946            Some(Target::Npc(npc_id, label)) => {
8947                // Quick-add sell: first storage template (the full sell flow
8948                // lives in the a → Sell sheet).
8949                let template = self.re_template_candidates().into_iter().next();
8950                let Some(template) = template else {
8951                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
8952                    return;
8953                };
8954                let added = self
8955                    .state
8956                    .worker_route_editor
8957                    .as_mut()
8958                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
8959                if added {
8960                    self.state
8961                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
8962                } else {
8963                    self.state.push_log(format!(
8964                        "Route: {label} already sells {template} — selected it (d to remove)"
8965                    ));
8966                }
8967            }
8968            Some(Target::Node(id, label)) => {
8969                let added = self
8970                    .state
8971                    .worker_route_editor
8972                    .as_mut()
8973                    .is_some_and(|ed| ed.append_harvest_node(&id));
8974                if added {
8975                    self.state
8976                        .push_log(format!("Route: + harvest node {label} ({id})"));
8977                } else {
8978                    self.state.push_log(format!(
8979                        "Route: {label} already in route — selected it (d to remove)"
8980                    ));
8981                }
8982            }
8983            None => {}
8984        }
8985    }
8986
8987    pub fn worker_route_editor_select(&mut self, delta: i32) {
8988        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8989            return;
8990        };
8991        if ed.stops.is_empty() {
8992            return;
8993        }
8994        let n = ed.stops.len() as i32;
8995        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
8996        ed.selected_stop_index = next;
8997    }
8998
8999    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
9000        let Some(ed) = self.state.worker_route_editor.as_mut() else {
9001            return;
9002        };
9003        if delta < 0 {
9004            ed.move_selected_up();
9005        } else if delta > 0 {
9006            ed.move_selected_down();
9007        }
9008    }
9009
9010    pub fn worker_route_editor_delete_selected(&mut self) {
9011        let removed = self
9012            .state
9013            .worker_route_editor
9014            .as_mut()
9015            .is_some_and(|ed| {
9016                let before = ed.stop_count();
9017                ed.remove_selected_stop();
9018                ed.stop_count() < before
9019            });
9020        if removed {
9021            self.state.push_log("Route: removed selected stop");
9022        }
9023    }
9024
9025    /// Clear all stops. Saving afterwards parks the worker in idle mode
9026    /// instead of leaving it in a broken job loop.
9027    pub fn worker_route_editor_clear_stops(&mut self) {
9028        let Some(ed) = self.state.worker_route_editor.as_mut() else {
9029            return;
9030        };
9031        if ed.stops.is_empty() {
9032            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
9033            return;
9034        }
9035        ed.stops.clear();
9036        ed.selected_stop_index = 0;
9037        self.state
9038            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
9039    }
9040
9041    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
9042        if self.state.pending_worker_job_ack.is_some() {
9043            anyhow::bail!("route save still pending — wait for server ack");
9044        }
9045        let Some(ed) = self.state.worker_route_editor.clone() else {
9046            anyhow::bail!("route editor not open");
9047        };
9048        // Empty route = stand down: park the worker in idle mode rather than
9049        // erroring out or leaving it marching a ghost loop.
9050        let (job_yaml, idle) = if ed.stops.is_empty() {
9051            (ed.build_idle_job_yaml(), true)
9052        } else {
9053            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
9054        };
9055        let worker_id = ed.worker_instance_id.clone();
9056        let route_view = if idle {
9057            None
9058        } else {
9059            Some(ed.to_route_view())
9060        };
9061        let mode = if idle {
9062            flatland_protocol::WorkerModeView::Idle
9063        } else {
9064            flatland_protocol::WorkerModeView::JobLoop
9065        };
9066        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
9067            .state
9068            .hired_workers
9069            .iter()
9070            .find(|w| w.instance_id == worker_id)
9071            .map(|w| {
9072                (
9073                    w.route.clone(),
9074                    w.mode,
9075                    w.step_label.clone(),
9076                    w.last_error.clone(),
9077                )
9078            })
9079            .unwrap_or((
9080                None,
9081                flatland_protocol::WorkerModeView::Idle,
9082                String::new(),
9083                None,
9084            ));
9085        self.seq += 1;
9086        let seq = self.seq;
9087        self.session
9088            .submit_intent(Intent::SetWorkerJob {
9089                entity_id: self.state.entity_id,
9090                worker_instance_id: worker_id.clone(),
9091                job_yaml,
9092                seq,
9093            })
9094            .await?;
9095        self.state.intents_sent += 1;
9096        if let Some(w) = self
9097            .state
9098            .hired_workers
9099            .iter_mut()
9100            .find(|w| w.instance_id == worker_id)
9101        {
9102            w.route = route_view;
9103            w.mode = mode;
9104            w.last_error = None;
9105            if idle {
9106                w.step_label.clear();
9107            }
9108        }
9109        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
9110            seq,
9111            worker_instance_id: worker_id,
9112            worker_label: ed.worker_label.clone(),
9113            idle,
9114            stop_count: ed.stops.len(),
9115            prev_route,
9116            prev_mode,
9117            prev_step_label,
9118            prev_last_error,
9119        });
9120        self.state.push_log(format!(
9121            "Route: saving for {}… (waiting for server)",
9122            ed.worker_label
9123        ));
9124        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
9125        Ok(())
9126    }
9127    pub fn quest_menu_move(&mut self, delta: i32) {
9128        let n = self.state.active_quest_entries().len();
9129        if n == 0 {
9130            return;
9131        }
9132        let idx = self.state.quest_menu_index as i32;
9133        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9134    }
9135
9136    pub fn quest_menu_page(&mut self, pages: i32) {
9137        let n = self.state.active_quest_entries().len();
9138        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
9139    }
9140
9141    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
9142        let Some(offer) = self.state.pending_quest_offer.clone() else {
9143            anyhow::bail!("no quest offer");
9144        };
9145        self.seq += 1;
9146        let seq = self.seq;
9147        self.session
9148            .submit_intent(Intent::AcceptQuest {
9149                entity_id: self.state.entity_id,
9150                quest_id: offer.quest_id,
9151                seq,
9152            })
9153            .await?;
9154        self.state.intents_sent += 1;
9155        Ok(())
9156    }
9157
9158    pub fn quest_offer_decline(&mut self) {
9159        self.state.show_quest_offer = false;
9160        self.state.pending_quest_offer = None;
9161        if !self.state.show_npc_chat
9162            && !self.state.show_shop_menu
9163            && self.state.npc_verb_target.is_some()
9164        {
9165            self.state.show_npc_verb_menu = true;
9166        }
9167    }
9168
9169    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
9170        if !self.state.show_quest_menu {
9171            return Ok(());
9172        }
9173        let active: Vec<_> = self
9174            .state
9175            .active_quest_entries()
9176            .into_iter()
9177            .cloned()
9178            .collect();
9179        let Some(entry) = active.get(self.state.quest_menu_index) else {
9180            return Ok(());
9181        };
9182        if self.state.quest_withdraw_confirm {
9183            if !entry.can_withdraw {
9184                anyhow::bail!("quest cannot be withdrawn");
9185            }
9186            self.seq += 1;
9187            let seq = self.seq;
9188            self.session
9189                .submit_intent(Intent::WithdrawQuest {
9190                    entity_id: self.state.entity_id,
9191                    quest_id: entry.quest_id.clone(),
9192                    seq,
9193                })
9194                .await?;
9195            self.state.intents_sent += 1;
9196            self.state.quest_withdraw_confirm = false;
9197            return Ok(());
9198        }
9199        self.seq += 1;
9200        let seq = self.seq;
9201        self.session
9202            .submit_intent(Intent::TrackQuest {
9203                entity_id: self.state.entity_id,
9204                quest_id: entry.quest_id.clone(),
9205                seq,
9206            })
9207            .await?;
9208        self.state.intents_sent += 1;
9209        Ok(())
9210    }
9211
9212    pub fn quest_request_withdraw(&mut self) {
9213        if self.state.show_quest_menu {
9214            self.state.quest_withdraw_confirm = true;
9215        }
9216    }
9217
9218    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
9219        if !self.state.is_alive() {
9220            anyhow::bail!("you are dead");
9221        }
9222        let Some(catalog) = self.state.shop_catalog.clone() else {
9223            anyhow::bail!("no shop open");
9224        };
9225        self.seq += 1;
9226        let seq = self.seq;
9227        match self.state.shop_tab {
9228            ShopTab::Buy => {
9229                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
9230                    anyhow::bail!("nothing selected");
9231                };
9232                if offer.already_owned {
9233                    anyhow::bail!("already owned");
9234                }
9235                self.session
9236                    .submit_intent(Intent::ShopBuy {
9237                        entity_id: self.state.entity_id,
9238                        npc_id: catalog.npc_id.clone(),
9239                        offer_id: offer.offer_id.clone(),
9240                        quantity: self.state.shop_quantity,
9241                        seq,
9242                    })
9243                    .await?;
9244            }
9245            ShopTab::Sell => {
9246                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
9247                    anyhow::bail!("nothing to sell");
9248                };
9249                if line.quantity == 0 {
9250                    anyhow::bail!("you have no {}", line.label);
9251                }
9252                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
9253                self.session
9254                    .submit_intent(Intent::ShopSell {
9255                        entity_id: self.state.entity_id,
9256                        npc_id: catalog.npc_id.clone(),
9257                        template_id: line.template_id.clone(),
9258                        quantity,
9259                        seq,
9260                    })
9261                    .await?;
9262            }
9263        }
9264        self.state.intents_sent += 1;
9265        Ok(())
9266    }
9267
9268    pub fn craft_menu_move(&mut self, delta: i32) {
9269        let n = self.state.blueprints.len();
9270        if n == 0 {
9271            return;
9272        }
9273        let idx = self.state.craft_menu_index as i32;
9274        let next = (idx + delta).rem_euclid(n as i32);
9275        self.state.craft_menu_index = next as usize;
9276        self.state.clamp_craft_batch_quantity();
9277    }
9278
9279    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
9280        self.state.craft_batch_adjust_quantity(delta);
9281    }
9282
9283    pub fn craft_batch_set_max(&mut self) {
9284        self.state.craft_batch_set_max();
9285    }
9286
9287    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
9288        let Some(blueprint) = self
9289            .state
9290            .blueprints
9291            .get(self.state.craft_menu_index)
9292            .cloned()
9293        else {
9294            anyhow::bail!("no blueprints known");
9295        };
9296        if !self.state.can_craft_blueprint(&blueprint) {
9297            let hint = self
9298                .state
9299                .craft_missing_hint(&blueprint)
9300                .unwrap_or_else(|| "missing materials".into());
9301            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
9302        }
9303        let count = self.state.craft_batch_quantity;
9304        let max = self.state.max_craft_batches(&blueprint);
9305        if max == 0 {
9306            anyhow::bail!("cannot craft {}", blueprint.label);
9307        }
9308        let batches = count.min(max);
9309        self.craft(&blueprint.id, Some(batches)).await?;
9310        self.state.show_craft_menu = false;
9311        Ok(())
9312    }
9313
9314    pub async fn move_by(
9315        &mut self,
9316        forward: f32,
9317        strafe: f32,
9318        vertical: f32,
9319        sprint: bool,
9320    ) -> anyhow::Result<()> {
9321        if !self.state.is_alive() {
9322            anyhow::bail!("you are dead");
9323        }
9324        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
9325            self.last_move_forward = forward;
9326            self.last_move_strafe = strafe;
9327        }
9328        self.seq += 1;
9329        self.session
9330            .submit_intent(Intent::Move {
9331                entity_id: self.state.entity_id,
9332                forward,
9333                strafe,
9334                vertical,
9335                sprint,
9336                seq: self.seq,
9337            })
9338            .await?;
9339        self.state.intents_sent += 1;
9340        Ok(())
9341    }
9342
9343    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
9344        if !self.state.connected {
9345            crate::harvest_trace!("harvest_nearest rejected: not connected");
9346            anyhow::bail!("not connected");
9347        }
9348        if !self.state.is_alive() {
9349            crate::harvest_trace!("harvest_nearest rejected: player dead");
9350            anyhow::bail!("you are dead");
9351        }
9352        if self.state.harvest_in_progress {
9353            if self.state.harvest_state_stale() {
9354                self.state.clear_harvest_state();
9355            } else {
9356                anyhow::bail!("already harvesting");
9357            }
9358        }
9359        let (px, py) = self
9360            .state
9361            .player
9362            .as_ref()
9363            .map(|p| (p.transform.position.x, p.transform.position.y))
9364            .unwrap_or((0.0, 0.0));
9365
9366        let available = self
9367            .state
9368            .resource_nodes
9369            .iter()
9370            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9371            .count();
9372        let node_id = self
9373            .state
9374            .resource_nodes
9375            .iter()
9376            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9377            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
9378            .min_by(|a, b| {
9379                let da = distance(px, py, a.x, a.y);
9380                let db = distance(px, py, b.x, b.y);
9381                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9382            })
9383            .map(|n| n.id.clone());
9384
9385        let Some(node_id) = node_id else {
9386            let has_loot = self
9387                .state
9388                .ground_drops
9389                .iter()
9390                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9391            if has_loot {
9392                return self.pickup_nearest().await;
9393            }
9394            anyhow::bail!(
9395                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
9396            );
9397        };
9398
9399        self.seq += 1;
9400        let seq = self.seq;
9401        crate::harvest_trace!(
9402            entity_id = self.state.entity_id,
9403            node_id = %node_id,
9404            seq,
9405            px,
9406            py,
9407            available_nodes = available,
9408            "submitting harvest intent"
9409        );
9410        self.session
9411            .submit_intent(Intent::Harvest {
9412                entity_id: self.state.entity_id,
9413                node_id,
9414                seq,
9415            })
9416            .await?;
9417        self.state.intents_sent += 1;
9418        self.state.harvest_in_progress = true;
9419        self.state.harvest_started_at = Some(Instant::now());
9420        self.state.push_log("Harvesting…");
9421        crate::harvest_trace!(
9422            entity_id = self.state.entity_id,
9423            seq,
9424            "harvest intent queued to session"
9425        );
9426        Ok(())
9427    }
9428
9429    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
9430        if !self.state.is_alive() {
9431            anyhow::bail!("you are dead");
9432        }
9433        let blueprint_id = self
9434            .state
9435            .blueprints
9436            .iter()
9437            .find(|bp| self.state.can_craft_blueprint(bp))
9438            .map(|bp| bp.id.clone())
9439            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
9440        self.craft(&blueprint_id, None).await
9441    }
9442
9443    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
9444        if !self.state.is_alive() {
9445            anyhow::bail!("you are dead");
9446        }
9447        self.seq += 1;
9448        self.session
9449            .submit_intent(Intent::Craft {
9450                entity_id: self.state.entity_id,
9451                blueprint_id: blueprint_id.to_string(),
9452                count,
9453                seq: self.seq,
9454            })
9455            .await?;
9456        self.state.intents_sent += 1;
9457        let (label, batches) = self
9458            .state
9459            .blueprints
9460            .iter()
9461            .find(|b| b.id == blueprint_id)
9462            .map(|b| {
9463                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
9464                (b.label.as_str(), n)
9465            })
9466            .unwrap_or((blueprint_id, count.unwrap_or(1)));
9467        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
9468        Ok(())
9469    }
9470
9471    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
9472        if !self.state.is_alive() {
9473            anyhow::bail!("you are dead");
9474        }
9475        let target_id = match self.state.nearest_interact_target() {
9476            Some(id) => id,
9477            None => {
9478                anyhow::bail!("nothing to interact with nearby");
9479            }
9480        };
9481        if self.state.npcs.iter().any(|n| n.id == target_id) {
9482            self.state.show_npc_verb_menu = true;
9483            self.state.npc_verb_target = Some(target_id);
9484            self.state.npc_verb_index = 0;
9485            return Ok(());
9486        }
9487        if let Ok(peer_id) = target_id.parse::<EntityId>() {
9488            if let Some(entity) = self
9489                .state
9490                .entities
9491                .iter()
9492                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
9493            {
9494                self.state
9495                    .player_verbs
9496                    .open_for(peer_id, &entity.label);
9497                return Ok(());
9498            }
9499        }
9500        self.seq += 1;
9501        self.session
9502            .submit_intent(Intent::Interact {
9503                entity_id: self.state.entity_id,
9504                target_id: target_id.clone(),
9505                seq: self.seq,
9506            })
9507            .await?;
9508        self.state.intents_sent += 1;
9509        Ok(())
9510    }
9511
9512    /// Context-sensitive world use: interact → pickup loot/chest → harvest/butcher.
9513    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
9514        if !self.state.is_alive() {
9515            anyhow::bail!("you are dead");
9516        }
9517        if self.state.nearest_interact_target().is_some() {
9518            return self.interact_nearest().await;
9519        }
9520        // Prefer a clear "move closer" when a board is visible but out of reach,
9521        // instead of silently falling through to harvest.
9522        if let Some((label, dist)) = self.state.nearest_quest_board() {
9523            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
9524                anyhow::bail!(
9525                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
9526                );
9527            }
9528        }
9529        let (px, py) = self.state.player_position();
9530        let has_loot = self
9531            .state
9532            .ground_drops
9533            .iter()
9534            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9535        if has_loot {
9536            return self.pickup_nearest().await;
9537        }
9538        if self
9539            .state
9540            .placed_containers
9541            .iter()
9542            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9543        {
9544            return self.pickup_nearest_container().await;
9545        }
9546        match self.harvest_nearest().await {
9547            Ok(()) => Ok(()),
9548            Err(err) => {
9549                let msg = err.to_string();
9550                if msg.contains("no harvestable")
9551                    || msg.contains("press p")
9552                    || msg.contains("press f")
9553                {
9554                    anyhow::bail!(
9555                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
9556                    );
9557                }
9558                Err(err)
9559            }
9560        }
9561    }
9562
9563    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
9564    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
9565    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
9566        if !self.state.is_alive() {
9567            anyhow::bail!("you are dead");
9568        }
9569        let binding = self
9570            .state
9571            .hotbar_ability(slot)
9572            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
9573            .to_string();
9574        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
9575            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
9576            if qty == 0 {
9577                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
9578            }
9579            return self.use_item(template_id).await;
9580        }
9581        let ability_id = binding;
9582        let target = if ability_id == "heal_touch" {
9583            Some(
9584                self.state
9585                    .target_for_slot(2)
9586                    .unwrap_or(self.state.entity_id),
9587            )
9588        } else {
9589            self.state
9590                .target_for_slot(1)
9591                .or_else(|| self.state.target_for_slot(2))
9592        };
9593        let Some(target_id) = target else {
9594            anyhow::bail!("no target — Tab to select, then press the hotbar key");
9595        };
9596        self.cast_ability(&ability_id, Some(target_id)).await
9597    }
9598
9599    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
9600    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
9601    pub async fn set_hotbar_slot(
9602        &mut self,
9603        slot: u8,
9604        ability_id: Option<&str>,
9605    ) -> anyhow::Result<()> {
9606        if !self.state.is_alive() {
9607            anyhow::bail!("you are dead");
9608        }
9609        if !(1..=9).contains(&slot) {
9610            anyhow::bail!("hotbar slot must be 1–9");
9611        }
9612        let ability_id = ability_id
9613            .map(str::trim)
9614            .filter(|id| !id.is_empty())
9615            .map(str::to_string);
9616        self.seq += 1;
9617        self.session
9618            .submit_intent(Intent::SetHotbarSlot {
9619                entity_id: self.state.entity_id,
9620                slot,
9621                ability_id: ability_id.clone(),
9622                seq: self.seq,
9623            })
9624            .await?;
9625        self.state.intents_sent += 1;
9626        let idx = (slot - 1) as usize;
9627        if self.state.hotbar.len() < 9 {
9628            self.state.hotbar.resize(9, None);
9629        }
9630        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
9631            *slot_mut = ability_id.clone();
9632        }
9633        match ability_id {
9634            Some(id) => {
9635                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
9636                    format!("use {tid}")
9637                } else {
9638                    id
9639                };
9640                self.state.push_log(format!("Hotbar {slot} → {label}"))
9641            }
9642            None => self.state.push_log(format!("Hotbar {slot} cleared")),
9643        }
9644        Ok(())
9645    }
9646
9647    pub fn npc_verb_options(&self) -> Vec<&'static str> {
9648        self.state.npc_verb_options()
9649    }
9650
9651    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
9652        let Some(npc_id) = self.state.npc_verb_target.clone() else {
9653            return Ok(());
9654        };
9655        let options = self.npc_verb_options();
9656        let choice = options
9657            .get(self.state.npc_verb_index)
9658            .copied()
9659            .unwrap_or("Talk");
9660        self.seq += 1;
9661        match choice {
9662            "Trade" | "Bank" | "Storage" => {
9663                self.session
9664                    .submit_intent(Intent::Interact {
9665                        entity_id: self.state.entity_id,
9666                        target_id: npc_id,
9667                        seq: self.seq,
9668                    })
9669                    .await?;
9670            }
9671            _ => {
9672                self.session
9673                    .submit_intent(Intent::NpcTalkOpen {
9674                        entity_id: self.state.entity_id,
9675                        npc_id,
9676                        seq: self.seq,
9677                    })
9678                    .await?;
9679            }
9680        }
9681        self.state.intents_sent += 1;
9682        Ok(())
9683    }
9684
9685    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
9686        let Some(chat) = self.state.npc_chat.clone() else {
9687            return Ok(());
9688        };
9689        let message = chat.input.trim().to_string();
9690        if message.is_empty() || chat.pending {
9691            return Ok(());
9692        }
9693        if let Some(c) = self.state.npc_chat.as_mut() {
9694            c.lines.push(format!("You: {message}"));
9695            c.input.clear();
9696            c.pending = true;
9697        }
9698        self.seq += 1;
9699        self.session
9700            .submit_intent(Intent::NpcTalkSay {
9701                entity_id: self.state.entity_id,
9702                npc_id: chat.npc_id,
9703                message,
9704                seq: self.seq,
9705            })
9706            .await?;
9707        self.state.intents_sent += 1;
9708        Ok(())
9709    }
9710
9711    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
9712        let return_to_verbs = self.state.npc_verb_target.is_some();
9713        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
9714            self.state.show_npc_chat = false;
9715            if return_to_verbs {
9716                self.state.show_npc_verb_menu = true;
9717            }
9718            return Ok(());
9719        };
9720        self.seq += 1;
9721        self.session
9722            .submit_intent(Intent::NpcTalkClose {
9723                entity_id: self.state.entity_id,
9724                npc_id,
9725                seq: self.seq,
9726            })
9727            .await?;
9728        self.state.intents_sent += 1;
9729        self.state.show_npc_chat = false;
9730        self.state.npc_chat = None;
9731        if return_to_verbs {
9732            self.state.show_npc_verb_menu = true;
9733        }
9734        Ok(())
9735    }
9736
9737    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
9738    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
9739        if self.state.show_quest_offer
9740            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
9741        {
9742            self.quest_offer_decline();
9743            return Ok(());
9744        }
9745        if self.state.show_npc_chat {
9746            return self.npc_talk_close().await;
9747        }
9748        if self.state.show_shop_menu {
9749            return self.back_from_shop_menu().await;
9750        }
9751        if self.state.bank_panel.is_some() {
9752            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9753                self.bank_transfer_back();
9754                return Ok(());
9755            }
9756            return self.close_bank_panel().await;
9757        }
9758        if self.state.storage_panel.is_some() {
9759            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9760                self.storage_ui_back();
9761                return Ok(());
9762            }
9763            return self.close_storage_panel().await;
9764        }
9765        if self.state.show_npc_verb_menu {
9766            self.state.show_npc_verb_menu = false;
9767            self.state.npc_verb_target = None;
9768        }
9769        Ok(())
9770    }
9771
9772    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
9773        self.seq += 1;
9774        self.session
9775            .submit_intent(Intent::TestDamage {
9776                entity_id: self.state.entity_id,
9777                amount,
9778                seq: self.seq,
9779            })
9780            .await?;
9781        self.state.intents_sent += 1;
9782        Ok(())
9783    }
9784
9785    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
9786        self.cycle_combat_target_slot(1, reverse).await
9787    }
9788
9789    pub async fn cycle_combat_target_slot(
9790        &mut self,
9791        slot_index: u8,
9792        reverse: bool,
9793    ) -> anyhow::Result<()> {
9794        if !self.state.is_alive() {
9795            anyhow::bail!("you are dead");
9796        }
9797        let candidates = self.state.candidates_for_slot(slot_index);
9798        if candidates.is_empty() {
9799            anyhow::bail!("no targets nearby");
9800        }
9801        let current = self.state.target_for_slot(slot_index);
9802        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
9803        let next_idx = match idx {
9804            None => 0,
9805            Some(i) if reverse => {
9806                if i == 0 {
9807                    candidates.len() - 1
9808                } else {
9809                    i - 1
9810                }
9811            }
9812            Some(i) => (i + 1) % candidates.len(),
9813        };
9814        if idx == Some(next_idx) && candidates.len() == 1 {
9815            self.clear_combat_target_slot(slot_index).await?;
9816            return Ok(());
9817        }
9818        let (target_id, label) = candidates[next_idx].clone();
9819        self.set_combat_target_slot(slot_index, target_id, &label)
9820            .await
9821    }
9822
9823    pub async fn set_combat_target_slot(
9824        &mut self,
9825        slot_index: u8,
9826        target_id: EntityId,
9827        label: &str,
9828    ) -> anyhow::Result<()> {
9829        if !self.state.is_alive() {
9830            anyhow::bail!("you are dead");
9831        }
9832        self.seq += 1;
9833        self.session
9834            .submit_intent(Intent::SetTargetSlot {
9835                entity_id: self.state.entity_id,
9836                slot_index,
9837                target_id,
9838                seq: self.seq,
9839            })
9840            .await?;
9841        self.state.intents_sent += 1;
9842        if slot_index == 1 {
9843            self.state.combat_target = Some(target_id);
9844            self.state.combat_target_label = Some(label.to_string());
9845        }
9846        self.state
9847            .push_log(format!("Slot {slot_index} target: {label}"));
9848        Ok(())
9849    }
9850
9851    pub async fn set_combat_target(
9852        &mut self,
9853        target_id: EntityId,
9854        label: &str,
9855    ) -> anyhow::Result<()> {
9856        self.set_combat_target_slot(1, target_id, label).await
9857    }
9858
9859    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9860        if slot_index == 1 && self.state.combat_target.is_none() {
9861            return Ok(());
9862        }
9863        self.seq += 1;
9864        self.session
9865            .submit_intent(Intent::ClearTargetSlot {
9866                entity_id: self.state.entity_id,
9867                slot_index,
9868                seq: self.seq,
9869            })
9870            .await?;
9871        if slot_index == 1 {
9872            self.state.combat_target = None;
9873            self.state.combat_target_label = None;
9874        }
9875        self.state.intents_sent += 1;
9876        self.state
9877            .push_log(format!("Slot {slot_index} target cleared"));
9878        Ok(())
9879    }
9880
9881    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
9882        self.clear_combat_target_slot(1).await
9883    }
9884
9885    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
9886        if !self.state.is_alive() {
9887            anyhow::bail!("you are dead");
9888        }
9889        self.seq += 1;
9890        self.session
9891            .submit_intent(Intent::AdvanceRotation {
9892                entity_id: self.state.entity_id,
9893                slot_index,
9894                seq: self.seq,
9895            })
9896            .await?;
9897        self.state.intents_sent += 1;
9898        Ok(())
9899    }
9900
9901    pub async fn assign_slot_preset(
9902        &mut self,
9903        slot_index: u8,
9904        preset_id: &str,
9905    ) -> anyhow::Result<()> {
9906        if !self.state.is_alive() {
9907            anyhow::bail!("you are dead");
9908        }
9909        self.seq += 1;
9910        self.session
9911            .submit_intent(Intent::AssignSlotPreset {
9912                entity_id: self.state.entity_id,
9913                slot_index,
9914                preset_id: preset_id.to_string(),
9915                seq: self.seq,
9916            })
9917            .await?;
9918        self.state.intents_sent += 1;
9919        if let Some(slot) = self
9920            .state
9921            .combat_slots
9922            .iter_mut()
9923            .find(|s| s.slot_index == slot_index)
9924        {
9925            slot.preset_id = Some(preset_id.to_string());
9926            if let Some(preset) = self
9927                .state
9928                .rotation_presets
9929                .iter()
9930                .find(|p| p.id == preset_id)
9931            {
9932                slot.preset_label = Some(preset.label.clone());
9933                slot.rotation = preset.abilities.clone();
9934                slot.rotation_index = 0;
9935            }
9936        }
9937        self.state
9938            .push_log(format!("T{slot_index} loadout → {preset_id}"));
9939        Ok(())
9940    }
9941
9942    pub async fn cast_ability(
9943        &mut self,
9944        ability_id: &str,
9945        target_id: Option<EntityId>,
9946    ) -> anyhow::Result<()> {
9947        if !self.state.is_alive() {
9948            anyhow::bail!("you are dead");
9949        }
9950        let target_id = target_id
9951            .or_else(|| self.state.target_for_slot(2))
9952            .or_else(|| self.state.target_for_slot(1))
9953            .unwrap_or(self.state.entity_id);
9954        self.seq += 1;
9955        self.session
9956            .submit_intent(Intent::Cast {
9957                entity_id: self.state.entity_id,
9958                ability_id: ability_id.to_string(),
9959                target_id,
9960                seq: self.seq,
9961            })
9962            .await?;
9963        self.state.intents_sent += 1;
9964        self.state
9965            .push_log(format!("Cast {ability_id} → {target_id}"));
9966        Ok(())
9967    }
9968
9969    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
9970        self.seq += 1;
9971        self.session
9972            .submit_intent(Intent::UpsertRotationPreset {
9973                entity_id: self.state.entity_id,
9974                preset: preset.clone(),
9975                seq: self.seq,
9976            })
9977            .await?;
9978        self.state.intents_sent += 1;
9979        if let Some(existing) = self
9980            .state
9981            .rotation_presets
9982            .iter_mut()
9983            .find(|p| p.id == preset.id)
9984        {
9985            *existing = preset.clone();
9986        } else {
9987            self.state.rotation_presets.push(preset.clone());
9988        }
9989        for slot in &mut self.state.combat_slots {
9990            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
9991                slot.preset_label = Some(preset.label.clone());
9992                slot.rotation = preset.abilities.clone();
9993            }
9994        }
9995        self.state
9996            .push_log(format!("Saved rotation: {}", preset.label));
9997        Ok(())
9998    }
9999
10000    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
10001        self.seq += 1;
10002        self.session
10003            .submit_intent(Intent::DeleteRotationPreset {
10004                entity_id: self.state.entity_id,
10005                preset_id: preset_id.to_string(),
10006                seq: self.seq,
10007            })
10008            .await?;
10009        self.state.intents_sent += 1;
10010        self.state.rotation_presets.retain(|p| p.id != preset_id);
10011        for slot in &mut self.state.combat_slots {
10012            if slot.preset_id.as_deref() == Some(preset_id) {
10013                slot.preset_id = None;
10014                slot.preset_label = None;
10015                slot.rotation.clear();
10016                slot.rotation_index = 0;
10017            }
10018        }
10019        self.state
10020            .push_log(format!("Deleted rotation: {preset_id}"));
10021        Ok(())
10022    }
10023
10024    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
10025        if !self.state.is_alive() {
10026            anyhow::bail!("you are dead");
10027        }
10028        let enabled = !self
10029            .state
10030            .combat_slots
10031            .iter()
10032            .find(|s| s.slot_index == slot_index)
10033            .map(|s| s.auto_enabled)
10034            .unwrap_or(false);
10035        self.seq += 1;
10036        self.session
10037            .submit_intent(Intent::SetAutoAttack {
10038                entity_id: self.state.entity_id,
10039                slot_index,
10040                enabled,
10041                seq: self.seq,
10042            })
10043            .await?;
10044        if slot_index == 1 {
10045            self.state.auto_attack = enabled;
10046        }
10047        self.state.intents_sent += 1;
10048        self.state.push_log(format!(
10049            "T{slot_index} auto {}",
10050            if enabled { "ON" } else { "OFF" }
10051        ));
10052        Ok(())
10053    }
10054
10055    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
10056        if !self.state.connected {
10057            anyhow::bail!("not connected");
10058        }
10059        if !self.state.is_alive() {
10060            anyhow::bail!("you are dead");
10061        }
10062        let (px, py) = self.state.player_position();
10063        if self
10064            .state
10065            .ground_drops
10066            .iter()
10067            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
10068        {
10069            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
10070        }
10071        self.seq += 1;
10072        self.session
10073            .submit_intent(Intent::Pickup {
10074                entity_id: self.state.entity_id,
10075                drop_id: None,
10076                seq: self.seq,
10077            })
10078            .await?;
10079        self.state.intents_sent += 1;
10080        Ok(())
10081    }
10082
10083    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
10084        self.toggle_auto_attack_slot(1).await
10085    }
10086
10087    pub async fn dodge(&mut self) -> anyhow::Result<()> {
10088        if !self.state.is_alive() {
10089            anyhow::bail!("you are dead");
10090        }
10091        self.seq += 1;
10092        self.session
10093            .submit_intent(Intent::Dodge {
10094                entity_id: self.state.entity_id,
10095                seq: self.seq,
10096            })
10097            .await?;
10098        self.state.intents_sent += 1;
10099        self.state.push_log("Dodge!");
10100        Ok(())
10101    }
10102
10103    pub async fn lunge(&mut self) -> anyhow::Result<()> {
10104        if !self.state.is_alive() {
10105            anyhow::bail!("you are dead");
10106        }
10107        let (forward, strafe) = self.last_move_axes();
10108        self.seq += 1;
10109        self.session
10110            .submit_intent(Intent::Lunge {
10111                entity_id: self.state.entity_id,
10112                forward,
10113                strafe,
10114                seq: self.seq,
10115            })
10116            .await?;
10117        self.state.intents_sent += 1;
10118        self.state.push_log("Lunge!");
10119        Ok(())
10120    }
10121
10122    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
10123        if !self.state.is_alive() {
10124            anyhow::bail!("you are dead");
10125        }
10126        self.seq += 1;
10127        self.session
10128            .submit_intent(Intent::DirectionalJump {
10129                entity_id: self.state.entity_id,
10130                forward,
10131                strafe,
10132                seq: self.seq,
10133            })
10134            .await?;
10135        self.state.intents_sent += 1;
10136        self.state.push_log("Jump!");
10137        Ok(())
10138    }
10139
10140    /// Remembered WASD axes for lunge when not currently moving.
10141    pub fn last_move_axes(&self) -> (f32, f32) {
10142        (self.last_move_forward, self.last_move_strafe)
10143    }
10144
10145    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
10146        if !self.state.is_alive() {
10147            anyhow::bail!("you are dead");
10148        }
10149        self.seq += 1;
10150        self.session
10151            .submit_intent(Intent::Block {
10152                entity_id: self.state.entity_id,
10153                enabled,
10154                seq: self.seq,
10155            })
10156            .await?;
10157        self.state.intents_sent += 1;
10158        if enabled {
10159            self.state.push_log("Blocking");
10160        }
10161        Ok(())
10162    }
10163
10164    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
10165        if !self.state.is_alive() {
10166            anyhow::bail!("you are dead");
10167        }
10168        self.seq += 1;
10169        self.session
10170            .submit_intent(Intent::EquipMainhand {
10171                entity_id: self.state.entity_id,
10172                template_id,
10173                instance_id: None,
10174                seq: self.seq,
10175            })
10176            .await?;
10177        self.state.intents_sent += 1;
10178        Ok(())
10179    }
10180
10181    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
10182    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
10183        let idx = self.state.equip_menu_index;
10184        let slots = equip_paperdoll_rows(&self.state);
10185        let Some(row) = slots.get(idx) else {
10186            return Ok(());
10187        };
10188        match row {
10189            EquipPaperdollRow::Body { slot, filled } => {
10190                if *filled {
10191                    self.equip_worn(*slot, None).await
10192                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
10193                    self.equip_worn(*slot, Some(inst)).await
10194                } else {
10195                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
10196                    Ok(())
10197                }
10198            }
10199            EquipPaperdollRow::Mainhand { filled } => {
10200                if *filled {
10201                    self.unequip_mainhand().await
10202                } else if let Some(tid) = first_inventory_weapon(&self.state) {
10203                    self.equip_mainhand(Some(tid)).await
10204                } else {
10205                    self.state.push_log("No weapon in inventory".to_string());
10206                    Ok(())
10207                }
10208            }
10209            EquipPaperdollRow::Offhand { filled, locked } => {
10210                if *locked {
10211                    self.state
10212                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
10213                    Ok(())
10214                } else if *filled {
10215                    self.unequip_offhand().await
10216                } else if let Some(tid) = first_inventory_offhand(&self.state) {
10217                    self.equip_offhand(Some(tid)).await
10218                } else {
10219                    self.state
10220                        .push_log("No offhand item in inventory".to_string());
10221                    Ok(())
10222                }
10223            }
10224        }
10225    }
10226
10227    pub async fn say(
10228        &mut self,
10229        channel: flatland_protocol::ChatChannel,
10230        text: &str,
10231    ) -> anyhow::Result<()> {
10232        self.say_to(channel, text, None).await
10233    }
10234
10235    pub async fn say_to(
10236        &mut self,
10237        channel: flatland_protocol::ChatChannel,
10238        text: &str,
10239        to_entity: Option<EntityId>,
10240    ) -> anyhow::Result<()> {
10241        self.seq += 1;
10242        self.session
10243            .submit_intent(Intent::Say {
10244                entity_id: self.state.entity_id,
10245                channel,
10246                text: text.to_string(),
10247                to_entity,
10248                seq: self.seq,
10249            })
10250            .await?;
10251        self.state.intents_sent += 1;
10252        Ok(())
10253    }
10254
10255    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
10256        let Some(peer) = self.state.player_verbs.target_entity else {
10257            return Ok(());
10258        };
10259        let label = self.state.player_verbs.target_label.clone();
10260        let choice = crate::social::PlayerVerbState::options()
10261            .get(self.state.player_verbs.index)
10262            .copied()
10263            .unwrap_or("Whisper");
10264        self.state.player_verbs.close();
10265        match choice {
10266            "Trade" => {
10267                // Only request — peer must accept (Y in chat). Do not auto-respond;
10268                // TradeRespond requires a pending inbound request from the peer.
10269                self.seq += 1;
10270                self.session
10271                    .submit_intent(Intent::TradeRequest {
10272                        entity_id: self.state.entity_id,
10273                        peer_entity_id: peer,
10274                        seq: self.seq,
10275                    })
10276                    .await?;
10277                self.state.intents_sent += 1;
10278                self.state
10279                    .social_chat
10280                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
10281            }
10282            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
10283            _ => self.state.social_chat.focus_nearby(),
10284        }
10285        Ok(())
10286    }
10287
10288    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
10289        let Some(pending) = self.state.social_chat.pending_trade.take() else {
10290            return Ok(());
10291        };
10292        self.seq += 1;
10293        self.session
10294            .submit_intent(Intent::TradeRespond {
10295                entity_id: self.state.entity_id,
10296                peer_entity_id: pending.from_entity,
10297                accept,
10298                seq: self.seq,
10299            })
10300            .await?;
10301        self.state.intents_sent += 1;
10302        if accept {
10303            self.state
10304                .social_chat
10305                .push_system(format!("Accepted trade with {}", pending.from_name));
10306        } else {
10307            self.state
10308                .social_chat
10309                .push_system(format!("Declined trade with {}", pending.from_name));
10310        }
10311        Ok(())
10312    }
10313
10314    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
10315        let text = self.state.social_chat.buffer.trim().to_string();
10316        if text.is_empty() {
10317            return Ok(());
10318        }
10319        let thread = self.state.social_chat.thread;
10320        let channel = thread.channel();
10321        let to = thread.to_entity();
10322        self.state.social_chat.buffer.clear();
10323        self.say_to(channel, &text, to).await
10324    }
10325
10326    pub async fn trade_present_selected(
10327        &mut self,
10328        item_instance_id: uuid::Uuid,
10329    ) -> anyhow::Result<()> {
10330        self.trade_present_quantity(item_instance_id, None).await
10331    }
10332
10333    pub async fn trade_present_quantity(
10334        &mut self,
10335        item_instance_id: uuid::Uuid,
10336        quantity: Option<u32>,
10337    ) -> anyhow::Result<()> {
10338        self.seq += 1;
10339        self.session
10340            .submit_intent(Intent::TradePresent {
10341                entity_id: self.state.entity_id,
10342                item_instance_id,
10343                quantity,
10344                seq: self.seq,
10345            })
10346            .await?;
10347        self.state.intents_sent += 1;
10348        self.state.trade_ui.qty_entry = None;
10349        self.state.trade_ui.picking_inventory = false;
10350        Ok(())
10351    }
10352
10353    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
10354    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
10355        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
10356            let qty = self.state.trade_ui.present_quantity();
10357            return self
10358                .trade_present_quantity(entry.item_instance_id, qty)
10359                .await;
10360        }
10361        if !self.state.trade_ui.picking_inventory {
10362            return Ok(());
10363        }
10364        let Some(stack) = self
10365            .state
10366            .inventory_stacks
10367            .get(self.state.trade_ui.inventory_index)
10368            .cloned()
10369        else {
10370            return Ok(());
10371        };
10372        let Some(id) = stack.item_instance_id else {
10373            return Ok(());
10374        };
10375        let label = stack
10376            .display_name
10377            .clone()
10378            .unwrap_or_else(|| stack.template_id.clone());
10379        if stack.quantity <= 1 {
10380            self.trade_present_quantity(id, Some(1)).await
10381        } else {
10382            self.state
10383                .trade_ui
10384                .begin_qty_entry(id, label, stack.quantity);
10385            Ok(())
10386        }
10387    }
10388
10389    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
10390        self.seq += 1;
10391        self.session
10392            .submit_intent(Intent::TradeSetReady {
10393                entity_id: self.state.entity_id,
10394                ready,
10395                seq: self.seq,
10396            })
10397            .await?;
10398        self.state.intents_sent += 1;
10399        Ok(())
10400    }
10401
10402    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
10403        self.seq += 1;
10404        self.session
10405            .submit_intent(Intent::TradeCancel {
10406                entity_id: self.state.entity_id,
10407                seq: self.seq,
10408            })
10409            .await?;
10410        self.state.intents_sent += 1;
10411        self.state.trade_ui.close();
10412        Ok(())
10413    }
10414
10415    pub async fn destroy_whisper_stone(
10416        &mut self,
10417        item_instance_id: uuid::Uuid,
10418    ) -> anyhow::Result<()> {
10419        self.seq += 1;
10420        self.session
10421            .submit_intent(Intent::DestroyWhisperStone {
10422                entity_id: self.state.entity_id,
10423                item_instance_id,
10424                seq: self.seq,
10425            })
10426            .await?;
10427        self.state.intents_sent += 1;
10428        Ok(())
10429    }
10430
10431    pub async fn stop(&mut self) -> anyhow::Result<()> {
10432        self.seq += 1;
10433        self.session
10434            .submit_intent(Intent::Stop {
10435                entity_id: self.state.entity_id,
10436                seq: self.seq,
10437            })
10438            .await?;
10439        self.state.intents_sent += 1;
10440        Ok(())
10441    }
10442
10443    pub fn disconnect(&self) {
10444        self.session.disconnect();
10445    }
10446}
10447
10448fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
10449    let dx = ax - bx;
10450    let dy = ay - by;
10451    (dx * dx + dy * dy).sqrt()
10452}
10453
10454#[cfg(test)]
10455mod tests {
10456    use std::collections::BTreeMap;
10457
10458    use super::*;
10459    use flatland_protocol::{
10460        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
10461    };
10462
10463    fn sample_state() -> GameState {
10464        let mut state = GameState {
10465            session_id: 1,
10466            entity_id: 1,
10467            character_id: None,
10468            tick: 0,
10469            chunk_rev: 0,
10470            content_rev: 0,
10471            publish_rev: 0,
10472            entities: vec![EntityState {
10473                id: 1,
10474                label: "You".into(),
10475                transform: Transform {
10476                    position: WorldCoord::surface(128.0, 128.0),
10477                    yaw: 0.0,
10478                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10479                },
10480                vitals: None,
10481                attributes: None,
10482                skills: None,
10483                inside_building: None,
10484                tile_id: None,
10485                paperdoll_ref: None,
10486                presentation_state: None,
10487                sprite_mode: None,
10488                progression_xp: None,
10489            }],
10490            player: None,
10491            resource_nodes: vec![ResourceNodeView {
10492                id: "oak-1".into(),
10493                label: "Oak".into(),
10494                x: 130.0,
10495                y: 128.0,
10496                z: 0.0,
10497                item_template: "oak_log".into(),
10498                state: ResourceNodeState::Available,
10499                blocking: true,
10500                blocking_radius_m: 0.8,
10501                tile_id: None,
10502                paperdoll_ref: None,
10503                yaw: 0.0,
10504                pitch: 0.0,
10505                roll: 0.0,
10506                draw_scale: 1.0,
10507                sprite_mode: None,
10508                presentation_state: None,
10509            }],
10510            ground_drops: vec![],
10511            placed_containers: vec![],
10512            buildings: vec![BuildingView {
10513                id: "broker-hut".into(),
10514                label: "Broker".into(),
10515                x: 148.0,
10516                y: 118.0,
10517                width_m: 8.0,
10518                depth_m: 6.0,
10519                interior_blueprint: Some("broker_hut".into()),
10520                tags: vec![],
10521            }],
10522            doors: vec![flatland_protocol::DoorView {
10523                id: "door-1".into(),
10524                building_id: "broker-hut".into(),
10525                x: 148.0,
10526                y: 118.0,
10527                open: false,
10528                portal: Some("front".into()),
10529            }],
10530            interior_map: None,
10531            npcs: vec![],
10532            blueprints: vec![],
10533            world_width_m: 256.0,
10534            world_height_m: 256.0,
10535            terrain_zones: Vec::new(),
10536            z_platforms: Vec::new(),
10537            z_transitions: Vec::new(),
10538            world_clock: flatland_protocol::WorldClock::default(),
10539            inventory: std::collections::HashMap::new(),
10540            inventory_hints: std::collections::HashMap::new(),
10541            logs: VecDeque::new(),
10542            intents_sent: 0,
10543            ticks_received: 0,
10544            connected: true,
10545            disconnect_reason: None,
10546            show_stats: false,
10547            hud_log_hidden: false,
10548            show_equip_menu: false,
10549            equip_menu_index: 0,
10550            show_craft_menu: false,
10551            craft_menu_index: 0,
10552            craft_batch_quantity: 1,
10553            show_shop_menu: false,
10554            shop_catalog: None,
10555            bank_panel: None,
10556            bank_menu_index: 0,
10557            bank_ui_mode: BankUiMode::Menu,
10558            storage_panel: None,
10559            storage_menu_index: 0,
10560            storage_ui_mode: StorageUiMode::Menu,
10561            shop_tab: ShopTab::default(),
10562            shop_menu_index: 0,
10563            shop_quantity: 1,
10564            shop_trade_log: VecDeque::new(),
10565            show_npc_verb_menu: false,
10566            npc_verb_target: None,
10567            npc_verb_index: 0,
10568            player_verbs: crate::social::PlayerVerbState::default(),
10569            social_chat: crate::social::SocialChatState::default(),
10570            trade_ui: crate::social::TradeUiState::default(),
10571            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
10572            show_npc_chat: false,
10573            npc_chat: None,
10574            show_inventory_menu: false,
10575            inventory_menu_index: 0,
10576            inventory_tab: InventoryTab::OnPerson,
10577            inventory_filter: String::new(),
10578            inventory_filter_focused: false,
10579            show_move_picker: false,
10580            show_rename_prompt: false,
10581            show_worker_rename: false,
10582            rename_buffer: String::new(),
10583            move_picker_index: 0,
10584            move_picker: None,
10585            show_grant_picker: false,
10586            grant_picker_index: 0,
10587            grant_picker: None,
10588            show_destroy_picker: false,
10589            destroy_confirm_pending: false,
10590            destroy_picker: None,
10591            combat_target: None,
10592            combat_target_label: None,
10593            combat_fx: Vec::new(),
10594            in_combat: false,
10595            auto_attack: true,
10596            combat_has_los: false,
10597            attack_cd_ticks: 0,
10598            gcd_ticks: 0,
10599            weapon_ability_id: "unarmed".into(),
10600            mainhand_template_id: None,
10601            mainhand_label: None,
10602            offhand_template_id: None,
10603            offhand_label: None,
10604            mainhand_hand_slots: 1,
10605            defense: None,
10606            worn: BTreeMap::new(),
10607            carry_mass: 0.0,
10608            carry_mass_max: 0.0,
10609            encumbrance: flatland_protocol::EncumbranceState::Light,
10610            inventory_stacks: Vec::new(),
10611            keychain_stacks: Vec::new(),
10612            whisper_pouch_stacks: Vec::new(),
10613            combat_target_detail: None,
10614            statuses: Vec::new(),
10615            cast_progress: None,
10616            ability_cooldowns: Vec::new(),
10617            blocking_active: false,
10618            max_target_slots: 1,
10619            combat_slots: Vec::new(),
10620            rotation_presets: Vec::new(),
10621            known_abilities: Vec::new(),
10622            hotbar: vec![None; 9],
10623            max_abilities_per_rotation: 0,
10624            show_loadout_menu: false,
10625            show_keychain_menu: false,
10626            keychain_menu_index: 0,
10627            show_rotation_editor: false,
10628            loadout_menu_index: 0,
10629            loadout_hotbar_slot: 1,
10630            loadout_ability_index: 0,
10631            loadout_focus_presets: false,
10632            rotation_editor: RotationEditorState::default(),
10633            harvest_in_progress: false,
10634            harvest_started_at: None,
10635            pending_craft_ack: None,
10636            pending_worker_job_ack: None,
10637            quest_log: Vec::new(),
10638            interactables: Vec::new(),
10639            ledger: None,
10640            career: None,
10641            character_sheet_tab: CharacterSheetTab::Character,
10642            ledger_period: LedgerPeriod::Day,
10643            show_quest_offer: false,
10644            pending_quest_offer: None,
10645            show_quest_menu: false,
10646            quest_menu_index: 0,
10647            quest_withdraw_confirm: false,
10648            hired_workers: Vec::new(),
10649            show_workers_menu: false,
10650            workers_menu_index: 0,
10651            workers_menu_compact: false,
10652            worker_step_display: BTreeMap::new(),
10653            worker_error_display: BTreeMap::new(),
10654            show_worker_give_picker: false,
10655            worker_give_picker_index: 0,
10656            worker_give_picker: None,
10657            show_worker_give_target_picker: false,
10658            worker_give_target_picker_index: 0,
10659            worker_give_target_picker: None,
10660            show_worker_take_picker: false,
10661            worker_take_picker_index: 0,
10662            worker_take_picker: None,
10663            show_worker_teach_picker: false,
10664            worker_teach_picker_index: 0,
10665            worker_teach_picker: None,
10666            worker_route_editor: None,
10667            progression_curve: None,
10668        };
10669        state.player = state.entities.first().cloned();
10670        state
10671    }
10672
10673    #[test]
10674    fn whisper_cancels_when_peer_walks_out_of_range() {
10675        let mut state = sample_state();
10676        state.player = state.entities.first().cloned();
10677        let mut peer = state.entities[0].clone();
10678        peer.id = 2;
10679        peer.label = "Ada".into();
10680        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
10681        state.entities.push(peer.clone());
10682        state.social_chat.focus_whisper(2, "Ada");
10683        state.refresh_whisper_range();
10684        assert!(matches!(
10685            state.social_chat.thread,
10686            crate::social::ChatThreadKind::Whisper { peer: 2 }
10687        ));
10688
10689        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
10690        state.entities[1] = peer;
10691        state.refresh_whisper_range();
10692        assert_eq!(
10693            state.social_chat.thread,
10694            crate::social::ChatThreadKind::Nearby
10695        );
10696        assert!(!state.social_chat.input_focused);
10697    }
10698
10699    #[test]
10700    fn probe_use_world_npc_beats_nearby_loot() {
10701        let mut state = sample_state();
10702        state.npcs.push(flatland_protocol::NpcView {
10703            id: "ada".into(),
10704            label: "Ada".into(),
10705            role: "broker".into(),
10706            x: 129.0,
10707            y: 128.0,
10708            building_id: None,
10709            entity_id: None,
10710            life_state: None,
10711            hp_pct: None,
10712            can_trade: true,
10713            tile_id: None,
10714            behavior_state: None,
10715            presentation_state: None,
10716            sprite_mode: None,
10717            paperdoll_ref: None,
10718        });
10719        state.ground_drops.push(flatland_protocol::GroundDropView {
10720            id: "d1".into(),
10721            template_id: "lumber".into(),
10722            quantity: 1,
10723            x: 128.5,
10724            y: 128.0,
10725            z: 0.0,
10726            tile_id: None,
10727            paperdoll_ref: None,
10728            yaw: 0.0,
10729            pitch: 0.0,
10730            roll: 0.0,
10731            draw_scale: 1.0,
10732        });
10733        let probe = state.probe_use_world();
10734        let primary = probe.primary.expect("primary");
10735        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
10736        assert_eq!(primary.id, "ada");
10737    }
10738
10739    #[test]
10740    fn probe_use_world_harvest_when_in_range() {
10741        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
10742        let probe = state.probe_use_world();
10743        assert!(
10744            probe.primary.is_none(),
10745            "oak is 2m away, out of harvest range"
10746        );
10747        assert!(probe
10748            .candidates
10749            .iter()
10750            .any(|c| c.kind == crate::UseWorldKind::Harvest));
10751
10752        let mut state = sample_state();
10753        state.resource_nodes[0].x = 129.0;
10754        let probe = state.probe_use_world();
10755        let primary = probe.primary.expect("primary");
10756        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
10757    }
10758
10759    #[test]
10760    fn probe_use_world_door_uses_building_label() {
10761        let mut state = sample_state();
10762        state.doors[0].x = 129.0;
10763        state.doors[0].y = 128.0;
10764        let probe = state.probe_use_world();
10765        let primary = probe.primary.expect("primary");
10766        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
10767        assert_eq!(primary.label, "Broker");
10768        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
10769    }
10770
10771    #[test]
10772    fn empty_entity_tick_preserves_welcome_snapshot() {
10773        let mut state = sample_state();
10774        state.inventory.insert("carrot".into(), 3);
10775        let delta = TickDelta {
10776            tick: 1,
10777            entities: vec![],
10778            resource_nodes: vec![],
10779            ground_drops: vec![],
10780            placed_containers: vec![],
10781            buildings: vec![],
10782            doors: vec![],
10783            interior_map: None,
10784            npcs: vec![],
10785            inventory: vec![],
10786            blueprints: vec![],
10787            world_clock: flatland_protocol::WorldClock::default(),
10788            combat: None,
10789            quest_log: vec![],
10790            hired_workers: Vec::new(),
10791            interactables: vec![],
10792            ledger: None,
10793            career: None,
10794            combat_fx: Vec::new(),
10795        };
10796
10797        state.apply_tick_fields(&delta, 1);
10798
10799        assert_eq!(state.entities.len(), 1);
10800        assert!(state.player.is_some());
10801        assert_eq!(state.inventory.get("carrot"), Some(&3));
10802        assert_eq!(state.resource_nodes.len(), 1);
10803    }
10804
10805    #[test]
10806    fn tick_preserves_world_layers_when_delta_omits_them() {
10807        let mut state = sample_state();
10808        let delta = TickDelta {
10809            tick: 1,
10810            entities: state.entities.clone(),
10811            resource_nodes: vec![],
10812            ground_drops: vec![],
10813            placed_containers: vec![],
10814            buildings: vec![],
10815            doors: vec![],
10816            interior_map: None,
10817            npcs: vec![],
10818            inventory: vec![],
10819            blueprints: vec![],
10820            world_clock: flatland_protocol::WorldClock::default(),
10821            combat: None,
10822            quest_log: vec![],
10823            hired_workers: Vec::new(),
10824            interactables: vec![],
10825            ledger: None,
10826            career: None,
10827            combat_fx: Vec::new(),
10828        };
10829
10830        state.apply_tick_fields(&delta, 1);
10831
10832        assert_eq!(state.resource_nodes.len(), 1);
10833        assert_eq!(state.buildings.len(), 1);
10834        assert_eq!(state.doors.len(), 1);
10835    }
10836
10837    #[test]
10838    fn tick_updates_resource_nodes_when_server_sends_them() {
10839        let mut state = sample_state();
10840        let delta = TickDelta {
10841            tick: 1,
10842            entities: state.entities.clone(),
10843            resource_nodes: vec![ResourceNodeView {
10844                id: "oak-1".into(),
10845                label: "Oak".into(),
10846                x: 130.0,
10847                y: 128.0,
10848                z: 0.0,
10849                item_template: "oak_log".into(),
10850                state: ResourceNodeState::Cooldown,
10851                blocking: true,
10852                blocking_radius_m: 0.8,
10853                tile_id: None,
10854                paperdoll_ref: None,
10855                yaw: 0.0,
10856                pitch: 0.0,
10857                roll: 0.0,
10858                draw_scale: 1.0,
10859                sprite_mode: None,
10860                presentation_state: None,
10861            }],
10862            buildings: vec![],
10863            doors: vec![],
10864            interior_map: None,
10865            npcs: vec![],
10866            inventory: vec![],
10867            blueprints: vec![],
10868            world_clock: flatland_protocol::WorldClock::default(),
10869            ground_drops: vec![],
10870            placed_containers: vec![],
10871            combat: None,
10872            quest_log: vec![],
10873            hired_workers: Vec::new(),
10874            interactables: vec![],
10875            ledger: None,
10876            career: None,
10877            combat_fx: Vec::new(),
10878        };
10879
10880        state.apply_tick_fields(&delta, 1);
10881
10882        assert!(matches!(
10883            state.resource_nodes[0].state,
10884            ResourceNodeState::Cooldown
10885        ));
10886    }
10887
10888    #[test]
10889    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
10890        let mut state = GameState {
10891            session_id: 1,
10892            entity_id: 1,
10893            character_id: None,
10894            tick: 0,
10895            chunk_rev: 0,
10896            content_rev: 0,
10897            publish_rev: 0,
10898            entities: vec![EntityState {
10899                id: 1,
10900                label: "You".into(),
10901                transform: Transform {
10902                    position: WorldCoord::surface(4.5, 2.0),
10903                    yaw: 0.0,
10904                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10905                },
10906                vitals: None,
10907                attributes: None,
10908                skills: None,
10909                inside_building: Some("broker_hut".into()),
10910                tile_id: None,
10911                paperdoll_ref: None,
10912                presentation_state: None,
10913                sprite_mode: None,
10914                progression_xp: None,
10915            }],
10916            player: None,
10917            resource_nodes: vec![],
10918            ground_drops: vec![],
10919            placed_containers: vec![],
10920            buildings: vec![BuildingView {
10921                id: "broker_hut".into(),
10922                label: "Broker".into(),
10923                x: 158.0,
10924                y: 124.0,
10925                width_m: 8.0,
10926                depth_m: 6.0,
10927                interior_blueprint: Some("broker_hut".into()),
10928                tags: vec![],
10929            }],
10930            doors: vec![flatland_protocol::DoorView {
10931                id: "broker_hut_exit".into(),
10932                building_id: "broker_hut".into(),
10933                x: 4.3,
10934                y: 0.9,
10935                open: true,
10936                portal: Some("front".into()),
10937            }],
10938            interior_map: None,
10939            npcs: vec![flatland_protocol::NpcView {
10940                id: "ada_broker".into(),
10941                label: "Ada".into(),
10942                x: 4.5,
10943                y: 2.0,
10944                building_id: Some("broker_hut".into()),
10945                role: "broker".into(),
10946                entity_id: None,
10947                life_state: None,
10948                hp_pct: None,
10949                can_trade: true,
10950                tile_id: None,
10951                behavior_state: None,
10952                presentation_state: None,
10953                sprite_mode: None,
10954                paperdoll_ref: None,
10955            }],
10956            blueprints: vec![],
10957            world_width_m: 256.0,
10958            world_height_m: 256.0,
10959            terrain_zones: Vec::new(),
10960            z_platforms: Vec::new(),
10961            z_transitions: Vec::new(),
10962            world_clock: flatland_protocol::WorldClock::default(),
10963            inventory: std::collections::HashMap::new(),
10964            inventory_hints: std::collections::HashMap::new(),
10965            logs: VecDeque::new(),
10966            intents_sent: 0,
10967            ticks_received: 0,
10968            connected: true,
10969            disconnect_reason: None,
10970            show_stats: false,
10971            hud_log_hidden: false,
10972            show_equip_menu: false,
10973            equip_menu_index: 0,
10974            show_craft_menu: false,
10975            craft_menu_index: 0,
10976            craft_batch_quantity: 1,
10977            show_shop_menu: false,
10978            shop_catalog: None,
10979            bank_panel: None,
10980            bank_menu_index: 0,
10981            bank_ui_mode: BankUiMode::Menu,
10982            storage_panel: None,
10983            storage_menu_index: 0,
10984            storage_ui_mode: StorageUiMode::Menu,
10985            shop_tab: ShopTab::default(),
10986            shop_menu_index: 0,
10987            shop_quantity: 1,
10988            shop_trade_log: VecDeque::new(),
10989            show_npc_verb_menu: false,
10990            npc_verb_target: None,
10991            npc_verb_index: 0,
10992            player_verbs: crate::social::PlayerVerbState::default(),
10993            social_chat: crate::social::SocialChatState::default(),
10994            trade_ui: crate::social::TradeUiState::default(),
10995            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
10996            show_npc_chat: false,
10997            npc_chat: None,
10998            show_inventory_menu: false,
10999            inventory_menu_index: 0,
11000            inventory_tab: InventoryTab::OnPerson,
11001            inventory_filter: String::new(),
11002            inventory_filter_focused: false,
11003            show_move_picker: false,
11004            show_rename_prompt: false,
11005            show_worker_rename: false,
11006            rename_buffer: String::new(),
11007            move_picker_index: 0,
11008            move_picker: None,
11009            show_grant_picker: false,
11010            grant_picker_index: 0,
11011            grant_picker: None,
11012            show_destroy_picker: false,
11013            destroy_confirm_pending: false,
11014            destroy_picker: None,
11015            combat_target: None,
11016            combat_target_label: None,
11017            combat_fx: Vec::new(),
11018            in_combat: false,
11019            auto_attack: true,
11020            combat_has_los: false,
11021            attack_cd_ticks: 0,
11022            gcd_ticks: 0,
11023            weapon_ability_id: "unarmed".into(),
11024            mainhand_template_id: None,
11025            mainhand_label: None,
11026            offhand_template_id: None,
11027            offhand_label: None,
11028            mainhand_hand_slots: 1,
11029            defense: None,
11030            worn: BTreeMap::new(),
11031            carry_mass: 0.0,
11032            carry_mass_max: 0.0,
11033            encumbrance: flatland_protocol::EncumbranceState::Light,
11034            inventory_stacks: Vec::new(),
11035            keychain_stacks: Vec::new(),
11036            whisper_pouch_stacks: Vec::new(),
11037            combat_target_detail: None,
11038            statuses: Vec::new(),
11039            cast_progress: None,
11040            ability_cooldowns: Vec::new(),
11041            blocking_active: false,
11042            max_target_slots: 1,
11043            combat_slots: Vec::new(),
11044            rotation_presets: Vec::new(),
11045            known_abilities: Vec::new(),
11046            hotbar: vec![None; 9],
11047            max_abilities_per_rotation: 0,
11048            show_loadout_menu: false,
11049            show_keychain_menu: false,
11050            keychain_menu_index: 0,
11051            show_rotation_editor: false,
11052            loadout_menu_index: 0,
11053            loadout_hotbar_slot: 1,
11054            loadout_ability_index: 0,
11055            loadout_focus_presets: false,
11056            rotation_editor: RotationEditorState::default(),
11057            harvest_in_progress: false,
11058            harvest_started_at: None,
11059            pending_craft_ack: None,
11060            pending_worker_job_ack: None,
11061            quest_log: Vec::new(),
11062            interactables: Vec::new(),
11063            ledger: None,
11064            career: None,
11065            character_sheet_tab: CharacterSheetTab::Character,
11066            ledger_period: LedgerPeriod::Day,
11067            show_quest_offer: false,
11068            pending_quest_offer: None,
11069            show_quest_menu: false,
11070            quest_menu_index: 0,
11071            quest_withdraw_confirm: false,
11072            hired_workers: Vec::new(),
11073            show_workers_menu: false,
11074            workers_menu_index: 0,
11075            workers_menu_compact: false,
11076            worker_step_display: BTreeMap::new(),
11077            worker_error_display: BTreeMap::new(),
11078            show_worker_give_picker: false,
11079            worker_give_picker_index: 0,
11080            worker_give_picker: None,
11081            show_worker_give_target_picker: false,
11082            worker_give_target_picker_index: 0,
11083            worker_give_target_picker: None,
11084            show_worker_take_picker: false,
11085            worker_take_picker_index: 0,
11086            worker_take_picker: None,
11087            show_worker_teach_picker: false,
11088            worker_teach_picker_index: 0,
11089            worker_teach_picker: None,
11090            worker_route_editor: None,
11091            progression_curve: None,
11092        };
11093        state.player = state.entities.first().cloned();
11094        assert_eq!(
11095            state.nearest_interact_target().as_deref(),
11096            Some("ada_broker")
11097        );
11098    }
11099
11100    #[test]
11101    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
11102        let mut state = sample_state();
11103        // Player is at (128, 128) per sample_state(). One chest just inside
11104        // CONTAINER_RANGE_M, one clearly beyond it.
11105        state.placed_containers = vec![
11106            flatland_protocol::PlacedContainerView {
11107                id: "near".into(),
11108                template_id: "wooden_chest_small".into(),
11109                display_name: "Wooden Chest".into(),
11110                x: 130.0,
11111                y: 128.0,
11112                z: 0.0,
11113                locked: true,
11114                accessible: true,
11115                owner_character_id: None,
11116                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
11117                lock_id: None,
11118                capacity_volume: None,
11119                item_instance_id: Some(uuid::Uuid::from_u128(1)),
11120                tile_id: None,
11121                paperdoll_ref: None,
11122                worker_lodging_capacity: None,
11123            },
11124            flatland_protocol::PlacedContainerView {
11125                id: "far".into(),
11126                template_id: "wooden_chest_small".into(),
11127                display_name: "Distant Chest".into(),
11128                x: 128.0 + CONTAINER_RANGE_M + 5.0,
11129                y: 128.0,
11130                z: 0.0,
11131                locked: false,
11132                accessible: true,
11133                owner_character_id: None,
11134                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
11135                lock_id: None,
11136                capacity_volume: None,
11137                item_instance_id: Some(uuid::Uuid::from_u128(2)),
11138                tile_id: None,
11139                paperdoll_ref: None,
11140                worker_lodging_capacity: None,
11141            },
11142        ];
11143
11144        let nearby = state.nearby_containers();
11145        assert_eq!(
11146            nearby.len(),
11147            1,
11148            "far chest must not appear once out of range"
11149        );
11150        assert_eq!(nearby[0].view.id, "near");
11151        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
11152        assert!(nearby[0].rows[0].is_chest_shell);
11153
11154        // The same chest, but locked and inaccessible (no key held), must hide
11155        // contents but still show the selectable chest shell row.
11156        state.placed_containers[0].accessible = false;
11157        let nearby = state.nearby_containers();
11158        assert_eq!(nearby.len(), 1);
11159        assert_eq!(nearby[0].rows.len(), 1);
11160        assert!(nearby[0].rows[0].is_chest_shell);
11161    }
11162
11163    #[test]
11164    fn chest_pickup_destinations_offer_person_and_worn_bag() {
11165        let mut state = sample_state();
11166        let back_id = uuid::Uuid::from_u128(42);
11167        state.worn.insert(
11168            BodySlot::Back,
11169            flatland_protocol::ItemStack {
11170                template_id: "travel_backpack".into(),
11171                quantity: 1,
11172                item_instance_id: Some(back_id),
11173                props: Default::default(),
11174                status_bindings: Vec::new(),
11175                contents: Vec::new(),
11176                display_name: Some("Travel Backpack".into()),
11177                category: Some("container".into()),
11178                base_mass: Some(2.5),
11179                base_volume: Some(12.0),
11180                capacity_volume: Some(80.0),
11181                stackable: Some(false),
11182                world_placeable: Some(false),
11183                worker_lodging_capacity: None,
11184                equip_slot: None,
11185                armor_physical: None,
11186                resists: vec![],
11187                hand_slots: None,
11188            },
11189        );
11190        let opts = state.chest_pickup_destinations("chest-1");
11191        assert!(opts.iter().any(|o| matches!(
11192            &o.kind,
11193            MoveOptionKind::PickupPlaced {
11194                nest_parent_instance_id: None,
11195                ..
11196            }
11197        )));
11198        assert!(opts.iter().any(|o| matches!(
11199            &o.kind,
11200            MoveOptionKind::PickupPlaced {
11201                nest_parent_instance_id: Some(id),
11202                ..
11203            } if *id == back_id
11204        )));
11205        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
11206    }
11207
11208    #[test]
11209    fn placed_container_public_label_hides_owner_custom_name() {
11210        let owner = uuid::Uuid::from_u128(99);
11211        let mut state = sample_state();
11212        state.character_id = Some(uuid::Uuid::from_u128(1));
11213        state.inventory_hints.insert(
11214            "wooden_chest_medium".into(),
11215            InventoryHint {
11216                display_name: "Medium Wooden Chest".into(),
11217                category: "container".into(),
11218                base_mass: None,
11219                base_volume: None,
11220                capacity_volume: None,
11221                stackable: false,
11222            },
11223        );
11224        let chest = flatland_protocol::PlacedContainerView {
11225            id: "c1".into(),
11226            template_id: "wooden_chest_medium".into(),
11227            display_name: "Barry's Loot #a3f2".into(),
11228            x: 128.0,
11229            y: 128.0,
11230            z: 0.0,
11231            locked: false,
11232            accessible: true,
11233            owner_character_id: Some(owner),
11234            contents: vec![],
11235            lock_id: None,
11236            capacity_volume: None,
11237            item_instance_id: None,
11238            tile_id: None,
11239            paperdoll_ref: None,
11240            worker_lodging_capacity: None,
11241        };
11242        assert_eq!(
11243            state.placed_container_public_label(&chest),
11244            "Medium Wooden Chest"
11245        );
11246        state.character_id = Some(owner);
11247        assert_eq!(
11248            state.placed_container_public_label(&chest),
11249            "Barry's Loot #a3f2"
11250        );
11251    }
11252
11253    #[test]
11254    fn location_context_lists_nearby_resource_node() {
11255        let mut state = sample_state();
11256        state.player = state.entities.first().cloned();
11257        state.resource_nodes[0].x = 128.2;
11258        state.resource_nodes[0].y = 128.0;
11259        let lines = state.location_context_lines();
11260        assert!(
11261            lines
11262                .iter()
11263                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
11264            "expected resource node in context: {:?}",
11265            lines
11266        );
11267    }
11268
11269    #[test]
11270    fn quest_board_usable_within_board_radius() {
11271        let mut state = sample_state();
11272        state.player = state.entities.first().cloned();
11273        state.interactables = vec![flatland_protocol::InteractableView {
11274            id: "board-1".into(),
11275            kind: "quest_board".into(),
11276            label: "Town Quest Board".into(),
11277            x: 130.5,
11278            y: 128.0,
11279            z: 0.0,
11280            board_id: Some("starter_town_board".into()),
11281        }];
11282        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
11283        assert_eq!(
11284            state.nearest_interact_target().as_deref(),
11285            Some("board-1"),
11286            "quest board should be selectable at ~2.5m"
11287        );
11288        let lines = state.location_context_lines();
11289        assert!(
11290            lines
11291                .iter()
11292                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
11293            "HUD should advertise f when board is in range: {:?}",
11294            lines
11295        );
11296    }
11297
11298    #[test]
11299    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
11300        let mut state = sample_state();
11301        state.worn.insert(
11302            BodySlot::Back,
11303            flatland_protocol::ItemStack {
11304                template_id: "travel_backpack".into(),
11305                quantity: 1,
11306                item_instance_id: Some(uuid::Uuid::from_u128(3)),
11307                props: Default::default(),
11308                status_bindings: Vec::new(),
11309                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
11310                display_name: None,
11311                category: None,
11312                base_mass: None,
11313                base_volume: None,
11314                capacity_volume: None,
11315                stackable: None,
11316                world_placeable: None,
11317                worker_lodging_capacity: None,
11318                equip_slot: None,
11319                armor_physical: None,
11320                resists: vec![],
11321                hand_slots: None,
11322            },
11323        );
11324        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
11325        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11326            id: "chest-1".into(),
11327            template_id: "wooden_chest_small".into(),
11328            display_name: "Wooden Chest".into(),
11329            x: 129.0,
11330            y: 128.0,
11331            z: 0.0,
11332            locked: false,
11333            accessible: true,
11334            owner_character_id: None,
11335            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
11336            lock_id: None,
11337            capacity_volume: None,
11338            item_instance_id: Some(uuid::Uuid::from_u128(4)),
11339            tile_id: None,
11340            paperdoll_ref: None,
11341            worker_lodging_capacity: None,
11342        }];
11343
11344        state.inventory_tab = InventoryTab::OnPerson;
11345        let rows = state.inventory_selectable_rows();
11346        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
11347        assert_eq!(
11348            sections,
11349            vec![
11350                InventorySection::Worn,   // backpack shell
11351                InventorySection::Worn,   // iron_ore nested in backpack
11352                InventorySection::Person, // lumber
11353            ]
11354        );
11355        assert_eq!(rows[0].stack.template_id, "travel_backpack");
11356        assert!(rows[0].is_equip_shell);
11357        assert_eq!(rows[1].stack.template_id, "iron_ore");
11358        assert_eq!(rows[1].depth, 1);
11359        assert_eq!(rows[2].stack.template_id, "lumber");
11360
11361        let lines = state.inventory_browser_lines();
11362        assert!(lines.iter().any(|l| matches!(
11363            l,
11364            InventoryBrowserLine::Section(s) if s.contains("Worn")
11365        )));
11366        assert!(lines.iter().any(|l| matches!(
11367            l,
11368            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
11369                || text.contains("backpack")
11370        )));
11371        assert!(!lines.iter().any(|l| matches!(
11372            l,
11373            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
11374        )));
11375
11376        state.inventory_tab = InventoryTab::Nearby;
11377        let nearby_rows = state.inventory_selectable_rows();
11378        assert_eq!(nearby_rows.len(), 2);
11379        assert!(nearby_rows[0].is_chest_shell);
11380        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
11381        let nearby_lines = state.inventory_browser_lines();
11382        assert!(nearby_lines.iter().any(|l| matches!(
11383            l,
11384            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
11385        )));
11386    }
11387
11388    #[test]
11389    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
11390        let mut state = sample_state();
11391        let back_id = uuid::Uuid::from_u128(5);
11392        state.worn.insert(
11393            BodySlot::Back,
11394            flatland_protocol::ItemStack {
11395                template_id: "travel_backpack".into(),
11396                quantity: 1,
11397                item_instance_id: Some(back_id),
11398                props: Default::default(),
11399                status_bindings: Vec::new(),
11400                contents: Vec::new(),
11401                display_name: None,
11402                category: Some("container".into()),
11403                base_mass: None,
11404                base_volume: None,
11405                capacity_volume: Some(80.0),
11406                stackable: None,
11407                world_placeable: None,
11408                worker_lodging_capacity: None,
11409                equip_slot: None,
11410                armor_physical: None,
11411                resists: vec![],
11412                hand_slots: None,
11413            },
11414        );
11415        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11416            id: "chest-1".into(),
11417            template_id: "wooden_chest_small".into(),
11418            display_name: "Wooden Chest".into(),
11419            x: 129.0,
11420            y: 128.0,
11421            z: 0.0,
11422            locked: false,
11423            accessible: true,
11424            owner_character_id: None,
11425            contents: Vec::new(),
11426            lock_id: None,
11427            capacity_volume: None,
11428            item_instance_id: Some(uuid::Uuid::from_u128(6)),
11429            tile_id: None,
11430            paperdoll_ref: None,
11431            worker_lodging_capacity: None,
11432        }];
11433
11434        // Item currently sitting loose on the person (Root): backpack + nearby
11435        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
11436        let opts = state.move_destinations_for(
11437            &flatland_protocol::InventoryLocation::Root,
11438            None,
11439            None,
11440            "lumber",
11441        );
11442        assert!(!opts.iter().any(|o| matches!(
11443            &o.kind,
11444            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
11445        )));
11446        assert!(opts.iter().any(|o| matches!(
11447            &o.kind,
11448            MoveOptionKind::Move { location, parent_instance_id, .. }
11449                if *location == flatland_protocol::InventoryLocation::Worn {
11450                    slot: BodySlot::Back,
11451                } && *parent_instance_id == Some(back_id)
11452        )));
11453        assert!(opts.iter().any(|o| matches!(
11454            &o.kind,
11455            MoveOptionKind::Move { location, .. }
11456                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
11457        )));
11458        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
11459        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
11460
11461        // Item currently inside the worn backpack: the backpack itself must be
11462        // excluded from its own destination list (can't move an item into the
11463        // container it's already in).
11464        let from_backpack = flatland_protocol::InventoryLocation::Worn {
11465            slot: BodySlot::Back,
11466        };
11467        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
11468        assert!(!opts.iter().any(|o| matches!(
11469            &o.kind,
11470            MoveOptionKind::Move { location, parent_instance_id, .. }
11471                if *location == from_backpack && *parent_instance_id == Some(back_id)
11472        )));
11473        assert!(opts.iter().any(|o| matches!(
11474            &o.kind,
11475            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
11476        )));
11477    }
11478
11479    #[test]
11480    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
11481        let mut state = sample_state();
11482        // Insert out of display order — BTreeMap iteration must still yield the
11483        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
11484        state.worn.insert(
11485            BodySlot::Waist,
11486            flatland_protocol::ItemStack {
11487                template_id: "simple_belt".into(),
11488                quantity: 1,
11489                item_instance_id: Some(uuid::Uuid::from_u128(10)),
11490                props: Default::default(),
11491                status_bindings: Vec::new(),
11492                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
11493                display_name: None,
11494                category: Some("container".into()),
11495                base_mass: None,
11496                base_volume: None,
11497                capacity_volume: None,
11498                stackable: None,
11499                world_placeable: None,
11500                worker_lodging_capacity: None,
11501                equip_slot: None,
11502                armor_physical: None,
11503                resists: vec![],
11504                hand_slots: None,
11505            },
11506        );
11507        state.worn.insert(
11508            BodySlot::Head,
11509            flatland_protocol::ItemStack {
11510                template_id: "cloth_cap".into(),
11511                quantity: 1,
11512                item_instance_id: Some(uuid::Uuid::from_u128(11)),
11513                props: Default::default(),
11514                status_bindings: Vec::new(),
11515                contents: Vec::new(),
11516                display_name: None,
11517                category: Some("armor".into()),
11518                base_mass: None,
11519                base_volume: None,
11520                capacity_volume: None,
11521                stackable: None,
11522                world_placeable: None,
11523                worker_lodging_capacity: None,
11524                equip_slot: None,
11525                armor_physical: None,
11526                resists: vec![],
11527                hand_slots: None,
11528            },
11529        );
11530        state.worn.insert(
11531            BodySlot::Back,
11532            flatland_protocol::ItemStack {
11533                template_id: "travel_backpack".into(),
11534                quantity: 1,
11535                item_instance_id: Some(uuid::Uuid::from_u128(12)),
11536                props: Default::default(),
11537                status_bindings: Vec::new(),
11538                contents: Vec::new(),
11539                display_name: None,
11540                category: Some("container".into()),
11541                base_mass: None,
11542                base_volume: None,
11543                capacity_volume: None,
11544                stackable: None,
11545                world_placeable: None,
11546                worker_lodging_capacity: None,
11547                equip_slot: None,
11548                armor_physical: None,
11549                resists: vec![],
11550                hand_slots: None,
11551            },
11552        );
11553
11554        let rows = state.worn_rows();
11555        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
11556        assert_eq!(rows.len(), 4);
11557        assert_eq!(rows[0].stack.template_id, "cloth_cap");
11558        assert!(rows[0].is_equip_shell);
11559        assert_eq!(rows[1].stack.template_id, "travel_backpack");
11560        assert!(rows[1].is_equip_shell);
11561        assert_eq!(rows[2].stack.template_id, "simple_belt");
11562        assert!(rows[2].is_equip_shell);
11563        assert_eq!(rows[3].stack.template_id, "leather_pouch");
11564        assert_eq!(rows[3].depth, 1);
11565        assert!(!rows[3].is_equip_shell);
11566    }
11567
11568    #[test]
11569    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
11570        let mut state = sample_state();
11571        state.worn.insert(
11572            BodySlot::Waist,
11573            flatland_protocol::ItemStack {
11574                template_id: "simple_belt".into(),
11575                quantity: 1,
11576                item_instance_id: Some(uuid::Uuid::from_u128(20)),
11577                props: Default::default(),
11578                status_bindings: Vec::new(),
11579                contents: Vec::new(),
11580                display_name: Some("Simple Belt".into()),
11581                category: Some("container".into()),
11582                base_mass: None,
11583                base_volume: None,
11584                capacity_volume: None,
11585                stackable: None,
11586                world_placeable: None,
11587                worker_lodging_capacity: None,
11588                equip_slot: None,
11589                armor_physical: None,
11590                resists: vec![],
11591                hand_slots: None,
11592            },
11593        );
11594        state.worn.insert(
11595            BodySlot::Head,
11596            flatland_protocol::ItemStack {
11597                template_id: "cloth_cap".into(),
11598                quantity: 1,
11599                item_instance_id: Some(uuid::Uuid::from_u128(21)),
11600                props: Default::default(),
11601                status_bindings: Vec::new(),
11602                contents: Vec::new(),
11603                display_name: Some("Cloth Cap".into()),
11604                category: Some("armor".into()),
11605                base_mass: None,
11606                base_volume: None,
11607                capacity_volume: None,
11608                stackable: None,
11609                world_placeable: None,
11610                worker_lodging_capacity: None,
11611                equip_slot: None,
11612                armor_physical: None,
11613                resists: vec![],
11614                hand_slots: None,
11615            },
11616        );
11617
11618        let opts = state.move_destinations_for(
11619            &flatland_protocol::InventoryLocation::Root,
11620            None,
11621            None,
11622            "leather_pouch",
11623        );
11624        assert!(
11625            opts.iter().any(|o| matches!(
11626                &o.kind,
11627                MoveOptionKind::Move { location, .. }
11628                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11629            )),
11630            "belt loop must be offered when moving a pouch"
11631        );
11632        assert!(
11633            !opts.iter().any(|o| matches!(
11634                &o.kind,
11635                MoveOptionKind::Move { location, .. }
11636                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
11637            )),
11638            "armor slots can't hold other items and must not appear as move destinations"
11639        );
11640        let belt_opt = opts
11641            .iter()
11642            .find(|o| matches!(
11643                &o.kind,
11644                MoveOptionKind::Move { location, .. }
11645                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11646            ))
11647            .unwrap();
11648        assert!(belt_opt.label.contains("belt loop"));
11649
11650        let opts = state.move_destinations_for(
11651            &flatland_protocol::InventoryLocation::Root,
11652            None,
11653            None,
11654            "lumber",
11655        );
11656        assert!(
11657            !opts.iter().any(|o| o.label.contains("belt loop")),
11658            "loose materials must not target the belt shell — only nested pouches"
11659        );
11660    }
11661
11662    #[test]
11663    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
11664        let mut state = sample_state();
11665        let belt_id = uuid::Uuid::from_u128(30);
11666        let pouch_id = uuid::Uuid::from_u128(31);
11667        state.worn.insert(
11668            BodySlot::Waist,
11669            flatland_protocol::ItemStack {
11670                template_id: "simple_belt".into(),
11671                quantity: 1,
11672                item_instance_id: Some(belt_id),
11673                props: Default::default(),
11674                status_bindings: Vec::new(),
11675                world_placeable: None,
11676                worker_lodging_capacity: None,
11677                equip_slot: None,
11678                armor_physical: None,
11679                resists: vec![],
11680                hand_slots: None,
11681                contents: vec![flatland_protocol::ItemStack {
11682                    template_id: "dimensional_pouch".into(),
11683                    quantity: 1,
11684                    item_instance_id: Some(pouch_id),
11685                    props: Default::default(),
11686                    status_bindings: Vec::new(),
11687                    contents: Vec::new(),
11688                    display_name: Some("Dimensional Pouch".into()),
11689                    category: Some("container".into()),
11690                    base_mass: None,
11691                    base_volume: None,
11692                    capacity_volume: Some(200.0),
11693                    stackable: None,
11694                    world_placeable: None,
11695                    worker_lodging_capacity: None,
11696                    equip_slot: None,
11697                    armor_physical: None,
11698                    resists: vec![],
11699                    hand_slots: None,
11700                }],
11701                display_name: Some("Simple Belt".into()),
11702                category: Some("container".into()),
11703                base_mass: None,
11704                base_volume: None,
11705                capacity_volume: None,
11706                stackable: None,
11707            },
11708        );
11709
11710        let opts = state.move_destinations_for(
11711            &flatland_protocol::InventoryLocation::Root,
11712            None,
11713            None,
11714            "iron_ore",
11715        );
11716        assert!(
11717            opts.iter().any(|o| matches!(
11718                &o.kind,
11719                MoveOptionKind::Move {
11720                    location,
11721                    parent_instance_id,
11722                    ..
11723                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11724                    && *parent_instance_id == Some(pouch_id)
11725            )),
11726            "dimensional pouch clipped on belt must accept loose items"
11727        );
11728        assert!(
11729            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
11730            "destination label should name the pouch"
11731        );
11732    }
11733
11734    #[test]
11735    fn container_volume_label_on_placed_chest_shell() {
11736        let mut state = sample_state();
11737        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11738            id: "chest-1".into(),
11739            template_id: "wooden_chest_small".into(),
11740            display_name: "Camp Chest".into(),
11741            worker_lodging_capacity: None,
11742            x: 129.0,
11743            y: 128.0,
11744            z: 0.0,
11745            locked: false,
11746            accessible: true,
11747            owner_character_id: None,
11748            contents: vec![flatland_protocol::ItemStack {
11749                template_id: "iron_ore".into(),
11750                quantity: 2,
11751                item_instance_id: None,
11752                props: Default::default(),
11753                status_bindings: Vec::new(),
11754                contents: Vec::new(),
11755                display_name: None,
11756                category: None,
11757                base_mass: None,
11758                base_volume: Some(2.0),
11759                capacity_volume: None,
11760                stackable: None,
11761                world_placeable: None,
11762                worker_lodging_capacity: None,
11763                equip_slot: None,
11764                armor_physical: None,
11765                resists: vec![],
11766                hand_slots: None,
11767            }],
11768            lock_id: None,
11769            capacity_volume: Some(60.0),
11770            item_instance_id: Some(uuid::Uuid::from_u128(4)),
11771            tile_id: None,
11772            paperdoll_ref: None,
11773        }];
11774        let nearby = state.nearby_containers();
11775        let label = state.container_volume_label(&nearby[0].rows[0]);
11776        assert!(
11777            label.contains("vol 4/60"),
11778            "expected used/cap in label, got {label}"
11779        );
11780        assert!(
11781            label.contains("56 free"),
11782            "expected free space, got {label}"
11783        );
11784    }
11785
11786    #[test]
11787    fn key_pair_chest_label_from_placed_lock_id() {
11788        let mut state = sample_state();
11789        let owner = uuid::Uuid::from_u128(77);
11790        state.character_id = Some(owner);
11791        let lock = uuid::Uuid::from_u128(99).to_string();
11792        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11793            id: "chest-1".into(),
11794            template_id: "wooden_chest_small".into(),
11795            display_name: "Barry's Loot #a3f2".into(),
11796            x: 129.0,
11797            y: 128.0,
11798            z: 0.0,
11799            locked: true,
11800            accessible: true,
11801            owner_character_id: Some(owner),
11802            contents: Vec::new(),
11803            lock_id: Some(lock.clone()),
11804            capacity_volume: None,
11805            item_instance_id: Some(uuid::Uuid::from_u128(4)),
11806            tile_id: None,
11807            paperdoll_ref: None,
11808            worker_lodging_capacity: None,
11809        }];
11810        let key_id = uuid::Uuid::from_u128(5);
11811        let key = flatland_protocol::ItemStack {
11812            template_id: KEY_TEMPLATE.into(),
11813            quantity: 1,
11814            item_instance_id: Some(key_id),
11815            props: BTreeMap::from([
11816                (PROP_OPENS_LOCK_ID.into(), lock),
11817                (
11818                    PROP_OPENS_CONTAINER_NAME.into(),
11819                    "Barry's Loot #a3f2".into(),
11820                ),
11821            ]),
11822            status_bindings: Vec::new(),
11823            contents: Vec::new(),
11824            display_name: Some("Container Key".into()),
11825            category: Some("key".into()),
11826            base_mass: None,
11827            base_volume: None,
11828            capacity_volume: None,
11829            stackable: None,
11830            world_placeable: None,
11831            worker_lodging_capacity: None,
11832            equip_slot: None,
11833            armor_physical: None,
11834            resists: vec![],
11835            hand_slots: None,
11836        };
11837        state.inventory_stacks = vec![key.clone()];
11838        assert_eq!(
11839            state.key_pair_chest_label(&key).as_deref(),
11840            Some("Barry's Loot #a3f2")
11841        );
11842        assert!(state.key_drop_blocked(&key));
11843    }
11844
11845    #[test]
11846    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
11847        let mut state = sample_state();
11848        let lock = uuid::Uuid::from_u128(101).to_string();
11849        let key = flatland_protocol::ItemStack {
11850            template_id: KEY_TEMPLATE.into(),
11851            quantity: 1,
11852            item_instance_id: Some(uuid::Uuid::from_u128(7)),
11853            props: BTreeMap::from([
11854                (PROP_OPENS_LOCK_ID.into(), lock),
11855                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
11856            ]),
11857            status_bindings: Vec::new(),
11858            contents: Vec::new(),
11859            display_name: None,
11860            category: Some("key".into()),
11861            base_mass: None,
11862            base_volume: None,
11863            capacity_volume: None,
11864            stackable: None,
11865            world_placeable: None,
11866            worker_lodging_capacity: None,
11867            equip_slot: None,
11868            armor_physical: None,
11869            resists: vec![],
11870            hand_slots: None,
11871        };
11872        state.placed_containers.clear();
11873        assert_eq!(
11874            state.key_pair_chest_label(&key).as_deref(),
11875            Some("Camp Stash")
11876        );
11877    }
11878
11879    #[test]
11880    fn key_drop_allowed_when_paired_chest_unlocked() {
11881        let mut state = sample_state();
11882        let lock = uuid::Uuid::from_u128(100).to_string();
11883        let key_id = uuid::Uuid::from_u128(6);
11884        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11885            id: "chest-1".into(),
11886            template_id: "wooden_chest_small".into(),
11887            display_name: "Camp Chest".into(),
11888            x: 129.0,
11889            y: 128.0,
11890            z: 0.0,
11891            locked: false,
11892            accessible: true,
11893            owner_character_id: None,
11894            contents: Vec::new(),
11895            lock_id: Some(lock.clone()),
11896            capacity_volume: None,
11897            item_instance_id: None,
11898            tile_id: None,
11899            paperdoll_ref: None,
11900            worker_lodging_capacity: None,
11901        }];
11902        let key = flatland_protocol::ItemStack {
11903            template_id: KEY_TEMPLATE.into(),
11904            quantity: 1,
11905            item_instance_id: Some(key_id),
11906            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
11907            status_bindings: Vec::new(),
11908            contents: Vec::new(),
11909            display_name: None,
11910            category: Some("key".into()),
11911            base_mass: None,
11912            base_volume: None,
11913            capacity_volume: None,
11914            stackable: None,
11915            world_placeable: None,
11916            worker_lodging_capacity: None,
11917                equip_slot: None,
11918                armor_physical: None,
11919                resists: vec![],
11920                hand_slots: None,
11921        };
11922        state.inventory_stacks = vec![key.clone()];
11923        assert!(!state.key_drop_blocked(&key));
11924        let opts = state.move_destinations_for(
11925            &flatland_protocol::InventoryLocation::Root,
11926            None,
11927            Some(key_id),
11928            KEY_TEMPLATE,
11929        );
11930        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
11931    }
11932
11933    #[test]
11934    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
11935        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
11936
11937        let mut state = sample_state();
11938        let curve = ProgressionCurve::default();
11939        let bootstrap = ProgressionXp::bootstrap_new(
11940            curve.baseline_display,
11941            curve.xp_base,
11942            curve.xp_growth,
11943        );
11944        let mut fresh = bootstrap.clone();
11945        fresh.strength += 0.08;
11946        if let Some(player) = state.player.as_mut() {
11947            player.progression_xp = Some(bootstrap);
11948        }
11949
11950        let combat = CombatHud {
11951            progression_xp: Some(fresh.clone()),
11952            progression_baseline: curve.baseline_display,
11953            progression_xp_base: curve.xp_base,
11954            progression_xp_growth: curve.xp_growth,
11955            attributes: state.player.as_ref().and_then(|p| p.attributes),
11956            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
11957            ..CombatHud::default()
11958        };
11959        state.apply_combat_hud(&combat);
11960
11961        let xp = state
11962            .player
11963            .as_ref()
11964            .and_then(|p| p.progression_xp.as_ref())
11965            .expect("xp");
11966        assert!((xp.strength - fresh.strength).abs() < 0.001);
11967        assert!(state.progression_curve.is_some());
11968    }
11969
11970    #[test]
11971    fn combat_hud_syncs_known_abilities_and_hotbar() {
11972        use flatland_protocol::CombatHud;
11973
11974        let mut state = sample_state();
11975        let combat = CombatHud {
11976            known_abilities: vec!["unarmed".into(), "fireball".into()],
11977            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
11978            max_abilities_per_rotation: 4,
11979            ability_id: "short_sword_slash".into(),
11980            ..CombatHud::default()
11981        };
11982        state.apply_combat_hud(&combat);
11983
11984        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
11985        assert_eq!(state.hotbar_ability(1), Some("fireball"));
11986        assert_eq!(state.hotbar_ability(2), None);
11987        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
11988        assert_eq!(state.max_abilities_per_rotation, 4);
11989        let choices = state.loadout_ability_choices();
11990        assert!(choices.iter().any(|a| a == "short_sword_slash"));
11991        assert!(choices.iter().any(|a| a == "fireball"));
11992    }
11993
11994    #[test]
11995    fn loadout_hotbar_choices_include_inventory_consumables() {
11996        let mut state = sample_state();
11997        state.known_abilities = vec!["unarmed".into()];
11998        state.weapon_ability_id = "unarmed".into();
11999        state.inventory_stacks = vec![flatland_protocol::ItemStack {
12000            template_id: "bottle_of_water".into(),
12001            quantity: 3,
12002            item_instance_id: Some(uuid::Uuid::from_u128(9)),
12003            display_name: Some("Bottle of Water".into()),
12004            category: Some("consumable".into()),
12005            ..Default::default()
12006        }];
12007        state.inventory.insert("bottle_of_water".into(), 3);
12008        state.inventory_hints.insert(
12009            "bottle_of_water".into(),
12010            InventoryHint {
12011                display_name: "Bottle of Water".into(),
12012                category: "consumable".into(),
12013                ..Default::default()
12014            },
12015        );
12016
12017        let choices = state.loadout_hotbar_choices();
12018        assert!(choices.iter().any(|c| c.binding == "unarmed"));
12019        let water = choices
12020            .iter()
12021            .find(|c| c.binding == "item:bottle_of_water")
12022            .expect("water binding");
12023        assert_eq!(water.meta.as_deref(), Some("use"));
12024        assert!(water.label.contains("Water"));
12025        assert_eq!(
12026            state.hotbar_slot_label(1),
12027            None,
12028            "unbound until set"
12029        );
12030        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
12031        assert_eq!(
12032            state.hotbar_slot_label(5).as_deref(),
12033            Some("Bottle of Water×3")
12034        );
12035    }
12036
12037    #[test]
12038    fn loose_consumable_move_picker_offers_use_and_storage() {
12039        let mut state = sample_state();
12040        let inst = uuid::Uuid::from_u128(77);
12041        state.inventory_stacks = vec![flatland_protocol::ItemStack {
12042            template_id: "carrot".into(),
12043            quantity: 2,
12044            item_instance_id: Some(inst),
12045            props: Default::default(),
12046            status_bindings: Vec::new(),
12047            contents: Vec::new(),
12048            display_name: Some("Wild Carrot".into()),
12049            category: Some("consumable".into()),
12050            base_mass: None,
12051            base_volume: None,
12052            capacity_volume: None,
12053            stackable: Some(true),
12054            world_placeable: None,
12055            worker_lodging_capacity: None,
12056                equip_slot: None,
12057                armor_physical: None,
12058                resists: vec![],
12059                hand_slots: None,
12060        }];
12061        state.inventory_hints.insert(
12062            "carrot".into(),
12063            InventoryHint {
12064                display_name: "Wild Carrot".into(),
12065                category: "consumable".into(),
12066                base_mass: Some(0.15),
12067                base_volume: Some(0.3),
12068                capacity_volume: None,
12069                stackable: true,
12070            },
12071        );
12072        state.show_inventory_menu = true;
12073        state.inventory_menu_index = 0;
12074
12075        let row = state.inventory_selected_row().expect("carrot row");
12076        let mut options = state.move_destinations_for(
12077            &row.from,
12078            row.from_parent_instance_id,
12079            row.stack.item_instance_id,
12080            &row.stack.template_id,
12081        );
12082        if row.from == flatland_protocol::InventoryLocation::Root
12083            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
12084        {
12085            options.insert(
12086                0,
12087                MoveOption {
12088                    label: "Use (eat / drink)".into(),
12089                    kind: MoveOptionKind::Use,
12090                },
12091            );
12092        }
12093
12094        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
12095        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
12096        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
12097    }
12098
12099    #[test]
12100    fn inventory_category_group_order_is_stable() {
12101        assert_eq!(inventory_category_group("weapon").0, "Weapons");
12102        assert_eq!(inventory_category_group("armor").0, "Armor");
12103        assert_eq!(inventory_category_group("consumable").0, "Consumables");
12104        assert_eq!(inventory_category_group("resource").0, "Resources");
12105        assert_eq!(inventory_category_group("container").0, "Containers");
12106        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
12107        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
12108    }
12109
12110    #[test]
12111    fn page_list_index_clamps_without_wrap() {
12112        assert_eq!(page_list_index(0, -1, 25), 0);
12113        assert_eq!(page_list_index(0, 1, 25), 10);
12114        assert_eq!(page_list_index(12, 1, 25), 22);
12115        assert_eq!(page_list_index(22, 1, 25), 24);
12116        assert_eq!(page_list_index(5, 1, 0), 0);
12117        assert_eq!(page_list_index(3, -1, 8), 0);
12118    }
12119
12120    #[test]
12121    fn inventory_filter_hides_non_matching_person_items() {
12122        let mut state = sample_state();
12123        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
12124        sword.display_name = Some("Iron Sword".into());
12125        sword.category = Some("weapon".into());
12126        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
12127        herb.display_name = Some("Wild Herb".into());
12128        herb.category = Some("consumable".into());
12129        state.inventory_stacks = vec![sword, herb];
12130        state.inventory_tab = InventoryTab::OnPerson;
12131        state.inventory_filter = "sword".into();
12132
12133        let rows = state.inventory_selectable_rows();
12134        assert_eq!(rows.len(), 1);
12135        assert_eq!(rows[0].stack.template_id, "iron_sword");
12136
12137        let lines = state.inventory_browser_lines();
12138        assert!(lines.iter().any(|l| matches!(
12139            l,
12140            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
12141        )));
12142        assert!(!lines.iter().any(|l| matches!(
12143            l,
12144            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
12145        )));
12146    }
12147
12148    #[test]
12149    fn inventory_person_rows_group_by_category() {
12150        let mut state = sample_state();
12151        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
12152        sword.category = Some("weapon".into());
12153        sword.display_name = Some("Iron Sword".into());
12154        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
12155        ore.category = Some("resource".into());
12156        ore.display_name = Some("Iron Ore".into());
12157        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
12158        potion.category = Some("consumable".into());
12159        potion.display_name = Some("Health Potion".into());
12160        state.inventory_stacks = vec![ore, potion, sword];
12161        state.inventory_tab = InventoryTab::OnPerson;
12162
12163        let lines = state.inventory_browser_lines();
12164        let labels: Vec<&str> = lines
12165            .iter()
12166            .filter_map(|l| match l {
12167                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
12168                _ => None,
12169            })
12170            .collect();
12171        assert!(
12172            labels.iter().any(|s| s.contains("Weapons")),
12173            "expected Weapons group: {labels:?}"
12174        );
12175        assert!(labels.iter().any(|s| s.contains("Consumables")));
12176        assert!(labels.iter().any(|s| s.contains("Resources")));
12177
12178        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
12179        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
12180        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
12181        assert!(weapon_pos < consumable_pos);
12182        assert!(consumable_pos < resource_pos);
12183    }
12184
12185    #[test]
12186    fn inventory_tab_cycle_resets_selection() {
12187        let mut state = sample_state();
12188        state.inventory_tab = InventoryTab::OnPerson;
12189        state.inventory_menu_index = 3;
12190        state.inventory_tab = state.inventory_tab.cycle(true);
12191        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
12192        // Client method resets index; enum cycle alone does not — verify cycle labels.
12193        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
12194        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
12195        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
12196        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
12197    }
12198
12199    #[test]
12200    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
12201        assert_eq!(parse_bank_copper_amount(""), Some(0));
12202        assert_eq!(parse_bank_copper_amount("  "), Some(0));
12203        assert_eq!(parse_bank_copper_amount("0"), Some(0));
12204        assert_eq!(parse_bank_copper_amount("250"), Some(250));
12205        assert_eq!(parse_bank_copper_amount("nope"), None);
12206    }
12207
12208    #[test]
12209    fn parse_storage_quantity_blank_and_zero_mean_all() {
12210        assert_eq!(parse_storage_quantity(""), Some(None));
12211        assert_eq!(parse_storage_quantity("  "), Some(None));
12212        assert_eq!(parse_storage_quantity("0"), Some(None));
12213        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
12214        assert_eq!(parse_storage_quantity("nope"), None);
12215    }
12216}