Skip to main content

flatland_client_lib/
game.rs

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