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