Skip to main content

flatland_client_lib/
game.rs

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