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