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