Skip to main content

flatland_client_lib/
game.rs

1use std::collections::{BTreeMap, HashMap, HashSet, 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 PROPERTY_DEED_TEMPLATE: &str = "property_deed";
73const PROP_LOCK_ID: &str = "lock_id";
74const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
75const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
76const PROP_CUSTOM_NAME: &str = "custom_name";
77const PROP_LOCKED: &str = "locked";
78
79/// Local claim-mode footprint editor (plan 40) — SW corner + size in meters.
80#[derive(Debug, Clone, PartialEq)]
81pub struct ClaimModeState {
82    pub zone_id: String,
83    pub width_m: u32,
84    pub height_m: u32,
85    pub anchor_x: f32,
86    pub anchor_y: f32,
87}
88
89/// Local relocate ghost for a placed chest/lodging (1×1 cell cursor).
90#[derive(Debug, Clone, PartialEq)]
91pub struct RelocateModeState {
92    pub container_id: String,
93    pub label: String,
94    pub cursor_x: f32,
95    pub cursor_y: f32,
96}
97
98fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
99    stack
100        .props
101        .get(PROP_LOCKED)
102        .is_some_and(|v| v == "true" || v == "1")
103}
104
105const MAX_LOG_LINES: usize = 200;
106const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
107const INTERACTION_RADIUS_M: f32 = 1.5;
108const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
109const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
110const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
111/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
112const CRAFT_STAMINA_COST: f32 = 3.0;
113/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
114const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
115/// Keep the last worker route error visible in the HUD after the server clears it.
116const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
117
118/// Catalog hints synced from server `ItemStack` wire rows.
119#[derive(Debug, Clone, Default)]
120pub struct InventoryHint {
121    pub display_name: String,
122    pub category: String,
123    pub base_mass: Option<f32>,
124    pub base_volume: Option<f32>,
125    pub capacity_volume: Option<f32>,
126    pub stackable: bool,
127    /// Market-hall list eligibility (from catalog enrichment).
128    pub listable: bool,
129}
130
131/// One row in the loadout hotbar picker (ability or inventory consumable).
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct LoadoutHotbarChoice {
134    /// Value sent via [`Intent::SetHotbarSlot`] (`ability_id` or `item:<template>`).
135    pub binding: String,
136    /// Display label (ability id, or "Carrot ×3").
137    pub label: String,
138    /// Optional meta badge (`weapon`, `use`, …).
139    pub meta: Option<String>,
140}
141
142/// Rotation editor overlay mode (`plans/26` §C2.5).
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum RotationEditorMode {
145    #[default]
146    List,
147    EditSequence,
148    PickAbility,
149    EditLabel,
150}
151
152/// Local rotation editor UI state (not persisted).
153#[derive(Debug, Clone, Default)]
154pub struct RotationEditorState {
155    pub mode: RotationEditorMode,
156    pub list_index: usize,
157    pub ability_index: usize,
158    pub picker_index: usize,
159    pub draft: Option<RotationPreset>,
160    pub label_buffer: String,
161}
162
163impl RotationEditorState {
164    pub fn reset(&mut self) {
165        *self = Self::default();
166    }
167}
168
169/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
170/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
171/// client only ever shows chests the server will actually let you use — this is
172/// what makes a chest disappear from the menu as soon as you walk away.
173pub const CONTAINER_RANGE_M: f32 = 3.0;
174
175/// Broad section of the inventory browser a row belongs to (drives the grouped
176/// "Worn" / "On you" / "Nearby chest" headers in the UI).
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum InventorySection {
179    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
180    Worn,
181    /// Loose on your person — not worn, not inside a placed chest.
182    Person,
183    /// Inside a placed chest within reach.
184    Nearby,
185}
186
187/// Top-level inventory browser tab (`b` menu).
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum InventoryTab {
190    #[default]
191    OnPerson,
192    Nearby,
193}
194
195impl InventoryTab {
196    pub fn label(self) -> &'static str {
197        match self {
198            Self::OnPerson => "On person",
199            Self::Nearby => "Nearby storage",
200        }
201    }
202
203    pub fn cycle(self, forward: bool) -> Self {
204        match (self, forward) {
205            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
206            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
207        }
208    }
209}
210
211/// Rows jumped by PageUp / PageDown in list UIs.
212pub const LIST_PAGE_SIZE: usize = 10;
213
214/// Case-insensitive substring match for list filters (name / template / label).
215pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
216    if filter.is_empty() {
217        return true;
218    }
219    haystack
220        .to_ascii_lowercase()
221        .contains(&filter.to_ascii_lowercase())
222}
223
224/// Clamp a list selection after a page jump (`pages` is typically ±1).
225pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
226    if len == 0 {
227        return 0;
228    }
229    let page = LIST_PAGE_SIZE as i32;
230    let next = index as i32 + pages * page;
231    next.clamp(0, (len as i32) - 1) as usize
232}
233
234/// Advance `index` by `delta` among indices where `pred` is true (wraps).
235pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
236    if len == 0 {
237        return 0;
238    }
239    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
240    if matching.is_empty() {
241        return index.min(len - 1);
242    }
243    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
244    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
245    matching[next]
246}
247
248/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
249pub fn page_filtered_index(
250    index: usize,
251    pages: i32,
252    len: usize,
253    pred: impl Fn(usize) -> bool,
254) -> usize {
255    if len == 0 {
256        return 0;
257    }
258    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
259    if matching.is_empty() {
260        return index.min(len - 1);
261    }
262    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
263    let next = page_list_index(pos, pages, matching.len());
264    matching[next]
265}
266
267/// Category group for On-person loose items (stable order).
268pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
269    match category {
270        "weapon" | "ammo" => ("Weapons", 0),
271        "armor" | "shield" | "offhand" => ("Armor", 1),
272        "consumable" => ("Consumables", 2),
273        "resource" | "harvest_node" | "seed" => ("Resources", 3),
274        "container" | "lodging" => ("Containers", 4),
275        "currency" | "key" => ("Currency & keys", 5),
276        "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
277        _ => ("Other", 7),
278    }
279}
280
281/// Whether a template category is listable when the wire/hint omits an explicit flag.
282pub fn category_default_listable(category: &str) -> bool {
283    !matches!(
284        category,
285        "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
286    )
287}
288
289/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
290fn parse_bank_copper_amount(input: &str) -> Option<u64> {
291    let s = input.trim();
292    if s.is_empty() {
293        return Some(0);
294    }
295    s.parse::<u64>().ok()
296}
297
298/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
299fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
300    let s = input.trim();
301    if s.is_empty() || s == "0" {
302        return Some(None);
303    }
304    let n = s.parse::<u32>().ok()?;
305    if n == 0 {
306        return Some(None);
307    }
308    Some(Some(n))
309}
310
311fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
312    let name = stack
313        .display_name
314        .as_deref()
315        .unwrap_or(stack.template_id.as_str());
316    if stack.quantity > 1 {
317        format!("{name} ×{}", stack.quantity)
318    } else {
319        name.to_string()
320    }
321}
322
323/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
324/// browser section headers and the move-destination picker.
325pub fn body_slot_label(slot: BodySlot) -> &'static str {
326    match slot {
327        BodySlot::Head => "Head",
328        BodySlot::Chest => "Chest",
329        BodySlot::Forearms => "Forearms",
330        BodySlot::Legs => "Legs",
331        BodySlot::Feet => "Feet",
332        BodySlot::Cloak => "Cloak",
333        BodySlot::Back => "Back",
334        BodySlot::Waist => "Waist",
335        BodySlot::Earrings => "Earrings",
336        BodySlot::Necklace => "Necklace",
337        BodySlot::Eyeglasses => "Eyeglasses",
338        BodySlot::RingLeft1 => "Ring L1",
339        BodySlot::RingLeft2 => "Ring L2",
340        BodySlot::RingRight1 => "Ring R1",
341        BodySlot::RingRight2 => "Ring R2",
342    }
343}
344
345fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
346    let cat = stack.category.as_deref().unwrap_or("");
347    match mode {
348        "while_equipped" => {
349            stack.equip_slot.is_some()
350                || cat == "weapon"
351                || cat == "shield"
352                || cat == "offhand"
353                || cat == "armor"
354        }
355        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
356    }
357}
358
359fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
360    if grant_tags.is_empty() {
361        return true;
362    }
363    let target_tags: Vec<&str> = stack
364        .props
365        .get("allowed_enchant_tags")
366        .map(|s| {
367            s.split(',')
368                .map(str::trim)
369                .filter(|t| !t.is_empty())
370                .collect()
371        })
372        .unwrap_or_default();
373    if target_tags.is_empty() {
374        return true;
375    }
376    grant_tags.iter().any(|t| target_tags.contains(t))
377}
378
379/// Default sim tick rate when the client has no settings (matches server-settings).
380pub const DEFAULT_TICK_HZ: u32 = 30;
381
382/// Human-readable remaining time for an item status binding.
383pub fn format_binding_ttl(
384    binding: &flatland_protocol::ItemStatusBinding,
385    tick: u64,
386    tick_hz: u32,
387) -> String {
388    let Some(expires) = binding.expires_at_tick else {
389        return "permanent".into();
390    };
391    let hz = tick_hz.max(1) as f32;
392    let remaining = expires.saturating_sub(tick) as f32 / hz;
393    if remaining <= 0.0 {
394        return "expired".into();
395    }
396    if remaining >= 120.0 {
397        format!("{:.0}m left", remaining / 60.0)
398    } else if remaining >= 10.0 {
399        format!("{remaining:.0}s left")
400    } else {
401        format!("{remaining:.1}s left")
402    }
403}
404
405pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
406    match mode {
407        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
408        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
409    }
410}
411
412/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
413pub fn format_status_bindings_suffix(
414    bindings: &[flatland_protocol::ItemStatusBinding],
415    tick: u64,
416    tick_hz: u32,
417) -> String {
418    if bindings.is_empty() {
419        return String::new();
420    }
421    let parts: Vec<String> = bindings
422        .iter()
423        .map(|b| {
424            format!(
425                "{} ({}, {})",
426                b.effect_id,
427                format_binding_mode(b.mode),
428                format_binding_ttl(b, tick, tick_hz)
429            )
430        })
431        .collect();
432    format!(" · {}", parts.join("; "))
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum EquipPaperdollRow {
437    Body { slot: BodySlot, filled: bool },
438    Mainhand { filled: bool },
439    Offhand { filled: bool, locked: bool },
440}
441
442pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
443    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
444        .iter()
445        .map(|slot| EquipPaperdollRow::Body {
446            slot: *slot,
447            filled: state.worn.contains_key(slot),
448        })
449        .collect();
450    let two_hand = state.mainhand_hand_slots >= 2;
451    rows.push(EquipPaperdollRow::Mainhand {
452        filled: state.mainhand_template_id.is_some(),
453    });
454    rows.push(EquipPaperdollRow::Offhand {
455        filled: state.offhand_template_id.is_some(),
456        locked: two_hand,
457    });
458    rows
459}
460
461fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
462    for stack in &state.inventory_stacks {
463        let matches = stack
464            .equip_slot
465            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
466            .unwrap_or(false)
467            || guess_body_slot(&stack.template_id) == Some(slot);
468        if matches {
469            return stack.item_instance_id;
470        }
471    }
472    None
473}
474
475fn is_client_ring(slot: BodySlot) -> bool {
476    matches!(
477        slot,
478        BodySlot::RingLeft1
479            | BodySlot::RingLeft2
480            | BodySlot::RingRight1
481            | BodySlot::RingRight2
482    )
483}
484
485fn first_inventory_weapon(state: &GameState) -> Option<String> {
486    for stack in &state.inventory_stacks {
487        if stack.category.as_deref() == Some("weapon") {
488            return Some(stack.template_id.clone());
489        }
490    }
491    None
492}
493
494fn first_inventory_offhand(state: &GameState) -> Option<String> {
495    for stack in &state.inventory_stacks {
496        let cat = stack.category.as_deref().unwrap_or("");
497        if matches!(cat, "shield" | "offhand") {
498            return Some(stack.template_id.clone());
499        }
500    }
501    None
502}
503
504/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
505/// on the stack when present; otherwise guesses from `template_id`.
506fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
507    if template_id.contains("backpack") {
508        Some(BodySlot::Back)
509    } else if template_id.contains("belt") {
510        Some(BodySlot::Waist)
511    } else if template_id.contains("cloak") || template_id.contains("cape") {
512        Some(BodySlot::Cloak)
513    } else if template_id.contains("cap")
514        || template_id.contains("hat")
515        || template_id.contains("helm")
516    {
517        Some(BodySlot::Head)
518    } else if template_id.contains("shirt")
519        || template_id.contains("robe")
520        || template_id.contains("vest")
521        || template_id.contains("chest")
522        || template_id.contains("jerkin")
523    {
524        Some(BodySlot::Chest)
525    } else if template_id.contains("sleeves")
526        || template_id.contains("gloves")
527        || template_id.contains("gauntlets")
528    {
529        Some(BodySlot::Forearms)
530    } else if template_id.contains("pants") || template_id.contains("leggings") {
531        Some(BodySlot::Legs)
532    } else if template_id.contains("boots") || template_id.contains("shoes") {
533        Some(BodySlot::Feet)
534    } else if template_id.contains("earring") {
535        Some(BodySlot::Earrings)
536    } else if template_id.contains("necklace") || template_id.contains("amulet") {
537        Some(BodySlot::Necklace)
538    } else if template_id.contains("glass")
539        || template_id.contains("spectacles")
540        || template_id.contains("goggles")
541    {
542        Some(BodySlot::Eyeglasses)
543    } else if template_id.contains("ring") {
544        Some(BodySlot::RingLeft1)
545    } else {
546        None
547    }
548}
549
550/// One row in the inventory browser tree.
551#[derive(Debug, Clone)]
552pub struct InventoryRow {
553    pub depth: usize,
554    pub stack: flatland_protocol::ItemStack,
555    /// `MoveItem` source location for this stack.
556    pub from: flatland_protocol::InventoryLocation,
557    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
558    pub from_parent_instance_id: Option<uuid::Uuid>,
559    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
560    pub is_equip_shell: bool,
561    /// Placed world chest shell — lock/unlock via Enter or `l`.
562    pub is_chest_shell: bool,
563    pub section: InventorySection,
564}
565
566/// Formatted inventory row text shared by TUI and gfx browsers.
567#[derive(Debug, Clone)]
568pub struct InventoryRowView {
569    pub depth: usize,
570    /// Dense single-line label (legacy / TUI).
571    pub text: String,
572    /// Primary label for redesigned gfx rows (name, qty, slot).
573    pub title: String,
574    pub mass_kg: Option<f32>,
575    pub volume: Option<(f32, f32)>,
576    /// Hover hint when multiple rows share the same visible identity (template + label + mods).
577    pub instance_tooltip: Option<String>,
578}
579
580/// One line in the sectioned inventory browser (headers are non-selectable).
581#[derive(Debug, Clone)]
582pub enum InventoryBrowserLine {
583    Section(String),
584    SlotLabel(String),
585    Hint(String),
586    Blank,
587    Item {
588        selectable_index: usize,
589        selected: bool,
590        depth: usize,
591        text: String,
592        title: String,
593        mass_kg: Option<f32>,
594        volume: Option<(f32, f32)>,
595        instance_tooltip: Option<String>,
596    },
597}
598
599/// Bank teller panel focus (action list vs amount / transfer prompts).
600#[derive(Debug, Clone, PartialEq, Eq, Default)]
601pub enum BankUiMode {
602    #[default]
603    Menu,
604    DepositAmount {
605        input: String,
606    },
607    WithdrawAmount {
608        input: String,
609    },
610    TransferName {
611        input: String,
612    },
613    TransferAmount {
614        to_name: String,
615        input: String,
616    },
617}
618
619/// Town storage manager focus (action list vs item pickers vs quantity).
620#[derive(Debug, Clone, PartialEq, Eq, Default)]
621pub enum StorageUiMode {
622    #[default]
623    Menu,
624    /// Pick a loose on-person stack to store.
625    StorePick {
626        index: usize,
627    },
628    /// Quantity for a chosen store stack (blank/0 = all).
629    StoreAmount {
630        pick_index: usize,
631        item_instance_id: uuid::Uuid,
632        label: String,
633        max_qty: u32,
634        input: String,
635    },
636    /// Pick a vault stack to take.
637    TakePick {
638        index: usize,
639    },
640    /// Quantity for a chosen take stack (blank/0 = all).
641    TakeAmount {
642        pick_index: usize,
643        item_instance_id: uuid::Uuid,
644        label: String,
645        max_qty: u32,
646        input: String,
647    },
648    /// Pick a vault stack to ship to `dest_building_id`.
649    ShipPick {
650        dest_building_id: String,
651        dest_label: String,
652        index: usize,
653    },
654    /// Quantity for a chosen ship stack (blank/0 = all).
655    ShipAmount {
656        dest_building_id: String,
657        dest_label: String,
658        pick_index: usize,
659        item_instance_id: uuid::Uuid,
660        label: String,
661        max_qty: u32,
662        input: String,
663    },
664}
665
666/// Where market list goods are taken from (`GoodsLocation` on submit).
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum MarketListSourceKind {
669    Person,
670    TownStorage { building_id: String },
671}
672
673/// Market hall clerk focus (browse vs list wizard).
674#[derive(Debug, Clone, PartialEq, Eq, Default)]
675pub enum MarketUiMode {
676    #[default]
677    Browse,
678    /// Pick on-person vs an eligible town vault.
679    ListSource {
680        index: usize,
681    },
682    /// Pick a stack from the chosen source.
683    ListPick {
684        source: MarketListSourceKind,
685        index: usize,
686    },
687    /// Quantity to list (blank/0 = all).
688    ListAmount {
689        source: MarketListSourceKind,
690        pick_index: usize,
691        item_instance_id: uuid::Uuid,
692        label: String,
693        max_qty: u32,
694        input: String,
695    },
696    /// Choose Fixed copper vs NPC-price dump queue (`plans/53`).
697    ListPricingMode {
698        source: MarketListSourceKind,
699        item_instance_id: uuid::Uuid,
700        label: String,
701        quantity: Option<u32>,
702        max_qty: u32,
703        /// 0 = NPC price, 1 = Fixed.
704        index: usize,
705    },
706    /// Unit price in copper.
707    ListPrice {
708        source: MarketListSourceKind,
709        item_instance_id: uuid::Uuid,
710        label: String,
711        /// Resolved quantity to list (`None` = all / omit on wire).
712        quantity: Option<u32>,
713        max_qty: u32,
714        input: String,
715    },
716}
717
718/// One selectable stack in a storage store/take/ship picker.
719#[derive(Debug, Clone)]
720pub struct StoragePickOption {
721    pub item_instance_id: uuid::Uuid,
722    pub label: String,
723    pub quantity: u32,
724    /// Catalog category when known (market list filters).
725    pub category: String,
726}
727
728/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
729/// the browser (empty when locked without the matching key).
730#[derive(Debug, Clone)]
731pub struct NearbyContainer {
732    pub view: flatland_protocol::PlacedContainerView,
733    pub distance_m: f32,
734    pub rows: Vec<InventoryRow>,
735}
736
737/// One key row in the keychain overlay (carried vs stowed).
738#[derive(Debug, Clone)]
739pub struct KeychainEntry {
740    pub stack: flatland_protocol::ItemStack,
741    pub stowed: bool,
742}
743
744/// A destination the currently-picked item could be moved to.
745#[derive(Debug, Clone)]
746pub struct MoveOption {
747    pub label: String,
748    pub kind: MoveOptionKind,
749}
750
751#[derive(Debug, Clone, PartialEq)]
752pub enum MoveOptionKind {
753    Move {
754        location: flatland_protocol::InventoryLocation,
755        parent_instance_id: Option<uuid::Uuid>,
756    },
757    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
758    PickupPlaced {
759        container_id: String,
760        nest_location: flatland_protocol::InventoryLocation,
761        nest_parent_instance_id: Option<uuid::Uuid>,
762    },
763    /// Enter map relocate mode for a placed chest (no pickup).
764    RelocatePlaced {
765        container_id: String,
766    },
767    /// Eat/drink a loose consumable (one unit per use).
768    Use,
769    /// Open grant-target picker for `grants_item_status` consumables.
770    GrantApply,
771    Drop,
772    /// Sell the plot tied to a property deed back to the crown.
773    SellPlotToCrown {
774        plot_id: uuid::Uuid,
775    },
776    Cancel,
777}
778
779/// One navigable row in the deed farm-access panel.
780#[derive(Debug, Clone, PartialEq)]
781pub enum FarmAccessRow {
782    PublicToggle,
783    PublicDiscount,
784    AllowRemove {
785        character_id: uuid::Uuid,
786        label: String,
787        tax_discount_bps: u32,
788    },
789    NearbyAdd {
790        name: String,
791    },
792}
793
794/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
795#[derive(Debug, Clone)]
796pub struct GrantTargetPicker {
797    pub grant_instance_id: uuid::Uuid,
798    pub grant_label: String,
799    pub effect_id: String,
800    pub mode: String,
801    pub options: Vec<GrantTargetOption>,
802    pub filter: String,
803    pub filter_focused: bool,
804}
805
806#[derive(Debug, Clone)]
807pub struct GrantTargetOption {
808    pub label: String,
809    pub target_instance_id: uuid::Uuid,
810}
811
812/// Active "move to…" destination picker state for the selected inventory item.
813#[derive(Debug, Clone)]
814pub struct MovePicker {
815    pub item_instance_id: uuid::Uuid,
816    pub from: flatland_protocol::InventoryLocation,
817    pub item_label: String,
818    pub template_id: String,
819    pub stack_quantity: u32,
820    pub quantity: u32,
821    pub options: Vec<MoveOption>,
822    pub filter: String,
823    pub filter_focused: bool,
824}
825
826/// Active permanent-delete picker for the selected inventory item.
827#[derive(Debug, Clone)]
828pub struct DestroyPicker {
829    pub item_instance_id: uuid::Uuid,
830    pub from: flatland_protocol::InventoryLocation,
831    pub item_label: String,
832    pub stack_quantity: u32,
833    pub quantity: u32,
834}
835
836/// One giveable stack in the workers-menu give picker.
837#[derive(Debug, Clone)]
838pub struct WorkerGiveOption {
839    pub item_instance_id: uuid::Uuid,
840    pub label: String,
841    pub quantity: u32,
842    pub template_id: String,
843}
844
845/// Give an on-person inventory stack to the selected hired worker.
846#[derive(Debug, Clone)]
847pub struct WorkerGivePicker {
848    pub worker_instance_id: String,
849    pub worker_label: String,
850    pub options: Vec<WorkerGiveOption>,
851}
852
853/// Nearby hired worker choice when giving a selected inventory stack (`g`).
854#[derive(Debug, Clone)]
855pub struct WorkerGiveTargetOption {
856    pub instance_id: String,
857    pub label: String,
858    pub distance_m: f32,
859}
860
861/// Pick which nearby worker receives the selected inventory item.
862#[derive(Debug, Clone)]
863pub struct WorkerGiveTargetPicker {
864    pub item_instance_id: uuid::Uuid,
865    pub item_label: String,
866    pub quantity: Option<u32>,
867    pub options: Vec<WorkerGiveTargetOption>,
868}
869
870/// Take an item from a hired worker back into the employer's inventory.
871#[derive(Debug, Clone)]
872pub struct WorkerTakePicker {
873    pub worker_instance_id: String,
874    pub worker_label: String,
875    pub options: Vec<WorkerGiveOption>,
876    /// How many of the selected stack to take (1..=stack quantity).
877    pub quantity: u32,
878}
879
880/// Max distance (m) to hand an item to a hired worker.
881pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
882
883/// One teachable blueprint in the workers-menu teach picker.
884#[derive(Debug, Clone)]
885pub struct WorkerTeachOption {
886    pub blueprint_id: String,
887    pub label: String,
888    pub cost_copper: u64,
889    pub min_level: u32,
890    pub worker_level: u32,
891    pub can_afford: bool,
892    pub level_ok: bool,
893}
894
895/// Teach a known blueprint to the selected hired worker.
896#[derive(Debug, Clone)]
897pub struct WorkerTeachPicker {
898    pub worker_instance_id: String,
899    pub worker_label: String,
900    pub worker_level: u32,
901    pub options: Vec<WorkerTeachOption>,
902}
903
904/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
905/// do not thrash the UI.
906#[derive(Debug, Clone, Default)]
907pub struct StickyWorkerStep {
908    shown: String,
909    pending: String,
910    pending_since: Option<Instant>,
911}
912
913impl StickyWorkerStep {
914    fn from_label(label: String) -> Self {
915        Self {
916            shown: label.clone(),
917            pending: label,
918            pending_since: Some(Instant::now()),
919        }
920    }
921
922    fn observe(&mut self, label: &str, now: Instant) {
923        let pending_since = self.pending_since.unwrap_or(now);
924        if label == self.pending {
925            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
926            {
927                self.shown = self.pending.clone();
928            }
929            return;
930        }
931        self.pending = label.to_string();
932        self.pending_since = Some(now);
933        // Empty → first value, or first observation: show immediately.
934        if self.shown.is_empty() {
935            self.shown = self.pending.clone();
936        }
937    }
938}
939
940/// Last non-transient worker error — held so route flicker stays readable.
941/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
942#[derive(Debug, Clone, Default)]
943pub struct StickyWorkerError {
944    message: String,
945    last_seen: Option<Instant>,
946}
947
948impl StickyWorkerError {
949    fn observe(&mut self, err: Option<&str>, now: Instant) {
950        if let Some(e) = err {
951            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
952                self.message = e.to_string();
953                self.last_seen = Some(now);
954            }
955            return;
956        }
957        if let Some(seen) = self.last_seen {
958            if now.duration_since(seen) > WORKER_ERROR_HOLD {
959                self.message.clear();
960                self.last_seen = None;
961            }
962        }
963    }
964
965    pub fn shown(&self, now: Instant) -> Option<&str> {
966        if self.message.is_empty() {
967            return None;
968        }
969        let seen = self.last_seen?;
970        if now.duration_since(seen) > WORKER_ERROR_HOLD {
971            return None;
972        }
973        Some(self.message.as_str())
974    }
975}
976
977/// First non-transient worker issue for the status bar (strike, route error, etc.).
978/// Routine recovery noise (path failures → lodging) stays in the game log only.
979pub fn worker_attention_line(state: &GameState) -> Option<String> {
980    use flatland_protocol::WorkerStateView;
981    let now = Instant::now();
982    for w in &state.hired_workers {
983        if matches!(w.state, WorkerStateView::Strike) {
984            return Some(format!(
985                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
986                w.label
987            ));
988        }
989        if let Some(err) = state
990            .worker_error_display
991            .get(&w.instance_id)
992            .and_then(|s| s.shown(now))
993        {
994            if !worker_error_is_hud_noise(err) {
995                return Some(format!("Worker {}: {err}", w.label));
996            }
997        }
998        if let Some(err) = &w.last_error {
999            if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
1000                return Some(format!("Worker {}: {err}", w.label));
1001            }
1002        }
1003    }
1004    None
1005}
1006
1007/// True when a worker `last_error` is transient noise (soft-skip chatter).
1008pub fn worker_error_is_transient(err: &str) -> bool {
1009    let e = err.to_ascii_lowercase();
1010    e.contains("continuing route")
1011        || e.contains("storage full")
1012        || e.starts_with("nothing to withdraw")
1013}
1014
1015/// True for routine worker recoveries that should not paint the status bar.
1016/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1017pub fn worker_error_is_hud_noise(err: &str) -> bool {
1018    let e = err.to_ascii_lowercase();
1019    e.contains("returned to lodging after path")
1020        || e.contains("path failure")
1021        || e.contains("no path to")
1022        || e.contains("pathfinding")
1023        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1024        || e.contains("repathing")
1025        || e.contains("nudged clear")
1026}
1027
1028/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1029#[derive(Debug, Clone)]
1030pub struct PendingWorkerJobAck {
1031    pub seq: u32,
1032    pub worker_instance_id: String,
1033    pub worker_label: String,
1034    pub idle: bool,
1035    pub stop_count: usize,
1036    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1037    pub prev_mode: flatland_protocol::WorkerModeView,
1038    pub prev_step_label: String,
1039    pub prev_last_error: Option<String>,
1040}
1041
1042fn push_inventory_rows(
1043    rows: &mut Vec<InventoryRow>,
1044    depth: usize,
1045    stack: &flatland_protocol::ItemStack,
1046    from: &flatland_protocol::InventoryLocation,
1047    from_parent_instance_id: Option<uuid::Uuid>,
1048    section: InventorySection,
1049) {
1050    push_inventory_rows_filtered(
1051        rows,
1052        depth,
1053        stack,
1054        from,
1055        from_parent_instance_id,
1056        section,
1057        "",
1058    );
1059}
1060
1061fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1062    if filter.is_empty() {
1063        return true;
1064    }
1065    let f = filter.to_ascii_lowercase();
1066    let name = stack
1067        .display_name
1068        .as_deref()
1069        .unwrap_or("")
1070        .to_ascii_lowercase();
1071    let tid = stack.template_id.to_ascii_lowercase();
1072    name.contains(&f)
1073        || tid.contains(&f)
1074        || stack
1075            .contents
1076            .iter()
1077            .any(|c| stack_matches_filter(c, filter))
1078}
1079
1080fn push_inventory_rows_filtered(
1081    rows: &mut Vec<InventoryRow>,
1082    depth: usize,
1083    stack: &flatland_protocol::ItemStack,
1084    from: &flatland_protocol::InventoryLocation,
1085    from_parent_instance_id: Option<uuid::Uuid>,
1086    section: InventorySection,
1087    filter: &str,
1088) {
1089    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1090        return;
1091    }
1092    let self_hit = filter.is_empty() || {
1093        let f = filter.to_ascii_lowercase();
1094        let name = stack
1095            .display_name
1096            .as_deref()
1097            .unwrap_or("")
1098            .to_ascii_lowercase();
1099        let tid = stack.template_id.to_ascii_lowercase();
1100        name.contains(&f) || tid.contains(&f)
1101    };
1102    rows.push(InventoryRow {
1103        depth,
1104        stack: stack.clone(),
1105        from: from.clone(),
1106        from_parent_instance_id,
1107        is_equip_shell: false,
1108        is_chest_shell: false,
1109        section,
1110    });
1111    for child in &stack.contents {
1112        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1113            push_inventory_rows_filtered(
1114                rows,
1115                depth + 1,
1116                child,
1117                from,
1118                stack.item_instance_id,
1119                section,
1120                if self_hit { "" } else { filter },
1121            );
1122        }
1123    }
1124}
1125
1126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1127pub enum ShopTab {
1128    #[default]
1129    Buy,
1130    Sell,
1131}
1132
1133#[derive(Debug, Clone)]
1134pub struct NpcChatState {
1135    pub npc_id: String,
1136    pub npc_label: String,
1137    pub lines: Vec<String>,
1138    pub input: String,
1139    pub pending: bool,
1140    pub talk_depth: flatland_protocol::NpcTalkDepth,
1141    pub trade_allowed: bool,
1142    pub banner: Option<String>,
1143}
1144
1145impl Default for NpcChatState {
1146    fn default() -> Self {
1147        Self {
1148            npc_id: String::new(),
1149            npc_label: String::new(),
1150            lines: Vec::new(),
1151            input: String::new(),
1152            pending: false,
1153            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1154            trade_allowed: true,
1155            banner: None,
1156        }
1157    }
1158}
1159
1160#[derive(Debug, Clone)]
1161pub struct GameState {
1162    pub session_id: SessionId,
1163    pub entity_id: EntityId,
1164    /// Logged-in character — used to show owner-only container labels.
1165    pub character_id: Option<uuid::Uuid>,
1166    pub tick: Tick,
1167    pub chunk_rev: u64,
1168    pub content_rev: u64,
1169    pub publish_rev: u64,
1170    pub entities: Vec<EntityState>,
1171    pub player: Option<EntityState>,
1172    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1173    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1174    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1175    pub buildings: Vec<BuildingView>,
1176    pub doors: Vec<DoorView>,
1177    pub interior_map: Option<InteriorMapView>,
1178    pub npcs: Vec<NpcView>,
1179    pub blueprints: Vec<BlueprintView>,
1180    /// Wall/roof packs for the B plot-build menu.
1181    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1182    /// Outdoor play AABB origin (meters).
1183    pub world_x0: f32,
1184    pub world_y0: f32,
1185    pub world_width_m: f32,
1186    pub world_height_m: f32,
1187    pub terrain_zones: Vec<TerrainZoneView>,
1188    pub z_platforms: Vec<ZPlatformView>,
1189    pub z_transitions: Vec<ZTransitionView>,
1190    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1191    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1192    #[doc(hidden)]
1193    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1194    pub world_clock: flatland_protocol::WorldClock,
1195    pub inventory: std::collections::HashMap<String, u32>,
1196    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1197    pub logs: VecDeque<String>,
1198    pub intents_sent: u64,
1199    pub ticks_received: u64,
1200    pub connected: bool,
1201    pub disconnect_reason: Option<String>,
1202    pub show_stats: bool,
1203    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1204    pub hud_log_hidden: bool,
1205    pub show_equip_menu: bool,
1206    pub equip_menu_index: usize,
1207    pub show_craft_menu: bool,
1208    pub craft_menu_index: usize,
1209    /// How many timed crafts to queue when confirming the craft menu.
1210    pub craft_batch_quantity: u32,
1211    /// B — wall/roof material picker for plot buildings.
1212    pub show_plot_build_menu: bool,
1213    /// True = navigate walls; false = navigate roofs (Tab switches).
1214    pub plot_build_focus_wall: bool,
1215    pub plot_build_wall_index: usize,
1216    pub plot_build_roof_index: usize,
1217    pub show_shop_menu: bool,
1218    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1219    pub bank_panel: Option<flatland_protocol::BankPanel>,
1220    pub bank_menu_index: usize,
1221    pub bank_ui_mode: BankUiMode,
1222    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1223    pub market_panel: Option<flatland_protocol::MarketPanel>,
1224    /// Selected row among filtered market browse listings.
1225    pub market_menu_index: usize,
1226    /// Browse / list-pick text filter (`/` to focus).
1227    pub market_filter: String,
1228    pub market_filter_focused: bool,
1229    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1230    pub market_category_filter: Option<&'static str>,
1231    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1232    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1233    pub market_ui_mode: MarketUiMode,
1234    pub storage_menu_index: usize,
1235    pub storage_ui_mode: StorageUiMode,
1236    pub shop_tab: ShopTab,
1237    pub shop_menu_index: usize,
1238    pub shop_quantity: u32,
1239    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1240    pub shop_trade_log: VecDeque<String>,
1241    pub show_npc_verb_menu: bool,
1242    pub npc_verb_target: Option<String>,
1243    pub npc_verb_index: usize,
1244    /// Nearby player Speak / Whisper / Trade dock.
1245    pub player_verbs: crate::social::PlayerVerbState,
1246    pub social_chat: crate::social::SocialChatState,
1247    pub trade_ui: crate::social::TradeUiState,
1248    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1249    pub show_npc_chat: bool,
1250    pub npc_chat: Option<NpcChatState>,
1251    pub show_inventory_menu: bool,
1252    pub inventory_menu_index: usize,
1253    pub inventory_tab: InventoryTab,
1254    pub inventory_filter: String,
1255    pub inventory_filter_focused: bool,
1256    pub show_move_picker: bool,
1257    pub move_picker_index: usize,
1258    pub move_picker: Option<MovePicker>,
1259    pub show_grant_picker: bool,
1260    pub grant_picker_index: usize,
1261    pub grant_picker: Option<GrantTargetPicker>,
1262    pub show_destroy_picker: bool,
1263    pub destroy_confirm_pending: bool,
1264    pub destroy_picker: Option<DestroyPicker>,
1265    /// Rename prompt for a selected container (`n` in inventory).
1266    pub show_rename_prompt: bool,
1267    /// Rename prompt for a hired worker (`n` in workers menu).
1268    pub show_worker_rename: bool,
1269    pub rename_buffer: String,
1270    /// Slot-1 combat target (mirrors server after SetTarget).
1271    pub combat_target: Option<EntityId>,
1272    pub combat_target_label: Option<String>,
1273    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1274    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1275    pub ground_target: Option<(f32, f32, f32)>,
1276    /// Active combat footprints from the server (`plans/39`).
1277    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1278    /// Authored crown property zones (claimable land) — plan 40.
1279    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1280    /// Tax overlays for claim cost premium preview.
1281    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1282    /// Growth / fertility overlays (farming, respawn).
1283    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1284    /// Climate / biome overlays.
1285    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1286    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1287    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1288    /// Claimed property plots near the observer.
1289    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1290    /// Server knobs for client-side claim quotes.
1291    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1292    /// Local claim-footprint editor (not server state).
1293    pub claim_mode: Option<ClaimModeState>,
1294    /// Local relocate ghost for a placed chest/lodging (not server state).
1295    pub relocate_mode: Option<RelocateModeState>,
1296    /// Second `f` within a short window confirms selling this plot back to the crown.
1297    pub sell_plot_confirm: Option<uuid::Uuid>,
1298    /// When `sell_plot_confirm` was armed (for double-tap window).
1299    pub sell_plot_armed_at: Option<Instant>,
1300    /// Choose seed type when planting on owned tilled soil (`f`).
1301    pub show_plant_menu: bool,
1302    pub plant_menu_index: usize,
1303    /// Deed-holder farm access panel (public toggle + allow-list).
1304    pub show_farm_access: bool,
1305    /// Draft name for adding a tenant from the farm-access panel.
1306    pub farm_access_name_draft: String,
1307    /// Public / grant discount draft (bps) while editing farm access.
1308    pub farm_access_discount_bps: u32,
1309    /// Selected row in the farm-access panel (0 = public toggle).
1310    pub farm_access_index: usize,
1311    pub plant_quantity: u32,
1312    pub in_combat: bool,
1313    pub auto_attack: bool,
1314    pub combat_has_los: bool,
1315    pub attack_cd_ticks: u64,
1316    pub gcd_ticks: u64,
1317    pub weapon_ability_id: String,
1318    pub mainhand_template_id: Option<String>,
1319    pub mainhand_label: Option<String>,
1320    pub mainhand_instance_id: Option<uuid::Uuid>,
1321    pub offhand_template_id: Option<String>,
1322    pub offhand_label: Option<String>,
1323    pub offhand_instance_id: Option<uuid::Uuid>,
1324    pub mainhand_hand_slots: u8,
1325    pub defense: Option<flatland_protocol::DefenseHud>,
1326    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1327    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1328    pub carry_mass: f32,
1329    pub carry_mass_max: f32,
1330    pub encumbrance: flatland_protocol::EncumbranceState,
1331    /// Full nested inventory stacks from the server (root only; worn are separate).
1332    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1333    /// Keys stowed on the virtual keychain (zero carry mass).
1334    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1335    /// Whisper stones in the pouch (zero carry mass).
1336    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1337    /// Active status effects on the local player (buff/debuff strip).
1338    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1339    pub combat_target_detail: Option<CombatTargetHud>,
1340    pub cast_progress: Option<CastProgressHud>,
1341    /// Till / plant / other non-combat timed channels on the local player.
1342    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1343    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1344    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1345    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1346    pub blocking_active: bool,
1347    pub max_target_slots: u8,
1348    pub combat_slots: Vec<CombatSlotHud>,
1349    pub rotation_presets: Vec<RotationPreset>,
1350    /// Learned abilities from combat HUD (usable casts).
1351    pub known_abilities: Vec<String>,
1352    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1353    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1354    /// Per-ability mastery from combat HUD (tier / XP).
1355    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1356    /// Server-persisted hotbar bindings (index 0 = key 1).
1357    pub hotbar: Vec<Option<String>>,
1358    /// Max abilities allowed in one rotation (mind score).
1359    pub max_abilities_per_rotation: u8,
1360    pub show_loadout_menu: bool,
1361    pub show_keychain_menu: bool,
1362    pub keychain_menu_index: usize,
1363    pub show_rotation_editor: bool,
1364    /// Selected rotation preset in the loadout menu.
1365    pub loadout_menu_index: usize,
1366    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1367    pub loadout_hotbar_slot: u8,
1368    /// Selected known-ability row in the loadout menu.
1369    pub loadout_ability_index: usize,
1370    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1371    pub loadout_focus_presets: bool,
1372    pub rotation_editor: RotationEditorState,
1373    /// True after a harvest intent is accepted until result/reject/disconnect.
1374    pub harvest_in_progress: bool,
1375    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1376    pub harvest_started_at: Option<Instant>,
1377    /// Craft log deferred until the server acks the craft intent.
1378    pub pending_craft_ack: Option<(u32, String, u32)>,
1379    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1380    pub interactables: Vec<flatland_protocol::InteractableView>,
1381    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1382    pub career: Option<flatland_protocol::PlayerCareerView>,
1383    pub character_sheet_tab: CharacterSheetTab,
1384    pub ledger_period: LedgerPeriod,
1385    pub show_quest_offer: bool,
1386    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1387    pub show_quest_menu: bool,
1388    pub quest_menu_index: usize,
1389    pub quest_withdraw_confirm: bool,
1390    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1391    pub show_workers_menu: bool,
1392    pub workers_menu_index: usize,
1393    /// Workers panel: one-line rows instead of full cards (more on screen).
1394    pub workers_menu_compact: bool,
1395    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1396    /// Public so out-of-crate tests can construct [`GameState`].
1397    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1398    /// Held worker route errors (readable when server clears `last_error` each tick).
1399    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1400    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1401    pub show_worker_give_picker: bool,
1402    pub worker_give_picker_index: usize,
1403    pub worker_give_picker: Option<WorkerGivePicker>,
1404    /// Inventory `g` — pick which nearby worker receives the selected stack.
1405    pub show_worker_give_target_picker: bool,
1406    pub worker_give_target_picker_index: usize,
1407    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1408    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1409    pub show_worker_take_picker: bool,
1410    pub worker_take_picker_index: usize,
1411    pub worker_take_picker: Option<WorkerTakePicker>,
1412    /// Teach-blueprint sheet opened from the workers menu (`t`).
1413    pub show_worker_teach_picker: bool,
1414    pub worker_teach_picker_index: usize,
1415    pub worker_teach_picker: Option<WorkerTeachPicker>,
1416    /// Active harvest-route editor (`h` → `e` on a worker).
1417    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1418    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1419    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1420    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1421    pub attending_worker_instance_id: Option<String>,
1422    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1423    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1424}
1425
1426impl GameState {
1427    pub fn push_log(&mut self, line: impl Into<String>) {
1428        self.logs.push_back(line.into());
1429        while self.logs.len() > MAX_LOG_LINES {
1430            self.logs.pop_front();
1431        }
1432    }
1433
1434    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1435        self.shop_trade_log.push_back(line.into());
1436        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1437            self.shop_trade_log.pop_front();
1438        }
1439    }
1440
1441    pub fn clear_shop_trade_log(&mut self) {
1442        self.shop_trade_log.clear();
1443    }
1444
1445    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1446        if !self.show_shop_menu {
1447            return;
1448        }
1449        let msg = notice.message.trim();
1450        if msg.is_empty() {
1451            return;
1452        }
1453        if notice.coins_delta != 0
1454            || msg.starts_with("Bought ")
1455            || msg.starts_with("Sold ")
1456            || msg.contains("taught you how to craft")
1457            || msg.starts_with("need ")
1458        {
1459            self.push_shop_trade_log(msg);
1460        }
1461    }
1462
1463    pub fn is_alive(&self) -> bool {
1464        self.player
1465            .as_ref()
1466            .and_then(|p| p.vitals)
1467            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1468            .unwrap_or(true)
1469    }
1470
1471    /// Verb menu entries for the current `npc_verb_target`.
1472    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1473        let Some(ref id) = self.npc_verb_target else {
1474            return vec![];
1475        };
1476        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1477            return vec!["Talk"];
1478        };
1479        let role = npc.role.as_str();
1480        if Self::npc_role_is_bank(role) {
1481            return vec!["Bank", "Talk"];
1482        }
1483        if Self::npc_role_is_storage(role) {
1484            return vec!["Storage", "Talk"];
1485        }
1486        if Self::npc_role_is_market(role) {
1487            return vec!["Market", "Talk"];
1488        }
1489        if npc.can_trade || Self::npc_role_can_trade(role) {
1490            vec!["Talk", "Trade"]
1491        } else {
1492            vec!["Talk"]
1493        }
1494    }
1495
1496    fn npc_role_can_trade(role: &str) -> bool {
1497        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1498    }
1499
1500    fn npc_role_is_bank(role: &str) -> bool {
1501        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1502    }
1503
1504    fn npc_role_is_storage(role: &str) -> bool {
1505        role.eq_ignore_ascii_case("storage_manager")
1506    }
1507
1508    fn npc_role_is_market(role: &str) -> bool {
1509        role.eq_ignore_ascii_case("market_clerk")
1510    }
1511
1512    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1513        vec![
1514            "Deposit…",
1515            "Withdraw…",
1516            "Deposit all",
1517            "Withdraw all",
1518            "Transfer…",
1519        ]
1520    }
1521
1522    pub fn storage_menu_options(&self) -> Vec<String> {
1523        let mut opts = vec!["Store…".into(), "Take…".into()];
1524        if let Some(panel) = &self.storage_panel {
1525            for dest in &panel.ship_destinations {
1526                opts.push(format!(
1527                    "Ship → {} ({} cp / {} ticks)",
1528                    dest.label, dest.fee_copper, dest.travel_ticks
1529                ));
1530            }
1531        }
1532        opts
1533    }
1534
1535    /// Loose on-person stacks eligible for town-storage store.
1536    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
1537    /// already outside root inventory.
1538    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1539        let equipped = self.hand_equipped_instance_ids();
1540        self.person_rows()
1541            .into_iter()
1542            .filter(|r| r.depth == 0)
1543            .filter_map(|r| {
1544                let id = r.stack.item_instance_id?;
1545                if equipped.contains(&id) {
1546                    return None;
1547                }
1548                Some(StoragePickOption {
1549                    item_instance_id: id,
1550                    label: storage_stack_label(&r.stack),
1551                    quantity: r.stack.quantity,
1552                    category: r.stack.category.clone().unwrap_or_default(),
1553                })
1554            })
1555            .collect()
1556    }
1557
1558    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
1559    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1560        let mut ids = std::collections::HashSet::new();
1561        if let Some(id) = self.mainhand_instance_id {
1562            ids.insert(id);
1563        } else if let Some(tid) = &self.mainhand_template_id {
1564            if let Some(id) = self
1565                .inventory_stacks
1566                .iter()
1567                .find(|s| &s.template_id == tid)
1568                .and_then(|s| s.item_instance_id)
1569            {
1570                ids.insert(id);
1571            }
1572        }
1573        if let Some(id) = self.offhand_instance_id {
1574            ids.insert(id);
1575        } else if let Some(tid) = &self.offhand_template_id {
1576            if let Some(id) = self
1577                .inventory_stacks
1578                .iter()
1579                .find(|s| {
1580                    &s.template_id == tid
1581                        && s.item_instance_id
1582                            .is_some_and(|iid| !ids.contains(&iid))
1583                })
1584                .and_then(|s| s.item_instance_id)
1585            {
1586                ids.insert(id);
1587            }
1588        }
1589        ids
1590    }
1591
1592    /// Vault stacks eligible for take / ship.
1593    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1594        let Some(panel) = &self.storage_panel else {
1595            return Vec::new();
1596        };
1597        panel
1598            .contents
1599            .iter()
1600            .filter_map(|s| {
1601                let id = s.item_instance_id?;
1602                Some(StoragePickOption {
1603                    item_instance_id: id,
1604                    label: storage_stack_label(s),
1605                    quantity: s.quantity,
1606                    category: s.category.clone().unwrap_or_default(),
1607                })
1608            })
1609            .collect()
1610    }
1611
1612    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1613    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1614        let mut opts = Vec::new();
1615        if !self
1616            .market_list_item_options(&MarketListSourceKind::Person)
1617            .is_empty()
1618        {
1619            opts.push((MarketListSourceKind::Person, "On person".into()));
1620        }
1621        if let Some(panel) = &self.market_panel {
1622            for vault in &panel.list_vaults {
1623                let source = MarketListSourceKind::TownStorage {
1624                    building_id: vault.building_id.clone(),
1625                };
1626                if self.market_list_item_options(&source).is_empty() {
1627                    continue;
1628                }
1629                let label = if vault.building_label.is_empty() {
1630                    format!("Town storage ({})", vault.building_id)
1631                } else {
1632                    format!("Town storage — {}", vault.building_label)
1633                };
1634                opts.push((source, label));
1635            }
1636        }
1637        opts
1638    }
1639
1640    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1641    pub fn market_list_item_options(
1642        &self,
1643        source: &MarketListSourceKind,
1644    ) -> Vec<StoragePickOption> {
1645        let filter = self.market_filter.as_str();
1646        let cat_filter = self.market_category_filter;
1647        let mut opts: Vec<StoragePickOption> = match source {
1648            MarketListSourceKind::Person => {
1649                let equipped = self.hand_equipped_instance_ids();
1650                self.person_rows()
1651                    .into_iter()
1652                    .filter(|r| r.depth == 0)
1653                    .filter(|r| self.stack_is_market_listable(&r.stack))
1654                    .filter_map(|r| {
1655                        let id = r.stack.item_instance_id?;
1656                        if equipped.contains(&id) {
1657                            return None;
1658                        }
1659                        Some(StoragePickOption {
1660                            item_instance_id: id,
1661                            label: storage_stack_label(&r.stack),
1662                            quantity: r.stack.quantity,
1663                            category: r
1664                                .stack
1665                                .category
1666                                .clone()
1667                                .or_else(|| {
1668                                    self.inventory_item_category(&r.stack.template_id)
1669                                        .map(str::to_string)
1670                                })
1671                                .unwrap_or_default(),
1672                        })
1673                    })
1674                    .collect()
1675            }
1676            MarketListSourceKind::TownStorage { building_id } => {
1677                let Some(panel) = &self.market_panel else {
1678                    return Vec::new();
1679                };
1680                let Some(vault) = panel
1681                    .list_vaults
1682                    .iter()
1683                    .find(|v| &v.building_id == building_id)
1684                else {
1685                    return Vec::new();
1686                };
1687                vault
1688                    .contents
1689                    .iter()
1690                    .filter(|s| self.stack_is_market_listable(s))
1691                    .filter_map(|s| {
1692                        let id = s.item_instance_id?;
1693                        Some(StoragePickOption {
1694                            item_instance_id: id,
1695                            label: storage_stack_label(s),
1696                            quantity: s.quantity,
1697                            category: s
1698                                .category
1699                                .clone()
1700                                .or_else(|| {
1701                                    self.inventory_item_category(&s.template_id)
1702                                        .map(str::to_string)
1703                                })
1704                                .unwrap_or_default(),
1705                        })
1706                    })
1707                    .collect()
1708            }
1709        };
1710        opts.retain(|o| {
1711            if !list_label_matches(&o.label, filter) {
1712                return false;
1713            }
1714            if let Some(group) = cat_filter {
1715                inventory_category_group(&o.category).0 == group
1716            } else {
1717                true
1718            }
1719        });
1720        opts
1721    }
1722
1723    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1724        if crate::currency::is_currency(&stack.template_id) {
1725            return false;
1726        }
1727        if let Some(flag) = stack.listable {
1728            return flag;
1729        }
1730        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1731            return hint.listable;
1732        }
1733        let cat = stack
1734            .category
1735            .as_deref()
1736            .or_else(|| self.inventory_item_category(&stack.template_id))
1737            .unwrap_or("");
1738        category_default_listable(cat)
1739    }
1740
1741    /// Category group labels present in the current browse book or list-pick source.
1742    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1743        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1744        match &self.market_ui_mode {
1745            MarketUiMode::ListPick { source, .. } => {
1746                let raw: Vec<_> = match source {
1747                    MarketListSourceKind::Person => self
1748                        .person_rows()
1749                        .into_iter()
1750                        .filter(|r| r.depth == 0)
1751                        .filter(|r| self.stack_is_market_listable(&r.stack))
1752                        .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1753                        .map(|r| {
1754                            r.stack
1755                                .category
1756                                .clone()
1757                                .or_else(|| {
1758                                    self.inventory_item_category(&r.stack.template_id)
1759                                        .map(str::to_string)
1760                                })
1761                                .unwrap_or_default()
1762                        })
1763                        .collect(),
1764                    MarketListSourceKind::TownStorage { building_id } => self
1765                        .market_panel
1766                        .as_ref()
1767                        .and_then(|p| {
1768                            p.list_vaults
1769                                .iter()
1770                                .find(|v| &v.building_id == building_id)
1771                        })
1772                        .map(|vault| {
1773                            vault
1774                                .contents
1775                                .iter()
1776                                .filter(|s| self.stack_is_market_listable(s))
1777                                .filter(|s| {
1778                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
1779                                })
1780                                .map(|s| {
1781                                    s.category
1782                                        .clone()
1783                                        .or_else(|| {
1784                                            self.inventory_item_category(&s.template_id)
1785                                                .map(str::to_string)
1786                                        })
1787                                        .unwrap_or_default()
1788                                })
1789                                .collect::<Vec<_>>()
1790                        })
1791                        .unwrap_or_default(),
1792                };
1793                for category in raw {
1794                    let (label, ord) = inventory_category_group(&category);
1795                    seen.insert(ord, label);
1796                }
1797            }
1798            _ => {
1799                if let Some(panel) = &self.market_panel {
1800                    for listing in &panel.listings {
1801                        if !list_label_matches(&listing.display_name, &self.market_filter)
1802                            && !list_label_matches(&listing.seller_label, &self.market_filter)
1803                        {
1804                            continue;
1805                        }
1806                        let (label, ord) = inventory_category_group(&listing.category);
1807                        seen.insert(ord, label);
1808                    }
1809                }
1810            }
1811        }
1812        seen.into_values().collect()
1813    }
1814
1815    /// Indices into `market_panel.listings` after category + text filter.
1816    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1817        let Some(panel) = &self.market_panel else {
1818            return Vec::new();
1819        };
1820        let filter = self.market_filter.as_str();
1821        let cat_filter = self.market_category_filter;
1822        panel
1823            .listings
1824            .iter()
1825            .enumerate()
1826            .filter(|(_, listing)| {
1827                if !list_label_matches(&listing.display_name, filter)
1828                    && !list_label_matches(&listing.seller_label, filter)
1829                    && !list_label_matches(&listing.template_id, filter)
1830                {
1831                    return false;
1832                }
1833                if let Some(group) = cat_filter {
1834                    inventory_category_group(&listing.category).0 == group
1835                } else {
1836                    true
1837                }
1838            })
1839            .map(|(i, _)| i)
1840            .collect()
1841    }
1842
1843    pub fn clear_harvest_state(&mut self) {
1844        self.harvest_in_progress = false;
1845        self.harvest_started_at = None;
1846    }
1847
1848    fn harvest_state_stale(&self) -> bool {
1849        match self.harvest_started_at {
1850            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1851            None => self.harvest_in_progress,
1852        }
1853    }
1854
1855    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1856        self.player.as_ref().and_then(|p| p.vitals)
1857    }
1858
1859    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1860        let materials_ok = blueprint.inputs.iter().all(|input| {
1861            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1862        });
1863        let tools_ok = blueprint
1864            .required_tools
1865            .iter()
1866            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1867        let station_ok = match blueprint.station.as_deref() {
1868            None | Some("hand") => true,
1869            Some(tag) => self.player_at_station_tag(tag),
1870        };
1871        materials_ok && tools_ok && station_ok
1872    }
1873
1874    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1875        if !self.can_craft_blueprint(blueprint) {
1876            return 0;
1877        }
1878        let mut limit = u32::MAX;
1879        for input in &blueprint.inputs {
1880            if input.quantity == 0 {
1881                continue;
1882            }
1883            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1884            limit = limit.min(have / input.quantity);
1885        }
1886        for tool in &blueprint.required_tools {
1887            if tool.consumed {
1888                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1889                limit = limit.min(have);
1890            }
1891        }
1892        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1893        if CRAFT_STAMINA_COST > 0.0 {
1894            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1895        }
1896        limit
1897    }
1898
1899    pub fn clamp_craft_batch_quantity(&mut self) {
1900        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1901            self.craft_batch_quantity = 1;
1902            return;
1903        };
1904        let max = self.max_craft_batches(bp).max(1);
1905        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1906    }
1907
1908    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1909        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1910            return;
1911        };
1912        let max = self.max_craft_batches(&bp).max(1);
1913        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1914        self.craft_batch_quantity = next as u32;
1915    }
1916
1917    pub fn craft_batch_set_max(&mut self) {
1918        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1919            return;
1920        };
1921        let max = self.max_craft_batches(&bp);
1922        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1923    }
1924
1925    pub fn craft_batch_set_min(&mut self) {
1926        self.craft_batch_quantity = 1;
1927    }
1928
1929    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1930        let preserve_ui = self.show_shop_menu;
1931        let tab = self.shop_tab;
1932        let index = self.shop_menu_index;
1933        let qty = self.shop_quantity;
1934
1935        self.show_shop_menu = true;
1936        self.bank_panel = None;
1937        self.show_craft_menu = false;
1938        self.show_inventory_menu = false;
1939        self.show_stats = false;
1940        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1941            self.npc_verb_target = Some(catalog.npc_id.clone());
1942        }
1943        self.shop_catalog = Some(catalog);
1944
1945        if preserve_ui {
1946            self.shop_tab = tab;
1947            self.shop_menu_index = index;
1948            self.shop_quantity = qty;
1949        } else {
1950            self.shop_tab = ShopTab::Buy;
1951            self.shop_menu_index = 0;
1952            self.shop_quantity = 1;
1953            self.clear_shop_trade_log();
1954        }
1955        self.show_npc_verb_menu = false;
1956        self.clamp_shop_selection();
1957    }
1958
1959    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1960        let same_teller = self
1961            .bank_panel
1962            .as_ref()
1963            .is_some_and(|p| p.npc_id == panel.npc_id);
1964        self.bank_panel = Some(panel);
1965        self.storage_panel = None;
1966        self.market_panel = None;
1967        self.shop_catalog = None;
1968        self.show_shop_menu = false;
1969        self.show_craft_menu = false;
1970        self.show_inventory_menu = false;
1971        self.show_stats = false;
1972        self.show_npc_verb_menu = false;
1973        self.show_npc_chat = false;
1974        self.npc_chat = None;
1975        if !same_teller {
1976            self.bank_menu_index = 0;
1977            self.bank_ui_mode = BankUiMode::Menu;
1978        }
1979        if let Some(panel) = &self.bank_panel {
1980            if self.npc_verb_target.is_none() {
1981                self.npc_verb_target = Some(panel.npc_id.clone());
1982            }
1983        }
1984    }
1985
1986    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1987        let same_manager = self
1988            .storage_panel
1989            .as_ref()
1990            .is_some_and(|p| p.npc_id == panel.npc_id);
1991        self.storage_panel = Some(panel);
1992        self.bank_panel = None;
1993        self.market_panel = None;
1994        self.bank_ui_mode = BankUiMode::Menu;
1995        self.shop_catalog = None;
1996        self.show_shop_menu = false;
1997        self.show_craft_menu = false;
1998        self.show_inventory_menu = false;
1999        self.show_stats = false;
2000        self.show_npc_verb_menu = false;
2001        self.show_npc_chat = false;
2002        self.npc_chat = None;
2003        if !same_manager {
2004            self.storage_menu_index = 0;
2005            self.storage_ui_mode = StorageUiMode::Menu;
2006        } else {
2007            self.clamp_storage_pick_index();
2008        }
2009        if let Some(panel) = &self.storage_panel {
2010            if self.npc_verb_target.is_none() {
2011                self.npc_verb_target = Some(panel.npc_id.clone());
2012            }
2013        }
2014    }
2015
2016    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2017        self.market_panel = Some(panel);
2018        self.bank_panel = None;
2019        self.storage_panel = None;
2020        self.shop_catalog = None;
2021        self.show_shop_menu = false;
2022        self.show_craft_menu = false;
2023        self.show_inventory_menu = false;
2024        self.show_stats = false;
2025        self.show_npc_verb_menu = false;
2026        self.show_npc_chat = false;
2027        self.npc_chat = None;
2028        self.market_menu_index = 0;
2029        self.market_buy_confirm = None;
2030        self.market_ui_mode = MarketUiMode::Browse;
2031        self.market_filter.clear();
2032        self.market_filter_focused = false;
2033        self.market_category_filter = None;
2034        if let Some(panel) = &self.market_panel {
2035            if self.npc_verb_target.is_none() {
2036                self.npc_verb_target = Some(panel.npc_id.clone());
2037            }
2038        }
2039    }
2040
2041    pub fn clear_market_panel(&mut self) {
2042        self.market_panel = None;
2043        self.market_menu_index = 0;
2044        self.market_buy_confirm = None;
2045        self.market_ui_mode = MarketUiMode::Browse;
2046        self.market_filter.clear();
2047        self.market_filter_focused = false;
2048        self.market_category_filter = None;
2049    }
2050
2051    pub fn clear_bank_panel(&mut self) {
2052        self.bank_panel = None;
2053        self.bank_menu_index = 0;
2054        self.bank_ui_mode = BankUiMode::Menu;
2055    }
2056
2057    pub fn clear_storage_panel(&mut self) {
2058        self.storage_panel = None;
2059        self.storage_menu_index = 0;
2060        self.storage_ui_mode = StorageUiMode::Menu;
2061    }
2062
2063    fn clamp_storage_pick_index(&mut self) {
2064        match &self.storage_ui_mode {
2065            StorageUiMode::StorePick { index } => {
2066                let n = self.storage_store_options().len();
2067                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2068                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2069            }
2070            StorageUiMode::TakePick { index } => {
2071                let n = self.storage_vault_options().len();
2072                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2073                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2074            }
2075            StorageUiMode::ShipPick {
2076                dest_building_id,
2077                dest_label,
2078                index,
2079            } => {
2080                let n = self.storage_vault_options().len();
2081                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2082                self.storage_ui_mode = StorageUiMode::ShipPick {
2083                    dest_building_id: dest_building_id.clone(),
2084                    dest_label: dest_label.clone(),
2085                    index: next,
2086                };
2087            }
2088            StorageUiMode::Menu
2089            | StorageUiMode::StoreAmount { .. }
2090            | StorageUiMode::TakeAmount { .. }
2091            | StorageUiMode::ShipAmount { .. } => {}
2092        }
2093    }
2094
2095    pub fn shop_list_len(&self) -> usize {
2096        let Some(catalog) = &self.shop_catalog else {
2097            return 0;
2098        };
2099        match self.shop_tab {
2100            ShopTab::Buy => catalog.sells.len(),
2101            ShopTab::Sell => catalog.buys.len(),
2102        }
2103    }
2104
2105    pub fn shop_menu_move(&mut self, delta: i32) {
2106        let n = self.shop_list_len();
2107        if n == 0 {
2108            return;
2109        }
2110        let idx = self.shop_menu_index as i32;
2111        let next = (idx + delta).rem_euclid(n as i32);
2112        self.shop_menu_index = next as usize;
2113        self.clamp_shop_quantity();
2114    }
2115
2116    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2117        let max = self.shop_quantity_max();
2118        if max == 0 {
2119            self.shop_quantity = 0;
2120            return;
2121        }
2122        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2123        self.shop_quantity = next as u32;
2124    }
2125
2126    pub(crate) fn clamp_shop_selection(&mut self) {
2127        let n = self.shop_list_len();
2128        if n == 0 {
2129            self.shop_menu_index = 0;
2130        } else {
2131            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2132        }
2133        self.clamp_shop_quantity();
2134    }
2135
2136    fn shop_quantity_max(&self) -> u32 {
2137        let Some(catalog) = &self.shop_catalog else {
2138            return 1;
2139        };
2140        match self.shop_tab {
2141            ShopTab::Buy => {
2142                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2143                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2144                        return 1;
2145                    }
2146                }
2147                99
2148            }
2149            ShopTab::Sell => catalog
2150                .buys
2151                .get(self.shop_menu_index)
2152                .map(|l| l.quantity)
2153                .unwrap_or(0),
2154        }
2155    }
2156
2157    pub fn shop_quantity_set_max(&mut self) {
2158        self.shop_quantity = self.shop_quantity_max();
2159    }
2160
2161    pub fn shop_quantity_set_min(&mut self) {
2162        let max = self.shop_quantity_max();
2163        self.shop_quantity = if max == 0 { 0 } else { 1 };
2164    }
2165
2166    fn clamp_shop_quantity(&mut self) {
2167        let max = self.shop_quantity_max();
2168        if max == 0 {
2169            self.shop_quantity = 0;
2170        } else {
2171            self.shop_quantity = self.shop_quantity.max(1).min(max);
2172        }
2173    }
2174
2175    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2176        let Some(id) = self.effective_inside_building() else {
2177            return false;
2178        };
2179        self.buildings
2180            .iter()
2181            .find(|b| b.id == id)
2182            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2183    }
2184
2185    /// Short hint for UI when a recipe cannot be started.
2186    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2187        if self.can_craft_blueprint(blueprint) {
2188            return None;
2189        }
2190        let mut missing = Vec::new();
2191        for input in &blueprint.inputs {
2192            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2193            if have < input.quantity {
2194                let name = self.blueprint_ingredient_label(input);
2195                missing.push(format!("{}×{} (have {have})", input.quantity, name));
2196            }
2197        }
2198        for tool in &blueprint.required_tools {
2199            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2200            if have < 1 {
2201                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2202            }
2203        }
2204        if let Some(station) = blueprint.station.as_deref() {
2205            if station != "hand" && !self.player_at_station_tag(station) {
2206                missing.push(format!("station: {station} (enter building)"));
2207            }
2208        }
2209        if missing.is_empty() {
2210            None
2211        } else {
2212            Some(missing.join(", "))
2213        }
2214    }
2215
2216    pub fn player_entity(&self) -> Option<&EntityState> {
2217        self.player
2218            .as_ref()
2219            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2220    }
2221
2222    /// Apply persisted HUD / workers UI prefs from `client.json`.
2223    pub fn apply_client_ui_prefs(&mut self) {
2224        let cfg = crate::client_config::ClientConfig::load();
2225        if let Some(hidden) = cfg.hud_log_hidden {
2226            self.hud_log_hidden = hidden;
2227        }
2228        if let Some(compact) = cfg.workers_menu_compact {
2229            self.workers_menu_compact = compact;
2230        }
2231    }
2232
2233    pub fn player_position(&self) -> (f32, f32) {
2234        let (x, y, _) = self.player_position_with_z();
2235        (x, y)
2236    }
2237
2238    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2239        if let Some(p) = self.player_entity() {
2240            (
2241                p.transform.position.x,
2242                p.transform.position.y,
2243                p.transform.position.z,
2244            )
2245        } else {
2246            (0.0, 0.0, 0.0)
2247        }
2248    }
2249
2250    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2251        let mut rows: Vec<(String, u32, String)> = self
2252            .inventory
2253            .iter()
2254            .filter(|(_, q)| **q > 0)
2255            .map(|(id, qty)| {
2256                let label = self
2257                    .inventory_hints
2258                    .get(id)
2259                    .map(|h| h.display_name.clone())
2260                    .unwrap_or_else(|| id.clone());
2261                (id.clone(), *qty, label)
2262            })
2263            .collect();
2264        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2265        rows
2266    }
2267
2268    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2269        self.inventory_hints
2270            .get(template_id)
2271            .map(|h| h.category.as_str())
2272            .filter(|c| !c.is_empty())
2273    }
2274
2275    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2276        stack
2277            .props
2278            .get("grants_item_status_effect")
2279            .map(|s| !s.is_empty())
2280            .unwrap_or(false)
2281    }
2282
2283    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2284        stack
2285            .props
2286            .get("grants_item_status_effect")
2287            .map(String::as_str)
2288            .filter(|s| !s.is_empty())
2289    }
2290
2291    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2292        stack
2293            .props
2294            .get("grants_item_status_mode")
2295            .map(String::as_str)
2296            .unwrap_or("on_hit")
2297    }
2298
2299    /// Candidate gear for a grant consumable (inventory + worn).
2300    pub fn grant_target_options(
2301        &self,
2302        grant: &flatland_protocol::ItemStack,
2303    ) -> Vec<GrantTargetOption> {
2304        let mode = Self::grant_mode(grant);
2305        let grant_tags: Vec<&str> = grant
2306            .props
2307            .get("grants_item_status_tags")
2308            .map(|s| {
2309                s.split(',')
2310                    .map(str::trim)
2311                    .filter(|t| !t.is_empty())
2312                    .collect()
2313            })
2314            .unwrap_or_default();
2315        let grant_id = grant.item_instance_id;
2316        let mut out = Vec::new();
2317        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2318            let Some(iid) = stack.item_instance_id else {
2319                return;
2320            };
2321            if Some(iid) == grant_id {
2322                return;
2323            }
2324            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2325                return;
2326            }
2327            if !grant_target_matches_mode(stack, mode) {
2328                return;
2329            }
2330            if !grant_tags_match(stack, &grant_tags) {
2331                return;
2332            }
2333            let name = stack
2334                .display_name
2335                .clone()
2336                .unwrap_or_else(|| stack.template_id.clone());
2337            let bindings = if stack.status_bindings.is_empty() {
2338                String::new()
2339            } else {
2340                format!(
2341                    " · {}",
2342                    stack
2343                        .status_bindings
2344                        .iter()
2345                        .map(|b| b.effect_id.as_str())
2346                        .collect::<Vec<_>>()
2347                        .join(", ")
2348                )
2349            };
2350            out.push(GrantTargetOption {
2351                label: format!("{where_label}: {name}{bindings}"),
2352                target_instance_id: iid,
2353            });
2354        };
2355        fn walk(
2356            stacks: &[flatland_protocol::ItemStack],
2357            where_label: &str,
2358            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2359        ) {
2360            for s in stacks {
2361                push(s, where_label);
2362                if !s.contents.is_empty() {
2363                    let nested = format!(
2364                        "{where_label}/{}",
2365                        s.display_name
2366                            .as_deref()
2367                            .unwrap_or(s.template_id.as_str())
2368                    );
2369                    walk(&s.contents, &nested, push);
2370                }
2371            }
2372        }
2373        walk(&self.inventory_stacks, "Bag", &mut push);
2374        for (slot, stack) in &self.worn {
2375            push(stack, body_slot_label(*slot));
2376            let nest = format!(
2377                "{}/{}",
2378                body_slot_label(*slot),
2379                stack
2380                    .display_name
2381                    .as_deref()
2382                    .unwrap_or(stack.template_id.as_str())
2383            );
2384            walk(&stack.contents, &nest, &mut push);
2385        }
2386        out
2387    }
2388
2389    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2390        self.inventory_hints
2391            .get(template_id)
2392            .and_then(|h| h.base_mass)
2393            .unwrap_or(0.5)
2394    }
2395
2396    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2397        self.inventory_hints
2398            .get(template_id)
2399            .and_then(|h| h.base_volume)
2400            .unwrap_or(1.0)
2401    }
2402
2403    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2404        let unit = stack
2405            .base_mass
2406            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2407        unit * stack.quantity as f32
2408    }
2409
2410    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2411        let unit = stack.base_volume.unwrap_or(1.0);
2412        unit * stack.quantity as f32
2413            + stack
2414                .contents
2415                .iter()
2416                .map(Self::stack_tree_volume)
2417                .sum::<f32>()
2418    }
2419
2420    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2421        contents.iter().map(Self::stack_tree_volume).sum()
2422    }
2423
2424    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2425        self.inventory_hints
2426            .get(template_id)
2427            .and_then(|h| h.capacity_volume)
2428            .filter(|c| *c > 0.0)
2429    }
2430
2431    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2432        stack
2433            .capacity_volume
2434            .filter(|c| *c > 0.0)
2435            .or_else(|| self.template_capacity_volume(&stack.template_id))
2436    }
2437
2438    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2439    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2440        let Some((used, cap)) = self.container_volume_stats(row) else {
2441            return String::new();
2442        };
2443        let free = (cap - used).max(0.0);
2444        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2445    }
2446
2447    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2448        if row.is_chest_shell {
2449            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2450                return None;
2451            };
2452            let chest = self
2453                .placed_containers
2454                .iter()
2455                .find(|c| c.id == *container_id)?;
2456            let cap = self
2457                .stack_capacity_volume(&row.stack)
2458                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2459            let used = if chest.accessible {
2460                Self::contents_used_volume(&chest.contents)
2461            } else {
2462                0.0
2463            };
2464            return Some((used, cap));
2465        }
2466
2467        let cap = self.stack_capacity_volume(&row.stack)?;
2468        let used = Self::contents_used_volume(&row.stack.contents);
2469        Some((used, cap))
2470    }
2471
2472    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2473        if row.is_chest_shell {
2474            return true;
2475        }
2476        if row.is_equip_shell {
2477            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2478        }
2479        self.inventory_item_category(&row.stack.template_id) == Some("container")
2480            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2481    }
2482
2483    fn container_stack_for(
2484        &self,
2485        location: &flatland_protocol::InventoryLocation,
2486        parent_instance_id: Option<uuid::Uuid>,
2487    ) -> Option<flatland_protocol::ItemStack> {
2488        match location {
2489            flatland_protocol::InventoryLocation::Root => {
2490                let pid = parent_instance_id?;
2491                self.find_stack_by_instance(&self.inventory_stacks, pid)
2492            }
2493            flatland_protocol::InventoryLocation::Worn { slot } => {
2494                let worn = self.worn.get(slot)?;
2495                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2496                    Some(worn.clone())
2497                } else {
2498                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2499                }
2500            }
2501            flatland_protocol::InventoryLocation::Placed { container_id } => {
2502                let chest = self
2503                    .placed_containers
2504                    .iter()
2505                    .find(|c| c.id == *container_id)?;
2506                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2507                    Some(flatland_protocol::ItemStack {
2508                        template_id: chest.template_id.clone(),
2509                        quantity: 1,
2510                        item_instance_id: chest.item_instance_id,
2511                        props: Default::default(),
2512                        status_bindings: Vec::new(),
2513                        contents: chest.contents.clone(),
2514                        display_name: Some(chest.display_name.clone()),
2515                        category: Some("container".into()),
2516                        capacity_volume: self
2517                            .inventory_hints
2518                            .get(&chest.template_id)
2519                            .and_then(|h| h.capacity_volume),
2520                        worker_lodging_capacity: chest.worker_lodging_capacity,
2521                        ..Default::default()
2522                    })
2523                } else {
2524                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2525                }
2526            }
2527            flatland_protocol::InventoryLocation::Keychain => None,
2528            flatland_protocol::InventoryLocation::WhisperPouch => None,
2529        }
2530    }
2531
2532    fn find_stack_by_instance(
2533        &self,
2534        stacks: &[flatland_protocol::ItemStack],
2535        instance_id: uuid::Uuid,
2536    ) -> Option<flatland_protocol::ItemStack> {
2537        for stack in stacks {
2538            if stack.item_instance_id == Some(instance_id) {
2539                return Some(stack.clone());
2540            }
2541            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2542                return Some(found);
2543            }
2544        }
2545        None
2546    }
2547
2548    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2549    pub fn max_movable_to(
2550        &self,
2551        template_id: &str,
2552        stack_qty: u32,
2553        from: &flatland_protocol::InventoryLocation,
2554        to: &flatland_protocol::InventoryLocation,
2555        parent_instance_id: Option<uuid::Uuid>,
2556    ) -> u32 {
2557        let unit_vol = self.item_base_volume(template_id);
2558        let unit_mass = self.item_base_mass(template_id);
2559        let mut limit = stack_qty;
2560
2561        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2562            let cap = parent
2563                .capacity_volume
2564                .or_else(|| {
2565                    self.inventory_hints
2566                        .get(&parent.template_id)
2567                        .and_then(|h| h.capacity_volume)
2568                })
2569                .unwrap_or(0.0);
2570            if cap > 0.0 && unit_vol > 0.0 {
2571                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2572                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2573            }
2574        }
2575
2576        let to_person = matches!(
2577            to,
2578            flatland_protocol::InventoryLocation::Root
2579                | flatland_protocol::InventoryLocation::Worn { .. }
2580        );
2581        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2582        if to_person && from_placed && unit_mass > 0.0 {
2583            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2584            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2585                limit = 0;
2586            } else {
2587                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2588            }
2589        }
2590
2591        limit.max(0).min(stack_qty)
2592    }
2593
2594    pub fn move_picker_max_at_selection(&self) -> u32 {
2595        let Some(picker) = &self.move_picker else {
2596            return 1;
2597        };
2598        let Some(opt) = picker.options.get(self.move_picker_index) else {
2599            return picker.stack_quantity;
2600        };
2601        match &opt.kind {
2602            MoveOptionKind::Cancel
2603            | MoveOptionKind::Drop
2604            | MoveOptionKind::Use
2605            | MoveOptionKind::GrantApply
2606            | MoveOptionKind::SellPlotToCrown { .. }
2607            | MoveOptionKind::PickupPlaced { .. }
2608            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2609            MoveOptionKind::Move {
2610                location,
2611                parent_instance_id,
2612            } => self.max_movable_to(
2613                &picker.template_id,
2614                picker.stack_quantity,
2615                &picker.from,
2616                location,
2617                *parent_instance_id,
2618            ),
2619        }
2620    }
2621
2622    pub fn clamp_move_picker_quantity(&mut self) {
2623        let max = self.move_picker_max_at_selection();
2624        if let Some(picker) = &mut self.move_picker {
2625            if max == 0 {
2626                picker.quantity = 1;
2627            } else {
2628                picker.quantity = picker.quantity.clamp(1, max);
2629            }
2630        }
2631    }
2632
2633    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2634        let max = self.move_picker_max_at_selection().max(1);
2635        if let Some(picker) = &mut self.move_picker {
2636            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2637            picker.quantity = next as u32;
2638        }
2639    }
2640
2641    pub fn move_picker_set_quantity_max(&mut self) {
2642        let max = self.move_picker_max_at_selection();
2643        if let Some(picker) = &mut self.move_picker {
2644            picker.quantity = if max == 0 {
2645                1
2646            } else {
2647                max.min(picker.stack_quantity)
2648            };
2649        }
2650    }
2651
2652    pub fn move_picker_set_quantity_min(&mut self) {
2653        if let Some(picker) = &mut self.move_picker {
2654            picker.quantity = 1;
2655        }
2656    }
2657
2658    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2659        if let Some(picker) = &mut self.destroy_picker {
2660            let max = picker.stack_quantity.max(1);
2661            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2662            picker.quantity = next as u32;
2663        }
2664    }
2665
2666    pub fn destroy_picker_set_quantity_max(&mut self) {
2667        if let Some(picker) = &mut self.destroy_picker {
2668            picker.quantity = picker.stack_quantity.max(1);
2669        }
2670    }
2671
2672    pub fn destroy_picker_set_quantity_min(&mut self) {
2673        if let Some(picker) = &mut self.destroy_picker {
2674            picker.quantity = 1;
2675        }
2676    }
2677
2678    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2679        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2680        (have, have >= need)
2681    }
2682
2683    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
2684    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2685        let have = self
2686            .plot_build_offer
2687            .as_ref()
2688            .and_then(|o| {
2689                o.available
2690                    .iter()
2691                    .find(|s| s.template_id == template_id)
2692                    .map(|s| s.quantity)
2693            })
2694            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
2695        (have, have >= need)
2696    }
2697
2698    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2699        self.building_materials
2700            .iter()
2701            .filter(|m| m.can_wall)
2702            .collect()
2703    }
2704
2705    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2706        self.building_materials
2707            .iter()
2708            .filter(|m| m.can_roof)
2709            .collect()
2710    }
2711
2712    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2713        self.plot_build_wall_options()
2714            .get(self.plot_build_wall_index)
2715            .copied()
2716    }
2717
2718    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2719        self.plot_build_roof_options()
2720            .get(self.plot_build_roof_index)
2721            .copied()
2722    }
2723
2724    /// BOM lines for the current wall/roof selection and tilled pad area.
2725    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
2726        let Some(wall) = self.plot_build_selected_wall() else {
2727            return Vec::new();
2728        };
2729        let Some(roof) = self.plot_build_selected_roof() else {
2730            return Vec::new();
2731        };
2732        let area = self
2733            .plot_build_offer
2734            .as_ref()
2735            .filter(|o| o.pad_ok)
2736            .map(|o| o.pad_width_m * o.pad_depth_m)
2737            .unwrap_or(0.0);
2738        if area <= 0.0 {
2739            return Vec::new();
2740        }
2741        let mut map: std::collections::HashMap<String, (String, u32)> =
2742            std::collections::HashMap::new();
2743        for line in &wall.wall_bom {
2744            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2745            if qty == 0 {
2746                continue;
2747            }
2748            let name = if line.display_name.is_empty() {
2749                line.template_id.clone()
2750            } else {
2751                line.display_name.clone()
2752            };
2753            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2754            entry.1 = entry.1.saturating_add(qty);
2755        }
2756        for line in &roof.roof_bom {
2757            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2758            if qty == 0 {
2759                continue;
2760            }
2761            let name = if line.display_name.is_empty() {
2762                line.template_id.clone()
2763            } else {
2764                line.display_name.clone()
2765            };
2766            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2767            entry.1 = entry.1.saturating_add(qty);
2768        }
2769        let mut out: Vec<_> = map
2770            .into_iter()
2771            .map(|(id, (name, qty))| (id, name, qty))
2772            .collect();
2773        out.sort_by(|a, b| a.0.cmp(&b.0));
2774        out
2775    }
2776
2777    pub fn plot_build_duration_secs(&self) -> Option<f32> {
2778        let wall = self.plot_build_selected_wall()?;
2779        let roof = self.plot_build_selected_roof()?;
2780        let offer = self.plot_build_offer.as_ref()?;
2781        if !offer.pad_ok {
2782            return None;
2783        }
2784        let area = offer.pad_width_m * offer.pad_depth_m;
2785        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
2786        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
2787        Some(ticks.max(2.0) / 30.0)
2788    }
2789
2790    pub fn plot_build_can_afford(&self) -> bool {
2791        if self
2792            .plot_build_offer
2793            .as_ref()
2794            .is_none_or(|o| !o.pad_ok)
2795        {
2796            return false;
2797        }
2798        self.plot_build_bom_lines()
2799            .iter()
2800            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
2801    }
2802
2803    pub fn currency_display(&self) -> String {
2804        crate::currency::currency_line(&self.inventory)
2805    }
2806
2807    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2808    pub fn in_shallow_water(&self) -> bool {
2809        let (px, py) = self.player_position();
2810        self.terrain_at(px, py)
2811            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2812    }
2813
2814    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2815        self.terrain_zone_at(x, y).map(|z| z.kind)
2816    }
2817
2818    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2819    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2820        use std::cell::RefCell;
2821
2822        const CHUNK: i32 = 8;
2823        thread_local! {
2824            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2825                RefCell::new(None);
2826        }
2827
2828        let zones = &self.terrain_zones;
2829        if zones.is_empty() {
2830            return None;
2831        }
2832        if zones.len() <= 48 {
2833            return zones
2834                .iter()
2835                .enumerate()
2836                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2837                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2838                .map(|(_, z)| z);
2839        }
2840
2841        let ptr = zones.as_ptr();
2842        let len = zones.len();
2843        INDEX.with(|cell| {
2844            let mut slot = cell.borrow_mut();
2845            let stale = match slot.as_ref() {
2846                Some((p, l, _)) => *p != ptr || *l != len,
2847                None => true,
2848            };
2849            if stale {
2850                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2851                    std::collections::HashMap::new();
2852                for (zi, z) in zones.iter().enumerate() {
2853                    let x0 = z.x0.min(z.x1).floor() as i32;
2854                    let y0 = z.y0.min(z.y1).floor() as i32;
2855                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2856                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2857                    let cx0 = x0.div_euclid(CHUNK);
2858                    let cy0 = y0.div_euclid(CHUNK);
2859                    let cx1 = x1.div_euclid(CHUNK);
2860                    let cy1 = y1.div_euclid(CHUNK);
2861                    for cy in cy0..=cy1 {
2862                        for cx in cx0..=cx1 {
2863                            chunks.entry((cx, cy)).or_default().push(zi);
2864                        }
2865                    }
2866                }
2867                *slot = Some((ptr, len, chunks));
2868            }
2869            let chunks = &slot.as_ref().expect("index").2;
2870            let cx = (x.floor() as i32).div_euclid(CHUNK);
2871            let cy = (y.floor() as i32).div_euclid(CHUNK);
2872            let mut best: Option<(usize, &TerrainZoneView)> = None;
2873            if let Some(list) = chunks.get(&(cx, cy)) {
2874                for &zi in list {
2875                    let Some(z) = zones.get(zi) else { continue };
2876                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2877                        continue;
2878                    }
2879                    best = match best {
2880                        None => Some((zi, z)),
2881                        Some((bi, bz)) => {
2882                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2883                                Some((zi, z))
2884                            } else {
2885                                Some((bi, bz))
2886                            }
2887                        }
2888                    };
2889                }
2890            }
2891            best.map(|(_, z)| z)
2892        })
2893    }
2894
2895    /// Ground elevation from terrain zones (m).
2896    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2897        self.terrain_zone_at(x, y)
2898            .map(|z| z.elevation)
2899            .unwrap_or(0.0)
2900    }
2901
2902    /// Walkable z levels at a map column (terrain + platforms).
2903    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2904        const TOL: f32 = 0.35;
2905        let mut levels = vec![self.elevation_at(x, y)];
2906        for p in &self.z_platforms {
2907            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2908                levels.push(p.z);
2909            }
2910        }
2911        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2912        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2913        levels
2914    }
2915
2916    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2917        const TOL: f32 = 0.35;
2918        self.walkable_levels_at(x, y)
2919            .iter()
2920            .any(|&l| (l - z).abs() <= TOL)
2921    }
2922
2923    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2924        let mut top = self.elevation_at(x, y);
2925        for p in &self.z_platforms {
2926            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2927                top = top.max(p.z);
2928            }
2929        }
2930        top
2931    }
2932
2933    /// Authoritative interior context from the server (`inside_building` flag).
2934    pub fn effective_inside_building(&self) -> Option<String> {
2935        self.player_entity().and_then(|p| p.inside_building.clone())
2936    }
2937
2938    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2939        self.inventory_stacks = stacks.to_vec();
2940        self.inventory.clear();
2941        self.inventory_hints.clear();
2942        fn walk(
2943            stacks: &[flatland_protocol::ItemStack],
2944            inventory: &mut std::collections::HashMap<String, u32>,
2945            hints: &mut std::collections::HashMap<String, InventoryHint>,
2946        ) {
2947            for stack in stacks {
2948                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2949                if stack.display_name.is_some()
2950                    || stack.category.is_some()
2951                    || stack.base_mass.is_some()
2952                    || stack.base_volume.is_some()
2953                {
2954                    hints.insert(
2955                        stack.template_id.clone(),
2956                        InventoryHint {
2957                            display_name: stack
2958                                .display_name
2959                                .clone()
2960                                .unwrap_or_else(|| stack.template_id.clone()),
2961                            category: stack.category.clone().unwrap_or_default(),
2962                            base_mass: stack.base_mass,
2963                            base_volume: stack.base_volume,
2964                            capacity_volume: stack.capacity_volume,
2965                            stackable: stack.stackable.unwrap_or(true),
2966                            listable: stack.listable.unwrap_or_else(|| {
2967                                category_default_listable(
2968                                    stack.category.as_deref().unwrap_or(""),
2969                                )
2970                            }),
2971                        },
2972                    );
2973                }
2974                walk(&stack.contents, inventory, hints);
2975            }
2976        }
2977        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2978        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
2979        for item in self.worn.values() {
2980            walk(
2981                std::slice::from_ref(item),
2982                &mut self.inventory,
2983                &mut self.inventory_hints,
2984            );
2985        }
2986    }
2987
2988    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
2989    /// inventory UI updates before the next tick snapshot arrives.
2990    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2991        let subtract_items =
2992            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2993        for stack in &notice.inventory_delta {
2994            if stack.quantity == 0 {
2995                continue;
2996            }
2997            if subtract_items {
2998                crate::currency::drain_template_stacks(
2999                    &mut self.inventory_stacks,
3000                    &stack.template_id,
3001                    stack.quantity,
3002                );
3003                continue;
3004            }
3005            let stackable = self
3006                .inventory_hints
3007                .get(&stack.template_id)
3008                .map(|h| h.stackable)
3009                .or(stack.stackable)
3010                .unwrap_or(true);
3011            if stackable {
3012                if let Some(existing) = self
3013                    .inventory_stacks
3014                    .iter_mut()
3015                    .find(|s| s.template_id == stack.template_id)
3016                {
3017                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
3018                    if stack.display_name.is_some() {
3019                        existing.display_name = stack.display_name.clone();
3020                    }
3021                    if stack.category.is_some() {
3022                        existing.category = stack.category.clone();
3023                    }
3024                    continue;
3025                }
3026            }
3027            self.inventory_stacks.push(stack.clone());
3028        }
3029        if notice.coins_delta != 0 {
3030            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3031        }
3032        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3033            let stacks = self.inventory_stacks.clone();
3034            self.sync_inventory_from_stacks(&stacks);
3035        }
3036        self.record_shop_trade_notice(notice);
3037    }
3038
3039    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
3040    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
3041    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
3042    /// iteration alone gives a stable row order.
3043    pub fn worn_rows(&self) -> Vec<InventoryRow> {
3044        let mut rows = Vec::new();
3045        for (slot, item) in &self.worn {
3046            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3047            rows.push(InventoryRow {
3048                depth: 0,
3049                stack: item.clone(),
3050                from: from.clone(),
3051                from_parent_instance_id: None,
3052                is_equip_shell: true,
3053                is_chest_shell: false,
3054                section: InventorySection::Worn,
3055            });
3056            for child in &item.contents {
3057                push_inventory_rows(
3058                    &mut rows,
3059                    1,
3060                    child,
3061                    &from,
3062                    item.item_instance_id,
3063                    InventorySection::Worn,
3064                );
3065            }
3066        }
3067        rows
3068    }
3069
3070    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
3071    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3072        let equipped = self.hand_equipped_instance_ids();
3073        self.inventory_stacks
3074            .iter()
3075            .filter(|s| {
3076                s.item_instance_id
3077                    .is_some_and(|id| !equipped.contains(&id))
3078            })
3079            .collect()
3080    }
3081
3082    /// Root on-person stacks that can be handed to a hired worker.
3083    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3084        let equipped = self.hand_equipped_instance_ids();
3085        self.inventory_stacks
3086            .iter()
3087            .filter_map(|stack| {
3088                let item_instance_id = stack.item_instance_id?;
3089                if equipped.contains(&item_instance_id) {
3090                    return None;
3091                }
3092                let label = stack
3093                    .display_name
3094                    .clone()
3095                    .unwrap_or_else(|| stack.template_id.clone());
3096                let label = if stack.quantity > 1 {
3097                    format!("{label} ×{}", stack.quantity)
3098                } else {
3099                    label
3100                };
3101                Some(WorkerGiveOption {
3102                    item_instance_id,
3103                    label,
3104                    quantity: stack.quantity,
3105                    template_id: stack.template_id.clone(),
3106                })
3107            })
3108            .collect()
3109    }
3110
3111    /// Employer-known blueprints the selected worker does not yet know.
3112    pub fn teachable_blueprint_options(
3113        &self,
3114        worker: &flatland_protocol::HiredWorkerView,
3115    ) -> Vec<WorkerTeachOption> {
3116        let copper = crate::currency::copper_from_counts(&self.inventory);
3117        let mut options: Vec<WorkerTeachOption> = self
3118            .blueprints
3119            .iter()
3120            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3121            .map(|bp| {
3122                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3123                let cost = bp.worker_train_copper;
3124                WorkerTeachOption {
3125                    blueprint_id: bp.id.clone(),
3126                    label: if bp.label.is_empty() {
3127                        bp.id.clone()
3128                    } else {
3129                        bp.label.clone()
3130                    },
3131                    cost_copper: cost,
3132                    min_level,
3133                    worker_level: worker.level,
3134                    can_afford: copper >= cost,
3135                    level_ok: worker.level >= min_level,
3136                }
3137            })
3138            .collect();
3139        options.sort_by(|a, b| a.label.cmp(&b.label));
3140        options
3141    }
3142
3143    /// Loose on-person inventory (not worn, not inside a placed chest).
3144    /// Root stacks are ordered by category group; nested contents stay under their parent.
3145    pub fn person_rows(&self) -> Vec<InventoryRow> {
3146        self.person_rows_filtered("")
3147    }
3148
3149    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3150        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3151        roots.sort_by(|a, b| {
3152            let ca = a
3153                .category
3154                .as_deref()
3155                .or_else(|| self.inventory_item_category(&a.template_id))
3156                .unwrap_or("");
3157            let cb = b
3158                .category
3159                .as_deref()
3160                .or_else(|| self.inventory_item_category(&b.template_id))
3161                .unwrap_or("");
3162            let ga = inventory_category_group(ca).1;
3163            let gb = inventory_category_group(cb).1;
3164            ga.cmp(&gb).then_with(|| {
3165                let na = a
3166                    .display_name
3167                    .as_deref()
3168                    .unwrap_or(a.template_id.as_str());
3169                let nb = b
3170                    .display_name
3171                    .as_deref()
3172                    .unwrap_or(b.template_id.as_str());
3173                na.cmp(nb)
3174            })
3175        });
3176        let mut rows = Vec::new();
3177        for stack in roots {
3178            push_inventory_rows_filtered(
3179                &mut rows,
3180                0,
3181                stack,
3182                &flatland_protocol::InventoryLocation::Root,
3183                None,
3184                InventorySection::Person,
3185                filter,
3186            );
3187        }
3188        rows
3189    }
3190
3191    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3192        if filter.is_empty() {
3193            return self.worn_rows();
3194        }
3195        let mut rows = Vec::new();
3196        for (slot, item) in &self.worn {
3197            if !stack_matches_filter(item, filter) {
3198                continue;
3199            }
3200            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3201            let self_hit = {
3202                let f = filter.to_ascii_lowercase();
3203                let name = item
3204                    .display_name
3205                    .as_deref()
3206                    .unwrap_or("")
3207                    .to_ascii_lowercase();
3208                let tid = item.template_id.to_ascii_lowercase();
3209                name.contains(&f) || tid.contains(&f)
3210            };
3211            rows.push(InventoryRow {
3212                depth: 0,
3213                stack: item.clone(),
3214                from: from.clone(),
3215                from_parent_instance_id: None,
3216                is_equip_shell: true,
3217                is_chest_shell: false,
3218                section: InventorySection::Worn,
3219            });
3220            for child in &item.contents {
3221                if self_hit || stack_matches_filter(child, filter) {
3222                    push_inventory_rows_filtered(
3223                        &mut rows,
3224                        1,
3225                        child,
3226                        &from,
3227                        item.item_instance_id,
3228                        InventorySection::Worn,
3229                        if self_hit { "" } else { filter },
3230                    );
3231                }
3232            }
3233        }
3234        rows
3235    }
3236
3237    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3238    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3239        let mut rows = self.worn_rows();
3240        rows.extend(self.person_rows());
3241        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3242    }
3243
3244    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3245    /// populated when `accessible` — this is what makes a chest's contents
3246    /// disappear the moment you walk away or it's locked without your key.
3247    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3248        let (px, py) = self.player_position();
3249        let mut list: Vec<NearbyContainer> = self
3250            .placed_containers
3251            .iter()
3252            .filter_map(|c| {
3253                let distance_m = (c.x - px).hypot(c.y - py);
3254                if distance_m > CONTAINER_RANGE_M {
3255                    return None;
3256                }
3257                let mut rows = Vec::new();
3258                let from = flatland_protocol::InventoryLocation::Placed {
3259                    container_id: c.id.clone(),
3260                };
3261                rows.push(InventoryRow {
3262                    depth: 0,
3263                    stack: flatland_protocol::ItemStack {
3264                        template_id: c.template_id.clone(),
3265                        quantity: 1,
3266                        item_instance_id: c.item_instance_id,
3267                        props: Default::default(),
3268                        status_bindings: Vec::new(),
3269                        contents: Vec::new(),
3270                        display_name: Some(c.display_name.clone()),
3271                        category: Some("container".into()),
3272                        capacity_volume: c.capacity_volume,
3273                        worker_lodging_capacity: c.worker_lodging_capacity,
3274                        ..Default::default()
3275                    },
3276                    from: from.clone(),
3277                    from_parent_instance_id: None,
3278                    is_equip_shell: false,
3279                    is_chest_shell: true,
3280                    section: InventorySection::Nearby,
3281                });
3282                if c.accessible {
3283                    for child in &c.contents {
3284                        push_inventory_rows(
3285                            &mut rows,
3286                            1,
3287                            child,
3288                            &from,
3289                            c.item_instance_id,
3290                            InventorySection::Nearby,
3291                        );
3292                    }
3293                }
3294                Some(NearbyContainer {
3295                    view: c.clone(),
3296                    distance_m,
3297                    rows,
3298                })
3299            })
3300            .collect();
3301        list.sort_by(|a, b| {
3302            a.distance_m
3303                .partial_cmp(&b.distance_m)
3304                .unwrap_or(std::cmp::Ordering::Equal)
3305        });
3306        list
3307    }
3308
3309    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3310    pub fn nearest_placed_container(
3311        &self,
3312        max_dist: f32,
3313    ) -> Option<flatland_protocol::PlacedContainerView> {
3314        let (px, py) = self.player_position();
3315        self.placed_containers
3316            .iter()
3317            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3318            .min_by(|a, b| {
3319                let da = (a.x - px).hypot(a.y - py);
3320                let db = (b.x - px).hypot(b.y - py);
3321                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3322            })
3323            .cloned()
3324    }
3325
3326    /// Selectable rows for the **active inventory tab** (and current filter).
3327    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3328    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3329        let filter = self.inventory_filter.as_str();
3330        match self.inventory_tab {
3331            InventoryTab::OnPerson => {
3332                let mut rows = self.worn_rows_filtered(filter);
3333                rows.extend(self.person_rows_filtered(filter));
3334                rows
3335            }
3336            InventoryTab::Nearby => {
3337                let mut rows = Vec::new();
3338                for nc in self.nearby_containers() {
3339                    if filter.is_empty() {
3340                        rows.extend(nc.rows);
3341                        continue;
3342                    }
3343                    let shell = nc.rows.first().cloned();
3344                    let contents: Vec<_> = nc
3345                        .rows
3346                        .iter()
3347                        .skip(1)
3348                        .filter(|r| stack_matches_filter(&r.stack, filter))
3349                        .cloned()
3350                        .collect();
3351                    let shell_hit = shell
3352                        .as_ref()
3353                        .map(|s| stack_matches_filter(&s.stack, filter))
3354                        .unwrap_or(false);
3355                    if shell_hit || !contents.is_empty() {
3356                        if let Some(s) = shell {
3357                            rows.push(s);
3358                        }
3359                        if shell_hit {
3360                            rows.extend(nc.rows.into_iter().skip(1));
3361                        } else {
3362                            rows.extend(contents);
3363                        }
3364                    }
3365                }
3366                rows
3367            }
3368        }
3369    }
3370
3371    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3372        self.inventory_selectable_rows()
3373            .into_iter()
3374            .nth(self.inventory_menu_index)
3375    }
3376
3377    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3378        let cat = self
3379            .inventory_item_category(&row.stack.template_id)
3380            .unwrap_or("");
3381        if cat == "key" {
3382            self.key_inventory_label(&row.stack)
3383        } else {
3384            row.stack
3385                .display_name
3386                .clone()
3387                .unwrap_or_else(|| row.stack.template_id.clone())
3388        }
3389    }
3390
3391    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3392    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3393        let bindings = format_status_bindings_suffix(
3394            &row.stack.status_bindings,
3395            self.tick,
3396            DEFAULT_TICK_HZ,
3397        );
3398        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3399            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3400            let mode = Self::grant_mode(&row.stack);
3401            format!(" [grant {effect} · {mode} — e apply]")
3402        } else {
3403            String::new()
3404        };
3405        let qty = if row.stack.quantity > 1 {
3406            format!(" ×{}", row.stack.quantity)
3407        } else {
3408            String::new()
3409        };
3410        let worn_slot = if row.is_equip_shell {
3411            match row.from {
3412                flatland_protocol::InventoryLocation::Worn { slot } => {
3413                    format!(" ({})", body_slot_label(slot))
3414                }
3415                _ => String::new(),
3416            }
3417        } else {
3418            String::new()
3419        };
3420        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3421    }
3422
3423    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3424        (
3425            row.stack.template_id.clone(),
3426            self.inventory_row_base_label(row),
3427            self.inventory_row_visible_mod_signature(row),
3428        )
3429    }
3430
3431    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3432    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3433        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3434        for row in self.inventory_selectable_rows() {
3435            if row.stack.item_instance_id.is_none() {
3436                continue;
3437            }
3438            let key = self.inventory_row_instance_identity_key(&row);
3439            *counts.entry(key).or_default() += 1;
3440        }
3441        counts
3442            .into_iter()
3443            .filter(|(_, n)| *n > 1)
3444            .map(|(k, _)| k)
3445            .collect()
3446    }
3447
3448    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3449        let hex: String = id
3450            .as_simple()
3451            .to_string()
3452            .chars()
3453            .filter(|c| c.is_ascii_hexdigit())
3454            .collect();
3455        let short = if hex.len() >= 4 {
3456            &hex[hex.len() - 4..]
3457        } else {
3458            hex.as_str()
3459        };
3460        format!("Instance {id} (#{short})")
3461    }
3462
3463    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3464    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3465        let cat = self
3466            .inventory_item_category(&row.stack.template_id)
3467            .unwrap_or("");
3468        let label = self.inventory_row_base_label(row);
3469        let hint: String = if row.is_equip_shell {
3470            " [worn — Enter to unequip]".into()
3471        } else if row.is_chest_shell {
3472            let (locked, lodging_note) = match &row.from {
3473                flatland_protocol::InventoryLocation::Placed { container_id } => {
3474                    let locked = self
3475                        .placed_containers
3476                        .iter()
3477                        .find(|c| c.id == *container_id)
3478                        .map(|c| c.locked)
3479                        .unwrap_or(false);
3480                    let lodging_note = self
3481                        .lodging_occupancy_label(container_id)
3482                        .map(|who| format!(" [lodging: {who}]"))
3483                        .unwrap_or_default();
3484                    (locked, lodging_note)
3485                }
3486                _ => (false, String::new()),
3487            };
3488            if locked {
3489                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3490            } else {
3491                format!(" [Enter pick up · l lock]{lodging_note}")
3492            }
3493        } else if cat == "key" {
3494            self.key_inventory_hint(&row.stack)
3495        } else {
3496            match cat {
3497                "weapon" => " [weapon]".into(),
3498                "container" => " [bag/chest/belt]".into(),
3499                "lodging" => " [worker lodging]".into(),
3500                "armor" => " [armor]".into(),
3501                _ => String::new(),
3502            }
3503        };
3504        let qty = if row.stack.quantity > 1 {
3505            format!(" ×{}", row.stack.quantity)
3506        } else {
3507            String::new()
3508        };
3509        let bindings = format_status_bindings_suffix(
3510            &row.stack.status_bindings,
3511            self.tick,
3512            DEFAULT_TICK_HZ,
3513        );
3514        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3515            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3516            let mode = Self::grant_mode(&row.stack);
3517            format!(" [grant {effect} · {mode} — e apply]")
3518        } else {
3519            String::new()
3520        };
3521        let mass = self.stack_mass(&row.stack);
3522        let mass_kg = (mass >= 0.05).then_some(mass);
3523        let mass_str = mass_kg
3524            .map(|m| format!("  {m:.1} kg"))
3525            .unwrap_or_default();
3526        let volume = self.container_volume_stats(row);
3527        let vol_str = self.container_volume_label(row);
3528
3529        let mut title = label.clone();
3530        title.push_str(&qty);
3531        if row.is_equip_shell {
3532            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3533                title.push_str(&format!(" ({})", body_slot_label(slot)));
3534            }
3535        }
3536
3537        InventoryRowView {
3538            depth: row.depth,
3539            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3540            title: format!("{title}{grant_hint}{bindings}"),
3541            mass_kg,
3542            volume,
3543            instance_tooltip: None,
3544        }
3545    }
3546
3547    fn push_browser_item(
3548        &self,
3549        lines: &mut Vec<InventoryBrowserLine>,
3550        row: &InventoryRow,
3551        global_idx: &mut usize,
3552        target: usize,
3553        highlight: bool,
3554        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3555    ) {
3556        let mut view = self.format_inventory_row(row);
3557        if let Some(id) = row.stack.item_instance_id {
3558            let key = self.inventory_row_instance_identity_key(row);
3559            if ambiguous_instance_keys.contains(&key) {
3560                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3561            }
3562        }
3563        lines.push(InventoryBrowserLine::Item {
3564            selectable_index: *global_idx,
3565            selected: highlight && *global_idx == target,
3566            depth: view.depth,
3567            text: view.text,
3568            title: view.title,
3569            mass_kg: view.mass_kg,
3570            volume: view.volume,
3571            instance_tooltip: view.instance_tooltip,
3572        });
3573        *global_idx += 1;
3574    }
3575
3576    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3577    /// `selectable_index` matching `inventory_menu_index`.
3578    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3579        let mut lines = Vec::new();
3580        let target = self.inventory_menu_index;
3581        let highlight = !self.show_move_picker && !self.show_grant_picker;
3582        let filter = self.inventory_filter.as_str();
3583        let mut global_idx = 0usize;
3584        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3585
3586        match self.inventory_tab {
3587            InventoryTab::OnPerson => {
3588                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3589                let worn = self.worn_rows_filtered(filter);
3590                if worn.is_empty() {
3591                    lines.push(InventoryBrowserLine::Hint(
3592                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3593                    ));
3594                } else {
3595                    for row in &worn {
3596                        if row.is_equip_shell {
3597                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3598                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3599                                    "  {}:",
3600                                    body_slot_label(slot)
3601                                )));
3602                            }
3603                        }
3604                        self.push_browser_item(
3605                            &mut lines,
3606                            row,
3607                            &mut global_idx,
3608                            target,
3609                            highlight,
3610                            &ambiguous_instance_keys,
3611                        );
3612                    }
3613                }
3614
3615                lines.push(InventoryBrowserLine::Blank);
3616                lines.push(InventoryBrowserLine::Section(
3617                    "— On you (loose, not worn) —".into(),
3618                ));
3619                let person = self.person_rows_filtered(filter);
3620                if person.is_empty() {
3621                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3622                } else {
3623                    let mut last_group: Option<&'static str> = None;
3624                    for row in &person {
3625                        if row.depth == 0 {
3626                            let cat = row
3627                                .stack
3628                                .category
3629                                .as_deref()
3630                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3631                                .unwrap_or("");
3632                            let (group, _) = inventory_category_group(cat);
3633                            if last_group != Some(group) {
3634                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3635                                    "  {group}"
3636                                )));
3637                                last_group = Some(group);
3638                            }
3639                        }
3640                        self.push_browser_item(
3641                            &mut lines,
3642                            row,
3643                            &mut global_idx,
3644                            target,
3645                            highlight,
3646                            &ambiguous_instance_keys,
3647                        );
3648                    }
3649                }
3650            }
3651            InventoryTab::Nearby => {
3652                let nearby = self.nearby_containers();
3653                if nearby.is_empty() {
3654                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3655                    lines.push(InventoryBrowserLine::Hint(
3656                        "  (none within reach — walk up to a chest)".into(),
3657                    ));
3658                    lines.push(InventoryBrowserLine::Hint(
3659                        "  Select an on-person item, then m / Enter → move into chest.".into(),
3660                    ));
3661                } else {
3662                    let mut any_visible = false;
3663                    for nc in &nearby {
3664                        let shell = nc.rows.first();
3665                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
3666                            nc.rows.iter().skip(1).collect()
3667                        } else {
3668                            let shell_hit = shell
3669                                .map(|s| {
3670                                    let f = filter.to_ascii_lowercase();
3671                                    let name = s
3672                                        .stack
3673                                        .display_name
3674                                        .as_deref()
3675                                        .unwrap_or("")
3676                                        .to_ascii_lowercase();
3677                                    let tid = s.stack.template_id.to_ascii_lowercase();
3678                                    name.contains(&f) || tid.contains(&f)
3679                                })
3680                                .unwrap_or(false);
3681                            if shell_hit {
3682                                nc.rows.iter().skip(1).collect()
3683                            } else {
3684                                nc.rows
3685                                    .iter()
3686                                    .skip(1)
3687                                    .filter(|r| stack_matches_filter(&r.stack, filter))
3688                                    .collect()
3689                            }
3690                        };
3691                        let shell_visible = filter.is_empty()
3692                            || shell
3693                                .map(|s| stack_matches_filter(&s.stack, filter))
3694                                .unwrap_or(false)
3695                            || !contents.is_empty();
3696                        if !shell_visible && shell.is_some() {
3697                            continue;
3698                        }
3699                        any_visible = true;
3700                        lines.push(InventoryBrowserLine::Blank);
3701                        let lock_note = if nc.view.locked && nc.view.accessible {
3702                            "  unlocked with your key"
3703                        } else if nc.view.locked {
3704                            "  locked"
3705                        } else {
3706                            ""
3707                        };
3708                        lines.push(InventoryBrowserLine::Section(format!(
3709                            "— {} ({:.0}m away){lock_note} —",
3710                            nc.view.display_name, nc.distance_m
3711                        )));
3712                        if !nc.view.accessible {
3713                            lines.push(InventoryBrowserLine::Hint(
3714                                "  locked — need the matching key (l to try)".into(),
3715                            ));
3716                        } else if nc.rows.is_empty() {
3717                            lines.push(InventoryBrowserLine::Hint(
3718                                "  (empty — switch to On person, select an item, m to move in)"
3719                                    .into(),
3720                            ));
3721                        } else if let Some(shell_row) = shell {
3722                            self.push_browser_item(
3723                                &mut lines,
3724                                shell_row,
3725                                &mut global_idx,
3726                                target,
3727                                highlight,
3728                                &ambiguous_instance_keys,
3729                            );
3730                            for row in contents {
3731                                self.push_browser_item(
3732                                    &mut lines,
3733                                    row,
3734                                    &mut global_idx,
3735                                    target,
3736                                    highlight,
3737                                    &ambiguous_instance_keys,
3738                                );
3739                            }
3740                        }
3741                    }
3742                    if !any_visible {
3743                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3744                        lines.push(InventoryBrowserLine::Hint(
3745                            "  (no matching items — clear filter with Esc)".into(),
3746                        ));
3747                    }
3748                }
3749            }
3750        }
3751        lines
3752    }
3753
3754    /// Destinations for picking up a placed chest/crate into inventory.
3755    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3756        let mut opts = Vec::new();
3757        opts.push(MoveOption {
3758            label: "Relocate…".into(),
3759            kind: MoveOptionKind::RelocatePlaced {
3760                container_id: container_id.to_string(),
3761            },
3762        });
3763        opts.push(MoveOption {
3764            label: "On your person (loose)".into(),
3765            kind: MoveOptionKind::PickupPlaced {
3766                container_id: container_id.to_string(),
3767                nest_location: flatland_protocol::InventoryLocation::Root,
3768                nest_parent_instance_id: None,
3769            },
3770        });
3771        for (slot, item) in &self.worn {
3772            if item.category.as_deref() != Some("container") {
3773                continue;
3774            }
3775            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3776                continue;
3777            }
3778            let Some(parent_id) = item.item_instance_id else {
3779                continue;
3780            };
3781            let shell_name = item
3782                .display_name
3783                .clone()
3784                .unwrap_or_else(|| item.template_id.clone());
3785            opts.push(MoveOption {
3786                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3787                kind: MoveOptionKind::PickupPlaced {
3788                    container_id: container_id.to_string(),
3789                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3790                    nest_parent_instance_id: Some(parent_id),
3791                },
3792            });
3793            // Nested pouches inside the worn bag.
3794            Self::append_chest_pickup_nested(
3795                &mut opts,
3796                container_id,
3797                flatland_protocol::InventoryLocation::Worn { slot: *slot },
3798                item,
3799                &format!("in {shell_name}"),
3800            );
3801        }
3802        opts.push(MoveOption {
3803            label: "Cancel".into(),
3804            kind: MoveOptionKind::Cancel,
3805        });
3806        opts
3807    }
3808
3809    fn append_chest_pickup_nested(
3810        opts: &mut Vec<MoveOption>,
3811        container_id: &str,
3812        location: flatland_protocol::InventoryLocation,
3813        parent: &flatland_protocol::ItemStack,
3814        context: &str,
3815    ) {
3816        for child in &parent.contents {
3817            if child.category.as_deref() != Some("container") {
3818                continue;
3819            }
3820            if !Self::is_volume_container_stack(child) {
3821                continue;
3822            }
3823            // Skip placeable nested chests — relocating into another chest is not useful.
3824            if child.world_placeable == Some(true) {
3825                continue;
3826            }
3827            let Some(child_id) = child.item_instance_id else {
3828                continue;
3829            };
3830            let name = child
3831                .display_name
3832                .clone()
3833                .unwrap_or_else(|| child.template_id.clone());
3834            opts.push(MoveOption {
3835                label: format!("{name} ({context})"),
3836                kind: MoveOptionKind::PickupPlaced {
3837                    container_id: container_id.to_string(),
3838                    nest_location: location.clone(),
3839                    nest_parent_instance_id: Some(child_id),
3840                },
3841            });
3842            Self::append_chest_pickup_nested(
3843                opts,
3844                container_id,
3845                location.clone(),
3846                child,
3847                &format!("in {name}"),
3848            );
3849        }
3850    }
3851
3852    /// Build the "move to…" destination list for an item currently at `from`.
3853    pub fn move_destinations_for(
3854        &self,
3855        from: &flatland_protocol::InventoryLocation,
3856        from_parent_instance_id: Option<uuid::Uuid>,
3857        moving_instance_id: Option<uuid::Uuid>,
3858        moving_template_id: &str,
3859    ) -> Vec<MoveOption> {
3860        let mut opts = Vec::new();
3861        if *from != flatland_protocol::InventoryLocation::Root {
3862            opts.push(MoveOption {
3863                label: "On your person (loose)".into(),
3864                kind: MoveOptionKind::Move {
3865                    location: flatland_protocol::InventoryLocation::Root,
3866                    parent_instance_id: None,
3867                },
3868            });
3869        }
3870        for (slot, item) in &self.worn {
3871            if item.category.as_deref() != Some("container") {
3872                continue;
3873            }
3874            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3875            let shell_name = item
3876                .display_name
3877                .clone()
3878                .unwrap_or_else(|| item.template_id.clone());
3879
3880            // Backpack and other worn volume containers — store directly inside the shell.
3881            if *slot != BodySlot::Waist
3882                && item.item_instance_id != moving_instance_id
3883                && Self::is_volume_container_stack(item)
3884            {
3885                Self::push_move_destination(
3886                    &mut opts,
3887                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3888                    location.clone(),
3889                    item.item_instance_id,
3890                    from,
3891                    from_parent_instance_id,
3892                );
3893            }
3894
3895            // Belt loops only accept pouch attachments — not loose materials.
3896            if *slot == BodySlot::Waist
3897                && Self::attaches_to_belt_loop(moving_template_id)
3898                && item.item_instance_id != moving_instance_id
3899            {
3900                Self::push_move_destination(
3901                    &mut opts,
3902                    format!("{shell_name} (belt loop)"),
3903                    location.clone(),
3904                    item.item_instance_id,
3905                    from,
3906                    from_parent_instance_id,
3907                );
3908            }
3909
3910            let context = if *slot == BodySlot::Waist {
3911                format!("on {shell_name}")
3912            } else {
3913                format!("in {shell_name}")
3914            };
3915            Self::append_nested_container_destinations(
3916                &mut opts,
3917                location,
3918                item,
3919                &context,
3920                from,
3921                from_parent_instance_id,
3922                moving_instance_id,
3923            );
3924        }
3925        for nc in self.nearby_containers() {
3926            if !nc.view.accessible {
3927                continue;
3928            }
3929            let location = flatland_protocol::InventoryLocation::Placed {
3930                container_id: nc.view.id.clone(),
3931            };
3932            Self::push_move_destination(
3933                &mut opts,
3934                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3935                location,
3936                nc.view.item_instance_id,
3937                from,
3938                from_parent_instance_id,
3939            );
3940        }
3941        let allow_drop = moving_instance_id
3942            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
3943            .unwrap_or(true)
3944            && moving_instance_id
3945                .and_then(|id| self.stack_for_instance(id))
3946                .map(|stack| {
3947                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3948                })
3949                .unwrap_or(
3950                    moving_template_id != KEY_TEMPLATE
3951                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
3952                );
3953        if allow_drop {
3954            opts.push(MoveOption {
3955                label: "Drop on the ground".into(),
3956                kind: MoveOptionKind::Drop,
3957            });
3958        }
3959        opts.push(MoveOption {
3960            label: "Cancel".into(),
3961            kind: MoveOptionKind::Cancel,
3962        });
3963        opts
3964    }
3965
3966    fn is_same_container_dest(
3967        dest_location: &flatland_protocol::InventoryLocation,
3968        dest_parent: Option<uuid::Uuid>,
3969        from: &flatland_protocol::InventoryLocation,
3970        from_parent: Option<uuid::Uuid>,
3971    ) -> bool {
3972        dest_location == from && dest_parent == from_parent
3973    }
3974
3975    fn push_move_destination(
3976        opts: &mut Vec<MoveOption>,
3977        label: String,
3978        location: flatland_protocol::InventoryLocation,
3979        parent_instance_id: Option<uuid::Uuid>,
3980        from: &flatland_protocol::InventoryLocation,
3981        from_parent_instance_id: Option<uuid::Uuid>,
3982    ) {
3983        if Self::is_same_container_dest(
3984            &location,
3985            parent_instance_id,
3986            from,
3987            from_parent_instance_id,
3988        ) {
3989            return;
3990        }
3991        opts.push(MoveOption {
3992            label,
3993            kind: MoveOptionKind::Move {
3994                location,
3995                parent_instance_id,
3996            },
3997        });
3998    }
3999
4000    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4001        stack.capacity_volume.is_some_and(|c| c > 0.0)
4002    }
4003
4004    fn attaches_to_belt_loop(template_id: &str) -> bool {
4005        matches!(template_id, "leather_pouch" | "dimensional_pouch")
4006    }
4007
4008    fn append_nested_container_destinations(
4009        opts: &mut Vec<MoveOption>,
4010        location: flatland_protocol::InventoryLocation,
4011        container: &flatland_protocol::ItemStack,
4012        context: &str,
4013        from: &flatland_protocol::InventoryLocation,
4014        from_parent_instance_id: Option<uuid::Uuid>,
4015        moving_instance_id: Option<uuid::Uuid>,
4016    ) {
4017        for child in &container.contents {
4018            if Self::is_volume_container_stack(child)
4019                && child.item_instance_id != moving_instance_id
4020            {
4021                let name = child
4022                    .display_name
4023                    .clone()
4024                    .unwrap_or_else(|| child.template_id.clone());
4025                Self::push_move_destination(
4026                    opts,
4027                    format!("{name} ({context})"),
4028                    location.clone(),
4029                    child.item_instance_id,
4030                    from,
4031                    from_parent_instance_id,
4032                );
4033            }
4034            let nested_context = format!(
4035                "in {}",
4036                child.display_name.as_deref().unwrap_or(&child.template_id)
4037            );
4038            Self::append_nested_container_destinations(
4039                opts,
4040                location.clone(),
4041                child,
4042                &nested_context,
4043                from,
4044                from_parent_instance_id,
4045                moving_instance_id,
4046            );
4047        }
4048    }
4049
4050    fn clamp_inventory_indices(&mut self) {
4051        let n = self.inventory_selectable_rows().len();
4052        self.inventory_menu_index = if n == 0 {
4053            0
4054        } else {
4055            self.inventory_menu_index.min(n - 1)
4056        };
4057        if let Some(picker) = &self.move_picker {
4058            let pn = picker.options.len();
4059            self.move_picker_index = if pn == 0 {
4060                0
4061            } else {
4062                self.move_picker_index.min(pn - 1)
4063            };
4064        }
4065    }
4066
4067    /// Drop interior map layers whenever the player is outdoors.
4068    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
4069    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
4070    /// force `build_grid` into a full-map z pass on every click until client restart.
4071    fn sync_interior_map_context(&mut self) {
4072        if self.effective_inside_building().is_none() {
4073            self.interior_map = None;
4074            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4075                self.z_platforms = platforms;
4076                self.z_transitions = transitions;
4077            }
4078            return;
4079        }
4080        self.sync_interior_z_bands();
4081    }
4082
4083    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
4084    fn sync_interior_z_bands(&mut self) {
4085        if self.effective_inside_building().is_some() {
4086            if let Some(map) = &self.interior_map {
4087                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4088                    if self.z_bands_outdoor_backup.is_none() {
4089                        self.z_bands_outdoor_backup = Some((
4090                            std::mem::take(&mut self.z_platforms),
4091                            std::mem::take(&mut self.z_transitions),
4092                        ));
4093                    }
4094                    self.z_platforms = map.z_platforms.clone();
4095                    self.z_transitions = map.z_transitions.clone();
4096                }
4097            }
4098        }
4099    }
4100
4101    fn apply_snapshot_fields(
4102        &mut self,
4103        snapshot: &flatland_protocol::Snapshot,
4104        entity_id: EntityId,
4105    ) {
4106        self.tick = snapshot.tick;
4107        self.chunk_rev = snapshot.chunk_rev;
4108        self.content_rev = snapshot.content_rev;
4109        self.publish_rev = snapshot.publish_rev;
4110        self.resource_nodes = snapshot.resource_nodes.clone();
4111        self.ground_drops = snapshot.ground_drops.clone();
4112        self.placed_containers = snapshot.placed_containers.clone();
4113        self.world_x0 = snapshot.world_x0;
4114        self.world_y0 = snapshot.world_y0;
4115        self.world_width_m = snapshot.world_width_m;
4116        self.world_height_m = snapshot.world_height_m;
4117        self.world_clock = snapshot.world_clock;
4118        self.terrain_zones = snapshot.terrain_zones.clone();
4119        self.z_platforms = snapshot.z_platforms.clone();
4120        self.z_transitions = snapshot.z_transitions.clone();
4121        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
4122        self.z_bands_outdoor_backup = None;
4123        self.buildings = snapshot.buildings.clone();
4124        self.doors = snapshot.doors.clone();
4125        self.interior_map = snapshot.interior_map.clone();
4126        self.npcs = snapshot.npcs.clone();
4127        self.blueprints = snapshot.blueprints.clone();
4128        self.building_materials = snapshot.building_materials.clone();
4129        self.sync_inventory_from_stacks(&snapshot.inventory);
4130        self.player = snapshot
4131            .entities
4132            .iter()
4133            .find(|e| e.id == entity_id)
4134            .cloned();
4135        self.entities = snapshot.entities.clone();
4136        self.quest_log = snapshot.quest_log.clone();
4137        self.apply_hired_workers(snapshot.hired_workers.clone());
4138        self.interactables = snapshot.interactables.clone();
4139        self.ledger = snapshot.ledger.clone();
4140        self.career = snapshot.career.clone();
4141        self.combat_fx = snapshot.combat_fx.clone();
4142        self.property_zones = snapshot.property_zones.clone();
4143        self.tax_zones = snapshot.tax_zones.clone();
4144        self.growth_zones = snapshot.growth_zones.clone();
4145        self.biome_zones = snapshot.biome_zones.clone();
4146        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4147        self.property_plots = snapshot.property_plots.clone();
4148        self.property_plot_settings = snapshot.property_plot_settings.clone();
4149        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
4150        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
4151        if self.effective_inside_building().is_some() {
4152            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4153        }
4154        self.sync_interior_map_context();
4155        self.refresh_whisper_range();
4156    }
4157
4158    /// Re-derive inventory selection state after a snapshot/tick — chests that
4159    /// went out of range or got locked simply vanish from the row list, and the
4160    /// move picker (if any) closes once its item is no longer reachable.
4161    fn refresh_inventory_ui(&mut self) {
4162        if let Some(picker) = &self.move_picker {
4163            let instance_id = picker.item_instance_id;
4164            let still_exists = self
4165                .inventory_selectable_rows()
4166                .iter()
4167                .any(|r| r.stack.item_instance_id == Some(instance_id));
4168            if !still_exists {
4169                self.move_picker = None;
4170                self.show_move_picker = false;
4171            }
4172        }
4173        if let Some(picker) = &self.destroy_picker {
4174            let instance_id = picker.item_instance_id;
4175            let still_exists = self
4176                .inventory_selectable_rows()
4177                .iter()
4178                .any(|r| r.stack.item_instance_id == Some(instance_id));
4179            if !still_exists {
4180                self.destroy_picker = None;
4181                self.show_destroy_picker = false;
4182                self.destroy_confirm_pending = false;
4183            }
4184        }
4185        self.clamp_inventory_indices();
4186    }
4187
4188    /// Replace the hired-worker list from a snapshot/delta.
4189    ///
4190    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
4191    /// selected worker (and its step line) does not jump when the server rebuilds
4192    /// the list.
4193    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4194        let selected_id = self
4195            .hired_workers
4196            .get(self.workers_menu_index)
4197            .map(|w| w.instance_id.clone());
4198        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4199        let now = Instant::now();
4200        for w in &workers {
4201            let prev_err = self
4202                .hired_workers
4203                .iter()
4204                .find(|p| p.instance_id == w.instance_id)
4205                .and_then(|p| p.last_error.as_deref());
4206            let new_err = w.last_error.as_deref();
4207            if new_err != prev_err {
4208                if let Some(err) = new_err {
4209                    if !worker_error_is_transient(err) {
4210                        self.push_log(format!("Worker {}: {err}", w.label));
4211                    }
4212                }
4213            }
4214        }
4215        let mut next_display = BTreeMap::new();
4216        let mut next_errors = BTreeMap::new();
4217        for w in &workers {
4218            let mut sticky = self
4219                .worker_step_display
4220                .remove(&w.instance_id)
4221                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4222            sticky.observe(&w.step_label, now);
4223            next_display.insert(w.instance_id.clone(), sticky);
4224
4225            let mut err_sticky = self
4226                .worker_error_display
4227                .remove(&w.instance_id)
4228                .unwrap_or_default();
4229            err_sticky.observe(w.last_error.as_deref(), now);
4230            if err_sticky.shown(now).is_some() {
4231                next_errors.insert(w.instance_id.clone(), err_sticky);
4232            }
4233        }
4234        self.worker_step_display = next_display;
4235        self.worker_error_display = next_errors;
4236        self.hired_workers = workers;
4237        self.sync_worker_take_picker_from_hired();
4238        if let Some(id) = selected_id {
4239            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4240                self.workers_menu_index = idx;
4241                return;
4242            }
4243        }
4244        if self.workers_menu_index >= self.hired_workers.len() {
4245            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4246        }
4247    }
4248
4249    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4250    fn sync_worker_take_picker_from_hired(&mut self) {
4251        if !self.show_worker_take_picker {
4252            return;
4253        }
4254        let Some(picker) = self.worker_take_picker.clone() else {
4255            return;
4256        };
4257        let Some(worker) = self
4258            .hired_workers
4259            .iter()
4260            .find(|w| w.instance_id == picker.worker_instance_id)
4261            .cloned()
4262        else {
4263            self.show_worker_take_picker = false;
4264            self.worker_take_picker = None;
4265            self.worker_take_picker_index = 0;
4266            return;
4267        };
4268        let options: Vec<WorkerGiveOption> = worker
4269            .inventory
4270            .iter()
4271            .filter_map(|stack| {
4272                let item_instance_id = stack.item_instance_id?;
4273                let label = stack
4274                    .display_name
4275                    .clone()
4276                    .unwrap_or_else(|| stack.template_id.clone());
4277                let label = if stack.quantity > 1 {
4278                    format!("{label} ×{}", stack.quantity)
4279                } else {
4280                    label
4281                };
4282                Some(WorkerGiveOption {
4283                    item_instance_id,
4284                    label,
4285                    quantity: stack.quantity,
4286                    template_id: stack.template_id.clone(),
4287                })
4288            })
4289            .collect();
4290        if options.is_empty() {
4291            self.show_worker_take_picker = false;
4292            self.worker_take_picker = None;
4293            self.worker_take_picker_index = 0;
4294            return;
4295        }
4296        let prev_id = picker
4297            .options
4298            .get(self.worker_take_picker_index)
4299            .map(|o| o.item_instance_id);
4300        let idx = prev_id
4301            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4302            .unwrap_or(0)
4303            .min(options.len().saturating_sub(1));
4304        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4305        let quantity = picker.quantity.clamp(1, max_qty);
4306        self.worker_take_picker_index = idx;
4307        self.worker_take_picker = Some(WorkerTakePicker {
4308            worker_instance_id: picker.worker_instance_id,
4309            worker_label: picker.worker_label,
4310            options,
4311            quantity,
4312        });
4313    }
4314
4315    /// Held coarse step label for the workers menu (`step:` line).
4316    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4317        self.worker_step_display
4318            .get(worker_instance_id)
4319            .map(|s| s.shown.as_str())
4320            .or_else(|| {
4321                self.hired_workers
4322                    .iter()
4323                    .find(|w| w.instance_id == worker_instance_id)
4324                    .map(|w| w.step_label.as_str())
4325            })
4326            .unwrap_or("")
4327    }
4328
4329    /// Held error line for workers UI (detail + compact).
4330    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4331        let now = Instant::now();
4332        self.worker_error_display
4333            .get(worker_instance_id)
4334            .and_then(|s| s.shown(now))
4335            .or_else(|| {
4336                self.hired_workers
4337                    .iter()
4338                    .find(|w| w.instance_id == worker_instance_id)
4339                    .and_then(|w| w.last_error.as_deref())
4340                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4341            })
4342            .filter(|e| !worker_error_is_hud_noise(e))
4343    }
4344
4345    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4346        self.in_combat = combat.in_combat;
4347        self.auto_attack = combat.auto_attack;
4348        self.combat_has_los = combat.has_los;
4349        self.attack_cd_ticks = combat.attack_cd_ticks;
4350        self.gcd_ticks = combat.gcd_ticks;
4351        self.weapon_ability_id = combat.ability_id.clone();
4352        self.mainhand_template_id = combat.mainhand_template_id.clone();
4353        self.mainhand_label = combat.mainhand_label.clone();
4354        self.mainhand_instance_id = combat.mainhand_instance_id;
4355        self.offhand_template_id = combat.offhand_template_id.clone();
4356        self.offhand_label = combat.offhand_label.clone();
4357        self.offhand_instance_id = combat.offhand_instance_id;
4358        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4359            1
4360        } else {
4361            combat.mainhand_hand_slots
4362        };
4363        self.defense = combat.defense.clone();
4364        self.worn = combat.worn.iter().cloned().collect();
4365        self.carry_mass = combat.carry_mass;
4366        self.carry_mass_max = combat.carry_mass_max;
4367        self.encumbrance = combat.encumbrance;
4368        self.cast_progress = combat.cast.clone();
4369        self.timed_channel = combat.timed_channel.clone();
4370        self.plot_build_offer = combat.plot_build.clone();
4371        self.ability_cooldowns = combat.ability_cooldowns.clone();
4372        self.blocking_active = combat.blocking_active;
4373        self.max_target_slots = combat.max_target_slots.max(1);
4374        self.combat_slots = combat.slots.clone();
4375        self.rotation_presets = combat.rotation_presets.clone();
4376        self.known_abilities = combat.known_abilities.clone();
4377        self.ability_meta = combat
4378            .ability_meta
4379            .iter()
4380            .cloned()
4381            .map(|meta| (meta.id.clone(), meta))
4382            .collect();
4383        self.ability_mastery = combat
4384            .ability_mastery
4385            .iter()
4386            .cloned()
4387            .map(|row| (row.ability_id.clone(), row))
4388            .collect();
4389        self.hotbar = combat.hotbar.clone();
4390        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4391        self.keychain_stacks = combat.keychain.clone();
4392        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4393        self.combat_target_detail = combat.target.clone();
4394        self.statuses = combat.statuses.clone();
4395        self.combat_target = combat.target_entity_id;
4396        if combat.progression_xp_base > 0.0 {
4397            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4398                baseline_display: combat.progression_baseline,
4399                xp_base: combat.progression_xp_base,
4400                xp_growth: combat.progression_xp_growth,
4401            });
4402        }
4403        if let Some(xp) = &combat.progression_xp {
4404            if let Some(player) = &mut self.player {
4405                player.progression_xp = Some(xp.clone());
4406                if let Some(attrs) = combat.attributes {
4407                    player.attributes = Some(attrs);
4408                }
4409                if let Some(skills) = &combat.skills {
4410                    player.skills = Some(skills.clone());
4411                }
4412            }
4413        }
4414        if let Some(label) = &combat.target_label {
4415            self.combat_target_label = Some(label.clone());
4416        } else if let Some(id) = combat.target_entity_id {
4417            self.combat_target_label = self
4418                .entities
4419                .iter()
4420                .find(|e| e.id == id)
4421                .map(|e| e.label.clone())
4422                .or_else(|| self.combat_target_label.clone());
4423        }
4424        self.refresh_inventory_ui();
4425    }
4426
4427    /// Entity id assigned to a combat slot (from server HUD).
4428    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4429        self.combat_slots
4430            .iter()
4431            .find(|s| s.slot_index == slot)
4432            .and_then(|s| s.target_entity_id)
4433            .or_else(|| if slot == 1 { self.combat_target } else { None })
4434    }
4435
4436    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4437    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4438        self.ability_meta
4439            .get(ability_id)
4440            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4441            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4442            // the server validates aim_mode.
4443            .unwrap_or(self.ground_target.is_some())
4444    }
4445
4446    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4447    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4448        self.ability_meta
4449            .get(ability_id)
4450            .map(|meta| meta.aim_mode == "ground")
4451            .unwrap_or(false)
4452    }
4453
4454    /// True when the ability may be placed in an auto-rotation preset.
4455    /// Missing meta (pre-sync) defaults to eligible; server still validates.
4456    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4457        self.ability_meta
4458            .get(ability_id)
4459            .map(|meta| meta.auto_rotation_eligible)
4460            .unwrap_or(true)
4461    }
4462
4463    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4464    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4465        self.ground_target = Some((x, y, 0.0));
4466    }
4467
4468    /// Clear the local free-aim ground target.
4469    pub fn clear_ground_target(&mut self) {
4470        self.ground_target = None;
4471    }
4472
4473    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4474    /// May be an ability id or `item:<template_id>` consumable binding.
4475    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4476        if !(1..=9).contains(&slot_1_to_9) {
4477            return None;
4478        }
4479        self.hotbar
4480            .get((slot_1_to_9 - 1) as usize)
4481            .and_then(|a| a.as_deref())
4482            .filter(|id| !id.is_empty())
4483    }
4484
4485    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4486    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4487        let binding = self.hotbar_ability(slot_1_to_9)?;
4488        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4489            let name = self
4490                .inventory_hints
4491                .get(template_id)
4492                .map(|h| h.display_name.as_str())
4493                .unwrap_or(template_id);
4494            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4495            Some(format!("{name}×{qty}"))
4496        } else {
4497            Some(binding.to_string())
4498        }
4499    }
4500
4501    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4502    pub fn loadout_ability_choices(&self) -> Vec<String> {
4503        let mut out = self.known_abilities.clone();
4504        let weapon = self.weapon_ability_id.trim();
4505        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4506            out.push(weapon.to_string());
4507        }
4508        out
4509    }
4510
4511    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4512    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4513        let mut out = Vec::new();
4514        for ability in self.loadout_ability_choices() {
4515            let meta = if ability == self.weapon_ability_id {
4516                Some("weapon".into())
4517            } else {
4518                None
4519            };
4520            out.push(LoadoutHotbarChoice {
4521                binding: ability.clone(),
4522                label: ability,
4523                meta,
4524            });
4525        }
4526        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4527        for stack in &self.inventory_stacks {
4528            if Self::stack_is_item_grant(stack) {
4529                continue;
4530            }
4531            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4532                continue;
4533            }
4534            let qty = stack.quantity.max(1);
4535            if let Some((_, _, existing)) = consumables
4536                .iter_mut()
4537                .find(|(id, _, _)| id == &stack.template_id)
4538            {
4539                *existing = existing.saturating_add(qty);
4540            } else {
4541                let label = stack
4542                    .display_name
4543                    .clone()
4544                    .or_else(|| {
4545                        self.inventory_hints
4546                            .get(&stack.template_id)
4547                            .map(|h| h.display_name.clone())
4548                    })
4549                    .unwrap_or_else(|| stack.template_id.clone());
4550                consumables.push((stack.template_id.clone(), label, qty));
4551            }
4552        }
4553        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4554        for (template_id, label, qty) in consumables {
4555            out.push(LoadoutHotbarChoice {
4556                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4557                label: format!("{label} ×{qty}"),
4558                meta: Some("use".into()),
4559            });
4560        }
4561        out
4562    }
4563
4564    /// Hostile wildlife / monsters for T1 (`Tab`).
4565    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4566        self.combat_candidates()
4567    }
4568
4569    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4570    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4571        let (px, py) = self.player_position();
4572        let dist = |id: EntityId| {
4573            self.entities
4574                .iter()
4575                .find(|e| e.id == id)
4576                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4577                .unwrap_or(f32::MAX)
4578        };
4579
4580        let mut allies = Vec::new();
4581        // Self first — heal_touch on T2.
4582        if let Some(me) = self.player.as_ref() {
4583            let alive = me
4584                .vitals
4585                .as_ref()
4586                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4587                .unwrap_or(true);
4588            if alive {
4589                allies.push((self.entity_id, "Yourself".into()));
4590            }
4591        }
4592        for entity in &self.entities {
4593            if entity.id == self.entity_id {
4594                continue;
4595            }
4596            if entity.vitals.is_some() {
4597                let alive = entity
4598                    .vitals
4599                    .as_ref()
4600                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4601                    .unwrap_or(true);
4602                if alive {
4603                    allies.push((entity.id, entity.label.clone()));
4604                }
4605            }
4606        }
4607        allies.sort_by(|(a, _), (b, _)| {
4608            if *a == self.entity_id {
4609                return std::cmp::Ordering::Less;
4610            }
4611            if *b == self.entity_id {
4612                return std::cmp::Ordering::Greater;
4613            }
4614            dist(*a)
4615                .partial_cmp(&dist(*b))
4616                .unwrap_or(std::cmp::Ordering::Equal)
4617        });
4618
4619        let mut monsters = self.combat_candidates();
4620        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4621        allies.into_iter().chain(monsters).collect()
4622    }
4623
4624    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4625        match slot_index {
4626            2 => self.t2_candidates(),
4627            _ => self.t1_candidates(),
4628        }
4629    }
4630
4631    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
4632    pub fn pick_combat_target_at(
4633        &self,
4634        wx: f32,
4635        wy: f32,
4636        slot_index: u8,
4637        radius_m: f32,
4638    ) -> Option<(EntityId, String)> {
4639        let mut best: Option<(f32, EntityId, String)> = None;
4640        for (id, label) in self.candidates_for_slot(slot_index) {
4641            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4642                // NPCs may only appear in NpcView — fall back to npc list coords.
4643                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4644                    let d = distance(wx, wy, npc.x, npc.y);
4645                    if d <= radius_m {
4646                        best = match best {
4647                            Some((bd, _, _)) if bd <= d => best,
4648                            _ => Some((d, id, label)),
4649                        };
4650                    }
4651                }
4652                continue;
4653            };
4654            let d = distance(
4655                wx,
4656                wy,
4657                entity.transform.position.x,
4658                entity.transform.position.y,
4659            );
4660            if d <= radius_m {
4661                best = match best {
4662                    Some((bd, _, _)) if bd <= d => best,
4663                    _ => Some((d, id, label)),
4664                };
4665            }
4666        }
4667        best.map(|(_, id, label)| (id, label))
4668    }
4669
4670    /// Full client reset after Welcome (initial connect or reconnect).
4671    pub(crate) fn restore_from_welcome(
4672        &mut self,
4673        session_id: SessionId,
4674        entity_id: EntityId,
4675        snapshot: &flatland_protocol::Snapshot,
4676    ) {
4677        self.clear_harvest_state();
4678        self.disconnect_reason = None;
4679        self.show_stats = false;
4680        self.show_craft_menu = false;
4681        self.show_shop_menu = false;
4682        self.shop_catalog = None;
4683        self.show_inventory_menu = false;
4684        self.session_id = session_id;
4685        self.entity_id = entity_id;
4686        self.connected = true;
4687        self.apply_snapshot_fields(snapshot, entity_id);
4688        if let Some(combat) = &snapshot.combat {
4689            self.apply_combat_hud(combat);
4690            let stacks = self.inventory_stacks.clone();
4691            self.sync_inventory_from_stacks(&stacks);
4692        }
4693    }
4694
4695    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4696        self.tick = delta.tick;
4697        self.world_clock = delta.world_clock;
4698
4699        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
4700        if delta.entities.is_empty() {
4701            self.ground_drops = delta.ground_drops.clone();
4702            self.combat_fx = delta.combat_fx.clone();
4703            self.property_plots = delta.property_plots.clone();
4704            self.apply_terrain_overlays(&delta.terrain_overlays);
4705            if let Some(combat) = &delta.combat {
4706                self.apply_combat_hud(combat);
4707                let stacks = self.inventory_stacks.clone();
4708                self.sync_inventory_from_stacks(&stacks);
4709            }
4710            // Peer vanished from AOI — drop directed whisper.
4711            self.refresh_whisper_range();
4712            return;
4713        }
4714        if !delta.buildings.is_empty() {
4715            self.buildings = delta.buildings.clone();
4716        }
4717        if !delta.blueprints.is_empty() {
4718            self.blueprints = delta.blueprints.clone();
4719        }
4720        if !delta.building_materials.is_empty() {
4721            self.building_materials = delta.building_materials.clone();
4722        }
4723        self.sync_inventory_from_stacks(&delta.inventory);
4724
4725        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4726            self.player = Some(updated.clone());
4727        }
4728        self.entities = delta.entities.clone();
4729        if self.player.is_none() {
4730            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4731        }
4732
4733        self.sync_interior_map_context();
4734
4735        // Static world layers: ticks often omit these (indoors, or unchanged).
4736        // Never wipe the welcome snapshot with an empty vec — except when indoors,
4737        // where empty means "no nodes in this building" (clear outdoor leftovers).
4738        if !delta.resource_nodes.is_empty() {
4739            self.resource_nodes = delta.resource_nodes.clone();
4740        } else if delta.interior_map.is_some()
4741            || self.effective_inside_building().is_some()
4742        {
4743            self.resource_nodes = delta.resource_nodes.clone();
4744        }
4745        self.ground_drops = delta.ground_drops.clone();
4746        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
4747        self.placed_containers = delta.placed_containers.clone();
4748        if !delta.doors.is_empty() {
4749            self.doors = delta.doors.clone();
4750        }
4751        if self.effective_inside_building().is_some() {
4752            if let Some(map) = &delta.interior_map {
4753                self.interior_map = Some(map.clone());
4754            }
4755        } else {
4756            self.interior_map = None;
4757        }
4758        self.sync_interior_z_bands();
4759        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
4760        self.npcs = delta.npcs.clone();
4761        if !delta.quest_log.is_empty() {
4762            self.quest_log = delta.quest_log.clone();
4763        }
4764        self.apply_hired_workers(delta.hired_workers.clone());
4765        if !delta.interactables.is_empty() {
4766            self.interactables = delta.interactables.clone();
4767        }
4768        if delta.ledger.is_some() {
4769            self.ledger = delta.ledger.clone();
4770        }
4771        if delta.career.is_some() {
4772            self.career = delta.career.clone();
4773        }
4774        self.combat_fx = delta.combat_fx.clone();
4775        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
4776        if !delta.property_plots.is_empty() {
4777            self.property_plots = delta.property_plots.clone();
4778        }
4779        self.apply_terrain_overlays(&delta.terrain_overlays);
4780        if let Some(combat) = &delta.combat {
4781            self.apply_combat_hud(combat);
4782            let stacks = self.inventory_stacks.clone();
4783            self.sync_inventory_from_stacks(&stacks);
4784        } else {
4785            self.refresh_inventory_ui();
4786        }
4787        self.refresh_whisper_range();
4788    }
4789
4790    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
4791    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
4792    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4793        self.terrain_zones
4794            .retain(|z| !z.id.starts_with("rt:"));
4795        self.terrain_zones.extend(overlays.iter().cloned());
4796    }
4797
4798    /// End directed whisper when the peer is farther than interact range (or missing).
4799    /// Each client runs this locally so both players drop whisper mode when either walks away.
4800    fn refresh_whisper_range(&mut self) {
4801        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4802            return;
4803        };
4804        let (px, py) = self.player_position();
4805        let in_range = self.entities.iter().any(|e| {
4806            e.id == peer
4807                && distance(
4808                    px,
4809                    py,
4810                    e.transform.position.x,
4811                    e.transform.position.y,
4812                ) <= INTERACTION_RADIUS_M
4813        });
4814        if !in_range {
4815            self.social_chat.cancel_whisper_out_of_range();
4816        }
4817    }
4818
4819    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
4820    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4821        let (px, py) = self.player_position();
4822        let mut out = Vec::new();
4823        for npc in &self.npcs {
4824            let Some(eid) = npc.entity_id else {
4825                continue;
4826            };
4827            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4828            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4829            if alive && has_hp {
4830                out.push((eid, npc.label.clone()));
4831            }
4832        }
4833        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4834            let dist = |id: EntityId| {
4835                self.entities
4836                    .iter()
4837                    .find(|e| e.id == id)
4838                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4839                    .unwrap_or(f32::MAX)
4840            };
4841            dist(*a_id)
4842                .partial_cmp(&dist(*b_id))
4843                .unwrap_or(std::cmp::Ordering::Equal)
4844                .then_with(|| a_label.cmp(b_label))
4845                .then_with(|| a_id.cmp(b_id))
4846        });
4847        out
4848    }
4849
4850    pub fn refresh_combat_target_label(&mut self) {
4851        let Some(id) = self.combat_target else {
4852            return;
4853        };
4854        if let Some((_, label)) = self
4855            .combat_candidates()
4856            .into_iter()
4857            .find(|(eid, _)| *eid == id)
4858        {
4859            self.combat_target_label = Some(label);
4860        } else if let Some(label) = self
4861            .entities
4862            .iter()
4863            .find(|e| e.id == id)
4864            .map(|e| e.label.clone())
4865        {
4866            self.combat_target_label = Some(label);
4867        }
4868    }
4869
4870    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4871        self.quest_log
4872            .iter()
4873            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4874            .collect()
4875    }
4876
4877    /// True when the observer has at least one free lodging slot (max workers = beds).
4878    pub fn has_worker_lodging(&self) -> bool {
4879        self.free_worker_lodging_slots() > 0
4880    }
4881
4882    /// Owned lodging capacity minus currently hired workers.
4883    pub fn free_worker_lodging_slots(&self) -> i64 {
4884        let slots: u32 = self
4885            .placed_containers
4886            .iter()
4887            .filter(|c| match (self.character_id, c.owner_character_id) {
4888                (Some(me), Some(owner)) => me == owner,
4889                (Some(_), None) => false,
4890                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4891            })
4892            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4893            .sum();
4894        let used = self.hired_workers.len() as u32;
4895        slots as i64 - used as i64
4896    }
4897
4898    /// Display names of hired workers assigned to this lodging container.
4899    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4900        let mut names: Vec<String> = self
4901            .hired_workers
4902            .iter()
4903            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4904            .map(|w| w.label.clone())
4905            .collect();
4906        names.sort();
4907        names
4908    }
4909
4910    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
4911    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4912        let is_lodging = self
4913            .placed_containers
4914            .iter()
4915            .find(|c| c.id == container_id)
4916            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4917        if !is_lodging {
4918            return None;
4919        }
4920        let names = self.lodging_occupant_labels(container_id);
4921        Some(if names.is_empty() {
4922            "vacant".into()
4923        } else {
4924            names.join(", ")
4925        })
4926    }
4927
4928    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4929        self.quest_log
4930            .iter()
4931            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4932            .or_else(|| {
4933                self.quest_log
4934                    .iter()
4935                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4936            })
4937    }
4938
4939    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
4940    pub fn nearby_lockable_door(&self) -> bool {
4941        let (px, py) = self.player_position();
4942        self.doors
4943            .iter()
4944            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= 3.5)
4945    }
4946
4947    /// True when an open player-house door is in range for Enter-to-go-inside.
4948    pub fn nearby_open_player_door(&self) -> bool {
4949        if self.effective_inside_building().is_some() {
4950            return false;
4951        }
4952        let (px, py) = self.player_position();
4953        self.doors.iter().any(|d| {
4954            if !d.open || d.locked {
4955                return false;
4956            }
4957            let player_house = self
4958                .buildings
4959                .iter()
4960                .find(|b| b.id == d.building_id)
4961                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
4962            player_house && (d.x - px).hypot(d.y - py) <= 3.5
4963        })
4964    }
4965
4966    /// True when inside a player house near the exterior portal (Enter exits freely).
4967    pub fn nearby_player_exit_door(&self) -> bool {
4968        let Some(bid) = self.effective_inside_building() else {
4969            return false;
4970        };
4971        let (px, py) = self.player_position();
4972        self.doors.iter().any(|d| {
4973            if d.building_id != bid || d.portal.is_none() {
4974                return false;
4975            }
4976            let player_house = self
4977                .buildings
4978                .iter()
4979                .find(|b| b.id == d.building_id)
4980                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
4981            player_house && (d.x - px).hypot(d.y - py) <= 1.5
4982        })
4983    }
4984
4985    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
4986    pub fn nearest_interact_target(&self) -> Option<String> {
4987        let (px, py) = self.player_position();
4988        let inside = self.effective_inside_building();
4989
4990        #[derive(Clone, Copy, PartialEq, Eq)]
4991        enum Kind {
4992            Player,
4993            Npc,
4994            HiredWorker,
4995            QuestBoard,
4996            ExitDoor,
4997            EnterDoor,
4998            Well,
4999            Water,
5000        }
5001
5002        fn kind_priority(kind: Kind) -> u8 {
5003            match kind {
5004                Kind::Player => 0,
5005                Kind::Npc => 0,
5006                Kind::HiredWorker => 0,
5007                Kind::QuestBoard => 1,
5008                Kind::ExitDoor => 2,
5009                Kind::EnterDoor => 3,
5010                Kind::Well => 4,
5011                Kind::Water => 5,
5012            }
5013        }
5014
5015        let mut best: Option<(f32, Kind, String)> = None;
5016
5017        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5018            if dist > max {
5019                return;
5020            }
5021            let replace = match best {
5022                None => true,
5023                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5024                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5025                    kind_priority(kind) < kind_priority(bk)
5026                }
5027                _ => false,
5028            };
5029            if replace {
5030                best = Some((dist, kind, id));
5031            }
5032        };
5033
5034        for npc in &self.npcs {
5035            consider(
5036                distance(px, py, npc.x, npc.y),
5037                INTERACTION_RADIUS_M,
5038                Kind::Npc,
5039                npc.id.clone(),
5040            );
5041        }
5042
5043        for worker in &self.hired_workers {
5044            consider(
5045                distance(px, py, worker.x, worker.y),
5046                INTERACTION_RADIUS_M,
5047                Kind::HiredWorker,
5048                worker.instance_id.clone(),
5049            );
5050        }
5051
5052        for entity in &self.entities {
5053            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
5054            {
5055                continue;
5056            }
5057            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
5058            if self
5059                .hired_workers
5060                .iter()
5061                .any(|w| w.entity_id == entity.id)
5062            {
5063                continue;
5064            }
5065            consider(
5066                distance(
5067                    px,
5068                    py,
5069                    entity.transform.position.x,
5070                    entity.transform.position.y,
5071                ),
5072                INTERACTION_RADIUS_M,
5073                Kind::Player,
5074                entity.id.to_string(),
5075            );
5076        }
5077
5078        for door in &self.doors {
5079            if let Some(ref bid) = inside {
5080                if door.building_id != *bid {
5081                    continue;
5082                }
5083                let is_exit = door.portal.is_some();
5084                let max = if is_exit {
5085                    INTERACTION_RADIUS_M
5086                } else {
5087                    DOOR_INTERACTION_RADIUS_M
5088                };
5089                let kind = if is_exit {
5090                    Kind::ExitDoor
5091                } else {
5092                    Kind::EnterDoor
5093                };
5094                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5095                continue;
5096            }
5097            consider(
5098                distance(px, py, door.x, door.y),
5099                DOOR_INTERACTION_RADIUS_M,
5100                Kind::EnterDoor,
5101                door.id.clone(),
5102            );
5103        }
5104
5105        if inside.is_none() {
5106            for inter in &self.interactables {
5107                if inter.kind == "quest_board" {
5108                    consider(
5109                        distance(px, py, inter.x, inter.y),
5110                        QUEST_BOARD_INTERACTION_RADIUS_M,
5111                        Kind::QuestBoard,
5112                        inter.id.clone(),
5113                    );
5114                }
5115            }
5116            for building in &self.buildings {
5117                if !building.tags.iter().any(|t| t == "well") {
5118                    continue;
5119                }
5120                consider(
5121                    distance(px, py, building.x, building.y),
5122                    INTERACTION_RADIUS_M,
5123                    Kind::Well,
5124                    building.id.clone(),
5125                );
5126            }
5127            if self.in_shallow_water() {
5128                consider(
5129                    0.0,
5130                    INTERACTION_RADIUS_M,
5131                    Kind::Water,
5132                    "water_source".into(),
5133                );
5134            }
5135        }
5136
5137        best.map(|(_, _, id)| id)
5138    }
5139
5140    /// Nearest quest board and distance (any distance), for out-of-range feedback.
5141    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5142        if self.effective_inside_building().is_some() {
5143            return None;
5144        }
5145        let (px, py) = self.player_position();
5146        self.interactables
5147            .iter()
5148            .filter(|i| i.kind == "quest_board")
5149            .map(|i| {
5150                let label = if i.label.is_empty() {
5151                    "Quest board".to_string()
5152                } else {
5153                    i.label.clone()
5154                };
5155                (label, distance(px, py, i.x, i.y))
5156            })
5157            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5158    }
5159
5160    /// Human-readable template name from synced catalog hints.
5161    pub fn template_display_name(&self, template_id: &str) -> String {
5162        self.inventory_hints
5163            .get(template_id)
5164            .map(|h| h.display_name.clone())
5165            .filter(|n| !n.is_empty())
5166            .unwrap_or_else(|| humanize_template_id(template_id))
5167    }
5168
5169    /// Prefer blueprint-view display name, then inventory hints / humanized id.
5170    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5171        if !display_name.is_empty() {
5172            display_name.to_string()
5173        } else {
5174            self.template_display_name(template_id)
5175        }
5176    }
5177
5178    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5179        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5180    }
5181
5182    pub fn blueprint_ingredient_label(
5183        &self,
5184        input: &flatland_protocol::BlueprintIngredientView,
5185    ) -> String {
5186        self.blueprint_item_label(&input.template_id, &input.display_name)
5187    }
5188
5189    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5190        self.blueprint_item_label(&tool.item, &tool.display_name)
5191    }
5192
5193    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
5194    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5195        use crate::worker_route_editor::{
5196            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5197        };
5198        let lodging = self
5199            .worker_route_editor
5200            .as_ref()
5201            .and_then(|ed| ed.lodging_container_id.as_deref());
5202        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5203            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5204            None => node_candidates_stable(&self.resource_nodes),
5205        }
5206    }
5207
5208    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5209        if dist_m.is_nan() {
5210            return "—".into();
5211        }
5212        let from_bed = self
5213            .worker_route_editor
5214            .as_ref()
5215            .and_then(|ed| ed.lodging_container_id.as_deref())
5216            .and_then(|id| {
5217                self.placed_containers
5218                    .iter()
5219                    .find(|c| c.id == id)
5220                    .map(|c| c.display_name.clone())
5221            });
5222        match from_bed {
5223            Some(bed) => format!("{dist_m:.0}m from {bed}"),
5224            None => format!("{dist_m:.0}m"),
5225        }
5226    }
5227
5228    /// Chest label for the location panel — generic type unless this player owns it.
5229    pub fn placed_container_public_label(
5230        &self,
5231        c: &flatland_protocol::PlacedContainerView,
5232    ) -> String {
5233        let is_owner = match (self.character_id, c.owner_character_id) {
5234            (Some(me), Some(owner)) => me == owner,
5235            _ => false,
5236        };
5237        if is_owner {
5238            c.display_name.clone()
5239        } else {
5240            self.template_display_name(&c.template_id)
5241        }
5242    }
5243
5244    /// Keys on person and stowed on the keychain for the keychain overlay.
5245    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5246        let mut out = Vec::new();
5247        for stack in &self.inventory_stacks {
5248            if stack.template_id == KEY_TEMPLATE {
5249                out.push(KeychainEntry {
5250                    stack: stack.clone(),
5251                    stowed: false,
5252                });
5253            }
5254        }
5255        for stack in &self.keychain_stacks {
5256            if stack.template_id == KEY_TEMPLATE {
5257                out.push(KeychainEntry {
5258                    stack: stack.clone(),
5259                    stowed: true,
5260                });
5261            }
5262        }
5263        out
5264    }
5265
5266    /// Display name of the chest a `container_key` opens, when known on the client.
5267    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5268        if stack.template_id != KEY_TEMPLATE {
5269            return None;
5270        }
5271        if let Some(name) = stack
5272            .props
5273            .get(PROP_OPENS_CONTAINER_NAME)
5274            .filter(|n| !n.is_empty())
5275        {
5276            return Some(name.clone());
5277        }
5278        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5279        self.container_name_for_lock_id(opens)
5280    }
5281
5282    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
5283    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5284        if stack.template_id == KEY_TEMPLATE {
5285            self.template_display_name(KEY_TEMPLATE)
5286        } else {
5287            stack
5288                .display_name
5289                .clone()
5290                .unwrap_or_else(|| stack.template_id.clone())
5291        }
5292    }
5293
5294    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
5295    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5296        if stack.template_id != KEY_TEMPLATE {
5297            return String::new();
5298        }
5299        match self.key_pair_chest_label(stack) {
5300            Some(chest) if self.key_drop_blocked(stack) => {
5301                format!(" [key for {chest} — can't drop while locked]")
5302            }
5303            Some(chest) => format!(" [key for {chest}]"),
5304            None => " [key — unpaired]".into(),
5305        }
5306    }
5307
5308    /// Resolve a lock id to a container label (placed chest or one still on your person).
5309    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5310        for c in &self.placed_containers {
5311            if c.lock_id.as_deref() == Some(lock) {
5312                return Some(c.display_name.clone());
5313            }
5314        }
5315        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5316            self.worn
5317                .values()
5318                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5319        })
5320    }
5321
5322    /// Keys cannot be dropped while their paired chest is locked.
5323    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5324        if stack.template_id != KEY_TEMPLATE {
5325            return false;
5326        }
5327        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5328            return false;
5329        };
5330        for c in &self.placed_containers {
5331            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5332                return true;
5333            }
5334        }
5335        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5336            return true;
5337        }
5338        self.worn
5339            .values()
5340            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5341    }
5342
5343    /// Property deeds cannot be dropped or destroyed (store or trade only).
5344    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5345        stack.template_id == PROPERTY_DEED_TEMPLATE
5346    }
5347
5348    pub fn is_property_deed_template(template_id: &str) -> bool {
5349        template_id == PROPERTY_DEED_TEMPLATE
5350    }
5351
5352    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5353        stack
5354            .props
5355            .get("plot_id")
5356            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5357    }
5358
5359    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5360    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5361        let (px, py) = self.player_position();
5362        let (cx, cy) = self.farm_plot_cell_under_player()?;
5363        let tx = cx as f32 + 0.5;
5364        let ty = cy as f32 + 0.5;
5365        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5366            return None;
5367        }
5368        let kind = self
5369            .terrain_at(tx, ty)
5370            .or_else(|| self.terrain_at(px, py));
5371        if kind == Some(TerrainKindView::Tilled) {
5372            return None;
5373        }
5374        if matches!(
5375            kind,
5376            Some(TerrainKindView::ShallowWater)
5377                | Some(TerrainKindView::DeepWater)
5378                | Some(TerrainKindView::Rock)
5379        ) {
5380            return None;
5381        }
5382        Some((tx, ty))
5383    }
5384
5385    fn container_name_in_stacks(
5386        stacks: &[flatland_protocol::ItemStack],
5387        lock: &str,
5388    ) -> Option<String> {
5389        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5390            for s in stacks {
5391                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5392                    return Some(GameState::stack_container_label(s));
5393                }
5394                if let Some(name) = walk(&s.contents, lock) {
5395                    return Some(name);
5396                }
5397            }
5398            None
5399        }
5400        walk(stacks, lock)
5401    }
5402
5403    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5404        stack
5405            .props
5406            .get(PROP_CUSTOM_NAME)
5407            .cloned()
5408            .or_else(|| stack.display_name.clone())
5409            .unwrap_or_else(|| stack.template_id.clone())
5410    }
5411
5412    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5413        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5414            for s in stacks {
5415                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5416                    return true;
5417                }
5418                if walk(&s.contents, lock) {
5419                    return true;
5420                }
5421            }
5422            false
5423        }
5424        walk(stacks, lock)
5425    }
5426
5427    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5428        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5429            return Some(stack.clone());
5430        }
5431        for worn in self.worn.values() {
5432            if worn.item_instance_id == Some(instance_id) {
5433                return Some(worn.clone());
5434            }
5435            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5436                return Some(stack.clone());
5437            }
5438        }
5439        None
5440    }
5441
5442    /// Highest-`z_order` property zone covering `(x, y)`.
5443    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5444        self.property_zones
5445            .iter()
5446            .enumerate()
5447            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5448            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5449            .map(|(_, z)| z)
5450    }
5451
5452    /// Highest-`z_order` tax zone covering `(x, y)`.
5453    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5454        self.tax_zones
5455            .iter()
5456            .enumerate()
5457            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5458            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5459            .map(|(_, z)| z)
5460    }
5461
5462    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5463    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5464        let mut max_bps = 0u32;
5465        let mut y = y0 + 0.5;
5466        while y < y1 {
5467            let mut x = x0 + 0.5;
5468            while x < x1 {
5469                if let Some(tz) = self.tax_zone_at(x, y) {
5470                    max_bps = max_bps.max(tz.rate_bps);
5471                }
5472                x += 1.0;
5473            }
5474            y += 1.0;
5475        }
5476        max_bps
5477    }
5478
5479    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5480    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5481        let mode = self.claim_mode.as_ref()?;
5482        let w = mode.width_m.max(1) as f32;
5483        let h = mode.height_m.max(1) as f32;
5484        Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5485    }
5486
5487    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5488    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5489        let mode = self.relocate_mode.as_ref()?;
5490        let x0 = mode.cursor_x.floor();
5491        let y0 = mode.cursor_y.floor();
5492        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5493    }
5494
5495    /// Client quote matching server `quote_plot`:
5496    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5497    pub fn claim_quote(
5498        &self,
5499    ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5500        let mode = self.claim_mode.as_ref()?;
5501        let zone = self
5502            .property_zones
5503            .iter()
5504            .find(|z| z.id == mode.zone_id)?;
5505        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5506        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5507        let zone_area = zone_view_area_m2(zone).max(1.0);
5508        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5509        let weight = self
5510            .property_plot_settings
5511            .as_ref()
5512            .map(|s| s.tax_premium_weight)
5513            .unwrap_or(0.5)
5514            .max(0.0);
5515        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5516        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5517        let purchase = ((zone.crown_price_copper as f64)
5518            * (area_frac as f64)
5519            * (premium as f64))
5520            .ceil()
5521            .max(0.0) as u64;
5522        let upkeep = if zone.upkeep_copper_per_day == 0 {
5523            0
5524        } else {
5525            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5526                .ceil()
5527                .max(1.0) as u64
5528        };
5529        let copper = crate::currency::copper_from_counts(&self.inventory);
5530        let can_afford = copper >= purchase;
5531        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5532        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5533    }
5534
5535    fn validate_claim_footprint(
5536        &self,
5537        zone: &flatland_protocol::PropertyZoneView,
5538        x0: f32,
5539        y0: f32,
5540        x1: f32,
5541        y1: f32,
5542        area: f32,
5543    ) -> (bool, String) {
5544        let min_area = self
5545            .property_plot_settings
5546            .as_ref()
5547            .map(|s| s.min_plot_area_m2)
5548            .unwrap_or(4.0);
5549        if area + f32::EPSILON < min_area {
5550            return (false, "plot too small".into());
5551        }
5552        if zone.max_area_m2.is_some_and(|m| area > m) {
5553            return (false, "plot exceeds max area".into());
5554        }
5555        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5556            return (false, "plot must lie inside the property zone".into());
5557        }
5558        if self.property_plots.iter().any(|p| {
5559            rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5560        }) {
5561            return (false, "plot overlaps an existing claim".into());
5562        }
5563        (true, String::new())
5564    }
5565
5566    /// Standing in a property zone on a free cell (not already claimed).
5567    pub fn free_property_zone_under_player(
5568        &self,
5569    ) -> Option<&flatland_protocol::PropertyZoneView> {
5570        let (px, py) = self.player_position();
5571        let zone = self.property_zone_at(px, py)?;
5572        if self
5573            .property_plots
5574            .iter()
5575            .any(|p| point_in_plot(px, py, p))
5576        {
5577            return None;
5578        }
5579        Some(zone)
5580    }
5581
5582    /// Own plot covering the player (`is_mine`).
5583    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5584        let (px, py) = self.player_position();
5585        self.property_plots
5586            .iter()
5587            .find(|p| p.is_mine && point_in_plot(px, py, p))
5588    }
5589
5590    /// Plot under the player that they may farm (deed, grant, or public).
5591    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5592        let (px, py) = self.player_position();
5593        self.property_plots
5594            .iter()
5595            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5596    }
5597
5598    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5599    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5600        if self.farmable_plot_under_player().is_none() {
5601            return None;
5602        }
5603        let (px, py) = self.player_position();
5604        Some((px.floor() as i32, py.floor() as i32))
5605    }
5606
5607    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5608        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5609        self.resource_nodes.iter().any(|n| {
5610            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5611            ncx == cx && ncy == cy
5612                || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5613        })
5614    }
5615
5616    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5617        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5618        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5619            || self
5620                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5621                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5622        if !tilled {
5623            return false;
5624        }
5625        !self.resource_node_occupies_farm_cell(cx, cy)
5626    }
5627
5628    /// Empty tilled soil on the cell the player is standing on (no crop node).
5629    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5630        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5631            return false;
5632        };
5633        self.free_tilled_plant_slot_at(cx, cy)
5634    }
5635
5636    /// True when a free tilled cell (no resource node) is within interact range.
5637    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5638        let (px, py) = self.player_position();
5639        for dy in -2..=2 {
5640            for dx in -2..=2 {
5641                let cx = px.floor() as i32 + dx;
5642                let cy = py.floor() as i32 + dy;
5643                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5644                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5645                    continue;
5646                }
5647                if self.free_tilled_plant_slot_at(cx, cy) {
5648                    return true;
5649                }
5650            }
5651        }
5652        false
5653    }
5654
5655    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5656        stack.quantity > 0
5657            && (stack.props.contains_key("seed_for")
5658                || stack.template_id.ends_with("_seed")
5659                || stack.template_id == "potato_seed"
5660                || stack.template_id == "carrot_seed")
5661    }
5662
5663    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
5664    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5665        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5666        fn walk(
5667            stacks: &[flatland_protocol::ItemStack],
5668            counts: &mut std::collections::HashMap<String, u32>,
5669        ) {
5670            for s in stacks {
5671                if GameState::stack_is_farm_seed(s) {
5672                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5673                }
5674                walk(&s.contents, counts);
5675            }
5676        }
5677        walk(&self.inventory_stacks, &mut counts);
5678        for worn in self.worn.values() {
5679            walk(std::slice::from_ref(worn), &mut counts);
5680        }
5681        let mut out: Vec<_> = counts
5682            .into_iter()
5683            .map(|(template_id, quantity)| {
5684                let label = self
5685                    .inventory_hints
5686                    .get(&template_id)
5687                    .map(|h| h.display_name.clone())
5688                    .filter(|n| !n.trim().is_empty())
5689                    .unwrap_or_else(|| humanize_template_id(&template_id));
5690                (template_id, quantity, label)
5691            })
5692            .collect();
5693        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5694        out
5695    }
5696
5697    /// First farm seed template in inventory (root + nested bags).
5698    pub fn first_farm_seed_template(&self) -> Option<String> {
5699        self.farm_seed_entries()
5700            .into_iter()
5701            .next()
5702            .map(|(id, _, _)| id)
5703    }
5704
5705    pub fn clamp_plant_menu(&mut self) {
5706        let n = self.farm_seed_entries().len();
5707        if n == 0 {
5708            self.plant_menu_index = 0;
5709            self.plant_quantity = 1;
5710            return;
5711        }
5712        self.plant_menu_index = self.plant_menu_index.min(n - 1);
5713        let max_qty = self
5714            .farm_seed_entries()
5715            .get(self.plant_menu_index)
5716            .map(|(_, q, _)| *q)
5717            .unwrap_or(1)
5718            .max(1);
5719        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5720    }
5721
5722    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5723        let entries = self.farm_seed_entries();
5724        let (id, max, label) = entries.get(self.plant_menu_index)?;
5725        let qty = self.plant_quantity.min(*max).max(1);
5726        Some((id.clone(), qty, label.clone()))
5727    }
5728
5729    /// Terrain, interactables, and map objects near the player for the HUD location panel.
5730    pub fn location_context_lines(&self) -> Vec<ContextLine> {
5731        let (px, py) = self.player_position();
5732        let inside = self.effective_inside_building();
5733        let mut lines = Vec::new();
5734
5735        if let Some(kind) = self.terrain_at(px, py) {
5736            lines.push(ContextLine {
5737                on_top: true,
5738                text: format!("Terrain: {}", terrain_kind_label(kind)),
5739            });
5740        }
5741
5742        if let Some(id) = inside.as_ref() {
5743            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5744                lines.push(ContextLine {
5745                    on_top: true,
5746                    text: format!("Inside: {}", b.label),
5747                });
5748            }
5749        }
5750
5751        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5752
5753        for node in &self.resource_nodes {
5754            if node.id.starts_with("preview:") {
5755                continue;
5756            }
5757            let dist = distance(px, py, node.x, node.y);
5758            if dist > NEARBY_SCAN_M {
5759                continue;
5760            }
5761            let on_top = dist <= ON_TOP_RADIUS_M;
5762            let prefix = if on_top { "On" } else { "Near" };
5763            let name = resource_node_near_display_label(&node.label);
5764            let action = resource_node_near_action_suffix(node);
5765            nearby.push((
5766                dist,
5767                ContextLine {
5768                    on_top,
5769                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5770                },
5771            ));
5772        }
5773
5774        for drop in &self.ground_drops {
5775            let dist = distance(px, py, drop.x, drop.y);
5776            if dist > INTERACTION_RADIUS_M {
5777                continue;
5778            }
5779            let on_top = dist <= ON_TOP_RADIUS_M;
5780            let name = self.template_display_name(&drop.template_id);
5781            let prefix = if on_top { "On" } else { "Near" };
5782            let qty = if drop.quantity > 1 {
5783                format!(" ×{}", drop.quantity)
5784            } else {
5785                String::new()
5786            };
5787            nearby.push((
5788                dist,
5789                ContextLine {
5790                    on_top,
5791                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5792                },
5793            ));
5794        }
5795
5796        for c in &self.placed_containers {
5797            let dist = distance(px, py, c.x, c.y);
5798            if dist > CONTAINER_RANGE_M {
5799                continue;
5800            }
5801            let on_top = dist <= ON_TOP_RADIUS_M;
5802            let name = self.placed_container_public_label(c);
5803            let lock = if c.locked { " [locked]" } else { "" };
5804            let prefix = if on_top { "On" } else { "Near" };
5805            nearby.push((
5806                dist,
5807                ContextLine {
5808                    on_top,
5809                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5810                },
5811            ));
5812        }
5813
5814        for npc in &self.npcs {
5815            let dist = distance(px, py, npc.x, npc.y);
5816            if dist > NEARBY_SCAN_M {
5817                continue;
5818            }
5819            let on_top = dist <= ON_TOP_RADIUS_M;
5820            let prefix = if on_top { "On" } else { "Near" };
5821            nearby.push((
5822                dist,
5823                ContextLine {
5824                    on_top,
5825                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5826                },
5827            ));
5828        }
5829
5830        for door in &self.doors {
5831            let dist = distance(px, py, door.x, door.y);
5832            if dist > DOOR_INTERACTION_RADIUS_M {
5833                continue;
5834            }
5835            let building = self
5836                .buildings
5837                .iter()
5838                .find(|b| b.id == door.building_id)
5839                .map(|b| b.label.as_str())
5840                .unwrap_or(door.building_id.as_str());
5841            let player_house = self
5842                .buildings
5843                .iter()
5844                .find(|b| b.id == door.building_id)
5845                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5846            let action = if inside.is_some() && door.portal.is_some() {
5847                if player_house {
5848                    if door.locked {
5849                        "locked — l unlock · Enter exit".to_string()
5850                    } else if door.open {
5851                        "close · Enter exit · l lock".to_string()
5852                    } else {
5853                        "open · Enter exit · l lock".to_string()
5854                    }
5855                } else {
5856                    "exit".to_string()
5857                }
5858            } else if player_house {
5859                if door.locked {
5860                    "locked — l unlock".to_string()
5861                } else if door.open {
5862                    "close · Enter go inside · l lock".to_string()
5863                } else {
5864                    "open · l lock".to_string()
5865                }
5866            } else {
5867                "enter".to_string()
5868            };
5869            nearby.push((
5870                dist,
5871                ContextLine {
5872                    on_top: dist <= ON_TOP_RADIUS_M,
5873                    text: format!("{building} door ({dist:.1}m) — f {action}"),
5874                },
5875            ));
5876        }
5877
5878        if inside.is_none() {
5879            for inter in &self.interactables {
5880                if inter.kind != "quest_board" {
5881                    continue;
5882                }
5883                let dist = distance(px, py, inter.x, inter.y);
5884                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5885                    continue;
5886                }
5887                let on_top = dist <= ON_TOP_RADIUS_M;
5888                let prefix = if on_top { "On" } else { "Near" };
5889                let label = if inter.label.is_empty() {
5890                    "Quest board".to_string()
5891                } else {
5892                    inter.label.clone()
5893                };
5894                nearby.push((
5895                    dist,
5896                    ContextLine {
5897                        on_top,
5898                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5899                    },
5900                ));
5901            }
5902        }
5903
5904        if self.in_shallow_water() {
5905            let already = self
5906                .terrain_at(px, py)
5907                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5908            if !already {
5909                nearby.push((
5910                    0.0,
5911                    ContextLine {
5912                        on_top: true,
5913                        text: "Shallow water — f fill bottle".into(),
5914                    },
5915                ));
5916            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5917                line.text.push_str(" — f fill bottle");
5918            }
5919        }
5920
5921        if self.claim_mode.is_some() {
5922            nearby.push((
5923                0.0,
5924                ContextLine {
5925                    on_top: true,
5926                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5927                        .into(),
5928                },
5929            ));
5930        } else if let Some(plot) = self.my_plot_under_player() {
5931            let zone = plot
5932                .zone_label
5933                .as_deref()
5934                .filter(|s| !s.trim().is_empty())
5935                .or_else(|| {
5936                    self.property_zones
5937                        .iter()
5938                        .find(|z| z.id == plot.property_zone_id)
5939                        .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5940                })
5941                .unwrap_or(plot.property_zone_id.as_str());
5942            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5943                format!("Your plot ({zone}) — f again to sell to crown")
5944            } else {
5945                format!(
5946                    "Your plot ({zone}) — c till · p plant · f harvest · B build · l door lock · o farm access"
5947                )
5948            };
5949            nearby.push((
5950                0.0,
5951                ContextLine {
5952                    on_top: true,
5953                    text: prompt,
5954                },
5955            ));
5956        } else if let Some(plot) = self.farmable_plot_under_player() {
5957            let owner = plot
5958                .owner_label
5959                .as_deref()
5960                .filter(|s| !s.trim().is_empty())
5961                .unwrap_or("owner");
5962            let disc = if plot.farm_public {
5963                plot.public_tax_discount_bps / 100
5964            } else {
5965                plot.farm_allow
5966                    .iter()
5967                    .find(|g| Some(g.character_id) == self.character_id)
5968                    .map(|g| g.tax_discount_bps / 100)
5969                    .unwrap_or(0)
5970            };
5971            nearby.push((
5972                0.0,
5973                ContextLine {
5974                    on_top: true,
5975                    text: format!(
5976                        "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5977                    ),
5978                },
5979            ));
5980        } else if let Some(zone) = self.free_property_zone_under_player() {
5981            let label = zone
5982                .label
5983                .as_deref()
5984                .filter(|s| !s.trim().is_empty())
5985                .unwrap_or(zone.id.as_str());
5986            nearby.push((
5987                0.0,
5988                ContextLine {
5989                    on_top: true,
5990                    text: format!("Claimable land: {label} — k buy plot"),
5991                },
5992            ));
5993        }
5994
5995        for entity in &self.entities {
5996            if entity.id == self.entity_id {
5997                continue;
5998            }
5999            let dist = distance(
6000                px,
6001                py,
6002                entity.transform.position.x,
6003                entity.transform.position.y,
6004            );
6005            if dist > NEARBY_SCAN_M {
6006                continue;
6007            }
6008            let label = if entity.label.is_empty() {
6009                format!("entity {}", entity.id)
6010            } else {
6011                entity.label.clone()
6012            };
6013            nearby.push((
6014                dist,
6015                ContextLine {
6016                    on_top: dist <= ON_TOP_RADIUS_M,
6017                    text: format!("Near: {label} ({dist:.1}m)"),
6018                },
6019            ));
6020        }
6021
6022        nearby.sort_by(|a, b| {
6023            a.0.partial_cmp(&b.0)
6024                .unwrap_or(std::cmp::Ordering::Equal)
6025                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6026        });
6027        lines.extend(nearby.into_iter().map(|(_, l)| l));
6028
6029        if lines.is_empty() {
6030            lines.push(ContextLine {
6031                on_top: false,
6032                text: "(nothing notable nearby)".into(),
6033            });
6034        }
6035
6036        lines
6037    }
6038}
6039
6040/// HUD line for the location / nearby panel.
6041#[derive(Debug, Clone)]
6042pub struct ContextLine {
6043    pub on_top: bool,
6044    pub text: String,
6045}
6046
6047const ON_TOP_RADIUS_M: f32 = 0.65;
6048const NEARBY_SCAN_M: f32 = 5.0;
6049
6050/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
6051pub fn resource_node_near_display_label(label: &str) -> String {
6052    label
6053        .strip_suffix(" (growing)")
6054        .unwrap_or(label)
6055        .to_string()
6056}
6057
6058/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
6059pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6060    use flatland_protocol::ResourceNodeState;
6061    if node.harvest_off {
6062        return " (decorative)".to_string();
6063    }
6064    if let Some(p) = node.growth_progress {
6065        if p < 1.0 - f32::EPSILON {
6066            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6067            return format!(" (growing, {pct}%)");
6068        }
6069        return " — f harvest".to_string();
6070    }
6071    match node.state {
6072        ResourceNodeState::Available => " — f harvest".to_string(),
6073        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6074        ResourceNodeState::Cooldown => " (depleted)".to_string(),
6075    }
6076}
6077
6078fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6079    use flatland_protocol::TerrainKindView;
6080    match kind {
6081        TerrainKindView::Grass => "Grass",
6082        TerrainKindView::Dirt => "Dirt",
6083        TerrainKindView::Tilled => "Tilled",
6084        TerrainKindView::Desert => "Desert",
6085        TerrainKindView::Hill => "Hills",
6086        TerrainKindView::Bog => "Bog",
6087        TerrainKindView::Beach => "Beach",
6088        TerrainKindView::ShallowWater => "Shallow water",
6089        TerrainKindView::DeepWater => "Deep water",
6090        TerrainKindView::Trail => "Trail",
6091        TerrainKindView::Road => "Road",
6092        TerrainKindView::Rock => "Rock",
6093    }
6094}
6095
6096fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6097    crate::world_zones::zone_rects_contain(rects, x, y)
6098}
6099
6100fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6101    zone.rects
6102        .iter()
6103        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6104        .sum()
6105}
6106
6107fn claim_rect_fully_inside_zone(
6108    zone: &flatland_protocol::PropertyZoneView,
6109    x0: f32,
6110    y0: f32,
6111    x1: f32,
6112    y1: f32,
6113) -> bool {
6114    let mut y = y0 + 0.5;
6115    while y < y1 {
6116        let mut x = x0 + 0.5;
6117        while x < x1 {
6118            if !zone_rects_contain(&zone.rects, x, y) {
6119                return false;
6120            }
6121            x += 1.0;
6122        }
6123        y += 1.0;
6124    }
6125    true
6126}
6127
6128fn rects_overlap_half_open(
6129    ax0: f32,
6130    ay0: f32,
6131    ax1: f32,
6132    ay1: f32,
6133    bx0: f32,
6134    by0: f32,
6135    bx1: f32,
6136    by1: f32,
6137) -> bool {
6138    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6139}
6140
6141fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6142    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6143}
6144
6145fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6146    p.zone_label
6147        .as_deref()
6148        .filter(|s| !s.trim().is_empty())
6149        .map(|s| s.to_string())
6150        .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
6151}
6152
6153/// Match `flatland_sim::economy_zones::snap_claim_rect`.
6154fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6155    let a = x0.min(x1).floor();
6156    let b = y0.min(y1).floor();
6157    let mut c = x0.max(x1).ceil();
6158    let mut d = y0.max(y1).ceil();
6159    if (c - a) < 1.0 {
6160        c = a + 1.0;
6161    }
6162    if (d - b) < 1.0 {
6163        d = b + 1.0;
6164    }
6165    (a, b, c, d)
6166}
6167
6168fn humanize_template_id(template_id: &str) -> String {
6169    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
6170    if looks_like_template_uuid(template_id) {
6171        return "Unknown item".into();
6172    }
6173    template_id
6174        .split('_')
6175        .map(|word| {
6176            let mut chars = word.chars();
6177            match chars.next() {
6178                None => String::new(),
6179                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6180            }
6181        })
6182        .collect::<Vec<_>>()
6183        .join(" ")
6184}
6185
6186fn looks_like_template_uuid(template_id: &str) -> bool {
6187    let bytes = template_id.as_bytes();
6188    if bytes.len() != 36 {
6189        return false;
6190    }
6191    let is_hex = |b: u8| b.is_ascii_hexdigit();
6192    let groups = [8usize, 4, 4, 4, 12];
6193    let mut i = 0;
6194    for (gi, &len) in groups.iter().enumerate() {
6195        if gi > 0 {
6196            if bytes.get(i) != Some(&b'-') {
6197                return false;
6198            }
6199            i += 1;
6200        }
6201        for _ in 0..len {
6202            if !bytes.get(i).copied().is_some_and(is_hex) {
6203                return false;
6204            }
6205            i += 1;
6206        }
6207    }
6208    true
6209}
6210
6211/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
6212const HARVEST_RANGE_M: f32 = 1.5;
6213
6214pub struct GameClient<S: PlayConnection> {
6215    session: S,
6216    seq: Seq,
6217    pub state: GameState,
6218    last_move_forward: f32,
6219    last_move_strafe: f32,
6220}
6221
6222impl<S: PlayConnection> GameClient<S> {
6223    pub fn new(session: S) -> Self {
6224        let session_id = session.session_id();
6225        let entity_id = session.entity_id();
6226        let mut client = Self {
6227            session,
6228            seq: 0,
6229            last_move_forward: 0.0,
6230            last_move_strafe: 0.0,
6231            state: GameState {
6232                session_id,
6233                entity_id,
6234                character_id: None,
6235                tick: 0,
6236                chunk_rev: 0,
6237                content_rev: 0,
6238                publish_rev: 0,
6239                entities: Vec::new(),
6240                player: None,
6241                resource_nodes: Vec::new(),
6242                ground_drops: Vec::new(),
6243                placed_containers: Vec::new(),
6244                buildings: Vec::new(),
6245                doors: Vec::new(),
6246                interior_map: None,
6247                npcs: Vec::new(),
6248                blueprints: Vec::new(),
6249                building_materials: Vec::new(),
6250                world_x0: 0.0,
6251                world_y0: 0.0,
6252                world_width_m: 0.0,
6253                world_height_m: 0.0,
6254                terrain_zones: Vec::new(),
6255                z_platforms: Vec::new(),
6256                z_transitions: Vec::new(),
6257                z_bands_outdoor_backup: None,
6258                world_clock: flatland_protocol::WorldClock::default(),
6259                inventory: std::collections::HashMap::new(),
6260                inventory_hints: std::collections::HashMap::new(),
6261                logs: VecDeque::new(),
6262                intents_sent: 0,
6263                ticks_received: 0,
6264                connected: false,
6265                disconnect_reason: None,
6266                show_stats: false,
6267            hud_log_hidden: false,
6268                show_equip_menu: false,
6269                equip_menu_index: 0,
6270                show_craft_menu: false,
6271                show_plot_build_menu: false,
6272                plot_build_focus_wall: true,
6273                plot_build_wall_index: 0,
6274                plot_build_roof_index: 0,
6275                craft_menu_index: 0,
6276                craft_batch_quantity: 1,
6277                show_shop_menu: false,
6278                shop_catalog: None,
6279                bank_panel: None,
6280                bank_menu_index: 0,
6281                bank_ui_mode: BankUiMode::Menu,
6282                storage_panel: None,
6283                market_panel: None,
6284                market_menu_index: 0,
6285                market_filter: String::new(),
6286                market_filter_focused: false,
6287                market_category_filter: None,
6288                market_buy_confirm: None,
6289                market_ui_mode: MarketUiMode::Browse,
6290                storage_menu_index: 0,
6291                storage_ui_mode: StorageUiMode::Menu,
6292                shop_tab: ShopTab::default(),
6293                shop_menu_index: 0,
6294                shop_quantity: 1,
6295                shop_trade_log: VecDeque::new(),
6296                show_npc_verb_menu: false,
6297                npc_verb_target: None,
6298                npc_verb_index: 0,
6299                player_verbs: crate::social::PlayerVerbState::default(),
6300                social_chat: crate::social::SocialChatState::default(),
6301                trade_ui: crate::social::TradeUiState::default(),
6302                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6303                show_npc_chat: false,
6304                npc_chat: None,
6305                show_inventory_menu: false,
6306                inventory_menu_index: 0,
6307                inventory_tab: InventoryTab::OnPerson,
6308                inventory_filter: String::new(),
6309                inventory_filter_focused: false,
6310                show_move_picker: false,
6311                show_rename_prompt: false,
6312                show_worker_rename: false,
6313                rename_buffer: String::new(),
6314                move_picker_index: 0,
6315                move_picker: None,
6316                show_grant_picker: false,
6317                grant_picker_index: 0,
6318                grant_picker: None,
6319                show_destroy_picker: false,
6320                destroy_confirm_pending: false,
6321                destroy_picker: None,
6322                combat_target: None,
6323                combat_target_label: None,
6324                ground_target: None,
6325                combat_fx: Vec::new(),
6326                property_zones: Vec::new(),
6327                tax_zones: Vec::new(),
6328                growth_zones: Vec::new(),
6329                biome_zones: Vec::new(),
6330                terrain_kind_nav: Vec::new(),
6331                property_plots: Vec::new(),
6332                property_plot_settings: None,
6333                claim_mode: None,
6334                relocate_mode: None,
6335                sell_plot_confirm: None,
6336                sell_plot_armed_at: None,
6337                show_plant_menu: false,
6338                plant_menu_index: 0,
6339                show_farm_access: false,
6340                farm_access_name_draft: String::new(),
6341                farm_access_discount_bps: 0,
6342                farm_access_index: 0,
6343                plant_quantity: 1,
6344                in_combat: false,
6345                auto_attack: true,
6346                combat_has_los: false,
6347                attack_cd_ticks: 0,
6348                gcd_ticks: 0,
6349                weapon_ability_id: "unarmed".into(),
6350                mainhand_template_id: None,
6351                mainhand_label: None,
6352                mainhand_instance_id: None,
6353                offhand_template_id: None,
6354                offhand_label: None,
6355                offhand_instance_id: None,
6356                mainhand_hand_slots: 1,
6357                defense: None,
6358                worn: BTreeMap::new(),
6359                carry_mass: 0.0,
6360                carry_mass_max: 0.0,
6361                encumbrance: flatland_protocol::EncumbranceState::Light,
6362                inventory_stacks: Vec::new(),
6363                keychain_stacks: Vec::new(),
6364            whisper_pouch_stacks: Vec::new(),
6365                combat_target_detail: None,
6366                statuses: Vec::new(),
6367                cast_progress: None,
6368                timed_channel: None,
6369                plot_build_offer: None,
6370                ability_cooldowns: Vec::new(),
6371                blocking_active: false,
6372                max_target_slots: 1,
6373                combat_slots: Vec::new(),
6374                rotation_presets: Vec::new(),
6375                known_abilities: Vec::new(),
6376                ability_meta: std::collections::HashMap::new(),
6377                ability_mastery: std::collections::HashMap::new(),
6378                hotbar: vec![None; 9],
6379                max_abilities_per_rotation: 0,
6380                show_loadout_menu: false,
6381                show_keychain_menu: false,
6382                keychain_menu_index: 0,
6383                show_rotation_editor: false,
6384                loadout_menu_index: 0,
6385                loadout_hotbar_slot: 1,
6386                loadout_ability_index: 0,
6387                loadout_focus_presets: false,
6388                rotation_editor: RotationEditorState::default(),
6389                harvest_in_progress: false,
6390                harvest_started_at: None,
6391                pending_craft_ack: None,
6392                pending_worker_job_ack: None,
6393                attending_worker_instance_id: None,
6394                quest_log: Vec::new(),
6395                interactables: Vec::new(),
6396                ledger: None,
6397                career: None,
6398                character_sheet_tab: CharacterSheetTab::Character,
6399                ledger_period: LedgerPeriod::Day,
6400                show_quest_offer: false,
6401                pending_quest_offer: None,
6402                show_quest_menu: false,
6403                quest_menu_index: 0,
6404                quest_withdraw_confirm: false,
6405                hired_workers: Vec::new(),
6406                show_workers_menu: false,
6407                workers_menu_index: 0,
6408                workers_menu_compact: false,
6409                worker_step_display: BTreeMap::new(),
6410                worker_error_display: BTreeMap::new(),
6411                show_worker_give_picker: false,
6412                worker_give_picker_index: 0,
6413                worker_give_picker: None,
6414                show_worker_give_target_picker: false,
6415                worker_give_target_picker_index: 0,
6416                worker_give_target_picker: None,
6417                show_worker_take_picker: false,
6418                worker_take_picker_index: 0,
6419                worker_take_picker: None,
6420                show_worker_teach_picker: false,
6421                worker_teach_picker_index: 0,
6422                worker_teach_picker: None,
6423                worker_route_editor: None,
6424                progression_curve: None,
6425            },
6426        };
6427        client.state.apply_client_ui_prefs();
6428        client
6429    }
6430
6431    pub fn entity_id(&self) -> EntityId {
6432        self.state.entity_id
6433    }
6434
6435    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6436        if self.state.connected {
6437            return Ok(());
6438        }
6439
6440        loop {
6441            match self.session.next_event().await {
6442                Some(SessionEvent::Welcome {
6443                    session_id,
6444                    entity_id,
6445                    snapshot,
6446                }) => {
6447                    self.state
6448                        .restore_from_welcome(session_id, entity_id, &snapshot);
6449                    self.state.apply_client_ui_prefs();
6450                    self.state.push_log(format!(
6451                        "Connected — session {session_id}, entity {entity_id}"
6452                    ));
6453                    return Ok(());
6454                }
6455                Some(SessionEvent::Disconnected { .. }) => {
6456                    anyhow::bail!("disconnected before welcome");
6457                }
6458                Some(_) => continue,
6459                None => anyhow::bail!("session closed before welcome"),
6460            }
6461        }
6462    }
6463
6464    /// Drain all pending server events (non-blocking).
6465    pub fn drain_events(&mut self) {
6466        while let Some(event) = self.session.try_next_event() {
6467            if self.handle_event_sync(event).is_err() {
6468                break;
6469            }
6470        }
6471    }
6472
6473    /// Wait for the next server event.
6474    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6475        self.session.next_event().await
6476    }
6477
6478    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6479        self.handle_event_sync(event)
6480    }
6481
6482    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6483        match event {
6484            SessionEvent::Welcome {
6485                session_id,
6486                entity_id,
6487                snapshot,
6488            } => {
6489                let resumed = self.state.connected;
6490                self.state
6491                    .restore_from_welcome(session_id, entity_id, &snapshot);
6492                if resumed {
6493                    self.state.push_log(format!(
6494                        "Session restored — session {session_id}, entity {entity_id}"
6495                    ));
6496                }
6497            }
6498            SessionEvent::ContentUpdated { snapshot } => {
6499                self.state
6500                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6501                self.state.push_log(format!(
6502                    "World updated (content rev {})",
6503                    snapshot.content_rev
6504                ));
6505            }
6506            SessionEvent::Tick(delta) => {
6507                self.state.apply_tick_fields(&delta, self.state.entity_id);
6508                self.state.ticks_received += 1;
6509            }
6510            SessionEvent::IntentAck {
6511                entity_id,
6512                seq,
6513                tick,
6514            } => {
6515                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6516                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6517                    if *craft_seq == seq {
6518                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6519                        if batches > 1 {
6520                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6521                        } else {
6522                            self.state.push_log(format!("Crafting {label}…"));
6523                        }
6524                    }
6525                }
6526                if self
6527                    .state
6528                    .pending_worker_job_ack
6529                    .as_ref()
6530                    .is_some_and(|p| p.seq == seq)
6531                {
6532                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6533                    if pending.idle {
6534                        self.state.push_log(format!(
6535                            "Route cleared for {} — worker idle",
6536                            pending.worker_label
6537                        ));
6538                    } else {
6539                        self.state.push_log(format!(
6540                            "Route saved for {} — {} stop(s), job loop active",
6541                            pending.worker_label, pending.stop_count
6542                        ));
6543                    }
6544                    if self
6545                        .state
6546                        .worker_route_editor
6547                        .as_ref()
6548                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6549                    {
6550                        self.close_worker_route_editor();
6551                    }
6552                }
6553            }
6554            SessionEvent::Chat(msg) => {
6555                let label = match msg.channel {
6556                    flatland_protocol::ChatChannel::Nearby => "nearby",
6557                    flatland_protocol::ChatChannel::Direct => "speak",
6558                    flatland_protocol::ChatChannel::Whisper => "whisper",
6559                    flatland_protocol::ChatChannel::WhisperStone => "stone",
6560                };
6561                let clarity = match msg.clarity {
6562                    flatland_protocol::ChatClarity::Clear => "",
6563                    flatland_protocol::ChatClarity::Partial => "~",
6564                    flatland_protocol::ChatClarity::Heavy => "…",
6565                };
6566                self.state.push_log(format!(
6567                    "[{label}{clarity}] {}: {}",
6568                    msg.from_name, msg.text
6569                ));
6570                let now_ms = std::time::SystemTime::now()
6571                    .duration_since(std::time::UNIX_EPOCH)
6572                    .map(|d| d.as_millis() as u64)
6573                    .unwrap_or(0);
6574                self.state
6575                    .social_chat
6576                    .note_speech(&msg, self.state.entity_id, now_ms);
6577                self.state
6578                    .social_chat
6579                    .push(crate::social::ChatLogEntry::from_message(
6580                        msg,
6581                        self.state.entity_id,
6582                    ));
6583            }
6584            SessionEvent::TradeOpened(panel) => {
6585                self.state.social_chat.pending_trade = None;
6586                let peer = panel.peer_name.clone();
6587                self.state.trade_ui.open(panel);
6588                self.state
6589                    .social_chat
6590                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6591                self.state
6592                    .social_chat
6593                    .push_cue(crate::social::AudioCue::TradeOpened);
6594            }
6595            SessionEvent::TradeClosed { reason } => {
6596                self.state.push_log(reason.clone());
6597                self.state.social_chat.push_system(reason);
6598                self.state.trade_ui.close();
6599            }
6600            SessionEvent::HarvestResult(result) => {
6601                self.state.clear_harvest_state();
6602                crate::harvest_trace!(
6603                    entity_id = self.state.entity_id,
6604                    node_id = %result.node_id,
6605                    template = %result.item_template,
6606                    quantity = result.quantity,
6607                    client_tick = self.state.tick,
6608                    "client applied harvest result"
6609                );
6610                let msg = if result.quantity == 0 {
6611                    format!(
6612                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6613                        result.item_template
6614                    )
6615                } else {
6616                    format!(
6617                        "Harvested {} x{} (on the ground — press P to pick up)",
6618                        result.item_template, result.quantity
6619                    )
6620                };
6621                self.state.push_log(msg);
6622            }
6623            SessionEvent::CraftResult(result) => {
6624                for stack in &result.consumed {
6625                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6626                        *qty = qty.saturating_sub(stack.quantity);
6627                        if *qty == 0 {
6628                            self.state.inventory.remove(&stack.template_id);
6629                        }
6630                    }
6631                }
6632                for stack in &result.outputs {
6633                    *self
6634                        .state
6635                        .inventory
6636                        .entry(stack.template_id.clone())
6637                        .or_insert(0) += stack.quantity;
6638                }
6639                if let Some(output) = result.outputs.first() {
6640                    if result.batch_total > 1 {
6641                        self.state.push_log(format!(
6642                            "Crafted {} x{} ({}/{})",
6643                            output.template_id,
6644                            output.quantity,
6645                            result.batch_index,
6646                            result.batch_total
6647                        ));
6648                    } else {
6649                        self.state.push_log(format!(
6650                            "Crafted {} x{}",
6651                            output.template_id, output.quantity
6652                        ));
6653                    }
6654                } else {
6655                    self.state
6656                        .push_log(format!("Craft finished: {}", result.blueprint_id));
6657                }
6658            }
6659            SessionEvent::Death(notice) => {
6660                self.state.clear_harvest_state();
6661                self.state.push_log(notice.message.clone());
6662                self.state.push_log(format!(
6663                    "Respawned at ({:.1}, {:.1})",
6664                    notice.respawn_x, notice.respawn_y
6665                ));
6666            }
6667            SessionEvent::Interaction(notice) => {
6668                if notice.message.starts_with("Harvest failed:") {
6669                    self.state.clear_harvest_state();
6670                }
6671                if notice.message.starts_with("Can't do that:") {
6672                    self.state.pending_craft_ack = None;
6673                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
6674                        if let Some(w) = self
6675                            .state
6676                            .hired_workers
6677                            .iter_mut()
6678                            .find(|w| w.instance_id == pending.worker_instance_id)
6679                        {
6680                            w.route = pending.prev_route;
6681                            w.mode = pending.prev_mode;
6682                            w.step_label = pending.prev_step_label;
6683                            w.last_error = pending.prev_last_error;
6684                        }
6685                        let reason = notice
6686                            .message
6687                            .strip_prefix("Can't do that:")
6688                            .unwrap_or(&notice.message)
6689                            .trim();
6690                        self.state.push_log(format!(
6691                            "Route save failed for {}: {reason}",
6692                            pending.worker_label
6693                        ));
6694                    }
6695                    let reason = notice
6696                        .message
6697                        .strip_prefix("Can't do that:")
6698                        .unwrap_or(&notice.message)
6699                        .trim();
6700                    if reason.contains("already tilled") {
6701                        if let Some(plot) = self.state.my_plot_under_player() {
6702                            self.state.sell_plot_confirm = Some(plot.plot_id);
6703                            self.state.sell_plot_armed_at = Some(Instant::now());
6704                        }
6705                    }
6706                }
6707                if notice.message.starts_with("Cast failed:") {
6708                    self.state.cast_progress = None;
6709                }
6710                if notice.message.contains("slain the") {
6711                    self.state.combat_target = None;
6712                    self.state.combat_target_label = None;
6713                }
6714                // Inbound trade request → inline Y/N in the CHAT column (no popup).
6715                if notice.message.contains("wants to trade") {
6716                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6717                        let from_name = notice
6718                            .message
6719                            .split(" wants to trade")
6720                            .next()
6721                            .unwrap_or("Player")
6722                            .to_string();
6723                        self.state.social_chat.pending_trade =
6724                            Some(crate::social::PendingTradeRequest {
6725                                from_entity,
6726                                from_name: from_name.clone(),
6727                            });
6728                        self.state.social_chat.push_system(format!(
6729                            "{from_name} wants to trade — [Y] accept · [N] decline"
6730                        ));
6731                        self.state
6732                            .social_chat
6733                            .push_cue(crate::social::AudioCue::TradeOffer);
6734                    }
6735                }
6736                if notice.message.starts_with("trade request declined") {
6737                    self.state
6738                        .social_chat
6739                        .push_system(notice.message.clone());
6740                    self.state
6741                        .social_chat
6742                        .push_cue(crate::social::AudioCue::TradeDeclined);
6743                }
6744                self.state.apply_interaction_notice(&notice);
6745                self.state.push_log(notice.message.clone());
6746            }
6747            SessionEvent::ShopOpened(catalog) => {
6748                self.state.apply_shop_catalog(catalog);
6749            }
6750            SessionEvent::BankOpened(panel) => {
6751                self.state.apply_bank_panel(panel);
6752            }
6753            SessionEvent::StorageOpened(panel) => {
6754                self.state.apply_storage_panel(panel);
6755            }
6756            SessionEvent::MarketOpened(panel) => {
6757                self.state.apply_market_panel(panel);
6758            }
6759            SessionEvent::NpcTalkOpened(opened) => {
6760                self.state.show_npc_verb_menu = false;
6761                if self.state.npc_verb_target.is_none() {
6762                    self.state.npc_verb_target = Some(opened.npc_id.clone());
6763                }
6764                let label = opened.npc_label.clone();
6765                let banner = if !opened.trade_allowed {
6766                    Some("Trade is unavailable right now.".to_string())
6767                } else {
6768                    None
6769                };
6770                self.state.show_npc_chat = true;
6771                self.state.npc_chat = Some(NpcChatState {
6772                    npc_id: opened.npc_id,
6773                    npc_label: opened.npc_label,
6774                    lines: if opened.greeting.is_empty() {
6775                        vec![]
6776                    } else {
6777                        vec![format!("{label}: {}", opened.greeting)]
6778                    },
6779                    input: String::new(),
6780                    pending: opened.greeting.is_empty(),
6781                    talk_depth: opened.talk_depth,
6782                    trade_allowed: opened.trade_allowed,
6783                    banner,
6784                });
6785            }
6786            SessionEvent::NpcTalkPending(_) => {
6787                if let Some(chat) = self.state.npc_chat.as_mut() {
6788                    chat.pending = true;
6789                }
6790            }
6791            SessionEvent::NpcTalkReply(reply) => {
6792                if let Some(chat) = self.state.npc_chat.as_mut() {
6793                    if chat.npc_id == reply.npc_id {
6794                        chat.pending = false;
6795                        if reply.trade_disabled {
6796                            chat.trade_allowed = false;
6797                            chat.banner = Some("Trade is unavailable right now.".to_string());
6798                        }
6799                        if reply.wind_down {
6800                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6801                            if chat.banner.is_none() {
6802                                chat.banner =
6803                                    Some("They're wrapping up — keep it brief.".to_string());
6804                            }
6805                        }
6806                        chat.lines
6807                            .push(format!("{}: {}", chat.npc_label, reply.line));
6808                    }
6809                }
6810            }
6811            SessionEvent::NpcTalkClosed(closed) => {
6812                if self
6813                    .state
6814                    .npc_chat
6815                    .as_ref()
6816                    .is_some_and(|c| c.npc_id == closed.npc_id)
6817                {
6818                    self.state.show_npc_chat = false;
6819                    self.state.npc_chat = None;
6820                }
6821            }
6822            SessionEvent::NpcTalkError(err) => {
6823                self.state.push_log(format!("Talk failed: {}", err.reason));
6824                if let Some(chat) = self.state.npc_chat.as_mut() {
6825                    chat.pending = false;
6826                }
6827            }
6828            SessionEvent::UseResult(result) => {
6829                // Inventory count hint only — the Interaction notice already logs
6830                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
6831                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6832                    *qty = qty.saturating_sub(1);
6833                    if *qty == 0 {
6834                        self.state.inventory.remove(&result.template_id);
6835                    }
6836                }
6837            }
6838            SessionEvent::QuestOffer(offer) => {
6839                self.state.pending_quest_offer = Some(offer.clone());
6840                self.state.show_quest_offer = true;
6841                self.state
6842                    .push_log(format!("Quest offered: {}", offer.title));
6843            }
6844            SessionEvent::QuestAccepted(notice) => {
6845                self.state.show_quest_offer = false;
6846                self.state.pending_quest_offer = None;
6847                self.state.push_log(notice.message);
6848            }
6849            SessionEvent::QuestWithdrawn(notice) => {
6850                self.state.show_quest_menu = false;
6851                self.state.quest_withdraw_confirm = false;
6852                self.state.push_log(notice.message);
6853            }
6854            SessionEvent::QuestStepCompleted(notice) => {
6855                self.state.push_log(notice.message);
6856            }
6857            SessionEvent::QuestCompleted(notice) => {
6858                self.state.push_log(notice.message);
6859            }
6860            SessionEvent::Disconnected { reason } => {
6861                self.state.clear_harvest_state();
6862                self.state.connected = false;
6863                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6864                if let Some(r) = &self.state.disconnect_reason {
6865                    self.state.push_log(format!("Disconnected: {r}"));
6866                } else {
6867                    self.state.push_log("Disconnected from server");
6868                }
6869            }
6870        }
6871        Ok(())
6872    }
6873
6874    pub fn is_connected(&self) -> bool {
6875        self.state.connected
6876    }
6877
6878    pub fn close_overlays(&mut self) {
6879        self.state.show_stats = false;
6880        self.state.show_craft_menu = false;
6881        self.state.show_plot_build_menu = false;
6882        self.state.show_shop_menu = false;
6883        self.state.shop_catalog = None;
6884        self.state.show_npc_verb_menu = false;
6885        self.state.npc_verb_target = None;
6886        self.state.show_npc_chat = false;
6887        self.state.npc_chat = None;
6888        self.state.show_inventory_menu = false;
6889        self.state.show_loadout_menu = false;
6890        self.state.show_rotation_editor = false;
6891        self.state.rotation_editor.reset();
6892        self.state.show_rename_prompt = false;
6893        self.state.show_worker_rename = false;
6894        self.state.rename_buffer.clear();
6895        self.state.show_move_picker = false;
6896        self.state.move_picker = None;
6897        self.state.show_destroy_picker = false;
6898        self.state.destroy_confirm_pending = false;
6899        self.state.destroy_picker = None;
6900        self.state.show_quest_offer = false;
6901        self.state.pending_quest_offer = None;
6902        self.state.show_quest_menu = false;
6903        self.state.quest_withdraw_confirm = false;
6904        self.state.show_workers_menu = false;
6905        self.close_worker_give_picker();
6906        self.close_worker_give_target_picker();
6907        self.close_worker_take_picker();
6908        self.close_worker_teach_picker();
6909        self.state.worker_route_editor = None;
6910        self.state.claim_mode = None;
6911        self.state.relocate_mode = None;
6912        self.state.sell_plot_confirm = None;
6913        self.state.sell_plot_armed_at = None;
6914        self.close_farm_access_panel();
6915        if self.state.show_plant_menu {
6916            self.close_plant_menu();
6917        }
6918    }
6919
6920    /// Esc / back — pop one UI layer instead of closing every overlay at once.
6921    pub fn back_on_esc(&mut self) -> bool {
6922        if self.state.social_chat.composer_open() {
6923            self.state.social_chat.close_composer();
6924            return true;
6925        }
6926        if self.state.player_verbs.open {
6927            self.state.player_verbs.close();
6928            return true;
6929        }
6930        if self.state.whisper_pouch_ui.open {
6931            self.state.whisper_pouch_ui.open = false;
6932            return true;
6933        }
6934        if self.state.trade_ui.panel.is_some() {
6935            // async cancel — caller should prefer trade_cancel; close UI optimistically
6936            self.state.trade_ui.close();
6937            return true;
6938        }
6939        if self.state.show_rename_prompt {
6940            self.cancel_rename_prompt();
6941            return true;
6942        }
6943        if self.state.show_worker_rename {
6944            self.cancel_worker_rename();
6945            return true;
6946        }
6947        if self.state.show_destroy_picker {
6948            if self.state.destroy_confirm_pending {
6949                self.cancel_destroy_confirm();
6950            } else {
6951                self.close_destroy_picker();
6952            }
6953            return true;
6954        }
6955        if self.state.claim_mode.is_some() {
6956            self.cancel_claim_mode();
6957            return true;
6958        }
6959        if self.state.relocate_mode.is_some() {
6960            self.cancel_relocate_mode();
6961            return true;
6962        }
6963        if self.state.show_plant_menu {
6964            self.close_plant_menu();
6965            return true;
6966        }
6967        if self.state.show_farm_access {
6968            self.close_farm_access_panel();
6969            return true;
6970        }
6971        if self.state.sell_plot_confirm.is_some() {
6972            self.state.sell_plot_confirm = None;
6973            self.state.sell_plot_armed_at = None;
6974            self.state.push_log("Sell cancelled");
6975            return true;
6976        }
6977        if self.state.show_move_picker {
6978            self.close_move_picker();
6979            return true;
6980        }
6981        if self.state.show_rotation_editor {
6982            match self.state.rotation_editor.mode {
6983                RotationEditorMode::List => {
6984                    self.state.show_rotation_editor = false;
6985                    self.state.rotation_editor.reset();
6986                }
6987                RotationEditorMode::EditLabel => {
6988                    self.state.rotation_editor.label_buffer.clear();
6989                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6990                }
6991                RotationEditorMode::PickAbility => {
6992                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6993                }
6994                RotationEditorMode::EditSequence => {
6995                    self.state.rotation_editor.draft = None;
6996                    self.state.rotation_editor.mode = RotationEditorMode::List;
6997                }
6998            }
6999            return true;
7000        }
7001        if self.state.show_inventory_menu {
7002            self.close_inventory_menu();
7003            return true;
7004        }
7005        if self.state.show_craft_menu {
7006            self.close_craft_menu();
7007            return true;
7008        }
7009        if self.state.show_plot_build_menu {
7010            self.close_plot_build_menu();
7011            return true;
7012        }
7013        if self.state.show_keychain_menu {
7014            self.close_keychain_menu();
7015            return true;
7016        }
7017        if self.state.show_quest_offer {
7018            self.quest_offer_decline();
7019            return true;
7020        }
7021        if self.state.show_shop_menu {
7022            // Caller must await shop close (npc_interaction_back / play-loop).
7023            return false;
7024        }
7025        if self.state.bank_panel.is_some() {
7026            return false;
7027        }
7028        if self.state.storage_panel.is_some() {
7029            return false;
7030        }
7031        if self.state.market_panel.is_some() {
7032            return false;
7033        }
7034        if self.state.show_npc_chat {
7035            // play.rs calls npc_interaction_back().await on Esc
7036            return false;
7037        }
7038        if self.state.show_npc_verb_menu {
7039            self.state.show_npc_verb_menu = false;
7040            self.state.npc_verb_target = None;
7041            return true;
7042        }
7043        if self.state.show_quest_menu {
7044            if self.state.quest_withdraw_confirm {
7045                self.state.quest_withdraw_confirm = false;
7046            } else {
7047                self.state.show_quest_menu = false;
7048            }
7049            return true;
7050        }
7051        if self.state.worker_route_editor.is_some() {
7052            // Pop one sheet level; only close the editor at the root stop list.
7053            if self.re_at_root_sheet() {
7054                let reopen = self.state.attending_worker_instance_id.clone();
7055                self.close_worker_route_editor();
7056                if let Some(id) = reopen {
7057                    if let Some(idx) = self
7058                        .state
7059                        .hired_workers
7060                        .iter()
7061                        .position(|w| w.instance_id == id)
7062                    {
7063                        self.state.workers_menu_index = idx;
7064                        self.state.show_workers_menu = true;
7065                    }
7066                }
7067            } else {
7068                self.re_sheet_back();
7069            }
7070            return true;
7071        }
7072        if self.state.show_worker_give_picker {
7073            self.close_worker_give_picker();
7074            return true;
7075        }
7076        if self.state.show_worker_give_target_picker {
7077            self.close_worker_give_target_picker();
7078            return true;
7079        }
7080        if self.state.show_worker_take_picker {
7081            self.close_worker_take_picker();
7082            return true;
7083        }
7084        if self.state.show_worker_teach_picker {
7085            self.close_worker_teach_picker();
7086            return true;
7087        }
7088        if self.state.show_workers_menu {
7089            self.close_workers_menu_ui();
7090            return true;
7091        }
7092        if self.state.show_loadout_menu {
7093            self.state.show_loadout_menu = false;
7094            return true;
7095        }
7096        if self.state.show_stats {
7097            self.state.show_stats = false;
7098            return true;
7099        }
7100        if self.state.show_equip_menu {
7101            self.state.show_equip_menu = false;
7102            return true;
7103        }
7104        false
7105    }
7106
7107    pub fn toggle_stats(&mut self) {
7108        self.state.show_stats = !self.state.show_stats;
7109        if self.state.show_stats {
7110            self.state.character_sheet_tab = CharacterSheetTab::Character;
7111            self.state.show_craft_menu = false;
7112            self.state.show_shop_menu = false;
7113            self.state.shop_catalog = None;
7114            self.state.show_inventory_menu = false;
7115            self.state.show_equip_menu = false;
7116        }
7117    }
7118
7119    pub fn toggle_equip_menu(&mut self) {
7120        self.state.show_equip_menu = !self.state.show_equip_menu;
7121        if self.state.show_equip_menu {
7122            self.state.show_stats = false;
7123            self.state.show_craft_menu = false;
7124            self.state.show_shop_menu = false;
7125            self.state.shop_catalog = None;
7126            self.state.show_inventory_menu = false;
7127            self.state.show_loadout_menu = false;
7128        }
7129    }
7130
7131    pub fn cycle_character_sheet_tab(&mut self) {
7132        if self.state.show_stats {
7133            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7134        }
7135    }
7136
7137    pub fn set_ledger_period_digit(&mut self, c: char) {
7138        if self.state.show_stats {
7139            if let Some(p) = LedgerPeriod::from_digit(c) {
7140                self.state.ledger_period = p;
7141                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7142            }
7143        }
7144    }
7145
7146    pub fn cycle_ledger_period(&mut self) {
7147        if self.state.show_stats
7148            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
7149        {
7150            self.state.ledger_period = self.state.ledger_period.cycle();
7151        }
7152    }
7153
7154    pub fn open_inventory_menu(&mut self) {
7155        self.state.show_inventory_menu = true;
7156        self.state.show_craft_menu = false;
7157        self.state.show_shop_menu = false;
7158        self.state.shop_catalog = None;
7159        self.state.show_stats = false;
7160        self.state.show_move_picker = false;
7161        self.state.move_picker = None;
7162        self.state.show_destroy_picker = false;
7163        self.state.destroy_confirm_pending = false;
7164        self.state.destroy_picker = None;
7165        self.state.show_rename_prompt = false;
7166        self.state.rename_buffer.clear();
7167        self.state.inventory_filter_focused = false;
7168        self.state.clamp_inventory_indices();
7169    }
7170
7171    pub fn close_inventory_menu(&mut self) {
7172        self.state.show_inventory_menu = false;
7173        self.state.show_move_picker = false;
7174        self.state.move_picker = None;
7175        self.close_grant_picker();
7176        self.state.show_destroy_picker = false;
7177        self.state.destroy_confirm_pending = false;
7178        self.state.destroy_picker = None;
7179        self.state.show_rename_prompt = false;
7180        self.state.rename_buffer.clear();
7181        self.state.inventory_filter_focused = false;
7182    }
7183
7184    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7185        let Some(row) = self.state.inventory_selected_row() else {
7186            anyhow::bail!("inventory empty");
7187        };
7188        if !self.state.row_is_renameable_container(&row) {
7189            anyhow::bail!("only storage containers can be renamed");
7190        }
7191        let current = row
7192            .stack
7193            .display_name
7194            .clone()
7195            .unwrap_or_else(|| row.stack.template_id.clone());
7196        self.state.rename_buffer = current;
7197        self.state.show_rename_prompt = true;
7198        self.state.show_worker_rename = false;
7199        self.state.show_move_picker = false;
7200        self.state.show_destroy_picker = false;
7201        self.state.destroy_confirm_pending = false;
7202        Ok(())
7203    }
7204
7205    pub fn cancel_rename_prompt(&mut self) {
7206        self.state.show_rename_prompt = false;
7207        self.state.rename_buffer.clear();
7208    }
7209
7210    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7211        let name = self.state.rename_buffer.trim().to_string();
7212        if name.is_empty() {
7213            anyhow::bail!("name cannot be empty");
7214        }
7215        let Some(row) = self.state.inventory_selected_row() else {
7216            anyhow::bail!("inventory empty");
7217        };
7218        let Some(instance_id) = row.stack.item_instance_id else {
7219            anyhow::bail!("item has no instance id");
7220        };
7221        self.seq += 1;
7222        self.session
7223            .submit_intent(Intent::RenameContainer {
7224                entity_id: self.state.entity_id,
7225                item_instance_id: instance_id,
7226                location: row.from.clone(),
7227                name,
7228                seq: self.seq,
7229            })
7230            .await?;
7231        self.state.intents_sent += 1;
7232        self.state.show_rename_prompt = false;
7233        self.state.rename_buffer.clear();
7234        Ok(())
7235    }
7236
7237    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7238        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7239            anyhow::bail!("no worker selected");
7240        };
7241        self.state.rename_buffer = worker.label.clone();
7242        self.state.show_worker_rename = true;
7243        self.state.show_rename_prompt = false;
7244        Ok(())
7245    }
7246
7247    pub fn cancel_worker_rename(&mut self) {
7248        self.state.show_worker_rename = false;
7249        self.state.rename_buffer.clear();
7250    }
7251
7252    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7253        let name = self.state.rename_buffer.trim().to_string();
7254        if name.is_empty() {
7255            anyhow::bail!("name cannot be empty");
7256        }
7257        if name.chars().count() > 32 {
7258            anyhow::bail!("name must be 1–32 characters");
7259        }
7260        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7261            anyhow::bail!("no worker selected");
7262        };
7263        let worker_instance_id = worker.instance_id.clone();
7264        self.seq += 1;
7265        self.session
7266            .submit_intent(Intent::RenameHiredWorker {
7267                entity_id: self.state.entity_id,
7268                worker_instance_id: worker_instance_id.clone(),
7269                name: name.clone(),
7270                seq: self.seq,
7271            })
7272            .await?;
7273        self.state.intents_sent += 1;
7274        if let Some(w) = self
7275            .state
7276            .hired_workers
7277            .iter_mut()
7278            .find(|w| w.instance_id == worker_instance_id)
7279        {
7280            w.label = name.clone();
7281        }
7282        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7283            if ed.worker_instance_id == worker_instance_id {
7284                ed.worker_label = name.clone();
7285            }
7286        }
7287        self.state.show_worker_rename = false;
7288        self.state.rename_buffer.clear();
7289        self.state.push_log(format!("Renamed worker to \"{name}\""));
7290        Ok(())
7291    }
7292
7293    pub fn toggle_inventory_menu(&mut self) {
7294        if self.state.show_inventory_menu {
7295            self.close_inventory_menu();
7296        } else {
7297            self.open_inventory_menu();
7298        }
7299    }
7300
7301    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
7302    pub fn inventory_menu_move(&mut self, delta: i32) {
7303        if self.state.show_grant_picker {
7304            let Some(picker) = self.state.grant_picker.as_ref() else {
7305                return;
7306            };
7307            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7308            let filter = picker.filter.clone();
7309            let n = labels.len();
7310            if n == 0 {
7311                return;
7312            }
7313            self.state.grant_picker_index = step_filtered_index(
7314                self.state.grant_picker_index,
7315                delta,
7316                n,
7317                |i| list_label_matches(&labels[i], &filter),
7318            );
7319            return;
7320        }
7321        if self.state.show_move_picker {
7322            let Some(picker) = self.state.move_picker.as_ref() else {
7323                return;
7324            };
7325            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7326            let filter = picker.filter.clone();
7327            let n = labels.len();
7328            if n == 0 {
7329                return;
7330            }
7331            self.state.move_picker_index = step_filtered_index(
7332                self.state.move_picker_index,
7333                delta,
7334                n,
7335                |i| list_label_matches(&labels[i], &filter),
7336            );
7337            self.state.clamp_move_picker_quantity();
7338            return;
7339        }
7340        let n = self.state.inventory_selectable_rows().len();
7341        if n == 0 {
7342            return;
7343        }
7344        let idx = self.state.inventory_menu_index as i32;
7345        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7346    }
7347
7348    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
7349    pub fn inventory_menu_page(&mut self, pages: i32) {
7350        if self.state.show_grant_picker {
7351            let Some(picker) = self.state.grant_picker.as_ref() else {
7352                return;
7353            };
7354            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7355            let filter = picker.filter.clone();
7356            let n = labels.len();
7357            self.state.grant_picker_index = page_filtered_index(
7358                self.state.grant_picker_index,
7359                pages,
7360                n,
7361                |i| list_label_matches(&labels[i], &filter),
7362            );
7363            return;
7364        }
7365        if self.state.show_move_picker {
7366            let Some(picker) = self.state.move_picker.as_ref() else {
7367                return;
7368            };
7369            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7370            let filter = picker.filter.clone();
7371            let n = labels.len();
7372            self.state.move_picker_index = page_filtered_index(
7373                self.state.move_picker_index,
7374                pages,
7375                n,
7376                |i| list_label_matches(&labels[i], &filter),
7377            );
7378            self.state.clamp_move_picker_quantity();
7379            return;
7380        }
7381        let n = self.state.inventory_selectable_rows().len();
7382        self.state.inventory_menu_index =
7383            page_list_index(self.state.inventory_menu_index, pages, n);
7384    }
7385
7386    pub fn cycle_inventory_tab(&mut self, forward: bool) {
7387        if self.state.show_move_picker
7388            || self.state.show_grant_picker
7389            || self.state.show_destroy_picker
7390            || self.state.show_rename_prompt
7391            || self.state.inventory_filter_focused
7392        {
7393            return;
7394        }
7395        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7396        self.state.inventory_menu_index = 0;
7397        self.state.clamp_inventory_indices();
7398    }
7399
7400    pub fn focus_inventory_filter(&mut self) {
7401        if self.state.show_grant_picker {
7402            if let Some(p) = self.state.grant_picker.as_mut() {
7403                p.filter_focused = true;
7404            }
7405            return;
7406        }
7407        if self.state.show_move_picker {
7408            if let Some(p) = self.state.move_picker.as_mut() {
7409                p.filter_focused = true;
7410            }
7411            return;
7412        }
7413        self.state.inventory_filter_focused = true;
7414    }
7415
7416    pub fn set_inventory_filter(&mut self, filter: String) {
7417        self.state.inventory_filter = filter;
7418        self.state.inventory_menu_index = 0;
7419        self.state.clamp_inventory_indices();
7420    }
7421
7422    pub fn append_inventory_filter_char(&mut self, ch: char) {
7423        if ch.is_control() {
7424            return;
7425        }
7426        if self.state.show_grant_picker {
7427            if let Some(p) = self.state.grant_picker.as_mut() {
7428                if p.filter_focused {
7429                    p.filter.push(ch);
7430                    self.state.grant_picker_index = 0;
7431                }
7432            }
7433            return;
7434        }
7435        if self.state.show_move_picker {
7436            if let Some(p) = self.state.move_picker.as_mut() {
7437                if p.filter_focused {
7438                    p.filter.push(ch);
7439                    self.state.move_picker_index = 0;
7440                    self.state.clamp_move_picker_quantity();
7441                }
7442            }
7443            return;
7444        }
7445        if !self.state.inventory_filter_focused {
7446            return;
7447        }
7448        self.state.inventory_filter.push(ch);
7449        self.state.inventory_menu_index = 0;
7450        self.state.clamp_inventory_indices();
7451    }
7452
7453    pub fn inventory_filter_backspace(&mut self) {
7454        if self.state.show_grant_picker {
7455            if let Some(p) = self.state.grant_picker.as_mut() {
7456                if p.filter_focused {
7457                    p.filter.pop();
7458                    self.state.grant_picker_index = 0;
7459                }
7460            }
7461            return;
7462        }
7463        if self.state.show_move_picker {
7464            if let Some(p) = self.state.move_picker.as_mut() {
7465                if p.filter_focused {
7466                    p.filter.pop();
7467                    self.state.move_picker_index = 0;
7468                    self.state.clamp_move_picker_quantity();
7469                }
7470            }
7471            return;
7472        }
7473        if !self.state.inventory_filter_focused {
7474            return;
7475        }
7476        self.state.inventory_filter.pop();
7477        self.state.inventory_menu_index = 0;
7478        self.state.clamp_inventory_indices();
7479    }
7480
7481    /// Esc while filter focused: clear filter then blur. Returns true if handled.
7482    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7483        if self.state.show_grant_picker {
7484            if let Some(p) = self.state.grant_picker.as_mut() {
7485                if p.filter_focused {
7486                    if !p.filter.is_empty() {
7487                        p.filter.clear();
7488                        self.state.grant_picker_index = 0;
7489                    } else {
7490                        p.filter_focused = false;
7491                    }
7492                    return true;
7493                }
7494                if !p.filter.is_empty() {
7495                    p.filter.clear();
7496                    self.state.grant_picker_index = 0;
7497                    return true;
7498                }
7499            }
7500            return false;
7501        }
7502        if self.state.show_move_picker {
7503            if let Some(p) = self.state.move_picker.as_mut() {
7504                if p.filter_focused {
7505                    if !p.filter.is_empty() {
7506                        p.filter.clear();
7507                        self.state.move_picker_index = 0;
7508                        self.state.clamp_move_picker_quantity();
7509                    } else {
7510                        p.filter_focused = false;
7511                    }
7512                    return true;
7513                }
7514                if !p.filter.is_empty() {
7515                    p.filter.clear();
7516                    self.state.move_picker_index = 0;
7517                    self.state.clamp_move_picker_quantity();
7518                    return true;
7519                }
7520            }
7521            return false;
7522        }
7523        if self.state.inventory_filter_focused {
7524            if !self.state.inventory_filter.is_empty() {
7525                self.state.inventory_filter.clear();
7526                self.state.inventory_menu_index = 0;
7527                self.state.clamp_inventory_indices();
7528            } else {
7529                self.state.inventory_filter_focused = false;
7530            }
7531            return true;
7532        }
7533        if !self.state.inventory_filter.is_empty() {
7534            self.state.inventory_filter.clear();
7535            self.state.inventory_menu_index = 0;
7536            self.state.clamp_inventory_indices();
7537            return true;
7538        }
7539        false
7540    }
7541
7542    pub fn craft_menu_page(&mut self, pages: i32) {
7543        let n = self.state.blueprints.len();
7544        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7545        self.state.clamp_craft_batch_quantity();
7546    }
7547
7548    pub fn shop_menu_page(&mut self, pages: i32) {
7549        let n = self.state.shop_list_len();
7550        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7551        self.state.clamp_shop_quantity();
7552    }
7553
7554    pub fn workers_menu_page(&mut self, pages: i32) {
7555        let n = self.state.hired_workers.len();
7556        self.state.workers_menu_index =
7557            page_list_index(self.state.workers_menu_index, pages, n);
7558    }
7559
7560    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
7561    /// chest — or fall back to the "move to…" destination picker (including
7562    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
7563    /// Placed chest shells open a pick-up destination picker (`l` still locks).
7564    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7565        if self.state.show_destroy_picker {
7566            if self.state.destroy_confirm_pending {
7567                return self.confirm_destroy_item().await;
7568            }
7569            return self.request_destroy_confirm();
7570        }
7571        if self.state.show_grant_picker {
7572            return self.confirm_grant_picker().await;
7573        }
7574        if self.state.show_move_picker {
7575            return self.confirm_move_picker().await;
7576        }
7577        let Some(row) = self.state.inventory_selected_row() else {
7578            anyhow::bail!("inventory empty");
7579        };
7580        if row.is_equip_shell {
7581            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7582                anyhow::bail!("not a worn item");
7583            };
7584            return self.equip_worn(slot, None).await;
7585        }
7586        if row.is_chest_shell {
7587            return self.open_chest_pickup_picker();
7588        }
7589        let template_id = row.stack.template_id.clone();
7590        let instance_id = row.stack.item_instance_id;
7591        let category = self.state.inventory_item_category(&template_id);
7592        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7593
7594        if category == Some("weapon") {
7595            return self.equip_mainhand(Some(template_id)).await;
7596        }
7597        if category == Some("lodging") && on_person {
7598            if let Some(inst) = instance_id {
7599                return self.place_container(inst).await;
7600            }
7601        }
7602        if (category == Some("container") || category == Some("armor")) && on_person {
7603            if let Some(inst) = instance_id {
7604                let world_placeable = row.stack.world_placeable == Some(true)
7605                    || template_id.contains("chest");
7606                if world_placeable {
7607                    return self.place_container(inst).await;
7608                }
7609                // Pouches no longer equip directly — they clip onto a worn belt's loops
7610                // instead, so fall through to the move picker (offers "belt loop" when a
7611                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
7612                if let Some(slot) = guess_body_slot(&template_id) {
7613                    return self.equip_worn(slot, Some(inst)).await;
7614                }
7615            }
7616        }
7617        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
7618        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
7619        // instead of guessing.
7620        self.open_move_picker()
7621    }
7622
7623    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
7624    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7625        let Some(row) = self.state.inventory_selected_row() else {
7626            anyhow::bail!("inventory empty");
7627        };
7628        if row.from != flatland_protocol::InventoryLocation::Root {
7629            anyhow::bail!("select a consumable on your person");
7630        }
7631        if GameState::stack_is_item_grant(&row.stack) {
7632            return self.open_grant_target_picker();
7633        }
7634        if GameState::is_property_deed_template(&row.stack.template_id) {
7635            return self.open_move_picker();
7636        }
7637        let category = self
7638            .state
7639            .inventory_item_category(&row.stack.template_id);
7640        if category != Some("consumable") {
7641            anyhow::bail!("selected item is not consumable");
7642        }
7643        self.use_item(&row.stack.template_id).await
7644    }
7645
7646    /// Open picker: apply selected grant item onto inventory / worn gear.
7647    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7648        let Some(row) = self.state.inventory_selected_row() else {
7649            anyhow::bail!("inventory empty");
7650        };
7651        if row.from != flatland_protocol::InventoryLocation::Root {
7652            anyhow::bail!("select a grant item on your person");
7653        }
7654        if !GameState::stack_is_item_grant(&row.stack) {
7655            anyhow::bail!("selected item does not grant onto gear");
7656        }
7657        let Some(grant_instance_id) = row.stack.item_instance_id else {
7658            anyhow::bail!("grant has no instance id");
7659        };
7660        let effect_id = GameState::grant_effect_id(&row.stack)
7661            .unwrap_or("?")
7662            .to_string();
7663        let mode = GameState::grant_mode(&row.stack).to_string();
7664        let options = self.state.grant_target_options(&row.stack);
7665        if options.is_empty() {
7666            anyhow::bail!("no valid gear to apply {effect_id} to");
7667        }
7668        let grant_label = row
7669            .stack
7670            .display_name
7671            .clone()
7672            .unwrap_or_else(|| row.stack.template_id.clone());
7673        self.state.show_grant_picker = true;
7674        self.state.grant_picker_index = 0;
7675        self.state.grant_picker = Some(GrantTargetPicker {
7676            grant_instance_id,
7677            grant_label,
7678            effect_id,
7679            mode,
7680            options,
7681            filter: String::new(),
7682            filter_focused: false,
7683        });
7684        Ok(())
7685    }
7686
7687    pub fn close_grant_picker(&mut self) {
7688        self.state.show_grant_picker = false;
7689        self.state.grant_picker = None;
7690        self.state.grant_picker_index = 0;
7691    }
7692
7693    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7694        let Some(picker) = self.state.grant_picker.clone() else {
7695            self.close_grant_picker();
7696            return Ok(());
7697        };
7698        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7699            self.close_grant_picker();
7700            return Ok(());
7701        };
7702        self.close_grant_picker();
7703        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7704            .await?;
7705        self.state.push_log(format!(
7706            "Applying {} onto {}…",
7707            picker.effect_id, opt.label
7708        ));
7709        Ok(())
7710    }
7711
7712    /// `m`: always open the "move to…" picker for the selected item, even for
7713    /// weapons/wearables that Enter would otherwise equip/wear directly.
7714    /// Placed chests open the pick-up destination picker instead.
7715    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7716        let Some(row) = self.state.inventory_selected_row() else {
7717            anyhow::bail!("inventory empty");
7718        };
7719        if row.is_equip_shell {
7720            anyhow::bail!("this is a worn bag — press Enter to unequip it");
7721        }
7722        if row.is_chest_shell {
7723            return self.open_chest_pickup_picker();
7724        }
7725        let Some(instance_id) = row.stack.item_instance_id else {
7726            anyhow::bail!("item has no instance id");
7727        };
7728        let mut options = self.state.move_destinations_for(
7729            &row.from,
7730            row.from_parent_instance_id,
7731            row.stack.item_instance_id,
7732            &row.stack.template_id,
7733        );
7734        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7735        let category = self.state.inventory_item_category(&row.stack.template_id);
7736        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7737            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7738                options.insert(
7739                    0,
7740                    MoveOption {
7741                        label: "Sell plot to crown…".into(),
7742                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
7743                    },
7744                );
7745            }
7746        }
7747        if on_person && category == Some("consumable") {
7748            if GameState::stack_is_item_grant(&row.stack) {
7749                options.insert(
7750                    0,
7751                    MoveOption {
7752                        label: "Apply onto gear…".into(),
7753                        kind: MoveOptionKind::GrantApply,
7754                    },
7755                );
7756            } else {
7757                options.insert(
7758                    0,
7759                    MoveOption {
7760                        label: "Use (eat / drink)".into(),
7761                        kind: MoveOptionKind::Use,
7762                    },
7763                );
7764            }
7765        }
7766        let item_label = row
7767            .stack
7768            .display_name
7769            .clone()
7770            .unwrap_or_else(|| row.stack.template_id.clone());
7771        // Default to 1 so withdrawing from storage is partial unless the player
7772        // presses `a` for max fit (full stack when the destination allows it).
7773        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7774        self.state.move_picker = Some(MovePicker {
7775            item_instance_id: instance_id,
7776            from: row.from,
7777            item_label,
7778            template_id: row.stack.template_id.clone(),
7779            stack_quantity: row.stack.quantity,
7780            quantity: initial_qty.max(1),
7781            options,
7782            filter: String::new(),
7783            filter_focused: false,
7784        });
7785        self.state.move_picker_index = 0;
7786        self.state.show_move_picker = true;
7787        self.state.show_destroy_picker = false;
7788        self.state.destroy_confirm_pending = false;
7789        self.state.destroy_picker = None;
7790        self.state.clamp_move_picker_quantity();
7791        Ok(())
7792    }
7793
7794    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
7795    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7796        let Some(row) = self.state.inventory_selected_row() else {
7797            anyhow::bail!("inventory empty");
7798        };
7799        if !row.is_chest_shell {
7800            anyhow::bail!("not a placed chest");
7801        }
7802        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7803            anyhow::bail!("not a placed chest");
7804        };
7805        let Some(instance_id) = row.stack.item_instance_id else {
7806            anyhow::bail!("chest has no instance id");
7807        };
7808        let chest = self
7809            .state
7810            .placed_containers
7811            .iter()
7812            .find(|c| c.id == *container_id)
7813            .cloned()
7814            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7815        let (px, py) = self.state.player_position();
7816        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7817            anyhow::bail!("too far from {}", chest.display_name);
7818        }
7819        if chest.locked && !chest.accessible {
7820            anyhow::bail!(
7821                "need the matching key for {} before picking it up",
7822                chest.display_name
7823            );
7824        }
7825        let options = self.state.chest_pickup_destinations(container_id);
7826        let item_label = row
7827            .stack
7828            .display_name
7829            .clone()
7830            .unwrap_or_else(|| row.stack.template_id.clone());
7831        self.state.move_picker = Some(MovePicker {
7832            item_instance_id: instance_id,
7833            from: row.from.clone(),
7834            item_label,
7835            template_id: row.stack.template_id.clone(),
7836            stack_quantity: 1,
7837            quantity: 1,
7838            options,
7839            filter: String::new(),
7840            filter_focused: false,
7841        });
7842        self.state.move_picker_index = 0;
7843        self.state.show_move_picker = true;
7844        self.state.show_destroy_picker = false;
7845        self.state.destroy_confirm_pending = false;
7846        self.state.destroy_picker = None;
7847        Ok(())
7848    }
7849
7850    pub fn close_move_picker(&mut self) {
7851        self.state.show_move_picker = false;
7852        self.state.move_picker = None;
7853    }
7854
7855    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7856        self.state.move_picker_adjust_quantity(delta);
7857    }
7858
7859    pub fn move_picker_set_quantity_max(&mut self) {
7860        self.state.move_picker_set_quantity_max();
7861    }
7862
7863    pub fn move_picker_set_quantity_min(&mut self) {
7864        self.state.move_picker_set_quantity_min();
7865    }
7866
7867    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7868        self.state.destroy_picker_adjust_quantity(delta);
7869    }
7870
7871    pub fn destroy_picker_set_quantity_max(&mut self) {
7872        self.state.destroy_picker_set_quantity_max();
7873    }
7874
7875    pub fn destroy_picker_set_quantity_min(&mut self) {
7876        self.state.destroy_picker_set_quantity_min();
7877    }
7878
7879    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7880        let Some(picker) = self.state.move_picker.clone() else {
7881            self.close_move_picker();
7882            return Ok(());
7883        };
7884        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7885            self.close_move_picker();
7886            return Ok(());
7887        };
7888        match option.kind {
7889            MoveOptionKind::Cancel => {
7890                self.close_move_picker();
7891            }
7892            MoveOptionKind::Use => {
7893                self.close_move_picker();
7894                self.use_item(&picker.template_id).await?;
7895            }
7896            MoveOptionKind::GrantApply => {
7897                self.close_move_picker();
7898                self.open_grant_target_picker()?;
7899            }
7900            MoveOptionKind::SellPlotToCrown { plot_id } => {
7901                self.close_move_picker();
7902                self.confirm_sell_plot_to_crown(plot_id).await?;
7903            }
7904            MoveOptionKind::RelocatePlaced { container_id } => {
7905                self.close_move_picker();
7906                self.state.show_inventory_menu = false;
7907                self.begin_relocate_container(&container_id)?;
7908            }
7909            MoveOptionKind::Drop => {
7910                self.close_move_picker();
7911                if self
7912                    .state
7913                    .hand_equipped_instance_ids()
7914                    .contains(&picker.item_instance_id)
7915                {
7916                    anyhow::bail!("unequip that item first");
7917                }
7918                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7919                    if self.state.deed_bound(&stack) {
7920                        anyhow::bail!(
7921                            "cannot drop a property deed — store it or trade it to another player"
7922                        );
7923                    }
7924                    if self.state.key_drop_blocked(&stack) {
7925                        anyhow::bail!("cannot drop the key while its chest is locked");
7926                    }
7927                }
7928                self.drop_item(picker.item_instance_id, picker.from).await?;
7929                self.state
7930                    .push_log(format!("Dropped {}", picker.item_label));
7931            }
7932            MoveOptionKind::PickupPlaced {
7933                container_id,
7934                nest_location,
7935                nest_parent_instance_id,
7936            } => {
7937                self.close_move_picker();
7938                self.pickup_container(container_id.clone()).await?;
7939                let nest_into_bag = nest_parent_instance_id.is_some()
7940                    || !matches!(
7941                        nest_location,
7942                        flatland_protocol::InventoryLocation::Root
7943                    );
7944                if nest_into_bag {
7945                    self.move_item(
7946                        picker.item_instance_id,
7947                        flatland_protocol::InventoryLocation::Root,
7948                        nest_location,
7949                        nest_parent_instance_id,
7950                        None,
7951                    )
7952                    .await?;
7953                    self.state
7954                        .push_log(format!("Picked up {} into bag", picker.item_label));
7955                } else {
7956                    self.state
7957                        .push_log(format!("Picked up {}", picker.item_label));
7958                }
7959            }
7960            MoveOptionKind::Move {
7961                location,
7962                parent_instance_id,
7963            } => {
7964                self.close_move_picker();
7965                let qty = if picker.quantity >= picker.stack_quantity {
7966                    None
7967                } else {
7968                    Some(picker.quantity)
7969                };
7970                self.move_item(
7971                    picker.item_instance_id,
7972                    picker.from,
7973                    location,
7974                    parent_instance_id,
7975                    qty,
7976                )
7977                .await?;
7978                let moved = qty.unwrap_or(picker.stack_quantity);
7979                if moved >= picker.stack_quantity {
7980                    self.state.push_log(format!("Moved {}", picker.item_label));
7981                } else {
7982                    self.state.push_log(format!(
7983                        "Moved {} ×{} of {}",
7984                        picker.item_label, moved, picker.stack_quantity
7985                    ));
7986                }
7987            }
7988        }
7989        Ok(())
7990    }
7991
7992    /// `d`: drop the selected item on the ground immediately (no picker).
7993    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7994        let Some(row) = self.state.inventory_selected_row() else {
7995            anyhow::bail!("inventory empty");
7996        };
7997        if row.is_equip_shell {
7998            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7999        }
8000        if row.is_chest_shell {
8001            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8002        }
8003        let Some(inst) = row.stack.item_instance_id else {
8004            anyhow::bail!("item has no instance id");
8005        };
8006        if self.state.hand_equipped_instance_ids().contains(&inst) {
8007            anyhow::bail!("unequip that item first");
8008        }
8009        if self.state.deed_bound(&row.stack) {
8010            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8011        }
8012        if self.state.key_drop_blocked(&row.stack) {
8013            anyhow::bail!("cannot drop the key while its chest is locked");
8014        }
8015        let label = row
8016            .stack
8017            .display_name
8018            .clone()
8019            .unwrap_or_else(|| row.stack.template_id.clone());
8020        self.drop_item(inst, row.from).await?;
8021        self.state.push_log(format!("Dropped {label}"));
8022        Ok(())
8023    }
8024
8025    pub async fn drop_item(
8026        &mut self,
8027        item_instance_id: uuid::Uuid,
8028        from: flatland_protocol::InventoryLocation,
8029    ) -> anyhow::Result<()> {
8030        self.seq += 1;
8031        self.session
8032            .submit_intent(Intent::DropItem {
8033                entity_id: self.state.entity_id,
8034                item_instance_id,
8035                from,
8036                seq: self.seq,
8037            })
8038            .await?;
8039        self.state.intents_sent += 1;
8040        Ok(())
8041    }
8042
8043    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
8044    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8045        let Some(row) = self.state.inventory_selected_row() else {
8046            anyhow::bail!("inventory empty");
8047        };
8048        if row.is_equip_shell {
8049            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8050        }
8051        if row.is_chest_shell {
8052            anyhow::bail!("can't destroy a placed chest from the inventory list");
8053        }
8054        let Some(instance_id) = row.stack.item_instance_id else {
8055            anyhow::bail!("item has no instance id");
8056        };
8057        if self.state.hand_equipped_instance_ids().contains(&instance_id) {
8058            anyhow::bail!("unequip that item first");
8059        }
8060        if self.state.deed_bound(&row.stack) {
8061            anyhow::bail!(
8062                "cannot destroy a property deed — store it or trade it to another player"
8063            );
8064        }
8065        if self.state.key_drop_blocked(&row.stack) {
8066            anyhow::bail!("cannot destroy the key while its chest is locked");
8067        }
8068        let item_label = row
8069            .stack
8070            .display_name
8071            .clone()
8072            .unwrap_or_else(|| row.stack.template_id.clone());
8073        self.state.destroy_picker = Some(DestroyPicker {
8074            item_instance_id: instance_id,
8075            from: row.from,
8076            item_label,
8077            stack_quantity: row.stack.quantity,
8078            quantity: row.stack.quantity,
8079        });
8080        self.state.destroy_confirm_pending = false;
8081        self.state.show_destroy_picker = true;
8082        self.state.show_move_picker = false;
8083        self.state.move_picker = None;
8084        Ok(())
8085    }
8086
8087    pub fn close_destroy_picker(&mut self) {
8088        self.state.show_destroy_picker = false;
8089        self.state.destroy_confirm_pending = false;
8090        self.state.destroy_picker = None;
8091    }
8092
8093    pub fn cancel_destroy_confirm(&mut self) {
8094        self.state.destroy_confirm_pending = false;
8095    }
8096
8097    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8098        if self.state.destroy_picker.is_none() {
8099            self.close_destroy_picker();
8100            return Ok(());
8101        }
8102        self.state.destroy_confirm_pending = true;
8103        Ok(())
8104    }
8105
8106    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8107        let Some(picker) = self.state.destroy_picker.clone() else {
8108            self.close_destroy_picker();
8109            return Ok(());
8110        };
8111        let qty = if picker.quantity >= picker.stack_quantity {
8112            None
8113        } else {
8114            Some(picker.quantity)
8115        };
8116        self.destroy_item(picker.item_instance_id, picker.from, qty)
8117            .await?;
8118        let destroyed = qty.unwrap_or(picker.stack_quantity);
8119        if destroyed >= picker.stack_quantity {
8120            self.state
8121                .push_log(format!("Destroyed {}", picker.item_label));
8122        } else {
8123            self.state.push_log(format!(
8124                "Destroyed {} ×{} of {}",
8125                picker.item_label, destroyed, picker.stack_quantity
8126            ));
8127        }
8128        self.close_destroy_picker();
8129        Ok(())
8130    }
8131
8132    pub async fn destroy_item(
8133        &mut self,
8134        item_instance_id: uuid::Uuid,
8135        from: flatland_protocol::InventoryLocation,
8136        quantity: Option<u32>,
8137    ) -> anyhow::Result<()> {
8138        self.seq += 1;
8139        self.session
8140            .submit_intent(Intent::DestroyItem {
8141                entity_id: self.state.entity_id,
8142                item_instance_id,
8143                from,
8144                quantity,
8145                seq: self.seq,
8146            })
8147            .await?;
8148        self.state.intents_sent += 1;
8149        Ok(())
8150    }
8151
8152    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
8153    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8154        if let Some(row) = self.state.inventory_selected_row() {
8155            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8156                return self.toggle_placed_chest_lock(container_id).await;
8157            }
8158        }
8159        self.toggle_nearby_chest_lock().await
8160    }
8161
8162    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8163        let chest = self
8164            .state
8165            .placed_containers
8166            .iter()
8167            .find(|c| c.id == container_id)
8168            .cloned()
8169            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8170        let (px, py) = self.state.player_position();
8171        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8172            anyhow::bail!("too far from {}", chest.display_name);
8173        }
8174        if !chest.accessible && chest.locked {
8175            anyhow::bail!(
8176                "need the matching key for {} (each crafted chest has its own key)",
8177                chest.display_name
8178            );
8179        }
8180        let lock = !chest.locked;
8181        self.set_container_locked(
8182            flatland_protocol::InventoryLocation::Placed {
8183                container_id: chest.id.clone(),
8184            },
8185            lock,
8186        )
8187        .await?;
8188        self.state.push_log(if lock {
8189            format!("Locked {}", chest.display_name)
8190        } else {
8191            format!("Unlocked {}", chest.display_name)
8192        });
8193        Ok(())
8194    }
8195
8196    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
8197    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8198        let chest = self
8199            .state
8200            .nearest_placed_container(CONTAINER_RANGE_M)
8201            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8202        self.toggle_placed_chest_lock(&chest.id).await
8203    }
8204
8205    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8206        self.equip_mainhand(None).await
8207    }
8208
8209    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8210        if !self.state.is_alive() {
8211            anyhow::bail!("you are dead");
8212        }
8213        self.seq += 1;
8214        self.session
8215            .submit_intent(Intent::EquipOffhand {
8216                entity_id: self.state.entity_id,
8217                template_id,
8218                instance_id: None,
8219                seq: self.seq,
8220            })
8221            .await?;
8222        self.state.intents_sent += 1;
8223        Ok(())
8224    }
8225
8226    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8227        self.equip_offhand(None).await
8228    }
8229
8230    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8231        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8232        for slot in slots {
8233            self.equip_worn(slot, None).await?;
8234        }
8235        Ok(())
8236    }
8237
8238    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8239        let (px, py) = self.state.player_position();
8240        let nearest = self
8241            .state
8242            .placed_containers
8243            .iter()
8244            .min_by(|a, b| {
8245                let da = (a.x - px).hypot(a.y - py);
8246                let db = (b.x - px).hypot(b.y - py);
8247                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8248            })
8249            .cloned();
8250        let Some(chest) = nearest else {
8251            anyhow::bail!("no chest nearby");
8252        };
8253        if (chest.x - px).hypot(chest.y - py) > 2.0 {
8254            anyhow::bail!("too far from chest");
8255        }
8256        self.pickup_container(chest.id).await
8257    }
8258
8259    pub async fn equip_worn(
8260        &mut self,
8261        slot: BodySlot,
8262        instance_id: Option<uuid::Uuid>,
8263    ) -> anyhow::Result<()> {
8264        self.seq += 1;
8265        self.session
8266            .submit_intent(Intent::EquipWorn {
8267                entity_id: self.state.entity_id,
8268                slot,
8269                instance_id,
8270                seq: self.seq,
8271            })
8272            .await?;
8273        self.state.intents_sent += 1;
8274        Ok(())
8275    }
8276
8277    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8278        self.seq += 1;
8279        self.session
8280            .submit_intent(Intent::PlaceContainer {
8281                entity_id: self.state.entity_id,
8282                item_instance_id,
8283                seq: self.seq,
8284            })
8285            .await?;
8286        self.state.intents_sent += 1;
8287        Ok(())
8288    }
8289
8290    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8291        self.seq += 1;
8292        self.session
8293            .submit_intent(Intent::PickupContainer {
8294                entity_id: self.state.entity_id,
8295                container_id,
8296                seq: self.seq,
8297            })
8298            .await?;
8299        self.state.intents_sent += 1;
8300        Ok(())
8301    }
8302
8303    pub async fn move_item(
8304        &mut self,
8305        item_instance_id: uuid::Uuid,
8306        from: flatland_protocol::InventoryLocation,
8307        to: flatland_protocol::InventoryLocation,
8308        to_parent_instance_id: Option<uuid::Uuid>,
8309        quantity: Option<u32>,
8310    ) -> anyhow::Result<()> {
8311        self.seq += 1;
8312        self.session
8313            .submit_intent(Intent::MoveItem {
8314                entity_id: self.state.entity_id,
8315                item_instance_id,
8316                from,
8317                to,
8318                to_parent_instance_id,
8319                quantity,
8320                seq: self.seq,
8321            })
8322            .await?;
8323        self.state.intents_sent += 1;
8324        Ok(())
8325    }
8326
8327    pub async fn set_container_locked(
8328        &mut self,
8329        location: flatland_protocol::InventoryLocation,
8330        locked: bool,
8331    ) -> anyhow::Result<()> {
8332        self.seq += 1;
8333        self.session
8334            .submit_intent(Intent::SetContainerLocked {
8335                entity_id: self.state.entity_id,
8336                location,
8337                locked,
8338                seq: self.seq,
8339            })
8340            .await?;
8341        self.state.intents_sent += 1;
8342        Ok(())
8343    }
8344
8345    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8346        if !self.state.is_alive() {
8347            anyhow::bail!("you are dead");
8348        }
8349        self.seq += 1;
8350        self.session
8351            .submit_intent(Intent::Use {
8352                entity_id: self.state.entity_id,
8353                template_id: template_id.to_string(),
8354                seq: self.seq,
8355            })
8356            .await?;
8357        self.state.intents_sent += 1;
8358        Ok(())
8359    }
8360
8361    /// Bind a grant consumable onto a specific item instance (unique gear status).
8362    pub async fn use_grant(
8363        &mut self,
8364        grant_instance_id: uuid::Uuid,
8365        target_instance_id: uuid::Uuid,
8366    ) -> anyhow::Result<()> {
8367        if !self.state.is_alive() {
8368            anyhow::bail!("you are dead");
8369        }
8370        self.seq += 1;
8371        self.session
8372            .submit_intent(Intent::UseGrant {
8373                entity_id: self.state.entity_id,
8374                grant_instance_id,
8375                target_instance_id,
8376                seq: self.seq,
8377            })
8378            .await?;
8379        self.state.intents_sent += 1;
8380        Ok(())
8381    }
8382
8383    pub fn open_craft_menu(&mut self) {
8384        self.state.show_craft_menu = true;
8385        self.state.show_shop_menu = false;
8386        self.state.shop_catalog = None;
8387        self.state.show_stats = false;
8388        self.state.show_inventory_menu = false;
8389        if self.state.blueprints.is_empty() {
8390            self.state.craft_menu_index = 0;
8391            self.state.craft_batch_quantity = 1;
8392            return;
8393        }
8394        self.state.craft_menu_index = self
8395            .state
8396            .craft_menu_index
8397            .min(self.state.blueprints.len() - 1);
8398        if let Some(idx) = self
8399            .state
8400            .blueprints
8401            .iter()
8402            .position(|bp| self.state.can_craft_blueprint(bp))
8403        {
8404            self.state.craft_menu_index = idx;
8405        }
8406        self.state.clamp_craft_batch_quantity();
8407    }
8408
8409    pub fn close_craft_menu(&mut self) {
8410        self.state.show_craft_menu = false;
8411    }
8412
8413    pub fn toggle_keychain_menu(&mut self) {
8414        if self.state.show_keychain_menu {
8415            self.close_keychain_menu();
8416        } else {
8417            self.state.show_keychain_menu = true;
8418            self.state.show_craft_menu = false;
8419            self.state.show_shop_menu = false;
8420            self.state.show_inventory_menu = false;
8421            let n = self.state.keychain_entries().len();
8422            if n == 0 {
8423                self.state.keychain_menu_index = 0;
8424            } else {
8425                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8426            }
8427        }
8428    }
8429
8430    pub fn close_keychain_menu(&mut self) {
8431        self.state.show_keychain_menu = false;
8432    }
8433
8434    pub fn keychain_menu_move(&mut self, delta: i32) {
8435        let n = self.state.keychain_entries().len();
8436        if n == 0 {
8437            self.state.keychain_menu_index = 0;
8438            return;
8439        }
8440        let idx = self.state.keychain_menu_index as i32 + delta;
8441        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8442    }
8443
8444    pub fn keychain_menu_page(&mut self, pages: i32) {
8445        let n = self.state.keychain_entries().len();
8446        self.state.keychain_menu_index =
8447            page_list_index(self.state.keychain_menu_index, pages, n);
8448    }
8449
8450    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8451        if !self.state.is_alive() {
8452            anyhow::bail!("you are dead");
8453        }
8454        let entries = self.state.keychain_entries();
8455        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8456            anyhow::bail!("nothing selected");
8457        };
8458        let Some(instance_id) = entry.stack.item_instance_id else {
8459            anyhow::bail!("key has no instance id");
8460        };
8461        if entry.stowed {
8462            self.move_item(
8463                instance_id,
8464                flatland_protocol::InventoryLocation::Keychain,
8465                flatland_protocol::InventoryLocation::Root,
8466                None,
8467                Some(1),
8468            )
8469            .await
8470        } else {
8471            self.move_item(
8472                instance_id,
8473                flatland_protocol::InventoryLocation::Root,
8474                flatland_protocol::InventoryLocation::Keychain,
8475                None,
8476                Some(1),
8477            )
8478            .await
8479        }
8480    }
8481
8482    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8483        let npc_id = self
8484            .state
8485            .shop_catalog
8486            .as_ref()
8487            .map(|c| c.npc_id.clone());
8488        self.state.show_shop_menu = false;
8489        self.state.shop_catalog = None;
8490        self.state.clear_shop_trade_log();
8491        if let Some(npc_id) = npc_id {
8492            self.seq += 1;
8493            self.session
8494                .submit_intent(Intent::ShopClose {
8495                    entity_id: self.state.entity_id,
8496                    npc_id,
8497                    seq: self.seq,
8498                })
8499                .await?;
8500            self.state.intents_sent += 1;
8501        }
8502        Ok(())
8503    }
8504
8505    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8506        let Some(panel) = self.state.bank_panel.clone() else {
8507            return Ok(());
8508        };
8509        self.seq += 1;
8510        self.session
8511            .submit_intent(Intent::BankDeposit {
8512                entity_id: self.state.entity_id,
8513                npc_id: panel.npc_id,
8514                amount_copper,
8515                seq: self.seq,
8516            })
8517            .await?;
8518        self.state.intents_sent += 1;
8519        Ok(())
8520    }
8521
8522    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8523        let Some(panel) = self.state.bank_panel.clone() else {
8524            return Ok(());
8525        };
8526        self.seq += 1;
8527        self.session
8528            .submit_intent(Intent::BankWithdraw {
8529                entity_id: self.state.entity_id,
8530                npc_id: panel.npc_id,
8531                amount_copper,
8532                seq: self.seq,
8533            })
8534            .await?;
8535        self.state.intents_sent += 1;
8536        Ok(())
8537    }
8538
8539    pub async fn bank_transfer(
8540        &mut self,
8541        to_character_id: Option<uuid::Uuid>,
8542        to_name: String,
8543        amount_copper: u64,
8544    ) -> anyhow::Result<()> {
8545        let Some(panel) = self.state.bank_panel.clone() else {
8546            return Ok(());
8547        };
8548        self.seq += 1;
8549        self.session
8550            .submit_intent(Intent::BankTransfer {
8551                entity_id: self.state.entity_id,
8552                npc_id: panel.npc_id,
8553                to_character_id,
8554                to_name,
8555                amount_copper,
8556                seq: self.seq,
8557            })
8558            .await?;
8559        self.state.intents_sent += 1;
8560        Ok(())
8561    }
8562
8563    pub fn bank_menu_move(&mut self, delta: i32) {
8564        let n = self.state.bank_menu_options().len();
8565        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8566            return;
8567        }
8568        let idx = self.state.bank_menu_index as i32;
8569        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8570    }
8571
8572    pub fn storage_menu_move(&mut self, delta: i32) {
8573        let n = self.state.storage_menu_options().len();
8574        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8575            return;
8576        }
8577        let idx = self.state.storage_menu_index as i32;
8578        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8579    }
8580
8581    pub fn storage_pick_move(&mut self, delta: i32) {
8582        let n = match &self.state.storage_ui_mode {
8583            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8584            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8585                self.state.storage_vault_options().len()
8586            }
8587            StorageUiMode::Menu
8588            | StorageUiMode::StoreAmount { .. }
8589            | StorageUiMode::TakeAmount { .. }
8590            | StorageUiMode::ShipAmount { .. } => 0,
8591        };
8592        if n == 0 {
8593            return;
8594        }
8595        match &mut self.state.storage_ui_mode {
8596            StorageUiMode::StorePick { index }
8597            | StorageUiMode::TakePick { index }
8598            | StorageUiMode::ShipPick { index, .. } => {
8599                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8600            }
8601            StorageUiMode::Menu
8602            | StorageUiMode::StoreAmount { .. }
8603            | StorageUiMode::TakeAmount { .. }
8604            | StorageUiMode::ShipAmount { .. } => {}
8605        }
8606    }
8607
8608    pub fn storage_ui_back(&mut self) {
8609        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8610            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8611                index: *pick_index,
8612            },
8613            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8614                index: *pick_index,
8615            },
8616            StorageUiMode::ShipAmount {
8617                dest_building_id,
8618                dest_label,
8619                pick_index,
8620                ..
8621            } => StorageUiMode::ShipPick {
8622                dest_building_id: dest_building_id.clone(),
8623                dest_label: dest_label.clone(),
8624                index: *pick_index,
8625            },
8626            StorageUiMode::StorePick { .. }
8627            | StorageUiMode::TakePick { .. }
8628            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8629            StorageUiMode::Menu => StorageUiMode::Menu,
8630        };
8631    }
8632
8633    pub fn storage_amount_append_char(&mut self, c: char) {
8634        match &mut self.state.storage_ui_mode {
8635            StorageUiMode::StoreAmount { input, .. }
8636            | StorageUiMode::TakeAmount { input, .. }
8637            | StorageUiMode::ShipAmount { input, .. } => {
8638                if c.is_ascii_digit() && input.len() < 8 {
8639                    input.push(c);
8640                }
8641            }
8642            _ => {}
8643        }
8644    }
8645
8646    pub fn storage_amount_backspace(&mut self) {
8647        match &mut self.state.storage_ui_mode {
8648            StorageUiMode::StoreAmount { input, .. }
8649            | StorageUiMode::TakeAmount { input, .. }
8650            | StorageUiMode::ShipAmount { input, .. } => {
8651                input.pop();
8652            }
8653            _ => {}
8654        }
8655    }
8656
8657    pub fn storage_ui_typing(&self) -> bool {
8658        matches!(
8659            self.state.storage_ui_mode,
8660            StorageUiMode::StoreAmount { .. }
8661                | StorageUiMode::TakeAmount { .. }
8662                | StorageUiMode::ShipAmount { .. }
8663        )
8664    }
8665
8666    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8667        match self.state.storage_ui_mode.clone() {
8668            StorageUiMode::Menu => {
8669                let index = self.state.storage_menu_index;
8670                match index {
8671                    0 => {
8672                        let opts = self.state.storage_store_options();
8673                        if opts.is_empty() {
8674                            self.state.push_log("Nothing loose to store.");
8675                            return Ok(());
8676                        }
8677                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8678                    }
8679                    1 => {
8680                        let opts = self.state.storage_vault_options();
8681                        if opts.is_empty() {
8682                            self.state.push_log("Vault is empty.");
8683                            return Ok(());
8684                        }
8685                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8686                    }
8687                    n => {
8688                        let dest = self
8689                            .state
8690                            .storage_panel
8691                            .as_ref()
8692                            .and_then(|p| p.ship_destinations.get(n - 2))
8693                            .cloned();
8694                        let Some(dest) = dest else {
8695                            return Ok(());
8696                        };
8697                        let opts = self.state.storage_vault_options();
8698                        if opts.is_empty() {
8699                            self.state
8700                                .push_log("Vault is empty — nothing to ship.");
8701                            return Ok(());
8702                        }
8703                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
8704                            dest_building_id: dest.building_id,
8705                            dest_label: dest.label,
8706                            index: 0,
8707                        };
8708                    }
8709                }
8710            }
8711            StorageUiMode::StorePick { index } => {
8712                let opts = self.state.storage_store_options();
8713                let Some(opt) = opts.get(index) else {
8714                    self.state.push_log("Nothing loose to store.");
8715                    self.state.storage_ui_mode = StorageUiMode::Menu;
8716                    return Ok(());
8717                };
8718                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8719                    pick_index: index,
8720                    item_instance_id: opt.item_instance_id,
8721                    label: opt.label.clone(),
8722                    max_qty: opt.quantity.max(1),
8723                    input: String::new(),
8724                };
8725            }
8726            StorageUiMode::TakePick { index } => {
8727                let opts = self.state.storage_vault_options();
8728                let Some(opt) = opts.get(index) else {
8729                    self.state.push_log("Vault is empty.");
8730                    self.state.storage_ui_mode = StorageUiMode::Menu;
8731                    return Ok(());
8732                };
8733                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8734                    pick_index: index,
8735                    item_instance_id: opt.item_instance_id,
8736                    label: opt.label.clone(),
8737                    max_qty: opt.quantity.max(1),
8738                    input: String::new(),
8739                };
8740            }
8741            StorageUiMode::ShipPick {
8742                dest_building_id,
8743                dest_label,
8744                index,
8745            } => {
8746                let opts = self.state.storage_vault_options();
8747                let Some(opt) = opts.get(index) else {
8748                    self.state
8749                        .push_log("Vault is empty — nothing to ship.");
8750                    self.state.storage_ui_mode = StorageUiMode::Menu;
8751                    return Ok(());
8752                };
8753                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8754                    dest_building_id,
8755                    dest_label,
8756                    pick_index: index,
8757                    item_instance_id: opt.item_instance_id,
8758                    label: opt.label.clone(),
8759                    max_qty: opt.quantity.max(1),
8760                    input: String::new(),
8761                };
8762            }
8763            StorageUiMode::StoreAmount {
8764                item_instance_id,
8765                max_qty,
8766                input,
8767                ..
8768            } => {
8769                let Some(qty) = parse_storage_quantity(&input) else {
8770                    self.state
8771                        .push_log("Enter a quantity (blank or 0 = all).");
8772                    return Ok(());
8773                };
8774                let qty = qty.map(|n| n.min(max_qty).max(1));
8775                self.storage_store(item_instance_id, qty).await?;
8776                self.state.storage_ui_mode = StorageUiMode::Menu;
8777            }
8778            StorageUiMode::TakeAmount {
8779                item_instance_id,
8780                max_qty,
8781                input,
8782                ..
8783            } => {
8784                let Some(qty) = parse_storage_quantity(&input) else {
8785                    self.state
8786                        .push_log("Enter a quantity (blank or 0 = all).");
8787                    return Ok(());
8788                };
8789                let qty = qty.map(|n| n.min(max_qty).max(1));
8790                self.storage_take(item_instance_id, qty).await?;
8791                self.state.storage_ui_mode = StorageUiMode::Menu;
8792            }
8793            StorageUiMode::ShipAmount {
8794                dest_building_id,
8795                item_instance_id,
8796                max_qty,
8797                input,
8798                ..
8799            } => {
8800                let Some(qty) = parse_storage_quantity(&input) else {
8801                    self.state
8802                        .push_log("Enter a quantity (blank or 0 = all).");
8803                    return Ok(());
8804                };
8805                let qty = qty.map(|n| n.min(max_qty).max(1));
8806                self.storage_ship(dest_building_id, item_instance_id, qty)
8807                    .await?;
8808                self.state.storage_ui_mode = StorageUiMode::Menu;
8809            }
8810        }
8811        Ok(())
8812    }
8813
8814    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8815        match self.state.bank_ui_mode.clone() {
8816            BankUiMode::Menu => {
8817                let choice = self
8818                    .state
8819                    .bank_menu_options()
8820                    .get(self.state.bank_menu_index)
8821                    .copied()
8822                    .unwrap_or("Deposit…");
8823                match choice {
8824                    "Withdraw…" => {
8825                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8826                            input: String::new(),
8827                        };
8828                    }
8829                    "Deposit all" => self.bank_deposit(0).await?,
8830                    "Withdraw all" => self.bank_withdraw(0).await?,
8831                    "Transfer…" => {
8832                        self.state.bank_ui_mode = BankUiMode::TransferName {
8833                            input: String::new(),
8834                        };
8835                    }
8836                    _ => {
8837                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
8838                            input: String::new(),
8839                        };
8840                    }
8841                }
8842            }
8843            BankUiMode::DepositAmount { input } => {
8844                let Some(amount) = parse_bank_copper_amount(&input) else {
8845                    self.state
8846                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
8847                    return Ok(());
8848                };
8849                self.bank_deposit(amount).await?;
8850                self.state.bank_ui_mode = BankUiMode::Menu;
8851            }
8852            BankUiMode::WithdrawAmount { input } => {
8853                let Some(amount) = parse_bank_copper_amount(&input) else {
8854                    self.state
8855                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
8856                    return Ok(());
8857                };
8858                self.bank_withdraw(amount).await?;
8859                self.state.bank_ui_mode = BankUiMode::Menu;
8860            }
8861            BankUiMode::TransferName { input } => {
8862                let name = input.trim().to_string();
8863                if name.is_empty() {
8864                    self.state.push_log("Enter the recipient character name.");
8865                    return Ok(());
8866                }
8867                self.state.bank_ui_mode = BankUiMode::TransferAmount {
8868                    to_name: name,
8869                    input: String::new(),
8870                };
8871            }
8872            BankUiMode::TransferAmount { to_name, input } => {
8873                let amount: u64 = match input.trim().parse() {
8874                    Ok(v) if v > 0 => v,
8875                    _ => {
8876                        self.state
8877                            .push_log("Enter a positive copper amount to transfer.");
8878                        return Ok(());
8879                    }
8880                };
8881                self.bank_transfer(None, to_name, amount).await?;
8882                self.state.bank_ui_mode = BankUiMode::Menu;
8883            }
8884        }
8885        Ok(())
8886    }
8887
8888    pub fn bank_transfer_back(&mut self) {
8889        match &self.state.bank_ui_mode {
8890            BankUiMode::TransferAmount { to_name, .. } => {
8891                self.state.bank_ui_mode = BankUiMode::TransferName {
8892                    input: to_name.clone(),
8893                };
8894            }
8895            BankUiMode::TransferName { .. }
8896            | BankUiMode::DepositAmount { .. }
8897            | BankUiMode::WithdrawAmount { .. } => {
8898                self.state.bank_ui_mode = BankUiMode::Menu;
8899            }
8900            BankUiMode::Menu => {}
8901        }
8902    }
8903
8904    pub fn bank_transfer_append_char(&mut self, c: char) {
8905        match &mut self.state.bank_ui_mode {
8906            BankUiMode::TransferName { input } => {
8907                if input.len() < 32 && !c.is_control() {
8908                    input.push(c);
8909                }
8910            }
8911            BankUiMode::DepositAmount { input }
8912            | BankUiMode::WithdrawAmount { input }
8913            | BankUiMode::TransferAmount { input, .. } => {
8914                if c.is_ascii_digit() && input.len() < 12 {
8915                    input.push(c);
8916                }
8917            }
8918            BankUiMode::Menu => {}
8919        }
8920    }
8921
8922    pub fn bank_transfer_backspace(&mut self) {
8923        match &mut self.state.bank_ui_mode {
8924            BankUiMode::TransferName { input }
8925            | BankUiMode::DepositAmount { input }
8926            | BankUiMode::WithdrawAmount { input }
8927            | BankUiMode::TransferAmount { input, .. } => {
8928                input.pop();
8929            }
8930            BankUiMode::Menu => {}
8931        }
8932    }
8933
8934    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8935        let npc_id = self
8936            .state
8937            .bank_panel
8938            .as_ref()
8939            .map(|p| p.npc_id.clone());
8940        self.state.clear_bank_panel();
8941        if let Some(npc_id) = npc_id {
8942            self.seq += 1;
8943            self.session
8944                .submit_intent(Intent::BankClose {
8945                    entity_id: self.state.entity_id,
8946                    npc_id,
8947                    seq: self.seq,
8948                })
8949                .await?;
8950            self.state.intents_sent += 1;
8951        }
8952        Ok(())
8953    }
8954
8955    pub async fn storage_store(
8956        &mut self,
8957        item_instance_id: uuid::Uuid,
8958        quantity: Option<u32>,
8959    ) -> anyhow::Result<()> {
8960        let Some(panel) = self.state.storage_panel.clone() else {
8961            return Ok(());
8962        };
8963        self.seq += 1;
8964        self.session
8965            .submit_intent(Intent::StorageStore {
8966                entity_id: self.state.entity_id,
8967                npc_id: panel.npc_id,
8968                item_instance_id,
8969                quantity,
8970                seq: self.seq,
8971            })
8972            .await?;
8973        self.state.intents_sent += 1;
8974        Ok(())
8975    }
8976
8977    pub async fn storage_take(
8978        &mut self,
8979        item_instance_id: uuid::Uuid,
8980        quantity: Option<u32>,
8981    ) -> anyhow::Result<()> {
8982        let Some(panel) = self.state.storage_panel.clone() else {
8983            return Ok(());
8984        };
8985        self.seq += 1;
8986        self.session
8987            .submit_intent(Intent::StorageTake {
8988                entity_id: self.state.entity_id,
8989                npc_id: panel.npc_id,
8990                item_instance_id,
8991                quantity,
8992                seq: self.seq,
8993            })
8994            .await?;
8995        self.state.intents_sent += 1;
8996        Ok(())
8997    }
8998
8999    pub async fn storage_ship(
9000        &mut self,
9001        dest_building_id: String,
9002        item_instance_id: uuid::Uuid,
9003        quantity: Option<u32>,
9004    ) -> anyhow::Result<()> {
9005        let Some(panel) = self.state.storage_panel.clone() else {
9006            return Ok(());
9007        };
9008        self.seq += 1;
9009        self.session
9010            .submit_intent(Intent::StorageShip {
9011                entity_id: self.state.entity_id,
9012                npc_id: panel.npc_id,
9013                dest_building_id,
9014                item_instance_id,
9015                quantity,
9016                seq: self.seq,
9017            })
9018            .await?;
9019        self.state.intents_sent += 1;
9020        Ok(())
9021    }
9022
9023    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9024        let npc_id = self
9025            .state
9026            .storage_panel
9027            .as_ref()
9028            .map(|p| p.npc_id.clone());
9029        self.state.clear_storage_panel();
9030        if let Some(npc_id) = npc_id {
9031            self.seq += 1;
9032            self.session
9033                .submit_intent(Intent::StorageClose {
9034                    entity_id: self.state.entity_id,
9035                    npc_id,
9036                    seq: self.seq,
9037                })
9038                .await?;
9039            self.state.intents_sent += 1;
9040        }
9041        Ok(())
9042    }
9043
9044    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9045        let npc_id = self
9046            .state
9047            .market_panel
9048            .as_ref()
9049            .map(|p| p.npc_id.clone());
9050        self.state.clear_market_panel();
9051        if let Some(npc_id) = npc_id {
9052            self.seq += 1;
9053            self.session
9054                .submit_intent(Intent::MarketClose {
9055                    entity_id: self.state.entity_id,
9056                    npc_id,
9057                    seq: self.seq,
9058                })
9059                .await?;
9060            self.state.intents_sent += 1;
9061        }
9062        Ok(())
9063    }
9064
9065    pub fn market_move_selection(&mut self, delta: i32) {
9066        let indices = self.state.market_filtered_listing_indices();
9067        let n = indices.len();
9068        if n == 0 {
9069            self.state.market_menu_index = 0;
9070            return;
9071        }
9072        let cur = self.state.market_menu_index as i32;
9073        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9074    }
9075
9076    pub fn market_page_selection(&mut self, pages: i32) {
9077        let indices = self.state.market_filtered_listing_indices();
9078        let n = indices.len();
9079        if n == 0 {
9080            self.state.market_menu_index = 0;
9081            return;
9082        }
9083        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9084    }
9085
9086    pub fn market_list_page(&mut self, pages: i32) {
9087        match &self.state.market_ui_mode {
9088            MarketUiMode::ListSource { index } => {
9089                let n = self.state.market_list_source_options().len();
9090                if n == 0 {
9091                    return;
9092                }
9093                let next = page_list_index(*index, pages, n);
9094                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9095            }
9096            MarketUiMode::ListPricingMode { index, .. } => {
9097                let next = page_list_index(*index, pages, 2);
9098                if let MarketUiMode::ListPricingMode { index, .. } =
9099                    &mut self.state.market_ui_mode
9100                {
9101                    *index = next;
9102                }
9103            }
9104            MarketUiMode::ListPick { source, index } => {
9105                let opts = self.state.market_list_item_options(source);
9106                let n = opts.len();
9107                if n == 0 {
9108                    return;
9109                }
9110                let next = page_list_index(*index, pages, n);
9111                self.state.market_ui_mode = MarketUiMode::ListPick {
9112                    source: source.clone(),
9113                    index: next,
9114                };
9115            }
9116            _ => {}
9117        }
9118    }
9119
9120    pub fn market_cycle_category(&mut self, delta: i32) {
9121        let groups = self.state.market_available_category_groups();
9122        // All + groups
9123        let mut labels: Vec<Option<&'static str>> = vec![None];
9124        labels.extend(groups.into_iter().map(Some));
9125        let n = labels.len() as i32;
9126        let cur = labels
9127            .iter()
9128            .position(|g| *g == self.state.market_category_filter)
9129            .unwrap_or(0) as i32;
9130        let next = (cur + delta).rem_euclid(n) as usize;
9131        self.state.market_category_filter = labels[next];
9132        self.state.market_menu_index = 0;
9133        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9134            let source = source.clone();
9135            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9136        }
9137    }
9138
9139    pub fn focus_market_filter(&mut self) {
9140        self.state.market_filter_focused = true;
9141    }
9142
9143    pub fn append_market_filter_char(&mut self, ch: char) {
9144        if !self.state.market_filter_focused {
9145            return;
9146        }
9147        if ch.is_control() {
9148            return;
9149        }
9150        if self.state.market_filter.len() < 48 {
9151            self.state.market_filter.push(ch);
9152            self.state.market_menu_index = 0;
9153            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9154                let source = source.clone();
9155                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9156            }
9157        }
9158    }
9159
9160    pub fn market_filter_backspace(&mut self) {
9161        if !self.state.market_filter_focused {
9162            return;
9163        }
9164        self.state.market_filter.pop();
9165        self.state.market_menu_index = 0;
9166        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9167            let source = source.clone();
9168            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9169        }
9170    }
9171
9172    /// Clears filter text, blurs search, or returns false if already idle.
9173    pub fn clear_or_blur_market_filter(&mut self) -> bool {
9174        if self.state.market_filter_focused {
9175            if !self.state.market_filter.is_empty() {
9176                self.state.market_filter.clear();
9177                self.state.market_menu_index = 0;
9178                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9179                    let source = source.clone();
9180                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9181                }
9182                return true;
9183            }
9184            self.state.market_filter_focused = false;
9185            return true;
9186        }
9187        if !self.state.market_filter.is_empty() {
9188            self.state.market_filter.clear();
9189            self.state.market_menu_index = 0;
9190            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9191                let source = source.clone();
9192                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9193            }
9194            return true;
9195        }
9196        false
9197    }
9198
9199    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9200        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9201            return self.market_confirm_buy(listing_id, qty).await;
9202        }
9203        let Some(panel) = self.state.market_panel.clone() else {
9204            return Ok(());
9205        };
9206        let indices = self.state.market_filtered_listing_indices();
9207        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9208            return Ok(());
9209        };
9210        let Some(listing) = panel.listings.get(raw_idx) else {
9211            return Ok(());
9212        };
9213        if listing.mine {
9214            self.seq += 1;
9215            self.session
9216                .submit_intent(Intent::MarketDelist {
9217                    entity_id: self.state.entity_id,
9218                    npc_id: panel.npc_id.clone(),
9219                    listing_id: listing.listing_id,
9220                    dest: flatland_protocol::GoodsLocation::Person,
9221                    seq: self.seq,
9222                })
9223                .await?;
9224            self.state.intents_sent += 1;
9225            return Ok(());
9226        }
9227        if listing.npc_price {
9228            self.state
9229                .push_log("NPC-price listings are bought by merchants only.");
9230            return Ok(());
9231        }
9232        let qty = 1u32.min(listing.quantity).max(1);
9233        let line = listing.unit_price_copper.saturating_mul(qty as u64);
9234        self.state.market_buy_confirm = Some((
9235            listing.listing_id,
9236            qty,
9237            listing.unit_price_copper,
9238            line,
9239            listing.display_name.clone(),
9240        ));
9241        Ok(())
9242    }
9243
9244    pub async fn market_confirm_buy(
9245        &mut self,
9246        listing_id: uuid::Uuid,
9247        quantity: u32,
9248    ) -> anyhow::Result<()> {
9249        let Some(panel) = self.state.market_panel.clone() else {
9250            self.state.market_buy_confirm = None;
9251            return Ok(());
9252        };
9253        self.state.market_buy_confirm = None;
9254        self.seq += 1;
9255        self.session
9256            .submit_intent(Intent::MarketBuy {
9257                entity_id: self.state.entity_id,
9258                npc_id: panel.npc_id,
9259                listing_id,
9260                quantity,
9261                dest: flatland_protocol::GoodsLocation::Person,
9262                seq: self.seq,
9263            })
9264            .await?;
9265        self.state.intents_sent += 1;
9266        Ok(())
9267    }
9268
9269    /// Begin the list wizard (`l` in market browse).
9270    pub fn market_begin_list(&mut self) {
9271        if self.state.market_panel.is_none() {
9272            return;
9273        }
9274        let sources = self.state.market_list_source_options();
9275        if sources.is_empty() {
9276            self.state.push_log("Nothing to list from.");
9277            return;
9278        }
9279        // One source (person only) → skip straight to item pick when it has stacks.
9280        if sources.len() == 1 {
9281            let (source, _) = sources[0].clone();
9282            let opts = self.state.market_list_item_options(&source);
9283            if opts.is_empty() {
9284                self.state.push_log("Nothing loose to list.");
9285                return;
9286            }
9287            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9288            self.state.market_buy_confirm = None;
9289            return;
9290        }
9291        self.state.market_buy_confirm = None;
9292        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9293    }
9294
9295    pub fn market_ui_back(&mut self) {
9296        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9297            MarketUiMode::Browse => MarketUiMode::Browse,
9298            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9299            MarketUiMode::ListPick { .. } => {
9300                if self.state.market_list_source_options().len() <= 1 {
9301                    MarketUiMode::Browse
9302                } else {
9303                    MarketUiMode::ListSource { index: 0 }
9304                }
9305            }
9306            MarketUiMode::ListAmount {
9307                source,
9308                pick_index,
9309                ..
9310            } => MarketUiMode::ListPick {
9311                source,
9312                index: pick_index,
9313            },
9314            MarketUiMode::ListPricingMode {
9315                source,
9316                item_instance_id,
9317                label,
9318                max_qty,
9319                quantity,
9320                ..
9321            } => {
9322                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9323                MarketUiMode::ListAmount {
9324                    source,
9325                    pick_index: 0,
9326                    item_instance_id,
9327                    label,
9328                    max_qty,
9329                    input,
9330                }
9331            }
9332            MarketUiMode::ListPrice {
9333                source,
9334                item_instance_id,
9335                label,
9336                max_qty,
9337                quantity,
9338                ..
9339            } => MarketUiMode::ListPricingMode {
9340                source,
9341                item_instance_id,
9342                label,
9343                quantity,
9344                max_qty,
9345                index: 1,
9346            },
9347        };
9348    }
9349
9350    pub fn market_list_move(&mut self, delta: i32) {
9351        match &self.state.market_ui_mode {
9352            MarketUiMode::ListSource { index } => {
9353                let n = self.state.market_list_source_options().len();
9354                if n == 0 {
9355                    return;
9356                }
9357                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9358                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9359            }
9360            MarketUiMode::ListPricingMode { index, .. } => {
9361                let next = (*index as i32 + delta).rem_euclid(2) as usize;
9362                if let MarketUiMode::ListPricingMode { index, .. } =
9363                    &mut self.state.market_ui_mode
9364                {
9365                    *index = next;
9366                }
9367            }
9368            MarketUiMode::ListPick { source, index } => {
9369                let opts = self.state.market_list_item_options(source);
9370                let n = opts.len();
9371                if n == 0 {
9372                    return;
9373                }
9374                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9375                self.state.market_ui_mode = MarketUiMode::ListPick {
9376                    source: source.clone(),
9377                    index: next,
9378                };
9379            }
9380            _ => {}
9381        }
9382    }
9383
9384    pub fn market_list_amount_append_char(&mut self, c: char) {
9385        if !c.is_ascii_digit() {
9386            return;
9387        }
9388        match &mut self.state.market_ui_mode {
9389            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9390                if input.len() < 12 {
9391                    input.push(c);
9392                }
9393            }
9394            _ => {}
9395        }
9396    }
9397
9398    pub fn market_list_amount_backspace(&mut self) {
9399        match &mut self.state.market_ui_mode {
9400            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9401                input.pop();
9402            }
9403            _ => {}
9404        }
9405    }
9406
9407    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9408        match self.state.market_ui_mode.clone() {
9409            MarketUiMode::Browse => Ok(()),
9410            MarketUiMode::ListSource { index } => {
9411                let sources = self.state.market_list_source_options();
9412                let Some((source, _)) = sources.get(index).cloned() else {
9413                    return Ok(());
9414                };
9415                let opts = self.state.market_list_item_options(&source);
9416                if opts.is_empty() {
9417                    self.state.push_log("Nothing to list from that source.");
9418                    return Ok(());
9419                }
9420                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9421                Ok(())
9422            }
9423            MarketUiMode::ListPick { source, index } => {
9424                let opts = self.state.market_list_item_options(&source);
9425                let Some(opt) = opts.get(index) else {
9426                    self.state.push_log("Nothing to list.");
9427                    self.state.market_ui_mode = MarketUiMode::Browse;
9428                    return Ok(());
9429                };
9430                self.state.market_ui_mode = MarketUiMode::ListAmount {
9431                    source,
9432                    pick_index: index,
9433                    item_instance_id: opt.item_instance_id,
9434                    label: opt.label.clone(),
9435                    max_qty: opt.quantity.max(1),
9436                    input: String::new(),
9437                };
9438                Ok(())
9439            }
9440            MarketUiMode::ListAmount {
9441                source,
9442                item_instance_id,
9443                label,
9444                max_qty,
9445                input,
9446                ..
9447            } => {
9448                let Some(qty_opt) = parse_storage_quantity(&input) else {
9449                    self.state.push_log("Enter a quantity (blank = all).");
9450                    return Ok(());
9451                };
9452                if let Some(q) = qty_opt {
9453                    if q > max_qty {
9454                        self.state
9455                            .push_log(format!("Only {max_qty} available."));
9456                        return Ok(());
9457                    }
9458                }
9459                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9460                    source,
9461                    item_instance_id,
9462                    label,
9463                    quantity: qty_opt,
9464                    max_qty,
9465                    index: 0,
9466                };
9467                Ok(())
9468            }
9469            MarketUiMode::ListPricingMode {
9470                source,
9471                item_instance_id,
9472                label,
9473                quantity,
9474                max_qty,
9475                index,
9476            } => {
9477                if index == 0 {
9478                    return self
9479                        .submit_market_list_intent(
9480                            source,
9481                            item_instance_id,
9482                            quantity,
9483                            0,
9484                            true,
9485                            &label,
9486                        )
9487                        .await;
9488                }
9489                self.state.market_ui_mode = MarketUiMode::ListPrice {
9490                    source,
9491                    item_instance_id,
9492                    label,
9493                    quantity,
9494                    max_qty,
9495                    input: String::new(),
9496                };
9497                Ok(())
9498            }
9499            MarketUiMode::ListPrice {
9500                source,
9501                item_instance_id,
9502                label,
9503                quantity,
9504                input,
9505                ..
9506            } => {
9507                let price = input.trim().parse::<u64>().unwrap_or(0);
9508                if price == 0 {
9509                    self.state.push_log("Enter a unit price of at least 1 copper.");
9510                    return Ok(());
9511                }
9512                self.submit_market_list_intent(
9513                    source,
9514                    item_instance_id,
9515                    quantity,
9516                    price,
9517                    false,
9518                    &label,
9519                )
9520                .await
9521            }
9522        }
9523    }
9524
9525    async fn submit_market_list_intent(
9526        &mut self,
9527        source: MarketListSourceKind,
9528        item_instance_id: uuid::Uuid,
9529        quantity: Option<u32>,
9530        unit_price_copper: u64,
9531        npc_price: bool,
9532        label: &str,
9533    ) -> anyhow::Result<()> {
9534        let Some(panel) = self.state.market_panel.clone() else {
9535            self.state.market_ui_mode = MarketUiMode::Browse;
9536            return Ok(());
9537        };
9538        let goods = match source {
9539            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9540            MarketListSourceKind::TownStorage { building_id } => {
9541                flatland_protocol::GoodsLocation::TownStorage { building_id }
9542            }
9543        };
9544        self.seq += 1;
9545        self.session
9546            .submit_intent(Intent::MarketList {
9547                entity_id: self.state.entity_id,
9548                npc_id: panel.npc_id,
9549                source: goods,
9550                item_instance_id,
9551                quantity,
9552                unit_price_copper,
9553                npc_price,
9554                seq: self.seq,
9555            })
9556            .await?;
9557        self.state.intents_sent += 1;
9558        if npc_price {
9559            self.state.push_log(format!("Listing {label} at NPC price…"));
9560        } else {
9561            self.state
9562                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
9563        }
9564        self.state.market_ui_mode = MarketUiMode::Browse;
9565        Ok(())
9566    }
9567
9568    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
9569    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9570        let return_to_verbs = self.state.npc_verb_target.is_some();
9571        self.close_shop_menu().await?;
9572        if return_to_verbs {
9573            self.state.show_npc_verb_menu = true;
9574        }
9575        Ok(())
9576    }
9577
9578    pub fn shop_tab_toggle(&mut self) {
9579        self.state.shop_tab = match self.state.shop_tab {
9580            ShopTab::Buy => ShopTab::Sell,
9581            ShopTab::Sell => ShopTab::Buy,
9582        };
9583        self.state.shop_menu_index = 0;
9584        if self.state.shop_tab == ShopTab::Sell {
9585            self.state.shop_quantity_set_max();
9586        }
9587        self.state.clamp_shop_selection();
9588    }
9589
9590    pub fn shop_menu_move(&mut self, delta: i32) {
9591        self.state.shop_menu_move(delta);
9592    }
9593
9594    pub fn shop_quantity_adjust(&mut self, delta: i32) {
9595        self.state.shop_quantity_adjust(delta);
9596    }
9597
9598    pub fn shop_quantity_set_max(&mut self) {
9599        self.state.shop_quantity_set_max();
9600    }
9601
9602    pub fn shop_quantity_set_min(&mut self) {
9603        self.state.shop_quantity_set_min();
9604    }
9605
9606    pub fn toggle_quest_menu(&mut self) {
9607        self.state.show_quest_menu = !self.state.show_quest_menu;
9608        if self.state.show_quest_menu {
9609            self.state.quest_menu_index = 0;
9610            self.state.quest_withdraw_confirm = false;
9611            self.state.show_workers_menu = false;
9612        }
9613    }
9614
9615    pub fn toggle_workers_menu(&mut self) {
9616        if self.state.show_workers_menu {
9617            self.close_workers_menu_ui();
9618        } else {
9619            self.state.show_workers_menu = true;
9620            self.state.workers_menu_index = 0;
9621            self.state.show_quest_menu = false;
9622            self.close_worker_give_picker();
9623            self.close_worker_give_target_picker();
9624            self.close_worker_take_picker();
9625            self.close_worker_teach_picker();
9626            self.cancel_worker_rename();
9627        }
9628    }
9629
9630    /// Close workers UI layers without network (caller should release attend).
9631    pub fn close_workers_menu_ui(&mut self) {
9632        self.state.show_workers_menu = false;
9633        self.close_worker_give_picker();
9634        self.close_worker_give_target_picker();
9635        self.close_worker_take_picker();
9636        self.close_worker_teach_picker();
9637        self.cancel_worker_rename();
9638    }
9639
9640    /// Open the workers menu focused on `instance_id` and pause that worker's job.
9641    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9642        let Some(idx) = self
9643            .state
9644            .hired_workers
9645            .iter()
9646            .position(|w| w.instance_id == instance_id)
9647        else {
9648            anyhow::bail!("worker not found");
9649        };
9650        let label = self.state.hired_workers[idx].label.clone();
9651        self.state.show_workers_menu = true;
9652        self.state.workers_menu_index = idx;
9653        self.state.show_quest_menu = false;
9654        self.close_worker_give_picker();
9655        self.close_worker_give_target_picker();
9656        self.close_worker_take_picker();
9657        self.close_worker_teach_picker();
9658        self.cancel_worker_rename();
9659        self.set_worker_attending(instance_id, true).await?;
9660        self.state
9661            .push_log(format!("Managing {label} — job paused while menu is open"));
9662        Ok(())
9663    }
9664
9665    /// Close workers menu and release any attend pause.
9666    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9667        self.close_workers_menu_ui();
9668        self.release_worker_attend().await
9669    }
9670
9671    async fn set_worker_attending(
9672        &mut self,
9673        instance_id: &str,
9674        attending: bool,
9675    ) -> anyhow::Result<()> {
9676        if attending {
9677            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9678                return Ok(());
9679            }
9680            // Switch target: release previous first.
9681            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9682                if prev != instance_id {
9683                    self.send_attend_hired_worker(&prev, false).await?;
9684                }
9685            }
9686            self.send_attend_hired_worker(instance_id, true).await?;
9687            self.state.attending_worker_instance_id = Some(instance_id.to_string());
9688        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9689            self.send_attend_hired_worker(instance_id, false).await?;
9690            self.state.attending_worker_instance_id = None;
9691        }
9692        Ok(())
9693    }
9694
9695    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9696        let Some(id) = self.state.attending_worker_instance_id.take() else {
9697            return Ok(());
9698        };
9699        self.send_attend_hired_worker(&id, false).await
9700    }
9701
9702    async fn send_attend_hired_worker(
9703        &mut self,
9704        worker_instance_id: &str,
9705        attending: bool,
9706    ) -> anyhow::Result<()> {
9707        self.seq += 1;
9708        self.session
9709            .submit_intent(Intent::AttendHiredWorker {
9710                entity_id: self.state.entity_id,
9711                worker_instance_id: worker_instance_id.to_string(),
9712                attending,
9713                seq: self.seq,
9714            })
9715            .await?;
9716        self.state.intents_sent += 1;
9717        Ok(())
9718    }
9719
9720    pub fn workers_menu_move(&mut self, delta: i32) {
9721        let n = self.state.hired_workers.len();
9722        if n == 0 {
9723            return;
9724        }
9725        let idx = self.state.workers_menu_index as i32;
9726        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9727    }
9728
9729    pub fn toggle_workers_menu_compact(&mut self) {
9730        self.state.workers_menu_compact = !self.state.workers_menu_compact;
9731        let mut cfg = crate::client_config::ClientConfig::load();
9732        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9733    }
9734
9735    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9736        let Some(worker) = self
9737            .state
9738            .hired_workers
9739            .get(self.state.workers_menu_index)
9740            .cloned()
9741        else {
9742            anyhow::bail!("no worker selected");
9743        };
9744        self.seq += 1;
9745        self.session
9746            .submit_intent(Intent::DismissWorker {
9747                entity_id: self.state.entity_id,
9748                worker_instance_id: worker.instance_id.clone(),
9749                seq: self.seq,
9750            })
9751            .await?;
9752        self.state.intents_sent += 1;
9753        self.state
9754            .hired_workers
9755            .retain(|w| w.instance_id != worker.instance_id);
9756        if self.state.workers_menu_index >= self.state.hired_workers.len() {
9757            self.state.workers_menu_index = self
9758                .state
9759                .hired_workers
9760                .len()
9761                .saturating_sub(1);
9762        }
9763        self.state.push_log(format!("Dismissed {}", worker.label));
9764        Ok(())
9765    }
9766
9767    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9768        let Some(worker) = self
9769            .state
9770            .hired_workers
9771            .get(self.state.workers_menu_index)
9772            .cloned()
9773        else {
9774            anyhow::bail!("no worker selected");
9775        };
9776        let mode = match worker.mode {
9777            flatland_protocol::WorkerModeView::Companion => "job_loop",
9778            flatland_protocol::WorkerModeView::JobLoop => "idle",
9779            flatland_protocol::WorkerModeView::Idle => "companion",
9780        };
9781        self.seq += 1;
9782        self.session
9783            .submit_intent(Intent::SetWorkerMode {
9784                entity_id: self.state.entity_id,
9785                worker_instance_id: worker.instance_id,
9786                mode: mode.into(),
9787                seq: self.seq,
9788            })
9789            .await?;
9790        self.state.intents_sent += 1;
9791        Ok(())
9792    }
9793
9794    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9795        if self.state.hired_workers.is_empty() {
9796            return self.hire_worker_laborer().await;
9797        }
9798        self.workers_toggle_mode_selected().await
9799    }
9800
9801    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
9802    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
9803    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9804        let row = self
9805            .state
9806            .inventory_selected_row()
9807            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9808            .clone();
9809        if row.from != flatland_protocol::InventoryLocation::Root {
9810            anyhow::bail!("select a carried item to give");
9811        }
9812        let Some(instance_id) = row.stack.item_instance_id else {
9813            anyhow::bail!("that stack can't be given");
9814        };
9815        let options = self.nearby_worker_give_targets();
9816        if options.is_empty() {
9817            anyhow::bail!(
9818                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9819            );
9820        }
9821        let item_label = row
9822            .stack
9823            .display_name
9824            .as_deref()
9825            .unwrap_or(&row.stack.template_id)
9826            .to_string();
9827        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9828            item_instance_id: instance_id,
9829            item_label,
9830            quantity: None,
9831            options,
9832        });
9833        self.state.worker_give_target_picker_index = 0;
9834        self.state.show_worker_give_target_picker = true;
9835        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
9836        self.state.show_inventory_menu = false;
9837        Ok(())
9838    }
9839
9840    /// Hired workers within give/take range, nearest first.
9841    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9842        let (px, py, _) = self.state.player_position_with_z();
9843        let mut options: Vec<WorkerGiveTargetOption> = self
9844            .state
9845            .hired_workers
9846            .iter()
9847            .filter_map(|w| {
9848                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9849                if dist > WORKER_GIVE_RANGE_M {
9850                    return None;
9851                }
9852                Some(WorkerGiveTargetOption {
9853                    instance_id: w.instance_id.clone(),
9854                    label: w.label.clone(),
9855                    distance_m: dist,
9856                })
9857            })
9858            .collect();
9859        options.sort_by(|a, b| {
9860            a.distance_m
9861                .partial_cmp(&b.distance_m)
9862                .unwrap_or(std::cmp::Ordering::Equal)
9863        });
9864        options
9865    }
9866
9867    pub fn close_worker_give_target_picker(&mut self) {
9868        self.state.show_worker_give_target_picker = false;
9869        self.state.worker_give_target_picker = None;
9870        self.state.worker_give_target_picker_index = 0;
9871    }
9872
9873    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9874        let Some(picker) = &self.state.worker_give_target_picker else {
9875            return;
9876        };
9877        let n = picker.options.len();
9878        if n == 0 {
9879            return;
9880        }
9881        let idx = self.state.worker_give_target_picker_index as i32;
9882        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9883    }
9884
9885    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9886        let Some(picker) = self.state.worker_give_target_picker.clone() else {
9887            anyhow::bail!("give target picker not open");
9888        };
9889        let Some(opt) = picker
9890            .options
9891            .get(self.state.worker_give_target_picker_index)
9892            .cloned()
9893        else {
9894            anyhow::bail!("no worker selected");
9895        };
9896        let Some(worker) = self
9897            .state
9898            .hired_workers
9899            .iter()
9900            .find(|w| w.instance_id == opt.instance_id)
9901            .cloned()
9902        else {
9903            self.close_worker_give_target_picker();
9904            anyhow::bail!("worker no longer hired");
9905        };
9906        self.give_item_to_worker(
9907            &worker.instance_id,
9908            &worker.label,
9909            worker.x,
9910            worker.y,
9911            picker.item_instance_id,
9912            &picker.item_label,
9913            picker.quantity,
9914        )
9915        .await?;
9916        self.close_worker_give_target_picker();
9917        Ok(())
9918    }
9919
9920    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
9921    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9922        self.open_worker_give_target_picker()
9923    }
9924
9925    /// Open the workers-menu give picker for the selected worker (must be in range).
9926    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9927        let Some(worker) = self
9928            .state
9929            .hired_workers
9930            .get(self.state.workers_menu_index)
9931            .cloned()
9932        else {
9933            anyhow::bail!("select a hired worker first");
9934        };
9935        let (px, py, _) = self.state.player_position_with_z();
9936        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9937        if dist > WORKER_GIVE_RANGE_M {
9938            anyhow::bail!(
9939                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9940                worker.label
9941            );
9942        }
9943        let options = self.state.giveable_inventory_options();
9944        if options.is_empty() {
9945            anyhow::bail!("nothing in inventory to give");
9946        }
9947        self.state.worker_give_picker = Some(WorkerGivePicker {
9948            worker_instance_id: worker.instance_id,
9949            worker_label: worker.label,
9950            options,
9951        });
9952        self.state.worker_give_picker_index = 0;
9953        self.state.show_worker_give_picker = true;
9954        Ok(())
9955    }
9956
9957    pub fn close_worker_give_picker(&mut self) {
9958        self.state.show_worker_give_picker = false;
9959        self.state.worker_give_picker = None;
9960        self.state.worker_give_picker_index = 0;
9961    }
9962
9963    pub fn worker_give_picker_move(&mut self, delta: i32) {
9964        let Some(picker) = &self.state.worker_give_picker else {
9965            return;
9966        };
9967        let n = picker.options.len();
9968        if n == 0 {
9969            return;
9970        }
9971        let idx = self.state.worker_give_picker_index as i32;
9972        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9973    }
9974
9975    /// Confirm the selected row in the workers-menu give picker.
9976    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9977        let Some(picker) = self.state.worker_give_picker.clone() else {
9978            anyhow::bail!("give picker not open");
9979        };
9980        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9981            anyhow::bail!("no item selected");
9982        };
9983        let Some(worker) = self
9984            .state
9985            .hired_workers
9986            .iter()
9987            .find(|w| w.instance_id == picker.worker_instance_id)
9988            .cloned()
9989        else {
9990            self.close_worker_give_picker();
9991            anyhow::bail!("worker no longer hired");
9992        };
9993        self.give_item_to_worker(
9994            &worker.instance_id,
9995            &worker.label,
9996            worker.x,
9997            worker.y,
9998            opt.item_instance_id,
9999            &opt.label,
10000            None,
10001        )
10002        .await?;
10003        // Refresh options (stack may be gone / reduced) or close when empty.
10004        let options = self.state.giveable_inventory_options();
10005        if options.is_empty() {
10006            self.close_worker_give_picker();
10007        } else {
10008            self.state.worker_give_picker = Some(WorkerGivePicker {
10009                worker_instance_id: picker.worker_instance_id,
10010                worker_label: picker.worker_label,
10011                options,
10012            });
10013            if self.state.worker_give_picker_index
10014                >= self
10015                    .state
10016                    .worker_give_picker
10017                    .as_ref()
10018                    .map(|p| p.options.len())
10019                    .unwrap_or(0)
10020            {
10021                self.state.worker_give_picker_index = self
10022                    .state
10023                    .worker_give_picker
10024                    .as_ref()
10025                    .map(|p| p.options.len().saturating_sub(1))
10026                    .unwrap_or(0);
10027            }
10028        }
10029        Ok(())
10030    }
10031
10032    /// Open the workers-menu teach picker for the selected worker (must be in range).
10033    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10034        let Some(worker) = self
10035            .state
10036            .hired_workers
10037            .get(self.state.workers_menu_index)
10038            .cloned()
10039        else {
10040            anyhow::bail!("select a hired worker first");
10041        };
10042        let (px, py, _) = self.state.player_position_with_z();
10043        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10044        if dist > WORKER_GIVE_RANGE_M {
10045            anyhow::bail!(
10046                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10047                worker.label
10048            );
10049        }
10050        let options = self.state.teachable_blueprint_options(&worker);
10051        if options.is_empty() {
10052            anyhow::bail!("no recipes you know that {} still needs", worker.label);
10053        }
10054        self.state.worker_teach_picker = Some(WorkerTeachPicker {
10055            worker_instance_id: worker.instance_id,
10056            worker_label: worker.label,
10057            worker_level: worker.level,
10058            options,
10059        });
10060        self.state.worker_teach_picker_index = 0;
10061        self.state.show_worker_teach_picker = true;
10062        Ok(())
10063    }
10064
10065    pub fn close_worker_teach_picker(&mut self) {
10066        self.state.show_worker_teach_picker = false;
10067        self.state.worker_teach_picker = None;
10068        self.state.worker_teach_picker_index = 0;
10069    }
10070
10071    pub fn worker_teach_picker_move(&mut self, delta: i32) {
10072        let Some(picker) = &self.state.worker_teach_picker else {
10073            return;
10074        };
10075        let n = picker.options.len();
10076        if n == 0 {
10077            return;
10078        }
10079        let idx = self.state.worker_teach_picker_index as i32;
10080        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10081    }
10082
10083    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10084        let Some(picker) = self.state.worker_teach_picker.clone() else {
10085            anyhow::bail!("teach picker not open");
10086        };
10087        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
10088            anyhow::bail!("nothing selected");
10089        };
10090        if !opt.level_ok {
10091            anyhow::bail!(
10092                "{} needs level {} (is level {})",
10093                picker.worker_label,
10094                opt.min_level,
10095                opt.worker_level
10096            );
10097        }
10098        if !opt.can_afford {
10099            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10100        }
10101        let Some(worker) = self
10102            .state
10103            .hired_workers
10104            .iter()
10105            .find(|w| w.instance_id == picker.worker_instance_id)
10106            .cloned()
10107        else {
10108            anyhow::bail!("worker gone");
10109        };
10110        let (px, py, _) = self.state.player_position_with_z();
10111        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10112        if dist > WORKER_GIVE_RANGE_M {
10113            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10114        }
10115        self.seq += 1;
10116        self.session
10117            .submit_intent(Intent::TeachWorkerBlueprint {
10118                entity_id: self.state.entity_id,
10119                worker_instance_id: picker.worker_instance_id.clone(),
10120                blueprint_id: opt.blueprint_id.clone(),
10121                seq: self.seq,
10122            })
10123            .await?;
10124        self.state.intents_sent += 1;
10125        self.state.push_log(format!(
10126            "Teaching {} to {} ({} cp)",
10127            opt.label, picker.worker_label, opt.cost_copper
10128        ));
10129        self.close_worker_teach_picker();
10130        Ok(())
10131    }
10132
10133    async fn give_item_to_worker(
10134        &mut self,
10135        worker_instance_id: &str,
10136        worker_label: &str,
10137        worker_x: f32,
10138        worker_y: f32,
10139        item_instance_id: uuid::Uuid,
10140        item_label: &str,
10141        quantity: Option<u32>,
10142    ) -> anyhow::Result<()> {
10143        let (px, py, _) = self.state.player_position_with_z();
10144        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10145        if dist > WORKER_GIVE_RANGE_M {
10146            anyhow::bail!("worker {worker_label} too far — stand next to them");
10147        }
10148        self.seq += 1;
10149        self.session
10150            .submit_intent(Intent::GiveWorkerItem {
10151                entity_id: self.state.entity_id,
10152                worker_instance_id: worker_instance_id.to_string(),
10153                item_instance_id,
10154                quantity,
10155                seq: self.seq,
10156            })
10157            .await?;
10158        self.state.intents_sent += 1;
10159        self.state
10160            .push_log(format!("Gave {item_label} to {worker_label}"));
10161        Ok(())
10162    }
10163
10164    /// Open the workers-menu take picker for the selected worker (must be in range).
10165    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10166        let Some(worker) = self
10167            .state
10168            .hired_workers
10169            .get(self.state.workers_menu_index)
10170            .cloned()
10171        else {
10172            anyhow::bail!("select a hired worker first");
10173        };
10174        let (px, py, _) = self.state.player_position_with_z();
10175        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10176        if dist > WORKER_GIVE_RANGE_M {
10177            anyhow::bail!(
10178                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10179                worker.label
10180            );
10181        }
10182        let options = Self::worker_inventory_options(&worker);
10183        if options.is_empty() {
10184            anyhow::bail!("{} isn't carrying anything", worker.label);
10185        }
10186        let initial_qty = options
10187            .first()
10188            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10189            .unwrap_or(1);
10190        self.state.worker_take_picker = Some(WorkerTakePicker {
10191            worker_instance_id: worker.instance_id,
10192            worker_label: worker.label,
10193            options,
10194            quantity: initial_qty,
10195        });
10196        self.state.worker_take_picker_index = 0;
10197        self.state.show_worker_take_picker = true;
10198        Ok(())
10199    }
10200
10201    fn worker_inventory_options(
10202        worker: &flatland_protocol::HiredWorkerView,
10203    ) -> Vec<WorkerGiveOption> {
10204        worker
10205            .inventory
10206            .iter()
10207            .filter_map(|stack| {
10208                let item_instance_id = stack.item_instance_id?;
10209                let label = stack
10210                    .display_name
10211                    .clone()
10212                    .unwrap_or_else(|| stack.template_id.clone());
10213                let label = if stack.quantity > 1 {
10214                    format!("{label} ×{}", stack.quantity)
10215                } else {
10216                    label
10217                };
10218                Some(WorkerGiveOption {
10219                    item_instance_id,
10220                    label,
10221                    quantity: stack.quantity,
10222                    template_id: stack.template_id.clone(),
10223                })
10224            })
10225            .collect()
10226    }
10227
10228    pub fn close_worker_take_picker(&mut self) {
10229        self.state.show_worker_take_picker = false;
10230        self.state.worker_take_picker = None;
10231        self.state.worker_take_picker_index = 0;
10232    }
10233
10234    pub fn worker_take_picker_move(&mut self, delta: i32) {
10235        let Some(picker) = &self.state.worker_take_picker else {
10236            return;
10237        };
10238        let n = picker.options.len();
10239        if n == 0 {
10240            return;
10241        }
10242        let idx = self.state.worker_take_picker_index as i32;
10243        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10244        self.clamp_worker_take_quantity();
10245    }
10246
10247    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10248        let Some(picker) = &mut self.state.worker_take_picker else {
10249            return;
10250        };
10251        let max = picker
10252            .options
10253            .get(self.state.worker_take_picker_index)
10254            .map(|o| o.quantity.max(1))
10255            .unwrap_or(1);
10256        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10257        picker.quantity = next as u32;
10258    }
10259
10260    pub fn worker_take_picker_set_quantity_max(&mut self) {
10261        let Some(picker) = &mut self.state.worker_take_picker else {
10262            return;
10263        };
10264        let max = picker
10265            .options
10266            .get(self.state.worker_take_picker_index)
10267            .map(|o| o.quantity.max(1))
10268            .unwrap_or(1);
10269        picker.quantity = max;
10270    }
10271
10272    pub fn worker_take_picker_set_quantity_min(&mut self) {
10273        let Some(picker) = &mut self.state.worker_take_picker else {
10274            return;
10275        };
10276        picker.quantity = 1;
10277        self.clamp_worker_take_quantity();
10278    }
10279
10280    fn clamp_worker_take_quantity(&mut self) {
10281        let Some(picker) = &mut self.state.worker_take_picker else {
10282            return;
10283        };
10284        let max = picker
10285            .options
10286            .get(self.state.worker_take_picker_index)
10287            .map(|o| o.quantity.max(1))
10288            .unwrap_or(1);
10289        if picker.quantity == 0 || picker.quantity > max {
10290            picker.quantity = if max > 1 { 1 } else { max };
10291        }
10292    }
10293
10294    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10295        let Some(picker) = self.state.worker_take_picker.clone() else {
10296            anyhow::bail!("take picker not open");
10297        };
10298        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
10299            anyhow::bail!("no item selected");
10300        };
10301        let Some(worker) = self
10302            .state
10303            .hired_workers
10304            .iter()
10305            .find(|w| w.instance_id == picker.worker_instance_id)
10306            .cloned()
10307        else {
10308            self.close_worker_take_picker();
10309            anyhow::bail!("worker no longer hired");
10310        };
10311        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10312        let intent_qty = if qty >= opt.quantity {
10313            None
10314        } else {
10315            Some(qty)
10316        };
10317        self.take_item_from_worker(
10318            &worker.instance_id,
10319            &worker.label,
10320            worker.x,
10321            worker.y,
10322            opt.item_instance_id,
10323            &opt.label,
10324            intent_qty,
10325        )
10326        .await?;
10327        // Leave the picker open — server Interaction / hired-workers sync refresh
10328        // options. Do not optimistic-strip stacks (rejects used to look like success).
10329        Ok(())
10330    }
10331
10332    async fn take_item_from_worker(
10333        &mut self,
10334        worker_instance_id: &str,
10335        worker_label: &str,
10336        worker_x: f32,
10337        worker_y: f32,
10338        item_instance_id: uuid::Uuid,
10339        item_label: &str,
10340        quantity: Option<u32>,
10341    ) -> anyhow::Result<()> {
10342        let (px, py, _) = self.state.player_position_with_z();
10343        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10344        if dist > WORKER_GIVE_RANGE_M {
10345            anyhow::bail!("worker {worker_label} too far — stand next to them");
10346        }
10347        self.seq += 1;
10348        self.session
10349            .submit_intent(Intent::TakeWorkerItem {
10350                entity_id: self.state.entity_id,
10351                worker_instance_id: worker_instance_id.to_string(),
10352                item_instance_id,
10353                quantity,
10354                seq: self.seq,
10355            })
10356            .await?;
10357        self.state.intents_sent += 1;
10358        let qty_note = quantity
10359            .map(|q| format!(" ×{q}"))
10360            .unwrap_or_default();
10361        self.state
10362            .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
10363        Ok(())
10364    }
10365
10366    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
10367        if !self.state.has_worker_lodging() {
10368            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
10369        }
10370        self.seq += 1;
10371        self.session
10372            .submit_intent(Intent::HireWorker {
10373                entity_id: self.state.entity_id,
10374                def_id: "worker_laborer".into(),
10375                wage_copper_per_interval: 8,
10376                lodging_container_id: None,
10377                job_yaml: None,
10378                seq: self.seq,
10379            })
10380            .await?;
10381        self.state.intents_sent += 1;
10382        Ok(())
10383    }
10384
10385    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
10386        let Some(worker) = self
10387            .state
10388            .hired_workers
10389            .get(self.state.workers_menu_index)
10390            .cloned()
10391        else {
10392            anyhow::bail!("select a hired worker first");
10393        };
10394        let lodging = worker.lodging_container_id.clone().or_else(|| {
10395            crate::worker_route_editor::owned_lodging_container_ids(
10396                &self.state.placed_containers,
10397                self.state.character_id,
10398            )
10399            .into_iter()
10400            .next()
10401            .map(|(id, _)| id)
10402        });
10403        let label = worker.label.clone();
10404        let editor = if let Some(route) = &worker.route {
10405            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
10406                worker.instance_id,
10407                worker.label,
10408                route,
10409                lodging,
10410            )
10411        } else {
10412            crate::worker_route_editor::WorkerRouteEditorState::new(
10413                worker.instance_id,
10414                worker.label,
10415                lodging,
10416            )
10417        };
10418        self.state.worker_route_editor = Some(editor);
10419        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10420            if let Some(collapsed) =
10421                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
10422            {
10423                ed.panel_collapsed = collapsed;
10424            }
10425        }
10426        self.state.show_workers_menu = false;
10427        self.state.push_log(format!(
10428            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
10429        ));
10430        Ok(())
10431    }
10432
10433    pub fn close_worker_route_editor(&mut self) {
10434        self.state.worker_route_editor = None;
10435    }
10436
10437    pub fn worker_route_editor_toggle_panel(&mut self) {
10438        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10439            ed.toggle_panel_collapsed();
10440            let collapsed = ed.panel_collapsed;
10441            let mut cfg = crate::client_config::ClientConfig::load();
10442            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
10443        }
10444    }
10445
10446    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
10447        let n = {
10448            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10449                return;
10450            };
10451            ed.append_waypoint(x, y, z);
10452            ed.stop_count()
10453        };
10454        self.state
10455            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
10456    }
10457
10458    // ---- picker candidate snapshots ----------------------------------
10459
10460    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
10461        let (px, py, _) = self.state.player_position_with_z();
10462        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
10463            &self.state.placed_containers,
10464            &self.state.buildings,
10465            self.state.character_id,
10466            px,
10467            py,
10468            &self.state.hired_workers,
10469        )
10470    }
10471
10472    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
10473        self.state.route_editor_node_candidates()
10474    }
10475
10476    fn re_open_harvest_picker(
10477        &mut self,
10478        index: usize,
10479        picked: std::collections::BTreeSet<String>,
10480    ) {
10481        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
10482        let nodes = self.state.route_editor_node_candidates();
10483        let index = if nodes.is_empty() {
10484            ROUTE_PICKER_DONE_ROW
10485        } else {
10486            index.max(1).min(nodes.len())
10487        };
10488        self.re_open_sheet(S::HarvestPicker {
10489            index,
10490            picked,
10491            nodes,
10492        });
10493    }
10494
10495    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10496        let (px, py, _) = self.state.player_position_with_z();
10497        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10498    }
10499
10500    fn re_template_candidates(&self) -> Vec<String> {
10501        let mut extra = Vec::new();
10502        if let Some(ed) = self.state.worker_route_editor.as_ref() {
10503            for stop in &ed.stops {
10504                match stop {
10505                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
10506                        filter: Some(filter),
10507                        ..
10508                    } => extra.extend(filter.iter().cloned()),
10509                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10510                        extra.push(template.clone());
10511                    }
10512                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10513                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10514                            extra.push(bp.output.clone());
10515                            for input in &bp.inputs {
10516                                extra.push(input.template_id.clone());
10517                            }
10518                        }
10519                    }
10520                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10521                        for it in items {
10522                            extra.push(it.template.clone());
10523                        }
10524                    }
10525                    _ => {}
10526                }
10527            }
10528            // Worker-known recipe outputs even if the player hasn't learned them yet.
10529            if let Some(worker) = self
10530                .state
10531                .hired_workers
10532                .iter()
10533                .find(|w| w.instance_id == ed.worker_instance_id)
10534            {
10535                for recipe in &worker.known_blueprint_ids {
10536                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10537                        extra.push(bp.output.clone());
10538                    }
10539                }
10540            }
10541        }
10542        crate::worker_route_editor::route_item_template_candidates(
10543            &self.state.placed_containers,
10544            self.state.character_id,
10545            &self.state.inventory,
10546            &self.state.blueprints,
10547            &self.state.resource_nodes,
10548            &extra,
10549        )
10550    }
10551
10552    fn re_blueprint_ids(&self) -> Vec<String> {
10553        let worker_known: Option<&[String]> = self
10554            .state
10555            .worker_route_editor
10556            .as_ref()
10557            .and_then(|ed| {
10558                self.state
10559                    .hired_workers
10560                    .iter()
10561                    .find(|w| w.instance_id == ed.worker_instance_id)
10562            })
10563            .map(|w| w.known_blueprint_ids.as_slice());
10564        crate::worker_route_editor::worker_craft_blueprint_ids(
10565            &self.state.blueprints,
10566            worker_known,
10567        )
10568    }
10569
10570    fn re_bed_candidates(&self) -> Vec<(String, String)> {
10571        crate::worker_route_editor::owned_lodging_container_ids(
10572            &self.state.placed_containers,
10573            self.state.character_id,
10574        )
10575    }
10576
10577    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10578        self.state
10579            .placed_containers
10580            .iter()
10581            .find(|c| c.id == container_id)
10582            .map(|c| c.contents.clone())
10583            .unwrap_or_default()
10584    }
10585
10586    // ---- sheet navigation (`plans/33`) --------------------------------
10587
10588    fn re_sheet_supports_filter(&self) -> bool {
10589        use crate::worker_route_editor::RouteEditorSheet as S;
10590        self.state
10591            .worker_route_editor
10592            .as_ref()
10593            .is_some_and(|ed| {
10594                matches!(
10595                    ed.sheet,
10596                    S::HarvestPicker { .. }
10597                        | S::SellItem { .. }
10598                        | S::DepositFilter { .. }
10599                        | S::WithdrawItems { .. }
10600                        | S::WithdrawContainers { .. }
10601                        | S::DepositContainers { .. }
10602                        | S::SellNpcs { .. }
10603                        | S::CraftBlueprint { .. }
10604                        | S::BedPicker { .. }
10605                )
10606            })
10607    }
10608
10609    /// Whether a sheet row is shown under the current `/` filter.
10610    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10611        use crate::worker_route_editor::{
10612            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10613            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10614        };
10615        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10616            return false;
10617        };
10618        let filter = &ed.sheet_filter;
10619        match &ed.sheet {
10620            S::HarvestPicker { nodes, .. } => {
10621                harvest_picker_row_matches(nodes, row, filter)
10622            }
10623            S::SellItem { templates, .. } => {
10624                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10625                    return true;
10626                }
10627                let slot = row.saturating_sub(2);
10628                templates.get(slot).is_some_and(|t| {
10629                    let label = self.state.template_display_name(t);
10630                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10631                })
10632            }
10633            S::DepositFilter { rows, .. } => {
10634                if row >= rows.len() {
10635                    return true;
10636                }
10637                rows.get(row).is_some_and(|(t, _)| {
10638                    let label = self.state.template_display_name(t);
10639                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10640                })
10641            }
10642            S::WithdrawItems { lines, .. } => {
10643                if row >= lines.len() {
10644                    return true;
10645                }
10646                lines.get(row).is_some_and(|l| {
10647                    let label = self.state.template_display_name(&l.template);
10648                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10649                })
10650            }
10651            S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10652                .re_container_candidates()
10653                .get(row)
10654                .is_some_and(|c| {
10655                    list_filter_row_matches(
10656                        filter,
10657                        Some(c.dist),
10658                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10659                    )
10660                }),
10661            S::SellNpcs { .. } => {
10662                if row == 0 {
10663                    return true;
10664                }
10665                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10666                    list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10667                })
10668            }
10669            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10670                let label = self
10671                    .state
10672                    .blueprints
10673                    .iter()
10674                    .find(|b| &b.id == id)
10675                    .map(|b| {
10676                        if b.label.is_empty() {
10677                            id.as_str()
10678                        } else {
10679                            b.label.as_str()
10680                        }
10681                    })
10682                    .unwrap_or(id.as_str());
10683                list_filter_row_matches(filter, None, &[id.as_str(), label])
10684            }),
10685            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10686                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10687            }),
10688            _ => true,
10689        }
10690    }
10691
10692    fn re_sheet_clamp_index(&mut self) {
10693        let count = self.re_sheet_row_count();
10694        if count == 0 {
10695            return;
10696        }
10697        let cur = self.re_sheet_index();
10698        if self.re_sheet_row_visible(cur) {
10699            return;
10700        }
10701        for offset in 1..count {
10702            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10703                self.re_sheet_set_index(cur + offset);
10704                return;
10705            }
10706            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10707                self.re_sheet_set_index(cur - offset);
10708                return;
10709            }
10710        }
10711    }
10712
10713    fn re_sheet_set_index(&mut self, index: usize) {
10714        use crate::worker_route_editor::RouteEditorSheet as S;
10715        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10716            return;
10717        };
10718        match &mut ed.sheet {
10719            S::AddMenu { index: slot }
10720            | S::WaypointMenu { index: slot }
10721            | S::HarvestPicker { index: slot, .. }
10722            | S::WithdrawContainers { index: slot }
10723            | S::DepositContainers { index: slot }
10724            | S::SellNpcs { index: slot }
10725            | S::CraftBlueprint { index: slot }
10726            | S::BedPicker { index: slot }
10727            | S::FarmPlotPicker { index: slot, .. }
10728            | S::FarmPlantSeed { index: slot, .. }
10729            | S::WithdrawItems { index: slot, .. }
10730            | S::DepositFilter { index: slot, .. }
10731            | S::SellItem { index: slot, .. } => *slot = index,
10732            _ => {}
10733        }
10734    }
10735
10736    pub fn re_focus_sheet_filter(&mut self) {
10737        if !self.re_sheet_supports_filter() {
10738            return;
10739        }
10740        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10741            ed.sheet_filter_focused = true;
10742        }
10743    }
10744
10745    pub fn re_blur_sheet_filter_keep_text(&mut self) {
10746        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10747            return;
10748        };
10749        if !ed.sheet_filter_focused {
10750            return;
10751        }
10752        ed.sheet_filter_focused = false;
10753        self.re_sheet_clamp_index();
10754    }
10755
10756    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10757        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10758            return false;
10759        };
10760        if ed.sheet_filter_focused {
10761            ed.sheet_filter_focused = false;
10762            self.re_sheet_clamp_index();
10763            return true;
10764        }
10765        if !ed.sheet_filter.is_empty() {
10766            ed.sheet_filter.clear();
10767            self.re_sheet_clamp_index();
10768            return true;
10769        }
10770        false
10771    }
10772
10773    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10774        if ch.is_control() {
10775            return;
10776        }
10777        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10778            return;
10779        };
10780        if !ed.sheet_filter_focused {
10781            return;
10782        }
10783        ed.sheet_filter.push(ch);
10784        self.re_sheet_set_index(0);
10785        self.re_sheet_clamp_index();
10786    }
10787
10788    pub fn re_sheet_filter_backspace(&mut self) {
10789        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10790            return;
10791        };
10792        if !ed.sheet_filter_focused {
10793            return;
10794        }
10795        ed.sheet_filter.pop();
10796        self.re_sheet_set_index(0);
10797        self.re_sheet_clamp_index();
10798    }
10799
10800    /// Row count of the current sheet (for cursor wrapping).
10801    pub fn re_sheet_row_count(&self) -> usize {
10802        use crate::worker_route_editor::{
10803            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10804        };
10805        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10806            return 0;
10807        };
10808        match &ed.sheet {
10809            S::Stops => ed.stops.len(),
10810            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10811            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10812            S::WaypointMapPick => 0,
10813            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10814            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10815                self.re_container_candidates().len()
10816            }
10817            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
10818            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
10819            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
10820            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
10821            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10822            S::WaitEntry { .. } => 1,
10823            S::BedPicker { .. } => self.re_bed_candidates().len(),
10824            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10825            S::FarmPlantSeed { seeds, .. } => seeds.len(),
10826        }
10827    }
10828
10829    /// Current sheet cursor index (0 for sheets without one).
10830    pub fn re_sheet_index(&self) -> usize {
10831        use crate::worker_route_editor::RouteEditorSheet as S;
10832        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10833            return 0;
10834        };
10835        match &ed.sheet {
10836            S::AddMenu { index }
10837            | S::WaypointMenu { index }
10838            | S::HarvestPicker { index, .. }
10839            | S::WithdrawContainers { index }
10840            | S::DepositContainers { index }
10841            | S::SellNpcs { index }
10842            | S::CraftBlueprint { index }
10843            | S::BedPicker { index }
10844            | S::FarmPlotPicker { index, .. }
10845            | S::FarmPlantSeed { index, .. }
10846            | S::WithdrawItems { index, .. }
10847            | S::DepositFilter { index, .. }
10848            | S::SellItem { index, .. } => *index,
10849            _ => 0,
10850        }
10851    }
10852
10853    /// Move the current sheet's cursor, wrapping within its rows.
10854    pub fn re_sheet_move(&mut self, delta: i32) {
10855        let count = self.re_sheet_row_count();
10856        if count == 0 {
10857            return;
10858        }
10859        let cur = self.re_sheet_index();
10860        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10861        self.re_sheet_set_index(next);
10862    }
10863
10864    pub fn re_sheet_page(&mut self, pages: i32) {
10865        let count = self.re_sheet_row_count();
10866        if count == 0 {
10867            return;
10868        }
10869        let cur = self.re_sheet_index();
10870        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10871        self.re_sheet_set_index(next);
10872    }
10873
10874    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
10875    pub fn re_sheet_adjust(&mut self, delta: i32) {
10876        use crate::worker_route_editor::RouteEditorSheet as S;
10877        let index = self.re_sheet_index();
10878        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10879            return;
10880        };
10881        match &mut ed.sheet {
10882            S::WithdrawItems { lines, .. } => {
10883                if let Some(line) = lines.get_mut(index) {
10884                    line.adjust_qty(delta);
10885                }
10886            }
10887            S::WaitEntry { ticks } => {
10888                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10889            }
10890            _ => {}
10891        }
10892    }
10893
10894    pub fn re_sheet_back(&mut self) {
10895        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10896            return;
10897        };
10898        use crate::worker_route_editor::RouteEditorSheet as S;
10899        let was_editing = ed.editing_index.is_some();
10900        let from_top_picker = matches!(
10901            ed.sheet,
10902            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10903        );
10904        ed.sheet_back();
10905        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10906            // Chest retarget commits on pick; Esc here just ends the edit sheet.
10907            self.state
10908                .push_log("Route: left edit sheet — press s to save current stops".to_string());
10909        }
10910    }
10911
10912    /// True when the editor is at its root sheet (Esc should close it).
10913    pub fn re_at_root_sheet(&self) -> bool {
10914        self.state
10915            .worker_route_editor
10916            .as_ref()
10917            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10918    }
10919
10920    pub fn re_open_add_menu(&mut self) {
10921        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10922            ed.open_add_menu();
10923        }
10924    }
10925
10926    pub fn re_open_bed_picker(&mut self) {
10927        let beds = self.re_bed_candidates();
10928        if beds.is_empty() {
10929            self.state
10930                .push_log("Route: place a camp bed first".to_string());
10931            return;
10932        }
10933        let current = self
10934            .state
10935            .worker_route_editor
10936            .as_ref()
10937            .and_then(|ed| ed.lodging_container_id.clone());
10938        let index = current
10939            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10940            .unwrap_or(0);
10941        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10942    }
10943
10944    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10945        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10946            ed.open_sheet(sheet);
10947        }
10948    }
10949
10950    /// Confirm a sheet's stop and log the outcome.
10951    fn re_confirm_stop(
10952        &mut self,
10953        stop: crate::worker_route_editor::WorkerRouteStop,
10954        what: String,
10955    ) {
10956        let appended = self
10957            .state
10958            .worker_route_editor
10959            .as_mut()
10960            .is_some_and(|ed| ed.confirm_stop(stop));
10961        if appended {
10962            self.state.push_log(format!("Route: + {what}"));
10963        } else {
10964            self.state
10965                .push_log(format!("Route: {what} already in route — selected it"));
10966        }
10967    }
10968
10969    fn re_open_withdraw_items(&mut self, container_id: String) {
10970        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10971        let contents = self.re_container_contents(&container_id);
10972        // When editing, keep the stop's item picks even if the player retargets
10973        // to a different (possibly empty) chest — otherwise Done has nothing to
10974        // confirm and the edit appears to "revert".
10975        let existing = self
10976            .state
10977            .worker_route_editor
10978            .as_ref()
10979            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10980            .and_then(|stop| match stop {
10981                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10982                _ => None,
10983            })
10984            .unwrap_or_default();
10985        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10986        // Commit the new chest immediately while editing so Esc-before-Done still
10987        // keeps the retarget (items update only when Done is pressed).
10988        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10989            let _ = ed.retarget_withdraw_container(container_id.clone());
10990        }
10991        self.re_open_sheet(S::WithdrawItems {
10992            container_id,
10993            lines,
10994            index: 0,
10995        });
10996    }
10997
10998    fn re_withdraw_items_activate(&mut self, index: usize) {
10999        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11000        enum Outcome {
11001            Cycled,
11002            Confirmed(String),
11003            Empty,
11004        }
11005        let outcome = {
11006            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11007                return;
11008            };
11009            let S::WithdrawItems {
11010                container_id,
11011                lines,
11012                index: sheet_index,
11013            } = &mut ed.sheet
11014            else {
11015                return;
11016            };
11017            *sheet_index = index;
11018            if index < lines.len() {
11019                lines[index].cycle();
11020                Outcome::Cycled
11021            } else {
11022                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11023                if items.is_empty() {
11024                    Outcome::Empty
11025                } else {
11026                    let stop = WorkerRouteStop::WithdrawFrom {
11027                        container_id: container_id.clone(),
11028                        items,
11029                    };
11030                    let summary = stop.summary();
11031                    ed.confirm_stop(stop);
11032                    Outcome::Confirmed(summary)
11033                }
11034            }
11035        };
11036        match outcome {
11037            Outcome::Cycled => {}
11038            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11039            Outcome::Empty => self
11040                .state
11041                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
11042        }
11043    }
11044
11045    fn re_open_deposit_filter(&mut self, container_id: String) {
11046        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11047        // Preserve filter when retargeting the deposit chest while editing.
11048        let existing_filter = self
11049            .state
11050            .worker_route_editor
11051            .as_ref()
11052            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11053            .and_then(|stop| match stop {
11054                WorkerRouteStop::DepositAt { filter, .. } => {
11055                    Some(filter.clone().unwrap_or_default())
11056                }
11057                _ => None,
11058            });
11059        let mut candidates = self.re_template_candidates();
11060        if let Some(ref chosen) = existing_filter {
11061            for t in chosen {
11062                if !candidates.iter().any(|c| c == t) {
11063                    candidates.push(t.clone());
11064                }
11065            }
11066            candidates.sort();
11067            candidates.dedup();
11068        }
11069        let rows: Vec<(String, bool)> = match existing_filter {
11070            Some(chosen) => candidates
11071                .iter()
11072                .map(|t| (t.clone(), chosen.contains(t)))
11073                .collect(),
11074            None => candidates.into_iter().map(|t| (t, false)).collect(),
11075        };
11076        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11077            let _ = ed.retarget_deposit_container(container_id.clone());
11078        }
11079        self.re_open_sheet(S::DepositFilter {
11080            container_id,
11081            rows,
11082            index: 0,
11083        });
11084    }
11085
11086    fn re_deposit_filter_activate(&mut self, index: usize) {
11087        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11088        let mut confirmed: Option<String> = None;
11089        {
11090            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11091                return;
11092            };
11093            let S::DepositFilter {
11094                container_id,
11095                rows,
11096                index: sheet_index,
11097            } = &mut ed.sheet
11098            else {
11099                return;
11100            };
11101            *sheet_index = index;
11102            if index < rows.len() {
11103                rows[index].1 = !rows[index].1;
11104            } else {
11105                // "Done" row.
11106                let chosen: Vec<String> = rows
11107                    .iter()
11108                    .filter(|(_, on)| *on)
11109                    .map(|(t, _)| t.clone())
11110                    .collect();
11111                let filter = if chosen.is_empty() { None } else { Some(chosen) };
11112                let stop = WorkerRouteStop::DepositAt {
11113                    container_id: container_id.clone(),
11114                    filter,
11115                };
11116                confirmed = Some(stop.summary());
11117                ed.confirm_stop(stop);
11118            }
11119        }
11120        if let Some(what) = confirmed {
11121            self.state.push_log(format!("Route: + {what}"));
11122        }
11123    }
11124
11125    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11126        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11127        let templates = self.re_template_candidates();
11128        if templates.is_empty() {
11129            self.state.push_log(
11130                "Route: no item templates available — learn a craft recipe or place a harvest node first"
11131                    .to_string(),
11132            );
11133            return;
11134        }
11135        // Prefill when editing an existing sell stop.
11136        let (pre_npc, pre_template, pre_all) = self
11137            .state
11138            .worker_route_editor
11139            .as_ref()
11140            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11141            .and_then(|stop| match stop {
11142                WorkerRouteStop::TradeWith {
11143                    npc_id,
11144                    template,
11145                    sell_all,
11146                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11147                _ => None,
11148            })
11149            .unwrap_or((None, None, true));
11150        let npc_id = npc_id.or(pre_npc);
11151        let mut picked = std::collections::BTreeSet::new();
11152        if let Some(t) = pre_template {
11153            picked.insert(t);
11154        }
11155        self.re_open_sheet(S::SellItem {
11156            npc_id,
11157            templates,
11158            index: if picked.is_empty() {
11159                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11160            } else {
11161                2
11162            },
11163            sell_all: pre_all,
11164            picked,
11165        });
11166    }
11167
11168    fn re_sell_item_activate(&mut self, index: usize) {
11169        use crate::worker_route_editor::{
11170            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11171        };
11172        let mut batch_log: Option<String> = None;
11173        {
11174            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11175                return;
11176            };
11177            let S::SellItem {
11178                npc_id,
11179                templates,
11180                index: sheet_index,
11181                sell_all,
11182                picked,
11183            } = &mut ed.sheet
11184            else {
11185                return;
11186            };
11187            *sheet_index = index;
11188            if index == ROUTE_PICKER_DONE_ROW {
11189                if picked.is_empty() {
11190                    batch_log = Some(
11191                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
11192                    );
11193                } else {
11194                    let picks: Vec<String> = picked.iter().cloned().collect();
11195                    let npc = npc_id.clone();
11196                    let all = *sell_all;
11197                    let added = ed.confirm_trade_picks(npc, &picks, all);
11198                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
11199                }
11200            } else if index == SELL_ITEM_TOGGLE_ROW {
11201                *sell_all = !*sell_all;
11202            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11203                if picked.contains(template) {
11204                    picked.remove(template);
11205                } else {
11206                    picked.insert(template.clone());
11207                }
11208            }
11209        }
11210        if let Some(msg) = batch_log {
11211            self.state.push_log(msg);
11212        }
11213    }
11214
11215    /// Enter on the stop list: open the selected stop's sheet, prefilled.
11216    pub fn re_edit_selected_stop(&mut self) {
11217        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11218        let Some(stop) = self
11219            .state
11220            .worker_route_editor
11221            .as_ref()
11222            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11223        else {
11224            self.state
11225                .push_log("Route: no stop selected — press a to add one".to_string());
11226            return;
11227        };
11228        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11229            ed.begin_edit_selected();
11230        }
11231        match stop {
11232            WorkerRouteStop::Waypoint { .. } => {
11233                self.re_open_sheet(S::WaypointMenu { index: 0 });
11234            }
11235            WorkerRouteStop::HarvestNode { node_id } => {
11236                let nodes = self.state.route_editor_node_candidates();
11237                if nodes.is_empty() {
11238                    self.re_cancel_edit();
11239                    self.state
11240                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
11241                } else {
11242                    let mut picked = std::collections::BTreeSet::new();
11243                    picked.insert(node_id.clone());
11244                    let index = nodes
11245                        .iter()
11246                        .position(|n| n.id == node_id)
11247                        .map(|i| i + 1)
11248                        .unwrap_or(1);
11249                    self.re_open_harvest_picker(index, picked);
11250                }
11251            }
11252            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11253                // Open the container picker so the player can retarget storage
11254                // (previously jumped straight to items on the same chest).
11255                let containers = self.re_container_candidates();
11256                if containers.is_empty() {
11257                    self.re_cancel_edit();
11258                    self.state
11259                        .push_log("Route: place a storage chest first".to_string());
11260                } else {
11261                    let index = containers
11262                        .iter()
11263                        .position(|c| c.id == container_id)
11264                        .unwrap_or(0);
11265                    self.re_open_sheet(S::WithdrawContainers { index });
11266                }
11267            }
11268            WorkerRouteStop::DepositAt { container_id, .. } => {
11269                let containers = self.re_container_candidates();
11270                if containers.is_empty() {
11271                    self.re_cancel_edit();
11272                    self.state
11273                        .push_log("Route: place a storage chest first".to_string());
11274                } else {
11275                    let index = containers
11276                        .iter()
11277                        .position(|c| c.id == container_id)
11278                        .unwrap_or(0);
11279                    self.re_open_sheet(S::DepositContainers { index });
11280                }
11281            }
11282            WorkerRouteStop::TradeWith { npc_id, .. } => {
11283                let npcs = self.re_npc_candidates();
11284                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
11285                let index = npc_id
11286                    .as_ref()
11287                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11288                    .unwrap_or(0);
11289                self.re_open_sheet(S::SellNpcs { index });
11290            }
11291            WorkerRouteStop::CraftAt { blueprint, .. } => {
11292                let bps = self.re_blueprint_ids();
11293                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11294                if bps.is_empty() {
11295                    self.re_cancel_edit();
11296                    self.state
11297                        .push_log("Route: no known blueprints to retarget".to_string());
11298                } else {
11299                    self.re_open_sheet(S::CraftBlueprint { index });
11300                }
11301            }
11302            WorkerRouteStop::CultivatePlot { .. } => {
11303                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
11304            }
11305            WorkerRouteStop::PlantPlot { .. } => {
11306                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11307            }
11308            WorkerRouteStop::HarvestPlot { .. } => {
11309                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
11310            }
11311            WorkerRouteStop::RestIfNeeded => {
11312                self.re_cancel_edit();
11313                self.state
11314                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
11315            }
11316            WorkerRouteStop::Wait { wait_ticks } => {
11317                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
11318            }
11319        }
11320    }
11321
11322    fn re_cancel_edit(&mut self) {
11323        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11324            ed.editing_index = None;
11325        }
11326    }
11327
11328    // ---- mouse clicks inside the overlay ------------------------------
11329
11330    pub fn worker_route_editor_ui_click(
11331        &mut self,
11332        click: crate::worker_route_editor::RouteEditorClick,
11333    ) {
11334        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
11335        match click {
11336            RouteEditorClick::SelectStop(i) => {
11337                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11338                    ed.sheet = S::Stops;
11339                    ed.select_stop(i);
11340                }
11341            }
11342            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
11343            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
11344            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
11345        }
11346    }
11347
11348    /// Activate (Enter / click) a row of the current sheet.
11349    pub fn re_sheet_row_activate(&mut self, row: usize) {
11350        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11351        let Some(sheet) = self
11352            .state
11353            .worker_route_editor
11354            .as_ref()
11355            .map(|ed| ed.sheet.clone())
11356        else {
11357            return;
11358        };
11359        match sheet {
11360            S::Stops => {
11361                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11362                    ed.select_stop(row);
11363                }
11364            }
11365            S::AddMenu { .. } => match row {
11366                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
11367                1 => {
11368                    if self.re_node_candidates().is_empty() {
11369                        self.state
11370                            .push_log("Route: no harvestable nodes visible in this region".to_string());
11371                    } else {
11372                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
11373                    }
11374                }
11375                2 | 3 => {
11376                    if self.re_container_candidates().is_empty() {
11377                        self.state
11378                            .push_log("Route: place a storage chest first".to_string());
11379                    } else if row == 2 {
11380                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
11381                    } else {
11382                        self.re_open_sheet(S::DepositContainers { index: 0 });
11383                    }
11384                }
11385                4 => {
11386                    if self.re_template_candidates().is_empty() {
11387                        self.state.push_log(
11388                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
11389                                .to_string(),
11390                        );
11391                    } else {
11392                        self.re_open_sheet(S::SellNpcs { index: 0 });
11393                    }
11394                }
11395                5 => {
11396                    if self.re_blueprint_ids().is_empty() {
11397                        self.state.push_log(
11398                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
11399                                .to_string(),
11400                        );
11401                    } else {
11402                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
11403                    }
11404                }
11405                6 => self.re_confirm_stop(
11406                    WorkerRouteStop::RestIfNeeded,
11407                    "rest at lodging (if needed)".into(),
11408                ),
11409                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
11410                8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
11411                9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
11412                10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
11413                _ => {}
11414            },
11415            S::WaypointMenu { .. } => match row {
11416                0 => {
11417                    let (x, y, z) = self.state.player_position_with_z();
11418                    let stop = WorkerRouteStop::Waypoint { x, y, z };
11419                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11420                }
11421                1 => {
11422                    self.re_open_sheet(S::WaypointMapPick);
11423                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
11424                }
11425                _ => {}
11426            },
11427            S::HarvestPicker { .. } => {
11428                let mut log: Option<String> = None;
11429                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11430                    let S::HarvestPicker {
11431                        index: sheet_index,
11432                        picked,
11433                        nodes,
11434                    } = &mut ed.sheet
11435                    else {
11436                        return;
11437                    };
11438                    *sheet_index = row;
11439                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
11440                        if picked.is_empty() {
11441                            log = Some(
11442                                "Route: pick at least one node (Space toggles, Done confirms)"
11443                                    .into(),
11444                            );
11445                        } else {
11446                            let ids: Vec<String> = picked.iter().cloned().collect();
11447                            let added = ed.confirm_harvest_picks(&ids);
11448                            log = Some(format!("Route: + {added} harvest stop(s)"));
11449                        }
11450                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
11451                        if picked.contains(&n.id) {
11452                            picked.remove(&n.id);
11453                        } else {
11454                            picked.insert(n.id.clone());
11455                        }
11456                    }
11457                }
11458                if let Some(msg) = log {
11459                    self.state.push_log(msg);
11460                }
11461            }
11462            S::WithdrawContainers { .. } => {
11463                let containers = self.re_container_candidates();
11464                if let Some(c) = containers.get(row) {
11465                    let id = c.id.clone();
11466                    self.re_open_withdraw_items(id);
11467                }
11468            }
11469            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
11470            S::DepositContainers { .. } => {
11471                let containers = self.re_container_candidates();
11472                if let Some(c) = containers.get(row) {
11473                    let id = c.id.clone();
11474                    self.re_open_deposit_filter(id);
11475                }
11476            }
11477            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
11478            S::SellNpcs { .. } => {
11479                let npcs = self.re_npc_candidates();
11480                let npc_id = if row == 0 {
11481                    None
11482                } else {
11483                    npcs.get(row - 1).map(|n| n.id.clone())
11484                };
11485                if row == 0 || npc_id.is_some() {
11486                    self.re_open_sell_item(npc_id);
11487                }
11488            }
11489            S::SellItem { .. } => self.re_sell_item_activate(row),
11490            S::CraftBlueprint { .. } => {
11491                let bps = self.re_blueprint_ids();
11492                if let Some(bp) = bps.get(row) {
11493                    let stop = WorkerRouteStop::CraftAt {
11494                        device: "hand".into(),
11495                        blueprint: bp.clone(),
11496                        qty: None,
11497                    };
11498                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11499                }
11500            }
11501            S::WaitEntry { ticks } => {
11502                let stop = WorkerRouteStop::Wait {
11503                    wait_ticks: ticks,
11504                };
11505                self.re_confirm_stop(stop, format!("wait {ticks}t"));
11506            }
11507            S::BedPicker { .. } => {
11508                let beds = self.re_bed_candidates();
11509                if let Some((id, name)) = beds.get(row) {
11510                    let (id, name) = (id.clone(), name.clone());
11511                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11512                        ed.lodging_container_id = Some(id.clone());
11513                        ed.sheet = S::Stops;
11514                    }
11515                    self.state
11516                        .push_log(format!("Route: rest bed set to {name}"));
11517                }
11518            }
11519            S::FarmPlotPicker { action, .. } => {
11520                let plots = self.re_farm_plot_candidates();
11521                let Some(plot) = plots.get(row).cloned() else {
11522                    return;
11523                };
11524                match action {
11525                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
11526                        let label = plot_route_label(&plot);
11527                        self.re_confirm_stop(
11528                            WorkerRouteStop::CultivatePlot {
11529                                plot_id: plot.plot_id,
11530                            },
11531                            format!("cultivate {label}"),
11532                        );
11533                    }
11534                    crate::worker_route_editor::FarmPlotAction::Harvest => {
11535                        let label = plot_route_label(&plot);
11536                        self.re_confirm_stop(
11537                            WorkerRouteStop::HarvestPlot {
11538                                plot_id: plot.plot_id,
11539                            },
11540                            format!("harvest {label}"),
11541                        );
11542                    }
11543                    crate::worker_route_editor::FarmPlotAction::Plant => {
11544                        let seeds = self.re_farm_seed_candidates();
11545                        if seeds.is_empty() {
11546                            self.state.push_log(
11547                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11548                            );
11549                            return;
11550                        }
11551                        self.re_open_sheet(S::FarmPlantSeed {
11552                            plot_id: plot.plot_id,
11553                            seeds,
11554                            index: 0,
11555                        });
11556                    }
11557                }
11558            }
11559            S::FarmPlantSeed { plot_id, seeds, .. } => {
11560                if let Some(seed) = seeds.get(row).cloned() {
11561                    self.re_confirm_stop(
11562                        WorkerRouteStop::PlantPlot {
11563                            plot_id,
11564                            seed_template: seed.clone(),
11565                        },
11566                        format!("plant {seed}"),
11567                    );
11568                }
11569            }
11570            S::WaypointMapPick => {}
11571        }
11572    }
11573
11574    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11575        use crate::worker_route_editor::RouteEditorSheet as S;
11576        if self.re_farm_plot_candidates().is_empty() {
11577            self.state.push_log(
11578                "Route: no farmable plots visible — claim land or get farm access first",
11579            );
11580            return;
11581        }
11582        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11583    }
11584
11585    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11586        self.state
11587            .property_plots
11588            .iter()
11589            .filter(|p| p.is_mine || p.may_farm)
11590            .cloned()
11591            .collect()
11592    }
11593
11594    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
11595    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
11596    /// and known crop seeds so routes can withdraw-then-plant.
11597    fn re_farm_seed_candidates(&self) -> Vec<String> {
11598        let mut set = std::collections::BTreeSet::new();
11599        let looks_like_seed = |id: &str| {
11600            id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11601        };
11602        for (id, _, _) in self.state.farm_seed_entries() {
11603            set.insert(id);
11604        }
11605        for c in &self.state.placed_containers {
11606            let mine = match (self.state.character_id, c.owner_character_id) {
11607                (Some(a), Some(b)) => a == b,
11608                _ => false,
11609            };
11610            if !mine {
11611                continue;
11612            }
11613            for s in &c.contents {
11614                if s.quantity > 0
11615                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11616                {
11617                    set.insert(s.template_id.clone());
11618                }
11619            }
11620        }
11621        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11622            for stop in &ed.stops {
11623                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11624                {
11625                    for it in items {
11626                        if looks_like_seed(&it.template) {
11627                            set.insert(it.template.clone());
11628                        }
11629                    }
11630                }
11631                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11632                    seed_template, ..
11633                } = stop
11634                {
11635                    if !seed_template.is_empty() {
11636                        set.insert(seed_template.clone());
11637                    }
11638                }
11639            }
11640        }
11641        for id in self.state.inventory_hints.keys() {
11642            if looks_like_seed(id) {
11643                set.insert(id.clone());
11644            }
11645        }
11646        // Authored crop seeds (always offer so withdraw-then-plant routes work).
11647        for id in ["potato_seed", "carrot_seed"] {
11648            set.insert(id.to_string());
11649        }
11650        set.into_iter().collect()
11651    }
11652
11653    // ---- map clicks (sheet-scoped accelerators) ------------------------
11654
11655    /// Map click while the route editor is open. When a picker sheet is open
11656    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
11657    /// container, NPC clicks the merchant, node clicks the harvest target);
11658    /// otherwise the click quick-adds the nearest target (context-aware).
11659    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11660        use crate::worker_route_editor as wre;
11661        use wre::RouteEditorSheet as S;
11662        if self.state.worker_route_editor.is_none() {
11663            return;
11664        }
11665        let sheet = self
11666            .state
11667            .worker_route_editor
11668            .as_ref()
11669            .map(|ed| ed.sheet.clone())
11670            .unwrap_or(S::Stops);
11671        match sheet {
11672            S::WaypointMapPick => {
11673                let (_, _, z) = self.state.player_position_with_z();
11674                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11675                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11676                // Stay in map-pick mode when adding (not editing) for fast pathing.
11677                let editing = self
11678                    .state
11679                    .worker_route_editor
11680                    .as_ref()
11681                    .is_some_and(|ed| ed.editing_index.is_some());
11682                if !editing {
11683                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11684                        ed.sheet = S::WaypointMapPick;
11685                    }
11686                }
11687            }
11688            S::HarvestPicker { .. } => {
11689                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11690                    let mut log: Option<String> = None;
11691                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11692                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11693                            return;
11694                        };
11695                        let selected = if picked.contains(&node.id) {
11696                            picked.remove(&node.id);
11697                            false
11698                        } else {
11699                            picked.insert(node.id.clone());
11700                            true
11701                        };
11702                        log = Some(format!(
11703                            "Route: {} {}",
11704                            if selected { "selected" } else { "deselected" },
11705                            node.label
11706                        ));
11707                    }
11708                    if let Some(msg) = log {
11709                        self.state.push_log(msg);
11710                    }
11711                }
11712            }
11713            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11714                // Chest click picks (or switches) the withdraw source.
11715                if let Some(cid) = wre::pick_storage_container_at(
11716                    &self.state.placed_containers,
11717                    self.state.character_id,
11718                    x,
11719                    y,
11720                ) {
11721                    self.re_open_withdraw_items(cid);
11722                }
11723            }
11724            S::DepositContainers { .. } | S::DepositFilter { .. } => {
11725                if let Some(cid) = wre::pick_storage_container_at(
11726                    &self.state.placed_containers,
11727                    self.state.character_id,
11728                    x,
11729                    y,
11730                ) {
11731                    self.re_open_deposit_filter(cid);
11732                }
11733            }
11734            S::SellNpcs { .. } => {
11735                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11736                    self.re_open_sell_item(Some(npc_id));
11737                }
11738            }
11739            S::SellItem { .. } => {
11740                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11741                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11742                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11743                            *slot = Some(npc_id.clone());
11744                        }
11745                    }
11746                    self.state
11747                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11748                }
11749            }
11750            // Stop list / other sheets: context-aware quick add.
11751            _ => self.worker_route_editor_quick_add_click(x, y),
11752        }
11753    }
11754
11755    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
11756    /// node wins; duplicates select the existing stop; a click on a merchant
11757    /// while a sell stop is selected pins it.
11758    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11759        use crate::worker_route_editor as wre;
11760        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11761            let dx = ax - bx;
11762            let dy = ay - by;
11763            (dx * dx + dy * dy).sqrt()
11764        };
11765
11766        // 1. Retarget the selected stop when the click names its target:
11767        //    sell stop → pin the merchant; withdraw stop → set the source chest.
11768        let selected_stop_kind = self
11769            .state
11770            .worker_route_editor
11771            .as_ref()
11772            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11773            .map(|s| match s {
11774                wre::WorkerRouteStop::TradeWith { .. } => 1,
11775                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11776                _ => 0,
11777            })
11778            .unwrap_or(0);
11779        if selected_stop_kind == 1 {
11780            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11781                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11782                    ed.set_selected_trade_npc(npc_id.clone());
11783                }
11784                self.state
11785                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11786                return;
11787            }
11788        }
11789        if selected_stop_kind == 2 {
11790            if let Some(cid) = wre::pick_storage_container_at(
11791                &self.state.placed_containers,
11792                self.state.character_id,
11793                x,
11794                y,
11795            ) {
11796                let name = self
11797                    .state
11798                    .placed_containers
11799                    .iter()
11800                    .find(|c| c.id == cid)
11801                    .map(|c| c.display_name.clone())
11802                    .unwrap_or_else(|| "container".into());
11803                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11804                    ed.set_selected_withdraw_container(cid.clone());
11805                }
11806                self.state
11807                    .push_log(format!("Route: withdraw source → {name}"));
11808                return;
11809            }
11810        }
11811
11812        // 2. Nearest candidate of any kind wins. Category order is the
11813        //    tie-break for exact overlaps (bed over chest over merchant over node).
11814        enum Target {
11815            Bed(String),
11816            Container(String),
11817            Npc(String, String),
11818            Node(String, String),
11819        }
11820        let mut best: Option<(f32, u8, Target)> = None;
11821        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11822            let better = match best {
11823                None => true,
11824                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11825            };
11826            if better {
11827                *best = Some((d, rank, t));
11828            }
11829        };
11830        if let Some(bed_id) = wre::pick_lodging_container_at(
11831            &self.state.placed_containers,
11832            self.state.character_id,
11833            x,
11834            y,
11835        ) {
11836            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11837                // A bed that's already the rest bed doubles as a storage chest —
11838                // a second click on it means "deposit here", not "set bed again".
11839                let already_bed = self
11840                    .state
11841                    .worker_route_editor
11842                    .as_ref()
11843                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11844                if already_bed {
11845                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11846                } else {
11847                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11848                }
11849            }
11850        }
11851        if let Some(cid) = wre::pick_storage_container_at(
11852            &self.state.placed_containers,
11853            self.state.character_id,
11854            x,
11855            y,
11856        ) {
11857            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11858                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11859            }
11860        }
11861        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11862            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11863                consider(
11864                    dist(x, y, n.x, n.y),
11865                    2,
11866                    Target::Npc(npc_id, label),
11867                    &mut best,
11868                );
11869            }
11870        }
11871        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11872            let d = dist(x, y, node.x, node.y);
11873            consider(
11874                d,
11875                3,
11876                Target::Node(node.id.clone(), node.label.clone()),
11877                &mut best,
11878            );
11879        }
11880
11881        match best.map(|(_, _, t)| t) {
11882            Some(Target::Bed(bed_id)) => {
11883                let name = self
11884                    .state
11885                    .placed_containers
11886                    .iter()
11887                    .find(|c| c.id == bed_id)
11888                    .map(|c| c.display_name.clone())
11889                    .unwrap_or_else(|| "camp bed".into());
11890                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11891                    ed.lodging_container_id = Some(bed_id.clone());
11892                }
11893                self.state
11894                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11895            }
11896            Some(Target::Container(cid)) => {
11897                let name = self
11898                    .state
11899                    .placed_containers
11900                    .iter()
11901                    .find(|c| c.id == cid)
11902                    .map(|c| c.display_name.clone())
11903                    .unwrap_or_else(|| "container".into());
11904                let added = self
11905                    .state
11906                    .worker_route_editor
11907                    .as_mut()
11908                    .is_some_and(|ed| ed.append_deposit_at(&cid));
11909                if added {
11910                    self.state
11911                        .push_log(format!("Route: + deposit at {name} ({cid})"));
11912                } else {
11913                    self.state.push_log(format!(
11914                        "Route: {name} already in route — selected it (d to remove)"
11915                    ));
11916                }
11917            }
11918            Some(Target::Npc(npc_id, label)) => {
11919                // Quick-add sell: first storage template (the full sell flow
11920                // lives in the a → Sell sheet).
11921                let template = self.re_template_candidates().into_iter().next();
11922                let Some(template) = template else {
11923                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11924                    return;
11925                };
11926                let added = self
11927                    .state
11928                    .worker_route_editor
11929                    .as_mut()
11930                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11931                if added {
11932                    self.state
11933                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11934                } else {
11935                    self.state.push_log(format!(
11936                        "Route: {label} already sells {template} — selected it (d to remove)"
11937                    ));
11938                }
11939            }
11940            Some(Target::Node(id, label)) => {
11941                let added = self
11942                    .state
11943                    .worker_route_editor
11944                    .as_mut()
11945                    .is_some_and(|ed| ed.append_harvest_node(&id));
11946                if added {
11947                    self.state
11948                        .push_log(format!("Route: + harvest node {label} ({id})"));
11949                } else {
11950                    self.state.push_log(format!(
11951                        "Route: {label} already in route — selected it (d to remove)"
11952                    ));
11953                }
11954            }
11955            None => {}
11956        }
11957    }
11958
11959    pub fn worker_route_editor_select(&mut self, delta: i32) {
11960        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11961            return;
11962        };
11963        if ed.stops.is_empty() {
11964            return;
11965        }
11966        let n = ed.stops.len() as i32;
11967        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11968        ed.selected_stop_index = next;
11969    }
11970
11971    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11972        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11973            return;
11974        };
11975        if delta < 0 {
11976            ed.move_selected_up();
11977        } else if delta > 0 {
11978            ed.move_selected_down();
11979        }
11980    }
11981
11982    pub fn worker_route_editor_delete_selected(&mut self) {
11983        let removed = self
11984            .state
11985            .worker_route_editor
11986            .as_mut()
11987            .is_some_and(|ed| {
11988                let before = ed.stop_count();
11989                ed.remove_selected_stop();
11990                ed.stop_count() < before
11991            });
11992        if removed {
11993            self.state.push_log("Route: removed selected stop");
11994        }
11995    }
11996
11997    /// Clear all stops. Saving afterwards parks the worker in idle mode
11998    /// instead of leaving it in a broken job loop.
11999    pub fn worker_route_editor_clear_stops(&mut self) {
12000        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12001            return;
12002        };
12003        if ed.stops.is_empty() {
12004            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
12005            return;
12006        }
12007        ed.stops.clear();
12008        ed.selected_stop_index = 0;
12009        self.state
12010            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
12011    }
12012
12013    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12014        if self.state.pending_worker_job_ack.is_some() {
12015            anyhow::bail!("route save still pending — wait for server ack");
12016        }
12017        let Some(ed) = self.state.worker_route_editor.clone() else {
12018            anyhow::bail!("route editor not open");
12019        };
12020        // Empty route = stand down: park the worker in idle mode rather than
12021        // erroring out or leaving it marching a ghost loop.
12022        let (job_yaml, idle) = if ed.stops.is_empty() {
12023            (ed.build_idle_job_yaml(), true)
12024        } else {
12025            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12026        };
12027        let worker_id = ed.worker_instance_id.clone();
12028        let route_view = if idle {
12029            None
12030        } else {
12031            Some(ed.to_route_view())
12032        };
12033        let mode = if idle {
12034            flatland_protocol::WorkerModeView::Idle
12035        } else {
12036            flatland_protocol::WorkerModeView::JobLoop
12037        };
12038        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12039            .state
12040            .hired_workers
12041            .iter()
12042            .find(|w| w.instance_id == worker_id)
12043            .map(|w| {
12044                (
12045                    w.route.clone(),
12046                    w.mode,
12047                    w.step_label.clone(),
12048                    w.last_error.clone(),
12049                )
12050            })
12051            .unwrap_or((
12052                None,
12053                flatland_protocol::WorkerModeView::Idle,
12054                String::new(),
12055                None,
12056            ));
12057        self.seq += 1;
12058        let seq = self.seq;
12059        self.session
12060            .submit_intent(Intent::SetWorkerJob {
12061                entity_id: self.state.entity_id,
12062                worker_instance_id: worker_id.clone(),
12063                job_yaml,
12064                seq,
12065            })
12066            .await?;
12067        self.state.intents_sent += 1;
12068        if let Some(w) = self
12069            .state
12070            .hired_workers
12071            .iter_mut()
12072            .find(|w| w.instance_id == worker_id)
12073        {
12074            w.route = route_view;
12075            w.mode = mode;
12076            w.last_error = None;
12077            if idle {
12078                w.step_label.clear();
12079                w.route_stop_index = None;
12080            }
12081        }
12082        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12083            seq,
12084            worker_instance_id: worker_id,
12085            worker_label: ed.worker_label.clone(),
12086            idle,
12087            stop_count: ed.stops.len(),
12088            prev_route,
12089            prev_mode,
12090            prev_step_label,
12091            prev_last_error,
12092        });
12093        self.state.push_log(format!(
12094            "Route: saving for {}… (waiting for server)",
12095            ed.worker_label
12096        ));
12097        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
12098        Ok(())
12099    }
12100    pub fn quest_menu_move(&mut self, delta: i32) {
12101        let n = self.state.active_quest_entries().len();
12102        if n == 0 {
12103            return;
12104        }
12105        let idx = self.state.quest_menu_index as i32;
12106        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12107    }
12108
12109    pub fn quest_menu_page(&mut self, pages: i32) {
12110        let n = self.state.active_quest_entries().len();
12111        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12112    }
12113
12114    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12115        let Some(offer) = self.state.pending_quest_offer.clone() else {
12116            anyhow::bail!("no quest offer");
12117        };
12118        self.seq += 1;
12119        let seq = self.seq;
12120        self.session
12121            .submit_intent(Intent::AcceptQuest {
12122                entity_id: self.state.entity_id,
12123                quest_id: offer.quest_id,
12124                seq,
12125            })
12126            .await?;
12127        self.state.intents_sent += 1;
12128        Ok(())
12129    }
12130
12131    pub fn quest_offer_decline(&mut self) {
12132        self.state.show_quest_offer = false;
12133        self.state.pending_quest_offer = None;
12134        if !self.state.show_npc_chat
12135            && !self.state.show_shop_menu
12136            && self.state.npc_verb_target.is_some()
12137        {
12138            self.state.show_npc_verb_menu = true;
12139        }
12140    }
12141
12142    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12143        if !self.state.show_quest_menu {
12144            return Ok(());
12145        }
12146        let active: Vec<_> = self
12147            .state
12148            .active_quest_entries()
12149            .into_iter()
12150            .cloned()
12151            .collect();
12152        let Some(entry) = active.get(self.state.quest_menu_index) else {
12153            return Ok(());
12154        };
12155        if self.state.quest_withdraw_confirm {
12156            if !entry.can_withdraw {
12157                anyhow::bail!("quest cannot be withdrawn");
12158            }
12159            self.seq += 1;
12160            let seq = self.seq;
12161            self.session
12162                .submit_intent(Intent::WithdrawQuest {
12163                    entity_id: self.state.entity_id,
12164                    quest_id: entry.quest_id.clone(),
12165                    seq,
12166                })
12167                .await?;
12168            self.state.intents_sent += 1;
12169            self.state.quest_withdraw_confirm = false;
12170            return Ok(());
12171        }
12172        self.seq += 1;
12173        let seq = self.seq;
12174        self.session
12175            .submit_intent(Intent::TrackQuest {
12176                entity_id: self.state.entity_id,
12177                quest_id: entry.quest_id.clone(),
12178                seq,
12179            })
12180            .await?;
12181        self.state.intents_sent += 1;
12182        Ok(())
12183    }
12184
12185    pub fn quest_request_withdraw(&mut self) {
12186        if self.state.show_quest_menu {
12187            self.state.quest_withdraw_confirm = true;
12188        }
12189    }
12190
12191    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12192        if !self.state.is_alive() {
12193            anyhow::bail!("you are dead");
12194        }
12195        let Some(catalog) = self.state.shop_catalog.clone() else {
12196            anyhow::bail!("no shop open");
12197        };
12198        self.seq += 1;
12199        let seq = self.seq;
12200        match self.state.shop_tab {
12201            ShopTab::Buy => {
12202                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12203                    anyhow::bail!("nothing selected");
12204                };
12205                if offer.already_owned {
12206                    anyhow::bail!("already owned");
12207                }
12208                self.session
12209                    .submit_intent(Intent::ShopBuy {
12210                        entity_id: self.state.entity_id,
12211                        npc_id: catalog.npc_id.clone(),
12212                        offer_id: offer.offer_id.clone(),
12213                        quantity: self.state.shop_quantity,
12214                        seq,
12215                    })
12216                    .await?;
12217            }
12218            ShopTab::Sell => {
12219                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12220                    anyhow::bail!("nothing to sell");
12221                };
12222                if line.quantity == 0 {
12223                    anyhow::bail!("you have no {}", line.label);
12224                }
12225                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12226                self.session
12227                    .submit_intent(Intent::ShopSell {
12228                        entity_id: self.state.entity_id,
12229                        npc_id: catalog.npc_id.clone(),
12230                        template_id: line.template_id.clone(),
12231                        quantity,
12232                        seq,
12233                    })
12234                    .await?;
12235            }
12236        }
12237        self.state.intents_sent += 1;
12238        Ok(())
12239    }
12240
12241    pub fn craft_menu_move(&mut self, delta: i32) {
12242        let n = self.state.blueprints.len();
12243        if n == 0 {
12244            return;
12245        }
12246        let idx = self.state.craft_menu_index as i32;
12247        let next = (idx + delta).rem_euclid(n as i32);
12248        self.state.craft_menu_index = next as usize;
12249        self.state.clamp_craft_batch_quantity();
12250    }
12251
12252    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12253        self.state.craft_batch_adjust_quantity(delta);
12254    }
12255
12256    pub fn craft_batch_set_max(&mut self) {
12257        self.state.craft_batch_set_max();
12258    }
12259
12260    pub fn craft_batch_set_min(&mut self) {
12261        self.state.craft_batch_set_min();
12262    }
12263
12264    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12265        let Some(blueprint) = self
12266            .state
12267            .blueprints
12268            .get(self.state.craft_menu_index)
12269            .cloned()
12270        else {
12271            anyhow::bail!("no blueprints known");
12272        };
12273        if !self.state.can_craft_blueprint(&blueprint) {
12274            let hint = self
12275                .state
12276                .craft_missing_hint(&blueprint)
12277                .unwrap_or_else(|| "missing materials".into());
12278            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12279        }
12280        let count = self.state.craft_batch_quantity;
12281        let max = self.state.max_craft_batches(&blueprint);
12282        if max == 0 {
12283            anyhow::bail!("cannot craft {}", blueprint.label);
12284        }
12285        let batches = count.min(max);
12286        self.craft(&blueprint.id, Some(batches)).await?;
12287        self.state.show_craft_menu = false;
12288        Ok(())
12289    }
12290
12291    pub async fn move_by(
12292        &mut self,
12293        forward: f32,
12294        strafe: f32,
12295        vertical: f32,
12296        sprint: bool,
12297    ) -> anyhow::Result<()> {
12298        if !self.state.is_alive() {
12299            anyhow::bail!("you are dead");
12300        }
12301        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
12302            self.last_move_forward = forward;
12303            self.last_move_strafe = strafe;
12304        }
12305        self.seq += 1;
12306        self.session
12307            .submit_intent(Intent::Move {
12308                entity_id: self.state.entity_id,
12309                forward,
12310                strafe,
12311                vertical,
12312                sprint,
12313                seq: self.seq,
12314            })
12315            .await?;
12316        self.state.intents_sent += 1;
12317        Ok(())
12318    }
12319
12320    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
12321        if !self.state.connected {
12322            crate::harvest_trace!("harvest_nearest rejected: not connected");
12323            anyhow::bail!("not connected");
12324        }
12325        if !self.state.is_alive() {
12326            crate::harvest_trace!("harvest_nearest rejected: player dead");
12327            anyhow::bail!("you are dead");
12328        }
12329        if self.state.harvest_in_progress {
12330            if self.state.harvest_state_stale() {
12331                self.state.clear_harvest_state();
12332            } else {
12333                anyhow::bail!("already harvesting");
12334            }
12335        }
12336        let (px, py) = self
12337            .state
12338            .player
12339            .as_ref()
12340            .map(|p| (p.transform.position.x, p.transform.position.y))
12341            .unwrap_or((0.0, 0.0));
12342
12343        let available = self
12344            .state
12345            .resource_nodes
12346            .iter()
12347            .filter(|n| !n.harvest_off)
12348            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12349            .count();
12350        let node_id = self
12351            .state
12352            .resource_nodes
12353            .iter()
12354            .filter(|n| !n.harvest_off)
12355            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12356            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
12357            .min_by(|a, b| {
12358                let da = distance(px, py, a.x, a.y);
12359                let db = distance(px, py, b.x, b.y);
12360                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
12361            })
12362            .map(|n| n.id.clone());
12363
12364        let Some(node_id) = node_id else {
12365            let has_loot = self
12366                .state
12367                .ground_drops
12368                .iter()
12369                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12370            if has_loot {
12371                return self.pickup_nearest().await;
12372            }
12373            anyhow::bail!(
12374                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
12375            );
12376        };
12377
12378        self.seq += 1;
12379        let seq = self.seq;
12380        crate::harvest_trace!(
12381            entity_id = self.state.entity_id,
12382            node_id = %node_id,
12383            seq,
12384            px,
12385            py,
12386            available_nodes = available,
12387            "submitting harvest intent"
12388        );
12389        self.session
12390            .submit_intent(Intent::Harvest {
12391                entity_id: self.state.entity_id,
12392                node_id,
12393                seq,
12394            })
12395            .await?;
12396        self.state.intents_sent += 1;
12397        self.state.harvest_in_progress = true;
12398        self.state.harvest_started_at = Some(Instant::now());
12399        self.state.push_log("Harvesting…");
12400        crate::harvest_trace!(
12401            entity_id = self.state.entity_id,
12402            seq,
12403            "harvest intent queued to session"
12404        );
12405        Ok(())
12406    }
12407
12408    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
12409        if !self.state.is_alive() {
12410            anyhow::bail!("you are dead");
12411        }
12412        let blueprint_id = self
12413            .state
12414            .blueprints
12415            .iter()
12416            .find(|bp| self.state.can_craft_blueprint(bp))
12417            .map(|bp| bp.id.clone())
12418            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
12419        self.craft(&blueprint_id, None).await
12420    }
12421
12422    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
12423        if !self.state.is_alive() {
12424            anyhow::bail!("you are dead");
12425        }
12426        self.seq += 1;
12427        self.session
12428            .submit_intent(Intent::Craft {
12429                entity_id: self.state.entity_id,
12430                blueprint_id: blueprint_id.to_string(),
12431                count,
12432                seq: self.seq,
12433            })
12434            .await?;
12435        self.state.intents_sent += 1;
12436        let (label, batches) = self
12437            .state
12438            .blueprints
12439            .iter()
12440            .find(|b| b.id == blueprint_id)
12441            .map(|b| {
12442                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
12443                (b.label.as_str(), n)
12444            })
12445            .unwrap_or((blueprint_id, count.unwrap_or(1)));
12446        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
12447        Ok(())
12448    }
12449
12450    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
12451        if !self.state.is_alive() {
12452            anyhow::bail!("you are dead");
12453        }
12454        let target_id = match self.state.nearest_interact_target() {
12455            Some(id) => id,
12456            None => {
12457                anyhow::bail!("nothing to interact with nearby");
12458            }
12459        };
12460        if self.state.npcs.iter().any(|n| n.id == target_id) {
12461            self.state.show_npc_verb_menu = true;
12462            self.state.npc_verb_target = Some(target_id);
12463            self.state.npc_verb_index = 0;
12464            return Ok(());
12465        }
12466        if self
12467            .state
12468            .hired_workers
12469            .iter()
12470            .any(|w| w.instance_id == target_id)
12471        {
12472            return self.open_workers_menu_for(&target_id).await;
12473        }
12474        if let Ok(peer_id) = target_id.parse::<EntityId>() {
12475            if self
12476                .state
12477                .hired_workers
12478                .iter()
12479                .any(|w| w.entity_id == peer_id)
12480            {
12481                if let Some(w) = self
12482                    .state
12483                    .hired_workers
12484                    .iter()
12485                    .find(|w| w.entity_id == peer_id)
12486                {
12487                    let id = w.instance_id.clone();
12488                    return self.open_workers_menu_for(&id).await;
12489                }
12490            }
12491            if let Some(entity) = self
12492                .state
12493                .entities
12494                .iter()
12495                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
12496            {
12497                self.state
12498                    .player_verbs
12499                    .open_for(peer_id, &entity.label);
12500                return Ok(());
12501            }
12502        }
12503        self.seq += 1;
12504        self.session
12505            .submit_intent(Intent::Interact {
12506                entity_id: self.state.entity_id,
12507                target_id: target_id.clone(),
12508                seq: self.seq,
12509            })
12510            .await?;
12511        self.state.intents_sent += 1;
12512        Ok(())
12513    }
12514
12515    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
12516    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12517        if !self.state.is_alive() {
12518            anyhow::bail!("you are dead");
12519        }
12520        let (px, py) = self.state.player_position();
12521        let has_loot = self
12522            .state
12523            .ground_drops
12524            .iter()
12525            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12526        if has_loot {
12527            return self.pickup_nearest().await;
12528        }
12529        if self
12530            .state
12531            .placed_containers
12532            .iter()
12533            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12534        {
12535            return self.pickup_nearest_container().await;
12536        }
12537
12538        if let Some(plot) = self.state.my_plot_under_player().cloned() {
12539            // Double-tap f within 1.2s sells; otherwise farm the plot.
12540            const SELL_WINDOW: Duration = Duration::from_millis(1200);
12541            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12542                && self
12543                    .state
12544                    .sell_plot_armed_at
12545                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12546            if sell_armed {
12547                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12548            }
12549            self.state.sell_plot_confirm = None;
12550            self.state.sell_plot_armed_at = None;
12551
12552            // Prefer farm actions on your own land unless an NPC/player/door/board
12553            // is in interact range (those still win).
12554            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12555                self.state.npcs.iter().any(|n| n.id == id)
12556                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12557                    || self.state.doors.iter().any(|d| d.id == id)
12558                    || self.state.interactables.iter().any(|i| {
12559                        i.id == id
12560                            && matches!(
12561                                i.kind.as_str(),
12562                                "quest_board" | "well" | "exit" | "enter"
12563                            )
12564                    })
12565                    || id.parse::<EntityId>().is_ok_and(|eid| {
12566                        self.state
12567                            .entities
12568                            .iter()
12569                            .any(|e| e.id == eid && e.id != self.state.entity_id)
12570                    })
12571            });
12572            if !blocking_interact {
12573                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
12574                match self.harvest_nearest().await {
12575                    Ok(()) => return Ok(()),
12576                    Err(err) => {
12577                        let msg = err.to_string();
12578                        if !(msg.contains("no harvestable")
12579                            || msg.contains("press p")
12580                            || msg.contains("press f")
12581                            || msg.contains("nothing"))
12582                        {
12583                            return Err(err);
12584                        }
12585                    }
12586                }
12587                return Ok(());
12588            }
12589        }
12590        if self.state.nearest_interact_target().is_some() {
12591            return self.interact_nearest().await;
12592        }
12593        // Prefer a clear "move closer" when a board is visible but out of reach,
12594        // instead of silently falling through to harvest.
12595        if let Some((label, dist)) = self.state.nearest_quest_board() {
12596            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12597                anyhow::bail!(
12598                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12599                );
12600            }
12601        }
12602
12603        match self.harvest_nearest().await {
12604            Ok(()) => Ok(()),
12605            Err(err) => {
12606                let msg = err.to_string();
12607                if msg.contains("no harvestable")
12608                    || msg.contains("press p")
12609                    || msg.contains("press f")
12610                {
12611                    anyhow::bail!(
12612                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12613                    );
12614                }
12615                Err(err)
12616            }
12617        }
12618    }
12619
12620    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
12621    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12622        if !self.state.is_alive() {
12623            anyhow::bail!("you are dead");
12624        }
12625        if self.state.claim_mode.is_some() {
12626            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12627        }
12628        let zone = self
12629            .state
12630            .free_property_zone_under_player()
12631            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12632        let zone_id = zone.id.clone();
12633        let label = zone
12634            .label
12635            .as_deref()
12636            .filter(|s| !s.trim().is_empty())
12637            .unwrap_or(zone.id.as_str())
12638            .to_string();
12639        self.enter_claim_mode(&zone_id);
12640        self.state
12641            .push_log(format!(
12642                "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12643            ));
12644        Ok(())
12645    }
12646
12647    /// Enter claim mode over a property zone (default 4×4 or min size).
12648    pub fn enter_claim_mode(&mut self, zone_id: &str) {
12649        let Some(zone) = self
12650            .state
12651            .property_zones
12652            .iter()
12653            .find(|z| z.id == zone_id)
12654            .cloned()
12655        else {
12656            self.state.push_log("unknown property zone");
12657            return;
12658        };
12659        self.state.sell_plot_confirm = None;
12660        self.state.sell_plot_armed_at = None;
12661        let min_area = self
12662            .state
12663            .property_plot_settings
12664            .as_ref()
12665            .map(|s| s.min_plot_area_m2)
12666            .unwrap_or(4.0)
12667            .max(1.0);
12668        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12669        let side = 4u32.max(min_side);
12670        let (px, py) = self.state.player_position();
12671        let anchor_x = px.floor();
12672        let anchor_y = py.floor();
12673        self.state.claim_mode = Some(ClaimModeState {
12674            zone_id: zone.id.clone(),
12675            width_m: side,
12676            height_m: side,
12677            anchor_x,
12678            anchor_y,
12679        });
12680        let label = zone
12681            .label
12682            .as_deref()
12683            .filter(|s| !s.trim().is_empty())
12684            .unwrap_or(zone.id.as_str());
12685        self.state.push_log(format!(
12686            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12687        ));
12688    }
12689
12690    pub fn cancel_claim_mode(&mut self) {
12691        if self.state.claim_mode.take().is_some() {
12692            self.state.push_log("Claim cancelled");
12693        }
12694    }
12695
12696    /// Enter relocate mode for a placed container (1×1 ghost).
12697    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12698        if !self.state.is_alive() {
12699            anyhow::bail!("you are dead");
12700        }
12701        if self.state.relocate_mode.is_some() {
12702            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12703        }
12704        if self.state.claim_mode.is_some() {
12705            anyhow::bail!("finish or cancel claim mode first");
12706        }
12707        let chest = self
12708            .state
12709            .placed_containers
12710            .iter()
12711            .find(|c| c.id == container_id)
12712            .cloned()
12713            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12714        let (px, py) = self.state.player_position();
12715        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12716            anyhow::bail!("too far from {}", chest.display_name);
12717        }
12718        if chest.locked && !chest.accessible {
12719            anyhow::bail!(
12720                "need the matching key for {} before moving it",
12721                chest.display_name
12722            );
12723        }
12724        let label = if chest.display_name.trim().is_empty() {
12725            chest.template_id.clone()
12726        } else {
12727            chest.display_name.clone()
12728        };
12729        self.state.relocate_mode = Some(RelocateModeState {
12730            container_id: chest.id.clone(),
12731            label: label.clone(),
12732            cursor_x: chest.x.floor() + 0.5,
12733            cursor_y: chest.y.floor() + 0.5,
12734        });
12735        self.state.push_log(format!(
12736            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12737        ));
12738        Ok(())
12739    }
12740
12741    /// World `Shift+m`: relocate nearest owned/accessible placed container.
12742    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12743        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12744            anyhow::bail!("no chest nearby to relocate");
12745        };
12746        if chest.locked && !chest.accessible {
12747            anyhow::bail!(
12748                "need the matching key for {} before moving it",
12749                chest.display_name
12750            );
12751        }
12752        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
12753        // Server enforces ownership; client starts on nearest accessible chest in range.
12754        self.begin_relocate_container(&chest.id)
12755    }
12756
12757    pub fn cancel_relocate_mode(&mut self) {
12758        if self.state.relocate_mode.take().is_some() {
12759            self.state.push_log("Relocate cancelled");
12760        }
12761    }
12762
12763    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12764        let Some(mode) = self.state.relocate_mode.as_mut() else {
12765            return;
12766        };
12767        let max_x = self.state.world_width_m.max(1.0);
12768        let max_y = self.state.world_height_m.max(1.0);
12769        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12770        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12771        mode.cursor_x = nx.floor() + 0.5;
12772        mode.cursor_y = ny.floor() + 0.5;
12773    }
12774
12775    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12776        let Some(mode) = self.state.relocate_mode.as_mut() else {
12777            return;
12778        };
12779        let max_x = self.state.world_width_m.max(1.0);
12780        let max_y = self.state.world_height_m.max(1.0);
12781        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12782        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12783    }
12784
12785    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12786        if !self.state.is_alive() {
12787            anyhow::bail!("you are dead");
12788        }
12789        let Some(mode) = self.state.relocate_mode.clone() else {
12790            anyhow::bail!("not relocating");
12791        };
12792        let (px, py) = self.state.player_position();
12793        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12794        if dist > 8.0 {
12795            anyhow::bail!("destination too far (max 8 m)");
12796        }
12797        self.seq += 1;
12798        self.session
12799            .submit_intent(Intent::MovePlacedContainer {
12800                entity_id: self.state.entity_id,
12801                container_id: mode.container_id.clone(),
12802                x: mode.cursor_x,
12803                y: mode.cursor_y,
12804                seq: self.seq,
12805            })
12806            .await?;
12807        self.state.intents_sent += 1;
12808        self.state.relocate_mode = None;
12809        self.state
12810            .push_log(format!("Moving {}…", mode.label));
12811        Ok(())
12812    }
12813
12814    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12815        let Some(mode) = self.state.claim_mode.as_mut() else {
12816            return;
12817        };
12818        mode.width_m = w.max(1);
12819        mode.height_m = h.max(1);
12820    }
12821
12822    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12823        let Some(mode) = self.state.claim_mode.as_mut() else {
12824            return;
12825        };
12826        let w = (mode.width_m as i32 + dw).max(1) as u32;
12827        let h = (mode.height_m as i32 + dh).max(1) as u32;
12828        mode.width_m = w;
12829        mode.height_m = h;
12830    }
12831
12832    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
12833    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12834        let Some(mode) = self.state.claim_mode.as_mut() else {
12835            return;
12836        };
12837        let max_x = self.state.world_width_m.max(1.0);
12838        let max_y = self.state.world_height_m.max(1.0);
12839        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12840        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12841        mode.anchor_x = nx.floor();
12842        mode.anchor_y = ny.floor();
12843    }
12844
12845    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12846        if !self.state.is_alive() {
12847            anyhow::bail!("you are dead");
12848        }
12849        let Some(mode) = self.state.claim_mode.clone() else {
12850            anyhow::bail!("not in claim mode");
12851        };
12852        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12853            self.state.claim_quote()
12854        else {
12855            anyhow::bail!("cannot quote claim");
12856        };
12857        if !valid {
12858            anyhow::bail!(reason);
12859        }
12860        if !can_afford {
12861            anyhow::bail!(
12862                "not enough copper (need {})",
12863                crate::currency::format_copper(purchase)
12864            );
12865        }
12866        let (x0, y0, x1, y1) = self
12867            .state
12868            .claim_footprint_rect()
12869            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12870        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12871        self.seq += 1;
12872        self.session
12873            .submit_intent(Intent::BuyPlot {
12874                entity_id: self.state.entity_id,
12875                zone_id: mode.zone_id,
12876                x0,
12877                y0,
12878                x1,
12879                y1,
12880                seq: self.seq,
12881            })
12882            .await?;
12883        self.state.intents_sent += 1;
12884        self.state.claim_mode = None;
12885        self.state
12886            .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12887        Ok(())
12888    }
12889
12890    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12891        if !self.state.is_alive() {
12892            anyhow::bail!("you are dead");
12893        }
12894        let zone_id = self
12895            .state
12896            .claim_mode
12897            .as_ref()
12898            .map(|m| m.zone_id.clone())
12899            .or_else(|| {
12900                self.state
12901                    .free_property_zone_under_player()
12902                    .map(|z| z.id.clone())
12903            })
12904            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12905        self.seq += 1;
12906        self.session
12907            .submit_intent(Intent::BuyPlotAllFree {
12908                entity_id: self.state.entity_id,
12909                zone_id,
12910                seq: self.seq,
12911            })
12912            .await?;
12913        self.state.intents_sent += 1;
12914        self.state.claim_mode = None;
12915        self.state.push_log("Claiming largest free plot…");
12916        Ok(())
12917    }
12918
12919    pub async fn confirm_sell_plot_to_crown(
12920        &mut self,
12921        plot_id: uuid::Uuid,
12922    ) -> anyhow::Result<()> {
12923        if !self.state.is_alive() {
12924            anyhow::bail!("you are dead");
12925        }
12926        self.seq += 1;
12927        self.session
12928            .submit_intent(Intent::SellPlotToCrown {
12929                entity_id: self.state.entity_id,
12930                plot_id,
12931                seq: self.seq,
12932            })
12933            .await?;
12934        self.state.intents_sent += 1;
12935        self.state.sell_plot_confirm = None;
12936        self.state.sell_plot_armed_at = None;
12937        self.state.push_log("Selling plot to the crown…");
12938        Ok(())
12939    }
12940
12941    pub async fn set_plot_farm_public(
12942        &mut self,
12943        plot_id: uuid::Uuid,
12944        public: bool,
12945        public_tax_discount_bps: u32,
12946    ) -> anyhow::Result<()> {
12947        self.seq += 1;
12948        self.session
12949            .submit_intent(Intent::SetPlotFarmPublic {
12950                entity_id: self.state.entity_id,
12951                plot_id,
12952                public,
12953                public_tax_discount_bps,
12954                seq: self.seq,
12955            })
12956            .await?;
12957        self.state.intents_sent += 1;
12958        Ok(())
12959    }
12960
12961    pub async fn plot_farm_allow_upsert(
12962        &mut self,
12963        plot_id: uuid::Uuid,
12964        character_id: Option<uuid::Uuid>,
12965        character_name: String,
12966        tax_discount_bps: u32,
12967    ) -> anyhow::Result<()> {
12968        self.seq += 1;
12969        self.session
12970            .submit_intent(Intent::PlotFarmAllowUpsert {
12971                entity_id: self.state.entity_id,
12972                plot_id,
12973                character_id,
12974                character_name,
12975                tax_discount_bps,
12976                seq: self.seq,
12977            })
12978            .await?;
12979        self.state.intents_sent += 1;
12980        Ok(())
12981    }
12982
12983    pub async fn plot_farm_allow_remove(
12984        &mut self,
12985        plot_id: uuid::Uuid,
12986        character_id: uuid::Uuid,
12987    ) -> anyhow::Result<()> {
12988        self.seq += 1;
12989        self.session
12990            .submit_intent(Intent::PlotFarmAllowRemove {
12991                entity_id: self.state.entity_id,
12992                plot_id,
12993                character_id,
12994                seq: self.seq,
12995            })
12996            .await?;
12997        self.state.intents_sent += 1;
12998        Ok(())
12999    }
13000
13001    pub fn open_farm_access_panel(&mut self) {
13002        let Some(plot) = self.state.my_plot_under_player() else {
13003            self.state
13004                .push_log("Stand on your deed plot to manage farm access");
13005            return;
13006        };
13007        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13008        self.state.farm_access_index = 0;
13009        self.state.show_farm_access = true;
13010    }
13011
13012    pub fn close_farm_access_panel(&mut self) {
13013        self.state.show_farm_access = false;
13014        self.state.farm_access_name_draft.clear();
13015        self.state.farm_access_index = 0;
13016    }
13017
13018    pub fn farm_access_move(&mut self, delta: i32) {
13019        let n = self.farm_access_row_count().max(1);
13020        let idx = self.state.farm_access_index as i32 + delta;
13021        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13022    }
13023
13024    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13025        let Some(plot) = self.state.my_plot_under_player() else {
13026            return vec![FarmAccessRow::PublicToggle];
13027        };
13028        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13029        for g in &plot.farm_allow {
13030            rows.push(FarmAccessRow::AllowRemove {
13031                character_id: g.character_id,
13032                label: if g.character_label.trim().is_empty() {
13033                    g.character_id.to_string()[..8].to_string()
13034                } else {
13035                    g.character_label.clone()
13036                },
13037                tax_discount_bps: g.tax_discount_bps,
13038            });
13039        }
13040        for e in &self.state.entities {
13041            if e.id == self.state.entity_id || e.label.trim().is_empty() {
13042                continue;
13043            }
13044            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13045                continue;
13046            }
13047            if self
13048                .state
13049                .npcs
13050                .iter()
13051                .any(|n| n.id == e.label || n.label == e.label)
13052            {
13053                continue;
13054            }
13055            if plot
13056                .farm_allow
13057                .iter()
13058                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13059            {
13060                continue;
13061            }
13062            rows.push(FarmAccessRow::NearbyAdd {
13063                name: e.label.clone(),
13064            });
13065        }
13066        rows
13067    }
13068
13069    pub fn farm_access_row_count(&self) -> usize {
13070        self.farm_access_rows().len().max(1)
13071    }
13072
13073    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13074        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13075            self.close_farm_access_panel();
13076            return Ok(());
13077        };
13078        let rows = self.farm_access_rows();
13079        let Some(row) = rows.get(self.state.farm_access_index) else {
13080            return Ok(());
13081        };
13082        match row {
13083            FarmAccessRow::PublicToggle => {
13084                self.set_plot_farm_public(
13085                    plot.plot_id,
13086                    !plot.farm_public,
13087                    plot.public_tax_discount_bps,
13088                )
13089                .await
13090            }
13091            FarmAccessRow::PublicDiscount => Ok(()),
13092            FarmAccessRow::AllowRemove { character_id, .. } => {
13093                self.plot_farm_allow_remove(plot.plot_id, *character_id)
13094                    .await
13095            }
13096            FarmAccessRow::NearbyAdd { name } => {
13097                let disc = self
13098                    .state
13099                    .farm_access_discount_bps
13100                    .max(plot.public_tax_discount_bps);
13101                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13102                    .await
13103            }
13104        }
13105    }
13106
13107    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13108        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13109            return Ok(());
13110        };
13111        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13112        self.state.farm_access_discount_bps = next;
13113        self.state.farm_access_index = 1;
13114        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13115            .await
13116    }
13117
13118    /// Till the cell under you on a farmable plot (`c`).
13119    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13120        if self.state.farmable_plot_under_player().is_none() {
13121            anyhow::bail!("stand on a farmable plot to cultivate");
13122        }
13123        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13124            let (px, py) = self.state.player_position();
13125            if self
13126                .state
13127                .terrain_at(px, py)
13128                .is_some_and(|k| k == TerrainKindView::Tilled)
13129            {
13130                anyhow::bail!("already tilled — stand on bare soil and press c");
13131            }
13132            anyhow::bail!("cannot till this cell — move onto soil on your plot");
13133        };
13134        self.cultivate_at(tx, ty).await
13135    }
13136
13137    /// Plant seeds on empty tilled soil under you (`p`).
13138    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13139        if self.state.farmable_plot_under_player().is_none() {
13140            anyhow::bail!("stand on a farmable plot to plant");
13141        }
13142        if !self.state.underfoot_free_tilled_plant_slot() {
13143            anyhow::bail!("stand on empty tilled soil and press p");
13144        }
13145        let seeds = self.state.farm_seed_entries();
13146        if seeds.is_empty() {
13147            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13148        }
13149        if seeds.len() == 1 {
13150            return self.plant_seeds(seeds[0].0.clone(), 1).await;
13151        }
13152        self.open_plant_menu();
13153        Ok(())
13154    }
13155
13156    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
13157    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13158        let Some(plot) = self.state.my_plot_under_player() else {
13159            anyhow::bail!("stand on your plot to build");
13160        };
13161        if plot.building_id.is_some() {
13162            anyhow::bail!("this plot already has a building");
13163        }
13164        let building_now = self
13165            .state
13166            .timed_channel
13167            .as_ref()
13168            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13169        if !building_now && self.state.building_materials.is_empty() {
13170            anyhow::bail!("no building materials loaded — wait a moment and try again");
13171        }
13172        self.state.show_plot_build_menu = true;
13173        self.state.show_craft_menu = false;
13174        self.state.show_shop_menu = false;
13175        self.state.shop_catalog = None;
13176        self.state.show_stats = false;
13177        self.state.show_inventory_menu = false;
13178        self.state.plot_build_focus_wall = true;
13179        let walls = self.state.plot_build_wall_options().len();
13180        let roofs = self.state.plot_build_roof_options().len();
13181        if walls > 0 {
13182            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13183        } else {
13184            self.state.plot_build_wall_index = 0;
13185        }
13186        if roofs > 0 {
13187            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13188        } else {
13189            self.state.plot_build_roof_index = 0;
13190        }
13191        Ok(())
13192    }
13193
13194    pub fn close_plot_build_menu(&mut self) {
13195        self.state.show_plot_build_menu = false;
13196    }
13197
13198    pub fn plot_build_menu_move(&mut self, delta: i32) {
13199        let walls = self.state.plot_build_wall_options();
13200        let roofs = self.state.plot_build_roof_options();
13201        if self.state.plot_build_focus_wall {
13202            if walls.is_empty() {
13203                return;
13204            }
13205            let n = walls.len() as i32;
13206            let cur = self.state.plot_build_wall_index as i32;
13207            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13208        } else {
13209            if roofs.is_empty() {
13210                return;
13211            }
13212            let n = roofs.len() as i32;
13213            let cur = self.state.plot_build_roof_index as i32;
13214            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13215        }
13216    }
13217
13218    pub fn plot_build_menu_toggle_focus(&mut self) {
13219        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13220    }
13221
13222    /// Confirm selection from the B menu.
13223    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13224        let wall = self
13225            .state
13226            .plot_build_selected_wall()
13227            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13228            .id
13229            .clone();
13230        let roof = self
13231            .state
13232            .plot_build_selected_roof()
13233            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13234            .id
13235            .clone();
13236        // Keep the B menu open so it switches to the in-progress panel.
13237        self.start_plot_build(&wall, &roof).await
13238    }
13239
13240    /// Cancel an in-progress plot build (materials returned).
13241    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13242        self.seq += 1;
13243        self.session
13244            .submit_intent(Intent::CancelPlotBuild {
13245                entity_id: self.state.entity_id,
13246                seq: self.seq,
13247            })
13248            .await?;
13249        self.state.intents_sent += 1;
13250        Ok(())
13251    }
13252
13253    /// Start timed shell craft on the plot's solid tilled rectangle.
13254    pub async fn start_plot_build(
13255        &mut self,
13256        wall_material_id: &str,
13257        roof_material_id: &str,
13258    ) -> anyhow::Result<()> {
13259        let Some(plot) = self.state.my_plot_under_player() else {
13260            anyhow::bail!("stand on your plot to build");
13261        };
13262        if plot.building_id.is_some() {
13263            anyhow::bail!("this plot already has a building");
13264        }
13265        let plot_id = plot.plot_id;
13266        self.seq += 1;
13267        self.session
13268            .submit_intent(Intent::StartPlotBuild {
13269                entity_id: self.state.entity_id,
13270                plot_id,
13271                wall_material_id: wall_material_id.to_string(),
13272                roof_material_id: roof_material_id.to_string(),
13273                seq: self.seq,
13274            })
13275            .await?;
13276        self.state.intents_sent += 1;
13277        Ok(())
13278    }
13279
13280    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
13281    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13282        let (px, py) = self.state.player_position();
13283        let mut best: Option<(f32, String, bool)> = None;
13284        for d in &self.state.doors {
13285            if d.lock_id.is_none() {
13286                continue;
13287            }
13288            let dist = (d.x - px).hypot(d.y - py);
13289            if dist > 3.5 {
13290                continue;
13291            }
13292            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13293                best = Some((dist, d.id.clone(), d.locked));
13294            }
13295        }
13296        let Some((_, door_id, locked_now)) = best else {
13297            anyhow::bail!("no lockable door nearby");
13298        };
13299        let locked = !locked_now;
13300        self.seq += 1;
13301        self.session
13302            .submit_intent(Intent::SetDoorLocked {
13303                entity_id: self.state.entity_id,
13304                door_id,
13305                locked,
13306                seq: self.seq,
13307            })
13308            .await?;
13309        self.state.intents_sent += 1;
13310        Ok(())
13311    }
13312
13313    /// Enter through a nearby open player-building door (`Enter`).
13314    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
13315        if !self.state.is_alive() {
13316            anyhow::bail!("you are dead");
13317        }
13318        if self.state.effective_inside_building().is_some() {
13319            anyhow::bail!("already inside");
13320        }
13321        let (px, py) = self.state.player_position();
13322        let mut best: Option<(f32, String)> = None;
13323        for d in &self.state.doors {
13324            if !d.open || d.locked {
13325                continue;
13326            }
13327            let player_house = self
13328                .state
13329                .buildings
13330                .iter()
13331                .find(|b| b.id == d.building_id)
13332                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13333            if !player_house {
13334                continue;
13335            }
13336            let dist = (d.x - px).hypot(d.y - py);
13337            if dist > 3.5 {
13338                continue;
13339            }
13340            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13341                best = Some((dist, d.id.clone()));
13342            }
13343        }
13344        let Some((_, door_id)) = best else {
13345            anyhow::bail!("no open house door nearby — open with f first");
13346        };
13347        self.seq += 1;
13348        self.session
13349            .submit_intent(Intent::EnterBuildingDoor {
13350                entity_id: self.state.entity_id,
13351                door_id,
13352                seq: self.seq,
13353            })
13354            .await?;
13355        self.state.intents_sent += 1;
13356        Ok(())
13357    }
13358
13359    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
13360    /// Works even when the door is closed or locked.
13361    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
13362        if !self.state.is_alive() {
13363            anyhow::bail!("you are dead");
13364        }
13365        let Some(bid) = self.state.effective_inside_building() else {
13366            anyhow::bail!("not inside a building");
13367        };
13368        let (px, py) = self.state.player_position();
13369        let mut best: Option<(f32, String)> = None;
13370        for d in &self.state.doors {
13371            if d.building_id != bid || d.portal.is_none() {
13372                continue;
13373            }
13374            let player_house = self
13375                .state
13376                .buildings
13377                .iter()
13378                .find(|b| b.id == d.building_id)
13379                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13380            if !player_house {
13381                continue;
13382            }
13383            let dist = (d.x - px).hypot(d.y - py);
13384            if dist > 1.5 {
13385                continue;
13386            }
13387            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13388                best = Some((dist, d.id.clone()));
13389            }
13390        }
13391        let Some((_, door_id)) = best else {
13392            anyhow::bail!("stand by the door to exit");
13393        };
13394        self.seq += 1;
13395        self.session
13396            .submit_intent(Intent::ExitBuildingDoor {
13397                entity_id: self.state.entity_id,
13398                door_id,
13399                seq: self.seq,
13400            })
13401            .await?;
13402        self.state.intents_sent += 1;
13403        Ok(())
13404    }
13405
13406    /// Confirm interior layout for your player building (copper charged on server).
13407    pub async fn confirm_interior_edit(
13408        &mut self,
13409        building_id: String,
13410        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
13411        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
13412    ) -> anyhow::Result<()> {
13413        self.seq += 1;
13414        self.session
13415            .submit_intent(Intent::ConfirmInteriorEdit {
13416                entity_id: self.state.entity_id,
13417                building_id,
13418                rooms,
13419                room_doors,
13420                seq: self.seq,
13421            })
13422            .await?;
13423        self.state.intents_sent += 1;
13424        Ok(())
13425    }
13426
13427    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
13428        if !self.state.is_alive() {
13429            anyhow::bail!("you are dead");
13430        }
13431        self.seq += 1;
13432        self.session
13433            .submit_intent(Intent::Cultivate {
13434                entity_id: self.state.entity_id,
13435                x,
13436                y,
13437                seq: self.seq,
13438            })
13439            .await?;
13440        self.state.intents_sent += 1;
13441        Ok(())
13442    }
13443
13444    pub async fn plant_seeds(
13445        &mut self,
13446        seed_template_id: String,
13447        quantity: u32,
13448    ) -> anyhow::Result<()> {
13449        if !self.state.is_alive() {
13450            anyhow::bail!("you are dead");
13451        }
13452        self.seq += 1;
13453        self.session
13454            .submit_intent(Intent::PlantSeeds {
13455                entity_id: self.state.entity_id,
13456                seed_template_id: seed_template_id.clone(),
13457                quantity,
13458                seq: self.seq,
13459            })
13460            .await?;
13461        self.state.intents_sent += 1;
13462        self.state
13463            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
13464        Ok(())
13465    }
13466
13467    pub fn open_plant_menu(&mut self) {
13468        if self.state.farm_seed_entries().is_empty() {
13469            self.state.push_log("No seeds in inventory to plant");
13470            return;
13471        }
13472        self.state.show_plant_menu = true;
13473        self.state.plant_menu_index = 0;
13474        self.state.plant_quantity = 1;
13475        self.state.clamp_plant_menu();
13476    }
13477
13478    pub fn close_plant_menu(&mut self) {
13479        self.state.show_plant_menu = false;
13480    }
13481
13482    pub fn plant_menu_move(&mut self, delta: i32) {
13483        let n = self.state.farm_seed_entries().len();
13484        if n == 0 {
13485            return;
13486        }
13487        let idx = self.state.plant_menu_index as i32 + delta;
13488        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
13489        self.state.clamp_plant_menu();
13490    }
13491
13492    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
13493        let next = self.state.plant_quantity as i32 + delta;
13494        self.state.plant_quantity = next.max(1) as u32;
13495        self.state.clamp_plant_menu();
13496    }
13497
13498    pub fn plant_menu_set_quantity_max(&mut self) {
13499        if let Some((_, max, _)) = self.state.plant_menu_selection() {
13500            self.state.plant_quantity = max;
13501        }
13502        self.state.clamp_plant_menu();
13503    }
13504
13505    pub fn plant_menu_set_quantity_min(&mut self) {
13506        self.state.plant_quantity = 1;
13507        self.state.clamp_plant_menu();
13508    }
13509
13510    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
13511        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
13512            self.close_plant_menu();
13513            anyhow::bail!("no seeds to plant");
13514        };
13515        self.close_plant_menu();
13516        self.plant_seeds(seed, qty).await?;
13517        self.state.push_log(format!("Planted {qty}× {label}"));
13518        Ok(())
13519    }
13520
13521    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
13522    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
13523    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
13524        if !self.state.is_alive() {
13525            anyhow::bail!("you are dead");
13526        }
13527        let binding = self
13528            .state
13529            .hotbar_ability(slot)
13530            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
13531            .to_string();
13532        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
13533            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
13534            if qty == 0 {
13535                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
13536            }
13537            return self.use_item(template_id).await;
13538        }
13539        let ability_id = binding;
13540        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
13541            return self
13542                .cast_ability(&ability_id, Some(self.state.entity_id))
13543                .await;
13544        }
13545        let is_heal = ability_id == "heal_touch"
13546            || self
13547                .state
13548                .ability_meta
13549                .get(&ability_id)
13550                .map(|meta| meta.is_heal)
13551                .unwrap_or(false);
13552        let target = if is_heal {
13553            Some(
13554                self.state
13555                    .target_for_slot(2)
13556                    .unwrap_or(self.state.entity_id),
13557            )
13558        } else {
13559            self.state
13560                .target_for_slot(1)
13561                .or_else(|| self.state.target_for_slot(2))
13562        };
13563        let Some(target_id) = target else {
13564            anyhow::bail!("no target — Tab to select, then press the hotbar key");
13565        };
13566        self.cast_ability(&ability_id, Some(target_id)).await
13567    }
13568
13569    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
13570    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
13571    pub async fn set_hotbar_slot(
13572        &mut self,
13573        slot: u8,
13574        ability_id: Option<&str>,
13575    ) -> anyhow::Result<()> {
13576        if !self.state.is_alive() {
13577            anyhow::bail!("you are dead");
13578        }
13579        if !(1..=9).contains(&slot) {
13580            anyhow::bail!("hotbar slot must be 1–9");
13581        }
13582        let ability_id = ability_id
13583            .map(str::trim)
13584            .filter(|id| !id.is_empty())
13585            .map(str::to_string);
13586        self.seq += 1;
13587        self.session
13588            .submit_intent(Intent::SetHotbarSlot {
13589                entity_id: self.state.entity_id,
13590                slot,
13591                ability_id: ability_id.clone(),
13592                seq: self.seq,
13593            })
13594            .await?;
13595        self.state.intents_sent += 1;
13596        let idx = (slot - 1) as usize;
13597        if self.state.hotbar.len() < 9 {
13598            self.state.hotbar.resize(9, None);
13599        }
13600        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
13601            *slot_mut = ability_id.clone();
13602        }
13603        match ability_id {
13604            Some(id) => {
13605                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
13606                    format!("use {tid}")
13607                } else {
13608                    id
13609                };
13610                self.state.push_log(format!("Hotbar {slot} → {label}"))
13611            }
13612            None => self.state.push_log(format!("Hotbar {slot} cleared")),
13613        }
13614        Ok(())
13615    }
13616
13617    pub fn npc_verb_options(&self) -> Vec<&'static str> {
13618        self.state.npc_verb_options()
13619    }
13620
13621    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
13622        let Some(npc_id) = self.state.npc_verb_target.clone() else {
13623            return Ok(());
13624        };
13625        let options = self.npc_verb_options();
13626        let choice = options
13627            .get(self.state.npc_verb_index)
13628            .copied()
13629            .unwrap_or("Talk");
13630        self.seq += 1;
13631        match choice {
13632            "Trade" | "Bank" | "Storage" | "Market" => {
13633                self.session
13634                    .submit_intent(Intent::Interact {
13635                        entity_id: self.state.entity_id,
13636                        target_id: npc_id,
13637                        seq: self.seq,
13638                    })
13639                    .await?;
13640            }
13641            _ => {
13642                self.session
13643                    .submit_intent(Intent::NpcTalkOpen {
13644                        entity_id: self.state.entity_id,
13645                        npc_id,
13646                        seq: self.seq,
13647                    })
13648                    .await?;
13649            }
13650        }
13651        self.state.intents_sent += 1;
13652        Ok(())
13653    }
13654
13655    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
13656        let Some(chat) = self.state.npc_chat.clone() else {
13657            return Ok(());
13658        };
13659        let message = chat.input.trim().to_string();
13660        if message.is_empty() || chat.pending {
13661            return Ok(());
13662        }
13663        if let Some(c) = self.state.npc_chat.as_mut() {
13664            c.lines.push(format!("You: {message}"));
13665            c.input.clear();
13666            c.pending = true;
13667        }
13668        self.seq += 1;
13669        self.session
13670            .submit_intent(Intent::NpcTalkSay {
13671                entity_id: self.state.entity_id,
13672                npc_id: chat.npc_id,
13673                message,
13674                seq: self.seq,
13675            })
13676            .await?;
13677        self.state.intents_sent += 1;
13678        Ok(())
13679    }
13680
13681    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
13682        let return_to_verbs = self.state.npc_verb_target.is_some();
13683        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
13684            self.state.show_npc_chat = false;
13685            if return_to_verbs {
13686                self.state.show_npc_verb_menu = true;
13687            }
13688            return Ok(());
13689        };
13690        self.seq += 1;
13691        self.session
13692            .submit_intent(Intent::NpcTalkClose {
13693                entity_id: self.state.entity_id,
13694                npc_id,
13695                seq: self.seq,
13696            })
13697            .await?;
13698        self.state.intents_sent += 1;
13699        self.state.show_npc_chat = false;
13700        self.state.npc_chat = None;
13701        if return_to_verbs {
13702            self.state.show_npc_verb_menu = true;
13703        }
13704        Ok(())
13705    }
13706
13707    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
13708    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
13709        if self.state.show_quest_offer
13710            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
13711        {
13712            self.quest_offer_decline();
13713            return Ok(());
13714        }
13715        if self.state.show_npc_chat {
13716            return self.npc_talk_close().await;
13717        }
13718        if self.state.show_shop_menu {
13719            return self.back_from_shop_menu().await;
13720        }
13721        if self.state.bank_panel.is_some() {
13722            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
13723                self.bank_transfer_back();
13724                return Ok(());
13725            }
13726            return self.close_bank_panel().await;
13727        }
13728        if self.state.storage_panel.is_some() {
13729            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
13730                self.storage_ui_back();
13731                return Ok(());
13732            }
13733            return self.close_storage_panel().await;
13734        }
13735        if self.state.market_panel.is_some() {
13736            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
13737                self.market_ui_back();
13738                return Ok(());
13739            }
13740            if self.state.market_buy_confirm.is_some() {
13741                self.state.market_buy_confirm = None;
13742                return Ok(());
13743            }
13744            return self.close_market_panel().await;
13745        }
13746        if self.state.show_npc_verb_menu {
13747            self.state.show_npc_verb_menu = false;
13748            self.state.npc_verb_target = None;
13749        }
13750        Ok(())
13751    }
13752
13753    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
13754        self.seq += 1;
13755        self.session
13756            .submit_intent(Intent::TestDamage {
13757                entity_id: self.state.entity_id,
13758                amount,
13759                seq: self.seq,
13760            })
13761            .await?;
13762        self.state.intents_sent += 1;
13763        Ok(())
13764    }
13765
13766    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
13767        self.cycle_combat_target_slot(1, reverse).await
13768    }
13769
13770    pub async fn cycle_combat_target_slot(
13771        &mut self,
13772        slot_index: u8,
13773        reverse: bool,
13774    ) -> anyhow::Result<()> {
13775        if !self.state.is_alive() {
13776            anyhow::bail!("you are dead");
13777        }
13778        let candidates = self.state.candidates_for_slot(slot_index);
13779        if candidates.is_empty() {
13780            anyhow::bail!("no targets nearby");
13781        }
13782        let current = self.state.target_for_slot(slot_index);
13783        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
13784        let next_idx = match idx {
13785            None => 0,
13786            Some(i) if reverse => {
13787                if i == 0 {
13788                    candidates.len() - 1
13789                } else {
13790                    i - 1
13791                }
13792            }
13793            Some(i) => (i + 1) % candidates.len(),
13794        };
13795        if idx == Some(next_idx) && candidates.len() == 1 {
13796            self.clear_combat_target_slot(slot_index).await?;
13797            return Ok(());
13798        }
13799        let (target_id, label) = candidates[next_idx].clone();
13800        self.set_combat_target_slot(slot_index, target_id, &label)
13801            .await
13802    }
13803
13804    pub async fn set_combat_target_slot(
13805        &mut self,
13806        slot_index: u8,
13807        target_id: EntityId,
13808        label: &str,
13809    ) -> anyhow::Result<()> {
13810        if !self.state.is_alive() {
13811            anyhow::bail!("you are dead");
13812        }
13813        self.seq += 1;
13814        self.session
13815            .submit_intent(Intent::SetTargetSlot {
13816                entity_id: self.state.entity_id,
13817                slot_index,
13818                target_id,
13819                seq: self.seq,
13820            })
13821            .await?;
13822        self.state.intents_sent += 1;
13823        if slot_index == 1 {
13824            self.state.combat_target = Some(target_id);
13825            self.state.combat_target_label = Some(label.to_string());
13826        }
13827        self.state
13828            .push_log(format!("Slot {slot_index} target: {label}"));
13829        Ok(())
13830    }
13831
13832    pub async fn set_combat_target(
13833        &mut self,
13834        target_id: EntityId,
13835        label: &str,
13836    ) -> anyhow::Result<()> {
13837        self.set_combat_target_slot(1, target_id, label).await
13838    }
13839
13840    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13841        if slot_index == 1 && self.state.combat_target.is_none() {
13842            return Ok(());
13843        }
13844        self.seq += 1;
13845        self.session
13846            .submit_intent(Intent::ClearTargetSlot {
13847                entity_id: self.state.entity_id,
13848                slot_index,
13849                seq: self.seq,
13850            })
13851            .await?;
13852        if slot_index == 1 {
13853            self.state.combat_target = None;
13854            self.state.combat_target_label = None;
13855        }
13856        self.state.intents_sent += 1;
13857        self.state
13858            .push_log(format!("Slot {slot_index} target cleared"));
13859        Ok(())
13860    }
13861
13862    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
13863        self.clear_combat_target_slot(1).await
13864    }
13865
13866    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
13867        if !self.state.is_alive() {
13868            anyhow::bail!("you are dead");
13869        }
13870        self.seq += 1;
13871        self.session
13872            .submit_intent(Intent::AdvanceRotation {
13873                entity_id: self.state.entity_id,
13874                slot_index,
13875                seq: self.seq,
13876            })
13877            .await?;
13878        self.state.intents_sent += 1;
13879        Ok(())
13880    }
13881
13882    pub async fn assign_slot_preset(
13883        &mut self,
13884        slot_index: u8,
13885        preset_id: &str,
13886    ) -> anyhow::Result<()> {
13887        if !self.state.is_alive() {
13888            anyhow::bail!("you are dead");
13889        }
13890        self.seq += 1;
13891        self.session
13892            .submit_intent(Intent::AssignSlotPreset {
13893                entity_id: self.state.entity_id,
13894                slot_index,
13895                preset_id: preset_id.to_string(),
13896                seq: self.seq,
13897            })
13898            .await?;
13899        self.state.intents_sent += 1;
13900        if let Some(slot) = self
13901            .state
13902            .combat_slots
13903            .iter_mut()
13904            .find(|s| s.slot_index == slot_index)
13905        {
13906            slot.preset_id = Some(preset_id.to_string());
13907            if let Some(preset) = self
13908                .state
13909                .rotation_presets
13910                .iter()
13911                .find(|p| p.id == preset_id)
13912            {
13913                slot.preset_label = Some(preset.label.clone());
13914                slot.rotation = preset.abilities.clone();
13915                slot.rotation_index = 0;
13916            }
13917        }
13918        self.state
13919            .push_log(format!("T{slot_index} loadout → {preset_id}"));
13920        Ok(())
13921    }
13922
13923    pub async fn cast_ability(
13924        &mut self,
13925        ability_id: &str,
13926        target_id: Option<EntityId>,
13927    ) -> anyhow::Result<()> {
13928        if !self.state.is_alive() {
13929            anyhow::bail!("you are dead");
13930        }
13931        let allows_ground = self.state.ability_allows_ground(ability_id);
13932        let requires_ground = self.state.ability_requires_ground(ability_id);
13933        if requires_ground && self.state.ground_target.is_none() {
13934            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
13935        }
13936        let (resolved_target_id, target_point) = if allows_ground {
13937            if let Some((x, y, z)) = self.state.ground_target {
13938                (
13939                    target_id.unwrap_or(self.state.entity_id),
13940                    Some(flatland_protocol::AimPoint { x, y, z }),
13941                )
13942            } else {
13943                (
13944                    target_id
13945                        .or_else(|| self.state.target_for_slot(2))
13946                        .or_else(|| self.state.target_for_slot(1))
13947                        .unwrap_or(self.state.entity_id),
13948                    None,
13949                )
13950            }
13951        } else {
13952            (
13953                target_id
13954                    .or_else(|| self.state.target_for_slot(2))
13955                    .or_else(|| self.state.target_for_slot(1))
13956                    .unwrap_or(self.state.entity_id),
13957                None,
13958            )
13959        };
13960        self.seq += 1;
13961        self.session
13962            .submit_intent(Intent::Cast {
13963                entity_id: self.state.entity_id,
13964                ability_id: ability_id.to_string(),
13965                target_id: resolved_target_id,
13966                target_point,
13967                seq: self.seq,
13968            })
13969            .await?;
13970        self.state.intents_sent += 1;
13971        match target_point {
13972            Some(point) => self.state.push_log(format!(
13973                "Cast {ability_id} → ({:.1}, {:.1})",
13974                point.x, point.y
13975            )),
13976            None => self
13977                .state
13978                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
13979        }
13980        Ok(())
13981    }
13982
13983    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13984        self.seq += 1;
13985        self.session
13986            .submit_intent(Intent::UpsertRotationPreset {
13987                entity_id: self.state.entity_id,
13988                preset: preset.clone(),
13989                seq: self.seq,
13990            })
13991            .await?;
13992        self.state.intents_sent += 1;
13993        if let Some(existing) = self
13994            .state
13995            .rotation_presets
13996            .iter_mut()
13997            .find(|p| p.id == preset.id)
13998        {
13999            *existing = preset.clone();
14000        } else {
14001            self.state.rotation_presets.push(preset.clone());
14002        }
14003        for slot in &mut self.state.combat_slots {
14004            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14005                slot.preset_label = Some(preset.label.clone());
14006                slot.rotation = preset.abilities.clone();
14007            }
14008        }
14009        self.state
14010            .push_log(format!("Saved rotation: {}", preset.label));
14011        Ok(())
14012    }
14013
14014    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14015        self.seq += 1;
14016        self.session
14017            .submit_intent(Intent::DeleteRotationPreset {
14018                entity_id: self.state.entity_id,
14019                preset_id: preset_id.to_string(),
14020                seq: self.seq,
14021            })
14022            .await?;
14023        self.state.intents_sent += 1;
14024        self.state.rotation_presets.retain(|p| p.id != preset_id);
14025        for slot in &mut self.state.combat_slots {
14026            if slot.preset_id.as_deref() == Some(preset_id) {
14027                slot.preset_id = None;
14028                slot.preset_label = None;
14029                slot.rotation.clear();
14030                slot.rotation_index = 0;
14031            }
14032        }
14033        self.state
14034            .push_log(format!("Deleted rotation: {preset_id}"));
14035        Ok(())
14036    }
14037
14038    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14039        if !self.state.is_alive() {
14040            anyhow::bail!("you are dead");
14041        }
14042        let enabled = !self
14043            .state
14044            .combat_slots
14045            .iter()
14046            .find(|s| s.slot_index == slot_index)
14047            .map(|s| s.auto_enabled)
14048            .unwrap_or(false);
14049        self.seq += 1;
14050        self.session
14051            .submit_intent(Intent::SetAutoAttack {
14052                entity_id: self.state.entity_id,
14053                slot_index,
14054                enabled,
14055                seq: self.seq,
14056            })
14057            .await?;
14058        if slot_index == 1 {
14059            self.state.auto_attack = enabled;
14060        }
14061        self.state.intents_sent += 1;
14062        self.state.push_log(format!(
14063            "T{slot_index} auto {}",
14064            if enabled { "ON" } else { "OFF" }
14065        ));
14066        Ok(())
14067    }
14068
14069    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14070        if !self.state.connected {
14071            anyhow::bail!("not connected");
14072        }
14073        if !self.state.is_alive() {
14074            anyhow::bail!("you are dead");
14075        }
14076        let (px, py) = self.state.player_position();
14077        if self
14078            .state
14079            .ground_drops
14080            .iter()
14081            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14082        {
14083            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14084        }
14085        self.seq += 1;
14086        self.session
14087            .submit_intent(Intent::Pickup {
14088                entity_id: self.state.entity_id,
14089                drop_id: None,
14090                seq: self.seq,
14091            })
14092            .await?;
14093        self.state.intents_sent += 1;
14094        Ok(())
14095    }
14096
14097    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
14098        self.toggle_auto_attack_slot(1).await
14099    }
14100
14101    pub async fn dodge(&mut self) -> anyhow::Result<()> {
14102        if !self.state.is_alive() {
14103            anyhow::bail!("you are dead");
14104        }
14105        self.seq += 1;
14106        self.session
14107            .submit_intent(Intent::Dodge {
14108                entity_id: self.state.entity_id,
14109                seq: self.seq,
14110            })
14111            .await?;
14112        self.state.intents_sent += 1;
14113        self.state.push_log("Dodge!");
14114        Ok(())
14115    }
14116
14117    pub async fn lunge(&mut self) -> anyhow::Result<()> {
14118        if !self.state.is_alive() {
14119            anyhow::bail!("you are dead");
14120        }
14121        let (forward, strafe) = self.last_move_axes();
14122        self.seq += 1;
14123        self.session
14124            .submit_intent(Intent::Lunge {
14125                entity_id: self.state.entity_id,
14126                forward,
14127                strafe,
14128                seq: self.seq,
14129            })
14130            .await?;
14131        self.state.intents_sent += 1;
14132        self.state.push_log("Lunge!");
14133        Ok(())
14134    }
14135
14136    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14137        if !self.state.is_alive() {
14138            anyhow::bail!("you are dead");
14139        }
14140        self.seq += 1;
14141        self.session
14142            .submit_intent(Intent::DirectionalJump {
14143                entity_id: self.state.entity_id,
14144                forward,
14145                strafe,
14146                seq: self.seq,
14147            })
14148            .await?;
14149        self.state.intents_sent += 1;
14150        self.state.push_log("Jump!");
14151        Ok(())
14152    }
14153
14154    /// Remembered WASD axes for lunge when not currently moving.
14155    pub fn last_move_axes(&self) -> (f32, f32) {
14156        (self.last_move_forward, self.last_move_strafe)
14157    }
14158
14159    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14160        if !self.state.is_alive() {
14161            anyhow::bail!("you are dead");
14162        }
14163        self.seq += 1;
14164        self.session
14165            .submit_intent(Intent::Block {
14166                entity_id: self.state.entity_id,
14167                enabled,
14168                seq: self.seq,
14169            })
14170            .await?;
14171        self.state.intents_sent += 1;
14172        if enabled {
14173            self.state.push_log("Blocking");
14174        }
14175        Ok(())
14176    }
14177
14178    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14179        if !self.state.is_alive() {
14180            anyhow::bail!("you are dead");
14181        }
14182        self.seq += 1;
14183        self.session
14184            .submit_intent(Intent::EquipMainhand {
14185                entity_id: self.state.entity_id,
14186                template_id,
14187                instance_id: None,
14188                seq: self.seq,
14189            })
14190            .await?;
14191        self.state.intents_sent += 1;
14192        Ok(())
14193    }
14194
14195    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
14196    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14197        let idx = self.state.equip_menu_index;
14198        let slots = equip_paperdoll_rows(&self.state);
14199        let Some(row) = slots.get(idx) else {
14200            return Ok(());
14201        };
14202        match row {
14203            EquipPaperdollRow::Body { slot, filled } => {
14204                if *filled {
14205                    self.equip_worn(*slot, None).await
14206                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14207                    self.equip_worn(*slot, Some(inst)).await
14208                } else {
14209                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
14210                    Ok(())
14211                }
14212            }
14213            EquipPaperdollRow::Mainhand { filled } => {
14214                if *filled {
14215                    self.unequip_mainhand().await
14216                } else if let Some(tid) = first_inventory_weapon(&self.state) {
14217                    self.equip_mainhand(Some(tid)).await
14218                } else {
14219                    self.state.push_log("No weapon in inventory".to_string());
14220                    Ok(())
14221                }
14222            }
14223            EquipPaperdollRow::Offhand { filled, locked } => {
14224                if *locked {
14225                    self.state
14226                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
14227                    Ok(())
14228                } else if *filled {
14229                    self.unequip_offhand().await
14230                } else if let Some(tid) = first_inventory_offhand(&self.state) {
14231                    self.equip_offhand(Some(tid)).await
14232                } else {
14233                    self.state
14234                        .push_log("No offhand item in inventory".to_string());
14235                    Ok(())
14236                }
14237            }
14238        }
14239    }
14240
14241    pub async fn say(
14242        &mut self,
14243        channel: flatland_protocol::ChatChannel,
14244        text: &str,
14245    ) -> anyhow::Result<()> {
14246        self.say_to(channel, text, None).await
14247    }
14248
14249    pub async fn say_to(
14250        &mut self,
14251        channel: flatland_protocol::ChatChannel,
14252        text: &str,
14253        to_entity: Option<EntityId>,
14254    ) -> anyhow::Result<()> {
14255        self.seq += 1;
14256        self.session
14257            .submit_intent(Intent::Say {
14258                entity_id: self.state.entity_id,
14259                channel,
14260                text: text.to_string(),
14261                to_entity,
14262                seq: self.seq,
14263            })
14264            .await?;
14265        self.state.intents_sent += 1;
14266        Ok(())
14267    }
14268
14269    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14270        let Some(peer) = self.state.player_verbs.target_entity else {
14271            return Ok(());
14272        };
14273        let label = self.state.player_verbs.target_label.clone();
14274        let choice = crate::social::PlayerVerbState::options()
14275            .get(self.state.player_verbs.index)
14276            .copied()
14277            .unwrap_or("Whisper");
14278        self.state.player_verbs.close();
14279        match choice {
14280            "Trade" => {
14281                // Only request — peer must accept (Y in chat). Do not auto-respond;
14282                // TradeRespond requires a pending inbound request from the peer.
14283                self.seq += 1;
14284                self.session
14285                    .submit_intent(Intent::TradeRequest {
14286                        entity_id: self.state.entity_id,
14287                        peer_entity_id: peer,
14288                        seq: self.seq,
14289                    })
14290                    .await?;
14291                self.state.intents_sent += 1;
14292                self.state
14293                    .social_chat
14294                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
14295            }
14296            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
14297            _ => self.state.social_chat.focus_nearby(),
14298        }
14299        Ok(())
14300    }
14301
14302    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
14303        let Some(pending) = self.state.social_chat.pending_trade.take() else {
14304            return Ok(());
14305        };
14306        self.seq += 1;
14307        self.session
14308            .submit_intent(Intent::TradeRespond {
14309                entity_id: self.state.entity_id,
14310                peer_entity_id: pending.from_entity,
14311                accept,
14312                seq: self.seq,
14313            })
14314            .await?;
14315        self.state.intents_sent += 1;
14316        if accept {
14317            self.state
14318                .social_chat
14319                .push_system(format!("Accepted trade with {}", pending.from_name));
14320        } else {
14321            self.state
14322                .social_chat
14323                .push_system(format!("Declined trade with {}", pending.from_name));
14324        }
14325        Ok(())
14326    }
14327
14328    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
14329        let text = self.state.social_chat.buffer.trim().to_string();
14330        if text.is_empty() {
14331            return Ok(());
14332        }
14333        self.state.social_chat.buffer.clear();
14334        if crate::social::is_chat_slash_line(&text) {
14335            match crate::social::parse_chat_slash(&text) {
14336                Some(cmd) => return self.apply_chat_slash(cmd).await,
14337                None => {
14338                    self.state.social_chat.push_system(format!(
14339                        "Unknown command — {}",
14340                        crate::social::chat_slash_help_text()
14341                    ));
14342                    return Ok(());
14343                }
14344            }
14345        }
14346        let thread = self.state.social_chat.thread;
14347        let channel = thread.channel();
14348        let to = thread.to_entity();
14349        if let Some(peer) = to {
14350            let label = self.state.social_chat.peer_label.clone();
14351            self.state
14352                .social_chat
14353                .remember_whisper_peer(peer, &label, channel);
14354        }
14355        self.say_to(channel, &text, to).await
14356    }
14357
14358    async fn apply_chat_slash(
14359        &mut self,
14360        cmd: crate::social::ChatSlashCommand,
14361    ) -> anyhow::Result<()> {
14362        use crate::social::{chat_slash_help_text, ChatSlashCommand};
14363        match cmd {
14364            ChatSlashCommand::Help => {
14365                self.state
14366                    .social_chat
14367                    .push_system(chat_slash_help_text().to_string());
14368                Ok(())
14369            }
14370            ChatSlashCommand::Nearby { message } => {
14371                self.state.social_chat.focus_nearby();
14372                self.state
14373                    .social_chat
14374                    .push_system("Nearby speech — everyone close can hear");
14375                if let Some(msg) = message {
14376                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
14377                        .await
14378                } else {
14379                    Ok(())
14380                }
14381            }
14382            ChatSlashCommand::Reply { message } => {
14383                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14384                    self.state.social_chat.push_system(
14385                        "No one to reply to — wait for a whisper, or /whisper Name",
14386                    );
14387                    return Ok(());
14388                };
14389                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
14390                self.state
14391                    .social_chat
14392                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
14393                self.state.social_chat.push_system(format!(
14394                    "Replying to {} — type and Enter · /nearby",
14395                    peer.label
14396                ));
14397                if let Some(msg) = message {
14398                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
14399                } else {
14400                    Ok(())
14401                }
14402            }
14403            ChatSlashCommand::Whisper { name, message } => {
14404                let (peer_id, label, stone) = if let Some(name) = name {
14405                    match self.resolve_whisper_target(&name) {
14406                        Ok(t) => t,
14407                        Err(err) => {
14408                            self.state.social_chat.push_system(err);
14409                            return Ok(());
14410                        }
14411                    }
14412                } else {
14413                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14414                        self.state.social_chat.push_system(
14415                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
14416                        );
14417                        return Ok(());
14418                    };
14419                    (
14420                        peer.entity_id,
14421                        peer.label,
14422                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
14423                    )
14424                };
14425                self.state
14426                    .social_chat
14427                    .set_whisper_thread(peer_id, &label, stone);
14428                let channel = if stone {
14429                    flatland_protocol::ChatChannel::WhisperStone
14430                } else {
14431                    flatland_protocol::ChatChannel::Whisper
14432                };
14433                if let Some(msg) = message {
14434                    self.state.social_chat.push_system(format!(
14435                        "Whisper → {label}"
14436                    ));
14437                    self.say_to(channel, &msg, Some(peer_id)).await
14438                } else {
14439                    self.state.social_chat.push_system(format!(
14440                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
14441                    ));
14442                    Ok(())
14443                }
14444            }
14445        }
14446    }
14447
14448    /// Resolve `/whisper Name` against AOI players (label match).
14449    fn resolve_whisper_target(
14450        &self,
14451        name: &str,
14452    ) -> Result<(EntityId, String, bool), String> {
14453        let needle = name.trim().to_ascii_lowercase();
14454        if needle.is_empty() {
14455            return Err("Usage: /whisper Name [message]".into());
14456        }
14457        let mut candidates: Vec<(EntityId, String)> = self
14458            .state
14459            .entities
14460            .iter()
14461            .filter(|e| e.id != self.state.entity_id)
14462            .filter(|e| !e.label.trim().is_empty())
14463            .filter(|e| e.vitals.is_some())
14464            .filter(|e| {
14465                !self
14466                    .state
14467                    .npcs
14468                    .iter()
14469                    .any(|n| n.id == e.id.to_string())
14470            })
14471            .filter(|e| {
14472                !self
14473                    .state
14474                    .hired_workers
14475                    .iter()
14476                    .any(|w| w.entity_id == e.id)
14477            })
14478            .map(|e| (e.id, e.label.clone()))
14479            .collect();
14480
14481        // Also allow matching the last whisper peer by name even if they left AOI briefly.
14482        if let Some(last) = &self.state.social_chat.last_whisper_peer {
14483            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
14484                candidates.push((last.entity_id, last.label.clone()));
14485            }
14486        }
14487
14488        let exact: Vec<_> = candidates
14489            .iter()
14490            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
14491            .cloned()
14492            .collect();
14493        let pool = if exact.len() == 1 {
14494            exact
14495        } else if exact.len() > 1 {
14496            return Err(format!(
14497                "Several players named '{name}' nearby — move closer and try again"
14498            ));
14499        } else {
14500            let starts: Vec<_> = candidates
14501                .iter()
14502                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
14503                .cloned()
14504                .collect();
14505            if starts.len() == 1 {
14506                starts
14507            } else if starts.len() > 1 {
14508                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
14509                return Err(format!(
14510                    "Ambiguous name '{name}' — matches: {}",
14511                    names.join(", ")
14512                ));
14513            } else {
14514                let contains: Vec<_> = candidates
14515                    .iter()
14516                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
14517                    .cloned()
14518                    .collect();
14519                if contains.len() == 1 {
14520                    contains
14521                } else if contains.is_empty() {
14522                    return Err(format!(
14523                        "No player matching '{name}' in range — get closer or check the spelling"
14524                    ));
14525                } else {
14526                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
14527                    return Err(format!(
14528                        "Ambiguous name '{name}' — matches: {}",
14529                        names.join(", ")
14530                    ));
14531                }
14532            }
14533        };
14534
14535        let (id, label) = pool.into_iter().next().unwrap();
14536        let stone = self
14537            .state
14538            .social_chat
14539            .last_whisper_peer
14540            .as_ref()
14541            .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
14542        Ok((id, label, stone))
14543    }
14544
14545    pub async fn trade_present_selected(
14546        &mut self,
14547        item_instance_id: uuid::Uuid,
14548    ) -> anyhow::Result<()> {
14549        self.trade_present_quantity(item_instance_id, None).await
14550    }
14551
14552    pub async fn trade_present_quantity(
14553        &mut self,
14554        item_instance_id: uuid::Uuid,
14555        quantity: Option<u32>,
14556    ) -> anyhow::Result<()> {
14557        self.seq += 1;
14558        self.session
14559            .submit_intent(Intent::TradePresent {
14560                entity_id: self.state.entity_id,
14561                item_instance_id,
14562                quantity,
14563                seq: self.seq,
14564            })
14565            .await?;
14566        self.state.intents_sent += 1;
14567        self.state.trade_ui.qty_entry = None;
14568        self.state.trade_ui.picking_inventory = false;
14569        Ok(())
14570    }
14571
14572    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
14573    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
14574        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
14575            let qty = self.state.trade_ui.present_quantity();
14576            return self
14577                .trade_present_quantity(entry.item_instance_id, qty)
14578                .await;
14579        }
14580        if !self.state.trade_ui.picking_inventory {
14581            return Ok(());
14582        }
14583        let stacks = self.state.trade_presentable_stacks();
14584        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
14585            return Ok(());
14586        };
14587        let Some(id) = stack.item_instance_id else {
14588            return Ok(());
14589        };
14590        let label = stack
14591            .display_name
14592            .clone()
14593            .unwrap_or_else(|| stack.template_id.clone());
14594        if stack.quantity <= 1 {
14595            self.trade_present_quantity(id, Some(1)).await
14596        } else {
14597            self.state
14598                .trade_ui
14599                .begin_qty_entry(id, label, stack.quantity);
14600            Ok(())
14601        }
14602    }
14603
14604    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
14605        self.seq += 1;
14606        self.session
14607            .submit_intent(Intent::TradeSetReady {
14608                entity_id: self.state.entity_id,
14609                ready,
14610                seq: self.seq,
14611            })
14612            .await?;
14613        self.state.intents_sent += 1;
14614        Ok(())
14615    }
14616
14617    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
14618        self.seq += 1;
14619        self.session
14620            .submit_intent(Intent::TradeCancel {
14621                entity_id: self.state.entity_id,
14622                seq: self.seq,
14623            })
14624            .await?;
14625        self.state.intents_sent += 1;
14626        self.state.trade_ui.close();
14627        Ok(())
14628    }
14629
14630    pub async fn destroy_whisper_stone(
14631        &mut self,
14632        item_instance_id: uuid::Uuid,
14633    ) -> anyhow::Result<()> {
14634        self.seq += 1;
14635        self.session
14636            .submit_intent(Intent::DestroyWhisperStone {
14637                entity_id: self.state.entity_id,
14638                item_instance_id,
14639                seq: self.seq,
14640            })
14641            .await?;
14642        self.state.intents_sent += 1;
14643        Ok(())
14644    }
14645
14646    pub async fn stop(&mut self) -> anyhow::Result<()> {
14647        self.seq += 1;
14648        self.session
14649            .submit_intent(Intent::Stop {
14650                entity_id: self.state.entity_id,
14651                seq: self.seq,
14652            })
14653            .await?;
14654        self.state.intents_sent += 1;
14655        Ok(())
14656    }
14657
14658    pub fn disconnect(&self) {
14659        self.session.disconnect();
14660    }
14661}
14662
14663fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14664    let dx = ax - bx;
14665    let dy = ay - by;
14666    (dx * dx + dy * dy).sqrt()
14667}
14668
14669#[cfg(test)]
14670mod tests {
14671    use std::collections::BTreeMap;
14672
14673    use super::*;
14674    use flatland_protocol::{
14675        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
14676    };
14677
14678    fn sample_state() -> GameState {
14679        let mut state = GameState {
14680            session_id: 1,
14681            entity_id: 1,
14682            character_id: None,
14683            tick: 0,
14684            chunk_rev: 0,
14685            content_rev: 0,
14686            publish_rev: 0,
14687            entities: vec![EntityState {
14688                id: 1,
14689                label: "You".into(),
14690                transform: Transform {
14691                    position: WorldCoord::surface(128.0, 128.0),
14692                    yaw: 0.0,
14693                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14694                },
14695                vitals: None,
14696                attributes: None,
14697                skills: None,
14698                inside_building: None,
14699                tile_id: None,
14700                paperdoll_ref: None,
14701                presentation_state: None,
14702                sprite_mode: None,
14703                progression_xp: None,
14704                combat_cues: vec![],
14705                statuses: vec![],
14706            }],
14707            player: None,
14708            resource_nodes: vec![ResourceNodeView {
14709                id: "oak-1".into(),
14710                label: "Oak".into(),
14711                x: 130.0,
14712                y: 128.0,
14713                z: 0.0,
14714                item_template: "oak_log".into(),
14715                state: ResourceNodeState::Available,
14716                blocking: true,
14717                blocking_radius_m: 0.8,
14718                harvest_off: false,
14719                tile_id: None,
14720                                yaw: 0.0,
14721                pitch: 0.0,
14722                roll: 0.0,
14723                draw_scale: 1.0,
14724                sprite_mode: None,
14725                growth_progress: None,
14726                presentation_state: None,
14727                channel_start_tick: None,
14728                channel_end_tick: None,
14729                harvest_drop_templates: vec![],
14730            }],
14731            ground_drops: vec![],
14732            placed_containers: vec![],
14733            buildings: vec![BuildingView {
14734                id: "broker-hut".into(),
14735                label: "Broker".into(),
14736                x: 148.0,
14737                y: 118.0,
14738                width_m: 8.0,
14739                depth_m: 6.0,
14740                interior_blueprint: Some("broker_hut".into()),
14741                tags: vec![],
14742                market_boundary_zone_ids: vec![],
14743                market_max_volume: None,
14744                wall_set: None,
14745                roof_set: None,
14746            }],
14747            doors: vec![flatland_protocol::DoorView {
14748                id: "door-1".into(),
14749                building_id: "broker-hut".into(),
14750                x: 148.0,
14751                y: 118.0,
14752                open: false,
14753                portal: Some("front".into()),
14754            locked: false,
14755            accessible: true,
14756            lock_id: None,
14757        }],
14758            interior_map: None,
14759            npcs: vec![],
14760            blueprints: vec![],
14761            building_materials: vec![],
14762            world_x0: 0.0,
14763            world_y0: 0.0,
14764            world_width_m: 256.0,
14765            world_height_m: 256.0,
14766            terrain_zones: Vec::new(),
14767            z_platforms: Vec::new(),
14768            z_transitions: Vec::new(),
14769            z_bands_outdoor_backup: None,
14770            world_clock: flatland_protocol::WorldClock::default(),
14771            inventory: std::collections::HashMap::new(),
14772            inventory_hints: std::collections::HashMap::new(),
14773            logs: VecDeque::new(),
14774            intents_sent: 0,
14775            ticks_received: 0,
14776            connected: true,
14777            disconnect_reason: None,
14778            show_stats: false,
14779            hud_log_hidden: false,
14780            show_equip_menu: false,
14781            equip_menu_index: 0,
14782            show_craft_menu: false,
14783            show_plot_build_menu: false,
14784            plot_build_focus_wall: true,
14785            plot_build_wall_index: 0,
14786            plot_build_roof_index: 0,
14787            craft_menu_index: 0,
14788            craft_batch_quantity: 1,
14789            show_shop_menu: false,
14790            shop_catalog: None,
14791            bank_panel: None,
14792            bank_menu_index: 0,
14793            bank_ui_mode: BankUiMode::Menu,
14794            storage_panel: None,
14795                market_panel: None,
14796            market_menu_index: 0,
14797            market_filter: String::new(),
14798            market_filter_focused: false,
14799            market_category_filter: None,
14800            market_buy_confirm: None,
14801            market_ui_mode: MarketUiMode::Browse,
14802            storage_menu_index: 0,
14803            storage_ui_mode: StorageUiMode::Menu,
14804            shop_tab: ShopTab::default(),
14805            shop_menu_index: 0,
14806            shop_quantity: 1,
14807            shop_trade_log: VecDeque::new(),
14808            show_npc_verb_menu: false,
14809            npc_verb_target: None,
14810            npc_verb_index: 0,
14811            player_verbs: crate::social::PlayerVerbState::default(),
14812            social_chat: crate::social::SocialChatState::default(),
14813            trade_ui: crate::social::TradeUiState::default(),
14814            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14815            show_npc_chat: false,
14816            npc_chat: None,
14817            show_inventory_menu: false,
14818            inventory_menu_index: 0,
14819            inventory_tab: InventoryTab::OnPerson,
14820            inventory_filter: String::new(),
14821            inventory_filter_focused: false,
14822            show_move_picker: false,
14823            show_rename_prompt: false,
14824            show_worker_rename: false,
14825            rename_buffer: String::new(),
14826            move_picker_index: 0,
14827            move_picker: None,
14828            show_grant_picker: false,
14829            grant_picker_index: 0,
14830            grant_picker: None,
14831            show_destroy_picker: false,
14832            destroy_confirm_pending: false,
14833            destroy_picker: None,
14834            combat_target: None,
14835            combat_target_label: None,
14836            ground_target: None,
14837            combat_fx: Vec::new(),
14838            property_zones: Vec::new(),
14839            tax_zones: Vec::new(),
14840            growth_zones: Vec::new(),
14841            biome_zones: Vec::new(),
14842            terrain_kind_nav: Vec::new(),
14843            property_plots: Vec::new(),
14844            property_plot_settings: None,
14845            claim_mode: None,
14846            relocate_mode: None,
14847            sell_plot_confirm: None,
14848            sell_plot_armed_at: None,
14849            show_plant_menu: false,
14850            plant_menu_index: 0,
14851            show_farm_access: false,
14852            farm_access_name_draft: String::new(),
14853            farm_access_discount_bps: 0,
14854            farm_access_index: 0,
14855            plant_quantity: 1,
14856            in_combat: false,
14857            auto_attack: true,
14858            combat_has_los: false,
14859            attack_cd_ticks: 0,
14860            gcd_ticks: 0,
14861            weapon_ability_id: "unarmed".into(),
14862            mainhand_template_id: None,
14863            mainhand_label: None,
14864            mainhand_instance_id: None,
14865            offhand_template_id: None,
14866            offhand_label: None,
14867            offhand_instance_id: None,
14868            mainhand_hand_slots: 1,
14869            defense: None,
14870            worn: BTreeMap::new(),
14871            carry_mass: 0.0,
14872            carry_mass_max: 0.0,
14873            encumbrance: flatland_protocol::EncumbranceState::Light,
14874            inventory_stacks: Vec::new(),
14875            keychain_stacks: Vec::new(),
14876            whisper_pouch_stacks: Vec::new(),
14877            combat_target_detail: None,
14878                statuses: Vec::new(),
14879            cast_progress: None,
14880            timed_channel: None,
14881            plot_build_offer: None,
14882            ability_cooldowns: Vec::new(),
14883            blocking_active: false,
14884            max_target_slots: 1,
14885            combat_slots: Vec::new(),
14886            rotation_presets: Vec::new(),
14887            known_abilities: Vec::new(),
14888            ability_meta: std::collections::HashMap::new(),
14889            ability_mastery: std::collections::HashMap::new(),
14890            hotbar: vec![None; 9],
14891            max_abilities_per_rotation: 0,
14892            show_loadout_menu: false,
14893            show_keychain_menu: false,
14894            keychain_menu_index: 0,
14895            show_rotation_editor: false,
14896            loadout_menu_index: 0,
14897            loadout_hotbar_slot: 1,
14898            loadout_ability_index: 0,
14899            loadout_focus_presets: false,
14900            rotation_editor: RotationEditorState::default(),
14901            harvest_in_progress: false,
14902            harvest_started_at: None,
14903            pending_craft_ack: None,
14904            pending_worker_job_ack: None,
14905            attending_worker_instance_id: None,
14906            quest_log: Vec::new(),
14907            interactables: Vec::new(),
14908            ledger: None,
14909            career: None,
14910            character_sheet_tab: CharacterSheetTab::Character,
14911            ledger_period: LedgerPeriod::Day,
14912            show_quest_offer: false,
14913            pending_quest_offer: None,
14914            show_quest_menu: false,
14915            quest_menu_index: 0,
14916            quest_withdraw_confirm: false,
14917            hired_workers: Vec::new(),
14918            show_workers_menu: false,
14919            workers_menu_index: 0,
14920            workers_menu_compact: false,
14921            worker_step_display: BTreeMap::new(),
14922            worker_error_display: BTreeMap::new(),
14923            show_worker_give_picker: false,
14924            worker_give_picker_index: 0,
14925            worker_give_picker: None,
14926            show_worker_give_target_picker: false,
14927            worker_give_target_picker_index: 0,
14928            worker_give_target_picker: None,
14929            show_worker_take_picker: false,
14930            worker_take_picker_index: 0,
14931            worker_take_picker: None,
14932            show_worker_teach_picker: false,
14933            worker_teach_picker_index: 0,
14934            worker_teach_picker: None,
14935            worker_route_editor: None,
14936            progression_curve: None,
14937        };
14938        state.player = state.entities.first().cloned();
14939        state
14940    }
14941
14942    #[test]
14943    fn whisper_cancels_when_peer_walks_out_of_range() {
14944        let mut state = sample_state();
14945        state.player = state.entities.first().cloned();
14946        let mut peer = state.entities[0].clone();
14947        peer.id = 2;
14948        peer.label = "Ada".into();
14949        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
14950        state.entities.push(peer.clone());
14951        state.social_chat.focus_whisper(2, "Ada");
14952        state.refresh_whisper_range();
14953        assert!(matches!(
14954            state.social_chat.thread,
14955            crate::social::ChatThreadKind::Whisper { peer: 2 }
14956        ));
14957
14958        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
14959        state.entities[1] = peer;
14960        state.refresh_whisper_range();
14961        assert_eq!(
14962            state.social_chat.thread,
14963            crate::social::ChatThreadKind::Nearby
14964        );
14965        assert!(!state.social_chat.input_focused);
14966    }
14967
14968    #[test]
14969    fn probe_use_world_hired_worker_manage() {
14970        let mut state = sample_state();
14971        state.hired_workers.push(flatland_protocol::HiredWorkerView {
14972            instance_id: "worker-1".into(),
14973            entity_id: 42,
14974            def_id: "worker_laborer".into(),
14975            label: "Sam".into(),
14976            x: 129.0,
14977            y: 128.0,
14978            z: 0.0,
14979            mode: flatland_protocol::WorkerModeView::JobLoop,
14980            state: flatland_protocol::WorkerStateView::Working,
14981            step_label: "cultivate".into(),
14982            vitals: flatland_protocol::WorkerVitalsSummary {
14983                health_pct: 100.0,
14984                stamina_pct: 100.0,
14985            },
14986            carry_pct: 0.0,
14987            last_error: None,
14988            wage_copper_per_interval: 1,
14989            effective_wage_copper: 1,
14990            wage_meters_walked: 0.0,
14991            lodging_container_id: None,
14992            route: None,
14993            route_stop_index: None,
14994            known_blueprint_ids: Vec::new(),
14995            level: 1,
14996            worker_xp: 0.0,
14997            inventory: Vec::new(),
14998        });
14999        let probe = state.probe_use_world();
15000        let primary = probe.primary.expect("primary");
15001        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15002        assert_eq!(primary.id, "worker-1");
15003        assert!(primary.hint_line().contains("Manage"));
15004        assert!(primary.hint_line().contains("Sam"));
15005        assert_eq!(
15006            state.nearest_interact_target().as_deref(),
15007            Some("worker-1")
15008        );
15009    }
15010
15011    #[test]
15012    fn market_clerk_verb_options_include_market() {
15013        let mut state = sample_state();
15014        state.npcs.push(flatland_protocol::NpcView {
15015            id: "mira_market".into(),
15016            label: "Mira".into(),
15017            role: "market_clerk".into(),
15018            x: 129.0,
15019            y: 128.0,
15020            building_id: Some("town_market".into()),
15021            entity_id: None,
15022            life_state: None,
15023            hp_pct: None,
15024            can_trade: false,
15025            tile_id: None,
15026            behavior_state: None,
15027            presentation_state: None,
15028            sprite_mode: None,
15029            paperdoll_ref: None,
15030        });
15031        state.npc_verb_target = Some("mira_market".into());
15032        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15033    }
15034
15035    #[test]
15036    fn market_list_excludes_currency_stacks() {
15037        let mut state = sample_state();
15038        state.inventory_stacks = vec![
15039            flatland_protocol::ItemStack {
15040                template_id: "copper_coin".into(),
15041                quantity: 50,
15042                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15043                display_name: Some("Copper Coin".into()),
15044                ..Default::default()
15045            },
15046            flatland_protocol::ItemStack {
15047                template_id: "oak_log".into(),
15048                quantity: 2,
15049                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15050                display_name: Some("Oak Log".into()),
15051                ..Default::default()
15052            },
15053            flatland_protocol::ItemStack {
15054                template_id: "whisper_stone".into(),
15055                quantity: 1,
15056                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15057                display_name: Some("Whisper Stone".into()),
15058                category: Some("quest".into()),
15059                listable: Some(false),
15060                ..Default::default()
15061            },
15062        ];
15063        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15064        assert_eq!(opts.len(), 1);
15065        assert!(opts[0].label.contains("Oak"));
15066    }
15067
15068    #[test]
15069    fn market_browse_filters_by_category_and_search() {
15070        let mut state = sample_state();
15071        state.market_panel = Some(flatland_protocol::MarketPanel {
15072            npc_id: "mira_market".into(),
15073            npc_label: "Mira".into(),
15074            building_id: "town_market".into(),
15075            building_label: "Town Market".into(),
15076            used_volume: 0.0,
15077            max_volume: 100.0,
15078            listings: vec![
15079                flatland_protocol::MarketListingView {
15080                    listing_id: uuid::Uuid::from_u128(1),
15081                    seller_character_id: uuid::Uuid::from_u128(2),
15082                    seller_label: "Ada".into(),
15083                    hall_building_id: "town_market".into(),
15084                    hall_label: "Town Market".into(),
15085                    template_id: "oak_log".into(),
15086                    display_name: "Oak Log".into(),
15087                    category: "resource".into(),
15088                    quantity: 3,
15089                    unit_price_copper: 10,
15090                    line_total_copper: 30,
15091                    npc_price: false,
15092                    mine: false,
15093                },
15094                flatland_protocol::MarketListingView {
15095                    listing_id: uuid::Uuid::from_u128(3),
15096                    seller_character_id: uuid::Uuid::from_u128(2),
15097                    seller_label: "Ada".into(),
15098                    hall_building_id: "town_market".into(),
15099                    hall_label: "Town Market".into(),
15100                    template_id: "short_sword".into(),
15101                    display_name: "Short Sword".into(),
15102                    category: "weapon".into(),
15103                    quantity: 1,
15104                    unit_price_copper: 100,
15105                    line_total_copper: 100,
15106                    npc_price: false,
15107                    mine: false,
15108                },
15109            ],
15110            tax_bps: 0,
15111            tax_flat_copper: 0,
15112            list_vaults: vec![],
15113        });
15114        assert_eq!(state.market_filtered_listing_indices().len(), 2);
15115        state.market_category_filter = Some("Weapons");
15116        let weapons = state.market_filtered_listing_indices();
15117        assert_eq!(weapons.len(), 1);
15118        assert_eq!(
15119            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15120            "Short Sword"
15121        );
15122        state.market_category_filter = None;
15123        state.market_filter = "oak".into();
15124        let oak = state.market_filtered_listing_indices();
15125        assert_eq!(oak.len(), 1);
15126        assert_eq!(
15127            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15128            "Oak Log"
15129        );
15130    }
15131
15132    #[test]
15133    fn market_list_source_includes_person_and_vaults() {
15134        let mut state = sample_state();
15135        let item_id = uuid::Uuid::from_u128(1);
15136        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15137            template_id: "oak_log".into(),
15138            quantity: 2,
15139            item_instance_id: Some(item_id),
15140            display_name: Some("Oak Log".into()),
15141            ..Default::default()
15142        }];
15143        state.market_panel = Some(flatland_protocol::MarketPanel {
15144            npc_id: "mira_market".into(),
15145            npc_label: "Mira".into(),
15146            building_id: "town_market".into(),
15147            building_label: "Town Market".into(),
15148            used_volume: 0.0,
15149            max_volume: 100.0,
15150            listings: vec![],
15151            tax_bps: 0,
15152            tax_flat_copper: 0,
15153            list_vaults: vec![flatland_protocol::MarketListVault {
15154                building_id: "town_storage".into(),
15155                building_label: "Town Storage".into(),
15156                contents: vec![flatland_protocol::ItemStack {
15157                    template_id: "lumber".into(),
15158                    quantity: 1,
15159                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
15160                    display_name: Some("Lumber".into()),
15161                    ..Default::default()
15162                }],
15163            }],
15164        });
15165        let sources = state.market_list_source_options();
15166        assert_eq!(sources.len(), 2);
15167        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15168        assert!(matches!(
15169            sources[1].0,
15170            MarketListSourceKind::TownStorage { .. }
15171        ));
15172        assert!(sources[1].1.contains("Town Storage"));
15173    }
15174
15175    #[test]
15176    fn probe_use_world_npc_beats_nearby_loot() {
15177        let mut state = sample_state();
15178        state.npcs.push(flatland_protocol::NpcView {
15179            id: "ada".into(),
15180            label: "Ada".into(),
15181            role: "broker".into(),
15182            x: 129.0,
15183            y: 128.0,
15184            building_id: None,
15185            entity_id: None,
15186            life_state: None,
15187            hp_pct: None,
15188            can_trade: true,
15189            tile_id: None,
15190            behavior_state: None,
15191            presentation_state: None,
15192            sprite_mode: None,
15193            paperdoll_ref: None,
15194        });
15195        state.ground_drops.push(flatland_protocol::GroundDropView {
15196            id: "d1".into(),
15197            template_id: "lumber".into(),
15198            quantity: 1,
15199            x: 128.5,
15200            y: 128.0,
15201            z: 0.0,
15202            tile_id: None,
15203            display_name: None,
15204            yaw: 0.0,
15205            pitch: 0.0,
15206            roll: 0.0,
15207            draw_scale: 1.0,
15208        });
15209        let probe = state.probe_use_world();
15210        let primary = probe.primary.expect("primary");
15211        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15212        assert_eq!(primary.id, "ada");
15213    }
15214
15215    #[test]
15216    fn probe_use_world_harvest_when_in_range() {
15217        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
15218        let probe = state.probe_use_world();
15219        assert!(
15220            probe.primary.is_none(),
15221            "oak is 2m away, out of harvest range"
15222        );
15223        assert!(probe
15224            .candidates
15225            .iter()
15226            .any(|c| c.kind == crate::UseWorldKind::Harvest));
15227
15228        let mut state = sample_state();
15229        state.resource_nodes[0].x = 129.0;
15230        let probe = state.probe_use_world();
15231        let primary = probe.primary.expect("primary");
15232        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15233    }
15234
15235    #[test]
15236    fn probe_use_world_door_uses_building_label() {
15237        let mut state = sample_state();
15238        state.doors[0].x = 129.0;
15239        state.doors[0].y = 128.0;
15240        let probe = state.probe_use_world();
15241        let primary = probe.primary.expect("primary");
15242        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
15243        assert_eq!(primary.label, "Broker");
15244        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
15245    }
15246
15247    #[test]
15248    fn empty_entity_tick_preserves_welcome_snapshot() {
15249        let mut state = sample_state();
15250        state.inventory.insert("carrot".into(), 3);
15251        let delta = TickDelta {
15252            tick: 1,
15253            entities: vec![],
15254            resource_nodes: vec![],
15255            ground_drops: vec![],
15256            placed_containers: vec![],
15257            buildings: vec![],
15258            doors: vec![],
15259            interior_map: None,
15260            npcs: vec![],
15261            inventory: vec![],
15262            blueprints: vec![],
15263            building_materials: vec![],
15264            world_clock: flatland_protocol::WorldClock::default(),
15265            combat: None,
15266            quest_log: vec![],
15267            hired_workers: Vec::new(),
15268            interactables: vec![],
15269            ledger: None,
15270            career: None,
15271            combat_fx: Vec::new(),
15272            property_plots: Vec::new(),
15273        terrain_overlays: Vec::new(),
15274        };
15275
15276        state.apply_tick_fields(&delta, 1);
15277
15278        assert_eq!(state.entities.len(), 1);
15279        assert!(state.player.is_some());
15280        assert_eq!(state.inventory.get("carrot"), Some(&3));
15281        assert_eq!(state.resource_nodes.len(), 1);
15282    }
15283
15284    #[test]
15285    fn tick_preserves_world_layers_when_delta_omits_them() {
15286        let mut state = sample_state();
15287        let delta = TickDelta {
15288            tick: 1,
15289            entities: state.entities.clone(),
15290            resource_nodes: vec![],
15291            ground_drops: vec![],
15292            placed_containers: vec![],
15293            buildings: vec![],
15294            doors: vec![],
15295            interior_map: None,
15296            npcs: vec![],
15297            inventory: vec![],
15298            blueprints: vec![],
15299            building_materials: vec![],
15300            world_clock: flatland_protocol::WorldClock::default(),
15301            combat: None,
15302            quest_log: vec![],
15303            hired_workers: Vec::new(),
15304            interactables: vec![],
15305            ledger: None,
15306            career: None,
15307            combat_fx: Vec::new(),
15308            property_plots: Vec::new(),
15309        terrain_overlays: Vec::new(),
15310        };
15311
15312        state.apply_tick_fields(&delta, 1);
15313
15314        assert_eq!(state.resource_nodes.len(), 1);
15315        assert_eq!(state.buildings.len(), 1);
15316        assert_eq!(state.doors.len(), 1);
15317    }
15318
15319    #[test]
15320    fn tick_updates_resource_nodes_when_server_sends_them() {
15321        let mut state = sample_state();
15322        let delta = TickDelta {
15323            tick: 1,
15324            entities: state.entities.clone(),
15325            resource_nodes: vec![ResourceNodeView {
15326                id: "oak-1".into(),
15327                label: "Oak".into(),
15328                x: 130.0,
15329                y: 128.0,
15330                z: 0.0,
15331                item_template: "oak_log".into(),
15332                state: ResourceNodeState::Cooldown,
15333                blocking: true,
15334                blocking_radius_m: 0.8,
15335                harvest_off: false,
15336                tile_id: None,
15337                                yaw: 0.0,
15338                pitch: 0.0,
15339                roll: 0.0,
15340                draw_scale: 1.0,
15341                sprite_mode: None,
15342                growth_progress: None,
15343                presentation_state: None,
15344                channel_start_tick: None,
15345                channel_end_tick: None,
15346                harvest_drop_templates: vec![],
15347            }],
15348            buildings: vec![],
15349            doors: vec![],
15350            interior_map: None,
15351            npcs: vec![],
15352            inventory: vec![],
15353            blueprints: vec![],
15354            building_materials: vec![],
15355            world_clock: flatland_protocol::WorldClock::default(),
15356            ground_drops: vec![],
15357            placed_containers: vec![],
15358            combat: None,
15359            quest_log: vec![],
15360            hired_workers: Vec::new(),
15361            interactables: vec![],
15362            ledger: None,
15363            career: None,
15364            combat_fx: Vec::new(),
15365            property_plots: Vec::new(),
15366        terrain_overlays: Vec::new(),
15367        };
15368
15369        state.apply_tick_fields(&delta, 1);
15370
15371        assert!(matches!(
15372            state.resource_nodes[0].state,
15373            ResourceNodeState::Cooldown
15374        ));
15375    }
15376
15377    #[test]
15378    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
15379        let mut state = GameState {
15380            session_id: 1,
15381            entity_id: 1,
15382            character_id: None,
15383            tick: 0,
15384            chunk_rev: 0,
15385            content_rev: 0,
15386            publish_rev: 0,
15387            entities: vec![EntityState {
15388                id: 1,
15389                label: "You".into(),
15390                transform: Transform {
15391                    position: WorldCoord::surface(4.5, 2.0),
15392                    yaw: 0.0,
15393                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15394                },
15395                vitals: None,
15396                attributes: None,
15397                skills: None,
15398                inside_building: Some("broker_hut".into()),
15399                tile_id: None,
15400                paperdoll_ref: None,
15401                presentation_state: None,
15402                sprite_mode: None,
15403                progression_xp: None,
15404                combat_cues: vec![],
15405                statuses: vec![],
15406            }],
15407            player: None,
15408            resource_nodes: vec![],
15409            ground_drops: vec![],
15410            placed_containers: vec![],
15411            buildings: vec![BuildingView {
15412                id: "broker_hut".into(),
15413                label: "Broker".into(),
15414                x: 158.0,
15415                y: 124.0,
15416                width_m: 8.0,
15417                depth_m: 6.0,
15418                interior_blueprint: Some("broker_hut".into()),
15419                tags: vec![],
15420                market_boundary_zone_ids: vec![],
15421                market_max_volume: None,
15422                wall_set: None,
15423                roof_set: None,
15424            }],
15425            doors: vec![flatland_protocol::DoorView {
15426                id: "broker_hut_exit".into(),
15427                building_id: "broker_hut".into(),
15428                x: 4.3,
15429                y: 0.9,
15430                open: true,
15431                portal: Some("front".into()),
15432            locked: false,
15433            accessible: true,
15434            lock_id: None,
15435        }],
15436            interior_map: None,
15437            npcs: vec![flatland_protocol::NpcView {
15438                id: "ada_broker".into(),
15439                label: "Ada".into(),
15440                x: 4.5,
15441                y: 2.0,
15442                building_id: Some("broker_hut".into()),
15443                role: "broker".into(),
15444                entity_id: None,
15445                life_state: None,
15446                hp_pct: None,
15447                can_trade: true,
15448                tile_id: None,
15449                behavior_state: None,
15450                presentation_state: None,
15451                sprite_mode: None,
15452                paperdoll_ref: None,
15453            }],
15454            blueprints: vec![],
15455            building_materials: vec![],
15456            world_x0: 0.0,
15457            world_y0: 0.0,
15458            world_width_m: 256.0,
15459            world_height_m: 256.0,
15460            terrain_zones: Vec::new(),
15461            z_platforms: Vec::new(),
15462            z_transitions: Vec::new(),
15463            z_bands_outdoor_backup: None,
15464            world_clock: flatland_protocol::WorldClock::default(),
15465            inventory: std::collections::HashMap::new(),
15466            inventory_hints: std::collections::HashMap::new(),
15467            logs: VecDeque::new(),
15468            intents_sent: 0,
15469            ticks_received: 0,
15470            connected: true,
15471            disconnect_reason: None,
15472            show_stats: false,
15473            hud_log_hidden: false,
15474            show_equip_menu: false,
15475            equip_menu_index: 0,
15476            show_craft_menu: false,
15477            show_plot_build_menu: false,
15478            plot_build_focus_wall: true,
15479            plot_build_wall_index: 0,
15480            plot_build_roof_index: 0,
15481            craft_menu_index: 0,
15482            craft_batch_quantity: 1,
15483            show_shop_menu: false,
15484            shop_catalog: None,
15485            bank_panel: None,
15486            bank_menu_index: 0,
15487            bank_ui_mode: BankUiMode::Menu,
15488            storage_panel: None,
15489                market_panel: None,
15490            market_menu_index: 0,
15491            market_filter: String::new(),
15492            market_filter_focused: false,
15493            market_category_filter: None,
15494            market_buy_confirm: None,
15495            market_ui_mode: MarketUiMode::Browse,
15496            storage_menu_index: 0,
15497            storage_ui_mode: StorageUiMode::Menu,
15498            shop_tab: ShopTab::default(),
15499            shop_menu_index: 0,
15500            shop_quantity: 1,
15501            shop_trade_log: VecDeque::new(),
15502            show_npc_verb_menu: false,
15503            npc_verb_target: None,
15504            npc_verb_index: 0,
15505            player_verbs: crate::social::PlayerVerbState::default(),
15506            social_chat: crate::social::SocialChatState::default(),
15507            trade_ui: crate::social::TradeUiState::default(),
15508            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15509            show_npc_chat: false,
15510            npc_chat: None,
15511            show_inventory_menu: false,
15512            inventory_menu_index: 0,
15513            inventory_tab: InventoryTab::OnPerson,
15514            inventory_filter: String::new(),
15515            inventory_filter_focused: false,
15516            show_move_picker: false,
15517            show_rename_prompt: false,
15518            show_worker_rename: false,
15519            rename_buffer: String::new(),
15520            move_picker_index: 0,
15521            move_picker: None,
15522            show_grant_picker: false,
15523            grant_picker_index: 0,
15524            grant_picker: None,
15525            show_destroy_picker: false,
15526            destroy_confirm_pending: false,
15527            destroy_picker: None,
15528            combat_target: None,
15529            combat_target_label: None,
15530            ground_target: None,
15531            combat_fx: Vec::new(),
15532            property_zones: Vec::new(),
15533            tax_zones: Vec::new(),
15534            growth_zones: Vec::new(),
15535            biome_zones: Vec::new(),
15536            terrain_kind_nav: Vec::new(),
15537            property_plots: Vec::new(),
15538            property_plot_settings: None,
15539            claim_mode: None,
15540            relocate_mode: None,
15541            sell_plot_confirm: None,
15542            sell_plot_armed_at: None,
15543            show_plant_menu: false,
15544            plant_menu_index: 0,
15545            show_farm_access: false,
15546            farm_access_name_draft: String::new(),
15547            farm_access_discount_bps: 0,
15548            farm_access_index: 0,
15549            plant_quantity: 1,
15550            in_combat: false,
15551            auto_attack: true,
15552            combat_has_los: false,
15553            attack_cd_ticks: 0,
15554            gcd_ticks: 0,
15555            weapon_ability_id: "unarmed".into(),
15556            mainhand_template_id: None,
15557            mainhand_label: None,
15558            mainhand_instance_id: None,
15559            offhand_template_id: None,
15560            offhand_label: None,
15561            offhand_instance_id: None,
15562            mainhand_hand_slots: 1,
15563            defense: None,
15564            worn: BTreeMap::new(),
15565            carry_mass: 0.0,
15566            carry_mass_max: 0.0,
15567            encumbrance: flatland_protocol::EncumbranceState::Light,
15568            inventory_stacks: Vec::new(),
15569            keychain_stacks: Vec::new(),
15570            whisper_pouch_stacks: Vec::new(),
15571            combat_target_detail: None,
15572                statuses: Vec::new(),
15573            cast_progress: None,
15574            timed_channel: None,
15575            plot_build_offer: None,
15576            ability_cooldowns: Vec::new(),
15577            blocking_active: false,
15578            max_target_slots: 1,
15579            combat_slots: Vec::new(),
15580            rotation_presets: Vec::new(),
15581            known_abilities: Vec::new(),
15582            ability_meta: std::collections::HashMap::new(),
15583            ability_mastery: std::collections::HashMap::new(),
15584            hotbar: vec![None; 9],
15585            max_abilities_per_rotation: 0,
15586            show_loadout_menu: false,
15587            show_keychain_menu: false,
15588            keychain_menu_index: 0,
15589            show_rotation_editor: false,
15590            loadout_menu_index: 0,
15591            loadout_hotbar_slot: 1,
15592            loadout_ability_index: 0,
15593            loadout_focus_presets: false,
15594            rotation_editor: RotationEditorState::default(),
15595            harvest_in_progress: false,
15596            harvest_started_at: None,
15597            pending_craft_ack: None,
15598            pending_worker_job_ack: None,
15599            attending_worker_instance_id: None,
15600            quest_log: Vec::new(),
15601            interactables: Vec::new(),
15602            ledger: None,
15603            career: None,
15604            character_sheet_tab: CharacterSheetTab::Character,
15605            ledger_period: LedgerPeriod::Day,
15606            show_quest_offer: false,
15607            pending_quest_offer: None,
15608            show_quest_menu: false,
15609            quest_menu_index: 0,
15610            quest_withdraw_confirm: false,
15611            hired_workers: Vec::new(),
15612            show_workers_menu: false,
15613            workers_menu_index: 0,
15614            workers_menu_compact: false,
15615            worker_step_display: BTreeMap::new(),
15616            worker_error_display: BTreeMap::new(),
15617            show_worker_give_picker: false,
15618            worker_give_picker_index: 0,
15619            worker_give_picker: None,
15620            show_worker_give_target_picker: false,
15621            worker_give_target_picker_index: 0,
15622            worker_give_target_picker: None,
15623            show_worker_take_picker: false,
15624            worker_take_picker_index: 0,
15625            worker_take_picker: None,
15626            show_worker_teach_picker: false,
15627            worker_teach_picker_index: 0,
15628            worker_teach_picker: None,
15629            worker_route_editor: None,
15630            progression_curve: None,
15631        };
15632        state.player = state.entities.first().cloned();
15633        assert_eq!(
15634            state.nearest_interact_target().as_deref(),
15635            Some("ada_broker")
15636        );
15637    }
15638
15639    #[test]
15640    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
15641        let mut state = sample_state();
15642        // Player is at (128, 128) per sample_state(). One chest just inside
15643        // CONTAINER_RANGE_M, one clearly beyond it.
15644        state.placed_containers = vec![
15645            flatland_protocol::PlacedContainerView {
15646                id: "near".into(),
15647                template_id: "wooden_chest_small".into(),
15648                display_name: "Wooden Chest".into(),
15649                x: 130.0,
15650                y: 128.0,
15651                z: 0.0,
15652                locked: true,
15653                accessible: true,
15654                owner_character_id: None,
15655                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
15656                lock_id: None,
15657                capacity_volume: None,
15658                item_instance_id: Some(uuid::Uuid::from_u128(1)),
15659                tile_id: None,
15660                worker_lodging_capacity: None,
15661                blocking: false,
15662                blocking_radius_m: 0.0,
15663            building_id: None,
15664            },
15665            flatland_protocol::PlacedContainerView {
15666                id: "far".into(),
15667                template_id: "wooden_chest_small".into(),
15668                display_name: "Distant Chest".into(),
15669                x: 128.0 + CONTAINER_RANGE_M + 5.0,
15670                y: 128.0,
15671                z: 0.0,
15672                locked: false,
15673                accessible: true,
15674                owner_character_id: None,
15675                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
15676                lock_id: None,
15677                capacity_volume: None,
15678                item_instance_id: Some(uuid::Uuid::from_u128(2)),
15679                tile_id: None,
15680                worker_lodging_capacity: None,
15681                blocking: false,
15682                blocking_radius_m: 0.0,
15683            building_id: None,
15684            },
15685        ];
15686
15687        let nearby = state.nearby_containers();
15688        assert_eq!(
15689            nearby.len(),
15690            1,
15691            "far chest must not appear once out of range"
15692        );
15693        assert_eq!(nearby[0].view.id, "near");
15694        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
15695        assert!(nearby[0].rows[0].is_chest_shell);
15696
15697        // The same chest, but locked and inaccessible (no key held), must hide
15698        // contents but still show the selectable chest shell row.
15699        state.placed_containers[0].accessible = false;
15700        let nearby = state.nearby_containers();
15701        assert_eq!(nearby.len(), 1);
15702        assert_eq!(nearby[0].rows.len(), 1);
15703        assert!(nearby[0].rows[0].is_chest_shell);
15704    }
15705
15706    #[test]
15707    fn chest_pickup_destinations_offer_person_and_worn_bag() {
15708        let mut state = sample_state();
15709        let back_id = uuid::Uuid::from_u128(42);
15710        state.worn.insert(
15711            BodySlot::Back,
15712            flatland_protocol::ItemStack {
15713                template_id: "travel_backpack".into(),
15714                quantity: 1,
15715                item_instance_id: Some(back_id),
15716                props: Default::default(),
15717                status_bindings: Vec::new(),
15718                contents: Vec::new(),
15719                display_name: Some("Travel Backpack".into()),
15720                category: Some("container".into()),
15721                base_mass: Some(2.5),
15722                base_volume: Some(12.0),
15723                capacity_volume: Some(80.0),
15724                stackable: Some(false),
15725                world_placeable: Some(false),
15726                worker_lodging_capacity: None,
15727                equip_slot: None,
15728                armor_physical: None,
15729                resists: vec![],
15730                hand_slots: None,
15731            listable: None,
15732        },
15733        );
15734        let opts = state.chest_pickup_destinations("chest-1");
15735        assert!(matches!(
15736            opts.first().map(|o| &o.kind),
15737            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
15738        ));
15739        assert!(opts.iter().any(|o| matches!(
15740            &o.kind,
15741            MoveOptionKind::PickupPlaced {
15742                nest_parent_instance_id: None,
15743                ..
15744            }
15745        )));
15746        assert!(opts.iter().any(|o| matches!(
15747            &o.kind,
15748            MoveOptionKind::PickupPlaced {
15749                nest_parent_instance_id: Some(id),
15750                ..
15751            } if *id == back_id
15752        )));
15753        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15754    }
15755
15756    #[test]
15757    fn placed_container_public_label_hides_owner_custom_name() {
15758        let owner = uuid::Uuid::from_u128(99);
15759        let mut state = sample_state();
15760        state.character_id = Some(uuid::Uuid::from_u128(1));
15761        state.inventory_hints.insert(
15762            "wooden_chest_medium".into(),
15763            InventoryHint {
15764                display_name: "Medium Wooden Chest".into(),
15765                category: "container".into(),
15766                base_mass: None,
15767                base_volume: None,
15768                capacity_volume: None,
15769                stackable: false,
15770                listable: true,
15771            },
15772        );
15773        let chest = flatland_protocol::PlacedContainerView {
15774            id: "c1".into(),
15775            template_id: "wooden_chest_medium".into(),
15776            display_name: "Barry's Loot #a3f2".into(),
15777            x: 128.0,
15778            y: 128.0,
15779            z: 0.0,
15780            locked: false,
15781            accessible: true,
15782            owner_character_id: Some(owner),
15783            contents: vec![],
15784            lock_id: None,
15785            capacity_volume: None,
15786            item_instance_id: None,
15787            tile_id: None,
15788            worker_lodging_capacity: None,
15789            blocking: false,
15790            blocking_radius_m: 0.0,
15791            building_id: None,
15792        };
15793        assert_eq!(
15794            state.placed_container_public_label(&chest),
15795            "Medium Wooden Chest"
15796        );
15797        state.character_id = Some(owner);
15798        assert_eq!(
15799            state.placed_container_public_label(&chest),
15800            "Barry's Loot #a3f2"
15801        );
15802    }
15803
15804    #[test]
15805    fn location_context_shows_crop_growth_percent_not_depleted() {
15806        let mut state = sample_state();
15807        state.player = state.entities.first().cloned();
15808        state.resource_nodes[0].label = "Carrot (growing)".into();
15809        state.resource_nodes[0].x = 128.2;
15810        state.resource_nodes[0].y = 128.0;
15811        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
15812        state.resource_nodes[0].growth_progress = Some(0.47);
15813        let lines = state.location_context_lines();
15814        let line = lines
15815            .iter()
15816            .find(|l| l.text.contains("Carrot"))
15817            .map(|l| l.text.as_str())
15818            .unwrap_or("");
15819        assert!(
15820            line.contains("(growing, 47%)"),
15821            "expected growth percent, got: {line}"
15822        );
15823        assert!(
15824            !line.contains("depleted"),
15825            "growing crop should not show depleted: {line}"
15826        );
15827    }
15828
15829    #[test]
15830    fn resource_node_near_action_suffix_prefers_growth() {
15831        let node = ResourceNodeView {
15832            id: "crop".into(),
15833            label: "Wheat".into(),
15834            x: 0.0,
15835            y: 0.0,
15836            z: 0.0,
15837            item_template: "wheat".into(),
15838            state: ResourceNodeState::Cooldown,
15839            blocking: false,
15840            blocking_radius_m: 0.0,
15841            harvest_off: false,
15842            tile_id: None,
15843                        yaw: 0.0,
15844            pitch: 0.0,
15845            roll: 0.0,
15846            draw_scale: 1.0,
15847            sprite_mode: None,
15848            growth_progress: Some(0.12),
15849            presentation_state: None,
15850            channel_start_tick: None,
15851            channel_end_tick: None,
15852            harvest_drop_templates: vec![],
15853        };
15854        assert_eq!(
15855            resource_node_near_action_suffix(&node),
15856            " (growing, 12%)"
15857        );
15858    }
15859
15860    #[test]
15861    fn location_context_lists_nearby_resource_node() {
15862        let mut state = sample_state();
15863        state.player = state.entities.first().cloned();
15864        state.resource_nodes[0].x = 128.2;
15865        state.resource_nodes[0].y = 128.0;
15866        let lines = state.location_context_lines();
15867        assert!(
15868            lines
15869                .iter()
15870                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
15871            "expected resource node in context: {:?}",
15872            lines
15873        );
15874    }
15875
15876    #[test]
15877    fn quest_board_usable_within_board_radius() {
15878        let mut state = sample_state();
15879        state.player = state.entities.first().cloned();
15880        state.interactables = vec![flatland_protocol::InteractableView {
15881            id: "board-1".into(),
15882            kind: "quest_board".into(),
15883            label: "Town Quest Board".into(),
15884            x: 130.5,
15885            y: 128.0,
15886            z: 0.0,
15887            board_id: Some("starter_town_board".into()),
15888        }];
15889        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
15890        assert_eq!(
15891            state.nearest_interact_target().as_deref(),
15892            Some("board-1"),
15893            "quest board should be selectable at ~2.5m"
15894        );
15895        let lines = state.location_context_lines();
15896        assert!(
15897            lines
15898                .iter()
15899                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
15900            "HUD should advertise f when board is in range: {:?}",
15901            lines
15902        );
15903    }
15904
15905    #[test]
15906    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
15907        let mut state = sample_state();
15908        state.worn.insert(
15909            BodySlot::Back,
15910            flatland_protocol::ItemStack {
15911                template_id: "travel_backpack".into(),
15912                quantity: 1,
15913                item_instance_id: Some(uuid::Uuid::from_u128(3)),
15914                props: Default::default(),
15915                status_bindings: Vec::new(),
15916                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
15917                display_name: None,
15918                category: None,
15919                base_mass: None,
15920                base_volume: None,
15921                capacity_volume: None,
15922                stackable: None,
15923                world_placeable: None,
15924                worker_lodging_capacity: None,
15925                equip_slot: None,
15926                armor_physical: None,
15927                resists: vec![],
15928                hand_slots: None,
15929            listable: None,
15930        },
15931        );
15932        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
15933        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15934            id: "chest-1".into(),
15935            template_id: "wooden_chest_small".into(),
15936            display_name: "Wooden Chest".into(),
15937            x: 129.0,
15938            y: 128.0,
15939            z: 0.0,
15940            locked: false,
15941            accessible: true,
15942            owner_character_id: None,
15943            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
15944            lock_id: None,
15945            capacity_volume: None,
15946            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15947            tile_id: None,
15948            worker_lodging_capacity: None,
15949            blocking: false,
15950            blocking_radius_m: 0.0,
15951            building_id: None,
15952        }];
15953
15954        state.inventory_tab = InventoryTab::OnPerson;
15955        let rows = state.inventory_selectable_rows();
15956        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15957        assert_eq!(
15958            sections,
15959            vec![
15960                InventorySection::Worn,   // backpack shell
15961                InventorySection::Worn,   // iron_ore nested in backpack
15962                InventorySection::Person, // lumber
15963            ]
15964        );
15965        assert_eq!(rows[0].stack.template_id, "travel_backpack");
15966        assert!(rows[0].is_equip_shell);
15967        assert_eq!(rows[1].stack.template_id, "iron_ore");
15968        assert_eq!(rows[1].depth, 1);
15969        assert_eq!(rows[2].stack.template_id, "lumber");
15970
15971        let lines = state.inventory_browser_lines();
15972        assert!(lines.iter().any(|l| matches!(
15973            l,
15974            InventoryBrowserLine::Section(s) if s.contains("Worn")
15975        )));
15976        assert!(lines.iter().any(|l| matches!(
15977            l,
15978            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15979                || text.contains("backpack")
15980        )));
15981        assert!(!lines.iter().any(|l| matches!(
15982            l,
15983            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15984        )));
15985
15986        state.inventory_tab = InventoryTab::Nearby;
15987        let nearby_rows = state.inventory_selectable_rows();
15988        assert_eq!(nearby_rows.len(), 2);
15989        assert!(nearby_rows[0].is_chest_shell);
15990        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15991        let nearby_lines = state.inventory_browser_lines();
15992        assert!(nearby_lines.iter().any(|l| matches!(
15993            l,
15994            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15995        )));
15996    }
15997
15998    #[test]
15999    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16000        let mut state = sample_state();
16001        let back_id = uuid::Uuid::from_u128(5);
16002        state.worn.insert(
16003            BodySlot::Back,
16004            flatland_protocol::ItemStack {
16005                template_id: "travel_backpack".into(),
16006                quantity: 1,
16007                item_instance_id: Some(back_id),
16008                props: Default::default(),
16009                status_bindings: Vec::new(),
16010                contents: Vec::new(),
16011                display_name: None,
16012                category: Some("container".into()),
16013                base_mass: None,
16014                base_volume: None,
16015                capacity_volume: Some(80.0),
16016                stackable: None,
16017                world_placeable: None,
16018                worker_lodging_capacity: None,
16019                equip_slot: None,
16020                armor_physical: None,
16021                resists: vec![],
16022                hand_slots: None,
16023            listable: None,
16024        },
16025        );
16026        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16027            id: "chest-1".into(),
16028            template_id: "wooden_chest_small".into(),
16029            display_name: "Wooden Chest".into(),
16030            x: 129.0,
16031            y: 128.0,
16032            z: 0.0,
16033            locked: false,
16034            accessible: true,
16035            owner_character_id: None,
16036            contents: Vec::new(),
16037            lock_id: None,
16038            capacity_volume: None,
16039            item_instance_id: Some(uuid::Uuid::from_u128(6)),
16040            tile_id: None,
16041            worker_lodging_capacity: None,
16042            blocking: false,
16043            blocking_radius_m: 0.0,
16044            building_id: None,
16045        }];
16046
16047        // Item currently sitting loose on the person (Root): backpack + nearby
16048        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
16049        let opts = state.move_destinations_for(
16050            &flatland_protocol::InventoryLocation::Root,
16051            None,
16052            None,
16053            "lumber",
16054        );
16055        assert!(!opts.iter().any(|o| matches!(
16056            &o.kind,
16057            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16058        )));
16059        assert!(opts.iter().any(|o| matches!(
16060            &o.kind,
16061            MoveOptionKind::Move { location, parent_instance_id, .. }
16062                if *location == flatland_protocol::InventoryLocation::Worn {
16063                    slot: BodySlot::Back,
16064                } && *parent_instance_id == Some(back_id)
16065        )));
16066        assert!(opts.iter().any(|o| matches!(
16067            &o.kind,
16068            MoveOptionKind::Move { location, .. }
16069                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16070        )));
16071        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16072        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16073
16074        // Item currently inside the worn backpack: the backpack itself must be
16075        // excluded from its own destination list (can't move an item into the
16076        // container it's already in).
16077        let from_backpack = flatland_protocol::InventoryLocation::Worn {
16078            slot: BodySlot::Back,
16079        };
16080        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16081        assert!(!opts.iter().any(|o| matches!(
16082            &o.kind,
16083            MoveOptionKind::Move { location, parent_instance_id, .. }
16084                if *location == from_backpack && *parent_instance_id == Some(back_id)
16085        )));
16086        assert!(opts.iter().any(|o| matches!(
16087            &o.kind,
16088            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16089        )));
16090    }
16091
16092    #[test]
16093    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16094        let mut state = sample_state();
16095        // Insert out of display order — BTreeMap iteration must still yield the
16096        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
16097        state.worn.insert(
16098            BodySlot::Waist,
16099            flatland_protocol::ItemStack {
16100                template_id: "simple_belt".into(),
16101                quantity: 1,
16102                item_instance_id: Some(uuid::Uuid::from_u128(10)),
16103                props: Default::default(),
16104                status_bindings: Vec::new(),
16105                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16106                display_name: None,
16107                category: Some("container".into()),
16108                base_mass: None,
16109                base_volume: None,
16110                capacity_volume: None,
16111                stackable: None,
16112                world_placeable: None,
16113                worker_lodging_capacity: None,
16114                equip_slot: None,
16115                armor_physical: None,
16116                resists: vec![],
16117                hand_slots: None,
16118            listable: None,
16119        },
16120        );
16121        state.worn.insert(
16122            BodySlot::Head,
16123            flatland_protocol::ItemStack {
16124                template_id: "cloth_cap".into(),
16125                quantity: 1,
16126                item_instance_id: Some(uuid::Uuid::from_u128(11)),
16127                props: Default::default(),
16128                status_bindings: Vec::new(),
16129                contents: Vec::new(),
16130                display_name: None,
16131                category: Some("armor".into()),
16132                base_mass: None,
16133                base_volume: None,
16134                capacity_volume: None,
16135                stackable: None,
16136                world_placeable: None,
16137                worker_lodging_capacity: None,
16138                equip_slot: None,
16139                armor_physical: None,
16140                resists: vec![],
16141                hand_slots: None,
16142            listable: None,
16143        },
16144        );
16145        state.worn.insert(
16146            BodySlot::Back,
16147            flatland_protocol::ItemStack {
16148                template_id: "travel_backpack".into(),
16149                quantity: 1,
16150                item_instance_id: Some(uuid::Uuid::from_u128(12)),
16151                props: Default::default(),
16152                status_bindings: Vec::new(),
16153                contents: Vec::new(),
16154                display_name: None,
16155                category: Some("container".into()),
16156                base_mass: None,
16157                base_volume: None,
16158                capacity_volume: None,
16159                stackable: None,
16160                world_placeable: None,
16161                worker_lodging_capacity: None,
16162                equip_slot: None,
16163                armor_physical: None,
16164                resists: vec![],
16165                hand_slots: None,
16166            listable: None,
16167        },
16168        );
16169
16170        let rows = state.worn_rows();
16171        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
16172        assert_eq!(rows.len(), 4);
16173        assert_eq!(rows[0].stack.template_id, "cloth_cap");
16174        assert!(rows[0].is_equip_shell);
16175        assert_eq!(rows[1].stack.template_id, "travel_backpack");
16176        assert!(rows[1].is_equip_shell);
16177        assert_eq!(rows[2].stack.template_id, "simple_belt");
16178        assert!(rows[2].is_equip_shell);
16179        assert_eq!(rows[3].stack.template_id, "leather_pouch");
16180        assert_eq!(rows[3].depth, 1);
16181        assert!(!rows[3].is_equip_shell);
16182    }
16183
16184    #[test]
16185    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16186        let mut state = sample_state();
16187        state.worn.insert(
16188            BodySlot::Waist,
16189            flatland_protocol::ItemStack {
16190                template_id: "simple_belt".into(),
16191                quantity: 1,
16192                item_instance_id: Some(uuid::Uuid::from_u128(20)),
16193                props: Default::default(),
16194                status_bindings: Vec::new(),
16195                contents: Vec::new(),
16196                display_name: Some("Simple Belt".into()),
16197                category: Some("container".into()),
16198                base_mass: None,
16199                base_volume: None,
16200                capacity_volume: None,
16201                stackable: None,
16202                world_placeable: None,
16203                worker_lodging_capacity: None,
16204                equip_slot: None,
16205                armor_physical: None,
16206                resists: vec![],
16207                hand_slots: None,
16208            listable: None,
16209        },
16210        );
16211        state.worn.insert(
16212            BodySlot::Head,
16213            flatland_protocol::ItemStack {
16214                template_id: "cloth_cap".into(),
16215                quantity: 1,
16216                item_instance_id: Some(uuid::Uuid::from_u128(21)),
16217                props: Default::default(),
16218                status_bindings: Vec::new(),
16219                contents: Vec::new(),
16220                display_name: Some("Cloth Cap".into()),
16221                category: Some("armor".into()),
16222                base_mass: None,
16223                base_volume: None,
16224                capacity_volume: None,
16225                stackable: None,
16226                world_placeable: None,
16227                worker_lodging_capacity: None,
16228                equip_slot: None,
16229                armor_physical: None,
16230                resists: vec![],
16231                hand_slots: None,
16232            listable: None,
16233        },
16234        );
16235
16236        let opts = state.move_destinations_for(
16237            &flatland_protocol::InventoryLocation::Root,
16238            None,
16239            None,
16240            "leather_pouch",
16241        );
16242        assert!(
16243            opts.iter().any(|o| matches!(
16244                &o.kind,
16245                MoveOptionKind::Move { location, .. }
16246                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16247            )),
16248            "belt loop must be offered when moving a pouch"
16249        );
16250        assert!(
16251            !opts.iter().any(|o| matches!(
16252                &o.kind,
16253                MoveOptionKind::Move { location, .. }
16254                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
16255            )),
16256            "armor slots can't hold other items and must not appear as move destinations"
16257        );
16258        let belt_opt = opts
16259            .iter()
16260            .find(|o| matches!(
16261                &o.kind,
16262                MoveOptionKind::Move { location, .. }
16263                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16264            ))
16265            .unwrap();
16266        assert!(belt_opt.label.contains("belt loop"));
16267
16268        let opts = state.move_destinations_for(
16269            &flatland_protocol::InventoryLocation::Root,
16270            None,
16271            None,
16272            "lumber",
16273        );
16274        assert!(
16275            !opts.iter().any(|o| o.label.contains("belt loop")),
16276            "loose materials must not target the belt shell — only nested pouches"
16277        );
16278    }
16279
16280    #[test]
16281    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
16282        let mut state = sample_state();
16283        let belt_id = uuid::Uuid::from_u128(30);
16284        let pouch_id = uuid::Uuid::from_u128(31);
16285        state.worn.insert(
16286            BodySlot::Waist,
16287            flatland_protocol::ItemStack {
16288                template_id: "simple_belt".into(),
16289                quantity: 1,
16290                item_instance_id: Some(belt_id),
16291                props: Default::default(),
16292                status_bindings: Vec::new(),
16293                world_placeable: None,
16294                worker_lodging_capacity: None,
16295                equip_slot: None,
16296                armor_physical: None,
16297                resists: vec![],
16298                hand_slots: None,
16299                contents: vec![flatland_protocol::ItemStack {
16300                    template_id: "dimensional_pouch".into(),
16301                    quantity: 1,
16302                    item_instance_id: Some(pouch_id),
16303                    props: Default::default(),
16304                    status_bindings: Vec::new(),
16305                    contents: Vec::new(),
16306                    display_name: Some("Dimensional Pouch".into()),
16307                    category: Some("container".into()),
16308                    base_mass: None,
16309                    base_volume: None,
16310                    capacity_volume: Some(200.0),
16311                    stackable: None,
16312                    world_placeable: None,
16313                    worker_lodging_capacity: None,
16314                    equip_slot: None,
16315                    armor_physical: None,
16316                    resists: vec![],
16317                    hand_slots: None,
16318            listable: None,
16319        }],
16320                display_name: Some("Simple Belt".into()),
16321                category: Some("container".into()),
16322                base_mass: None,
16323                base_volume: None,
16324                capacity_volume: None,
16325                stackable: None,
16326            listable: None,
16327        },
16328        );
16329
16330        let opts = state.move_destinations_for(
16331            &flatland_protocol::InventoryLocation::Root,
16332            None,
16333            None,
16334            "iron_ore",
16335        );
16336        assert!(
16337            opts.iter().any(|o| matches!(
16338                &o.kind,
16339                MoveOptionKind::Move {
16340                    location,
16341                    parent_instance_id,
16342                    ..
16343                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16344                    && *parent_instance_id == Some(pouch_id)
16345            )),
16346            "dimensional pouch clipped on belt must accept loose items"
16347        );
16348        assert!(
16349            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
16350            "destination label should name the pouch"
16351        );
16352    }
16353
16354    #[test]
16355    fn container_volume_label_on_placed_chest_shell() {
16356        let mut state = sample_state();
16357        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16358            id: "chest-1".into(),
16359            template_id: "wooden_chest_small".into(),
16360            display_name: "Camp Chest".into(),
16361            x: 129.0,
16362            y: 128.0,
16363            z: 0.0,
16364            locked: false,
16365            accessible: true,
16366            owner_character_id: None,
16367            contents: vec![flatland_protocol::ItemStack {
16368                template_id: "iron_ore".into(),
16369                quantity: 2,
16370                item_instance_id: None,
16371                props: Default::default(),
16372                status_bindings: Vec::new(),
16373                contents: Vec::new(),
16374                display_name: None,
16375                category: None,
16376                base_mass: None,
16377                base_volume: Some(2.0),
16378                capacity_volume: None,
16379                stackable: None,
16380                world_placeable: None,
16381                worker_lodging_capacity: None,
16382                equip_slot: None,
16383                armor_physical: None,
16384                resists: vec![],
16385                hand_slots: None,
16386            listable: None,
16387        }],
16388            lock_id: None,
16389            capacity_volume: Some(60.0),
16390            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16391            tile_id: None,
16392            worker_lodging_capacity: None,
16393            blocking: false,
16394            blocking_radius_m: 0.0,
16395            building_id: None,
16396        }];
16397        let nearby = state.nearby_containers();
16398        let label = state.container_volume_label(&nearby[0].rows[0]);
16399        assert!(
16400            label.contains("vol 4/60"),
16401            "expected used/cap in label, got {label}"
16402        );
16403        assert!(
16404            label.contains("56 free"),
16405            "expected free space, got {label}"
16406        );
16407    }
16408
16409    #[test]
16410    fn key_pair_chest_label_from_placed_lock_id() {
16411        let mut state = sample_state();
16412        let owner = uuid::Uuid::from_u128(77);
16413        state.character_id = Some(owner);
16414        let lock = uuid::Uuid::from_u128(99).to_string();
16415        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16416            id: "chest-1".into(),
16417            template_id: "wooden_chest_small".into(),
16418            display_name: "Barry's Loot #a3f2".into(),
16419            x: 129.0,
16420            y: 128.0,
16421            z: 0.0,
16422            locked: true,
16423            accessible: true,
16424            owner_character_id: Some(owner),
16425            contents: Vec::new(),
16426            lock_id: Some(lock.clone()),
16427            capacity_volume: None,
16428            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16429            tile_id: None,
16430            worker_lodging_capacity: None,
16431            blocking: false,
16432            blocking_radius_m: 0.0,
16433            building_id: None,
16434        }];
16435        let key_id = uuid::Uuid::from_u128(5);
16436        let key = flatland_protocol::ItemStack {
16437            template_id: KEY_TEMPLATE.into(),
16438            quantity: 1,
16439            item_instance_id: Some(key_id),
16440            props: BTreeMap::from([
16441                (PROP_OPENS_LOCK_ID.into(), lock),
16442                (
16443                    PROP_OPENS_CONTAINER_NAME.into(),
16444                    "Barry's Loot #a3f2".into(),
16445                ),
16446            ]),
16447            status_bindings: Vec::new(),
16448            contents: Vec::new(),
16449            display_name: Some("Container Key".into()),
16450            category: Some("key".into()),
16451            base_mass: None,
16452            base_volume: None,
16453            capacity_volume: None,
16454            stackable: None,
16455            world_placeable: None,
16456            worker_lodging_capacity: None,
16457            equip_slot: None,
16458            armor_physical: None,
16459            resists: vec![],
16460            hand_slots: None,
16461            listable: None,
16462        };
16463        state.inventory_stacks = vec![key.clone()];
16464        assert_eq!(
16465            state.key_pair_chest_label(&key).as_deref(),
16466            Some("Barry's Loot #a3f2")
16467        );
16468        assert!(state.key_drop_blocked(&key));
16469    }
16470
16471    #[test]
16472    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
16473        let mut state = sample_state();
16474        let lock = uuid::Uuid::from_u128(101).to_string();
16475        let key = flatland_protocol::ItemStack {
16476            template_id: KEY_TEMPLATE.into(),
16477            quantity: 1,
16478            item_instance_id: Some(uuid::Uuid::from_u128(7)),
16479            props: BTreeMap::from([
16480                (PROP_OPENS_LOCK_ID.into(), lock),
16481                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
16482            ]),
16483            status_bindings: Vec::new(),
16484            contents: Vec::new(),
16485            display_name: None,
16486            category: Some("key".into()),
16487            base_mass: None,
16488            base_volume: None,
16489            capacity_volume: None,
16490            stackable: None,
16491            world_placeable: None,
16492            worker_lodging_capacity: None,
16493            equip_slot: None,
16494            armor_physical: None,
16495            resists: vec![],
16496            hand_slots: None,
16497            listable: None,
16498        };
16499        state.placed_containers.clear();
16500        assert_eq!(
16501            state.key_pair_chest_label(&key).as_deref(),
16502            Some("Camp Stash")
16503        );
16504    }
16505
16506    #[test]
16507    fn key_drop_allowed_when_paired_chest_unlocked() {
16508        let mut state = sample_state();
16509        let lock = uuid::Uuid::from_u128(100).to_string();
16510        let key_id = uuid::Uuid::from_u128(6);
16511        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16512            id: "chest-1".into(),
16513            template_id: "wooden_chest_small".into(),
16514            display_name: "Camp Chest".into(),
16515            x: 129.0,
16516            y: 128.0,
16517            z: 0.0,
16518            locked: false,
16519            accessible: true,
16520            owner_character_id: None,
16521            contents: Vec::new(),
16522            lock_id: Some(lock.clone()),
16523            capacity_volume: None,
16524            item_instance_id: None,
16525            tile_id: None,
16526            worker_lodging_capacity: None,
16527            blocking: false,
16528            blocking_radius_m: 0.0,
16529            building_id: None,
16530        }];
16531        let key = flatland_protocol::ItemStack {
16532            template_id: KEY_TEMPLATE.into(),
16533            quantity: 1,
16534            item_instance_id: Some(key_id),
16535            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
16536            status_bindings: Vec::new(),
16537            contents: Vec::new(),
16538            display_name: None,
16539            category: Some("key".into()),
16540            base_mass: None,
16541            base_volume: None,
16542            capacity_volume: None,
16543            stackable: None,
16544            world_placeable: None,
16545            worker_lodging_capacity: None,
16546                equip_slot: None,
16547                armor_physical: None,
16548                resists: vec![],
16549                hand_slots: None,
16550            listable: None,
16551        };
16552        state.inventory_stacks = vec![key.clone()];
16553        assert!(!state.key_drop_blocked(&key));
16554        let opts = state.move_destinations_for(
16555            &flatland_protocol::InventoryLocation::Root,
16556            None,
16557            Some(key_id),
16558            KEY_TEMPLATE,
16559        );
16560        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
16561    }
16562
16563    #[test]
16564    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
16565        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
16566
16567        let mut state = sample_state();
16568        let curve = ProgressionCurve::default();
16569        let bootstrap = ProgressionXp::bootstrap_new(
16570            curve.baseline_display,
16571            curve.xp_base,
16572            curve.xp_growth,
16573        );
16574        let mut fresh = bootstrap.clone();
16575        fresh.strength += 0.08;
16576        if let Some(player) = state.player.as_mut() {
16577            player.progression_xp = Some(bootstrap);
16578        }
16579
16580        let combat = CombatHud {
16581            progression_xp: Some(fresh.clone()),
16582            progression_baseline: curve.baseline_display,
16583            progression_xp_base: curve.xp_base,
16584            progression_xp_growth: curve.xp_growth,
16585            attributes: state.player.as_ref().and_then(|p| p.attributes),
16586            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
16587            ..CombatHud::default()
16588        };
16589        state.apply_combat_hud(&combat);
16590
16591        let xp = state
16592            .player
16593            .as_ref()
16594            .and_then(|p| p.progression_xp.as_ref())
16595            .expect("xp");
16596        assert!((xp.strength - fresh.strength).abs() < 0.001);
16597        assert!(state.progression_curve.is_some());
16598    }
16599
16600    #[test]
16601    fn combat_hud_syncs_known_abilities_and_hotbar() {
16602        use flatland_protocol::CombatHud;
16603
16604        let mut state = sample_state();
16605        let combat = CombatHud {
16606            known_abilities: vec!["unarmed".into(), "fireball".into()],
16607            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
16608            max_abilities_per_rotation: 4,
16609            ability_id: "short_sword_slash".into(),
16610            ..CombatHud::default()
16611        };
16612        state.apply_combat_hud(&combat);
16613
16614        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
16615        assert_eq!(state.hotbar_ability(1), Some("fireball"));
16616        assert_eq!(state.hotbar_ability(2), None);
16617        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
16618        assert_eq!(state.max_abilities_per_rotation, 4);
16619        let choices = state.loadout_ability_choices();
16620        assert!(choices.iter().any(|a| a == "short_sword_slash"));
16621        assert!(choices.iter().any(|a| a == "fireball"));
16622    }
16623
16624    #[test]
16625    fn loadout_hotbar_choices_include_inventory_consumables() {
16626        let mut state = sample_state();
16627        state.known_abilities = vec!["unarmed".into()];
16628        state.weapon_ability_id = "unarmed".into();
16629        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16630            template_id: "bottle_of_water".into(),
16631            quantity: 3,
16632            item_instance_id: Some(uuid::Uuid::from_u128(9)),
16633            display_name: Some("Bottle of Water".into()),
16634            category: Some("consumable".into()),
16635            ..Default::default()
16636        }];
16637        state.inventory.insert("bottle_of_water".into(), 3);
16638        state.inventory_hints.insert(
16639            "bottle_of_water".into(),
16640            InventoryHint {
16641                display_name: "Bottle of Water".into(),
16642                category: "consumable".into(),
16643                ..Default::default()
16644            },
16645        );
16646
16647        let choices = state.loadout_hotbar_choices();
16648        assert!(choices.iter().any(|c| c.binding == "unarmed"));
16649        let water = choices
16650            .iter()
16651            .find(|c| c.binding == "item:bottle_of_water")
16652            .expect("water binding");
16653        assert_eq!(water.meta.as_deref(), Some("use"));
16654        assert!(water.label.contains("Water"));
16655        assert_eq!(
16656            state.hotbar_slot_label(1),
16657            None,
16658            "unbound until set"
16659        );
16660        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
16661        assert_eq!(
16662            state.hotbar_slot_label(5).as_deref(),
16663            Some("Bottle of Water×3")
16664        );
16665    }
16666
16667    #[test]
16668    fn storage_store_options_excludes_hand_equipped() {
16669        let mut state = sample_state();
16670        let sword_id = uuid::Uuid::from_u128(11);
16671        let ore_id = uuid::Uuid::from_u128(22);
16672        state.inventory_stacks = vec![
16673            flatland_protocol::ItemStack {
16674                template_id: "short_sword".into(),
16675                quantity: 1,
16676                item_instance_id: Some(sword_id),
16677                display_name: Some("Short Sword".into()),
16678                category: Some("weapon".into()),
16679                ..Default::default()
16680            },
16681            flatland_protocol::ItemStack {
16682                template_id: "iron_ore".into(),
16683                quantity: 5,
16684                item_instance_id: Some(ore_id),
16685                display_name: Some("Iron Ore".into()),
16686                category: Some("resource".into()),
16687                ..Default::default()
16688            },
16689        ];
16690        state.mainhand_template_id = Some("short_sword".into());
16691        state.mainhand_instance_id = Some(sword_id);
16692
16693        let opts = state.storage_store_options();
16694        assert_eq!(opts.len(), 1);
16695        assert_eq!(opts[0].item_instance_id, ore_id);
16696        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
16697    }
16698
16699    #[test]
16700    fn loose_consumable_move_picker_offers_use_and_storage() {
16701        let mut state = sample_state();
16702        let inst = uuid::Uuid::from_u128(77);
16703        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16704            template_id: "carrot".into(),
16705            quantity: 2,
16706            item_instance_id: Some(inst),
16707            props: Default::default(),
16708            status_bindings: Vec::new(),
16709            contents: Vec::new(),
16710            display_name: Some("Wild Carrot".into()),
16711            category: Some("consumable".into()),
16712            base_mass: None,
16713            base_volume: None,
16714            capacity_volume: None,
16715            stackable: Some(true),
16716            world_placeable: None,
16717            worker_lodging_capacity: None,
16718                equip_slot: None,
16719                armor_physical: None,
16720                resists: vec![],
16721                hand_slots: None,
16722            listable: None,
16723        }];
16724        state.inventory_hints.insert(
16725            "carrot".into(),
16726            InventoryHint {
16727                display_name: "Wild Carrot".into(),
16728                category: "consumable".into(),
16729                base_mass: Some(0.15),
16730                base_volume: Some(0.3),
16731                capacity_volume: None,
16732                stackable: true,
16733                listable: true,
16734            },
16735        );
16736        state.show_inventory_menu = true;
16737        state.inventory_menu_index = 0;
16738
16739        let row = state.inventory_selected_row().expect("carrot row");
16740        let mut options = state.move_destinations_for(
16741            &row.from,
16742            row.from_parent_instance_id,
16743            row.stack.item_instance_id,
16744            &row.stack.template_id,
16745        );
16746        if row.from == flatland_protocol::InventoryLocation::Root
16747            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
16748        {
16749            options.insert(
16750                0,
16751                MoveOption {
16752                    label: "Use (eat / drink)".into(),
16753                    kind: MoveOptionKind::Use,
16754                },
16755            );
16756        }
16757
16758        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
16759        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
16760        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
16761    }
16762
16763    #[test]
16764    fn inventory_category_group_order_is_stable() {
16765        assert_eq!(inventory_category_group("weapon").0, "Weapons");
16766        assert_eq!(inventory_category_group("armor").0, "Armor");
16767        assert_eq!(inventory_category_group("consumable").0, "Consumables");
16768        assert_eq!(inventory_category_group("resource").0, "Resources");
16769        assert_eq!(inventory_category_group("container").0, "Containers");
16770        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
16771        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
16772    }
16773
16774    #[test]
16775    fn page_list_index_clamps_without_wrap() {
16776        assert_eq!(page_list_index(0, -1, 25), 0);
16777        assert_eq!(page_list_index(0, 1, 25), 10);
16778        assert_eq!(page_list_index(12, 1, 25), 22);
16779        assert_eq!(page_list_index(22, 1, 25), 24);
16780        assert_eq!(page_list_index(5, 1, 0), 0);
16781        assert_eq!(page_list_index(3, -1, 8), 0);
16782    }
16783
16784    #[test]
16785    fn inventory_filter_hides_non_matching_person_items() {
16786        let mut state = sample_state();
16787        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16788        sword.display_name = Some("Iron Sword".into());
16789        sword.category = Some("weapon".into());
16790        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
16791        herb.display_name = Some("Wild Herb".into());
16792        herb.category = Some("consumable".into());
16793        state.inventory_stacks = vec![sword, herb];
16794        state.inventory_tab = InventoryTab::OnPerson;
16795        state.inventory_filter = "sword".into();
16796
16797        let rows = state.inventory_selectable_rows();
16798        assert_eq!(rows.len(), 1);
16799        assert_eq!(rows[0].stack.template_id, "iron_sword");
16800
16801        let lines = state.inventory_browser_lines();
16802        assert!(lines.iter().any(|l| matches!(
16803            l,
16804            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
16805        )));
16806        assert!(!lines.iter().any(|l| matches!(
16807            l,
16808            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
16809        )));
16810    }
16811
16812    #[test]
16813    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
16814        let mut state = sample_state();
16815        let id_a = uuid::Uuid::from_u128(0xa1);
16816        let id_b = uuid::Uuid::from_u128(0xb2);
16817        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
16818        sword_a.display_name = Some("Iron Sword".into());
16819        sword_a.category = Some("weapon".into());
16820        sword_a.item_instance_id = Some(id_a);
16821        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
16822        sword_b.display_name = Some("Iron Sword".into());
16823        sword_b.category = Some("weapon".into());
16824        sword_b.item_instance_id = Some(id_b);
16825        state.inventory_stacks = vec![sword_a, sword_b];
16826        state.inventory_tab = InventoryTab::OnPerson;
16827
16828        let lines = state.inventory_browser_lines();
16829        let items: Vec<_> = lines
16830            .iter()
16831            .filter_map(|l| match l {
16832                InventoryBrowserLine::Item {
16833                    title,
16834                    instance_tooltip,
16835                    ..
16836                } => Some((title.clone(), instance_tooltip.clone())),
16837                _ => None,
16838            })
16839            .collect();
16840        assert_eq!(items.len(), 2);
16841        for (title, tip) in &items {
16842            assert!(
16843                !title.contains('#'),
16844                "title should not show instance suffix: {title}"
16845            );
16846            assert!(
16847                tip.is_some(),
16848                "two identical rows should expose instance on hover"
16849            );
16850        }
16851
16852        state.inventory_stacks.pop();
16853        let lines = state.inventory_browser_lines();
16854        let one = lines.iter().find_map(|l| match l {
16855            InventoryBrowserLine::Item {
16856                title,
16857                instance_tooltip,
16858                ..
16859            } => Some((title.clone(), instance_tooltip.clone())),
16860            _ => None,
16861        });
16862        let (title, tip) = one.expect("one sword row");
16863        assert!(!title.contains('#'));
16864        assert!(tip.is_none(), "single row should not need instance tooltip");
16865    }
16866
16867    #[test]
16868    fn inventory_person_rows_group_by_category() {
16869        let mut state = sample_state();
16870        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16871        sword.category = Some("weapon".into());
16872        sword.display_name = Some("Iron Sword".into());
16873        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
16874        ore.category = Some("resource".into());
16875        ore.display_name = Some("Iron Ore".into());
16876        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
16877        potion.category = Some("consumable".into());
16878        potion.display_name = Some("Health Potion".into());
16879        state.inventory_stacks = vec![ore, potion, sword];
16880        state.inventory_tab = InventoryTab::OnPerson;
16881
16882        let lines = state.inventory_browser_lines();
16883        let labels: Vec<&str> = lines
16884            .iter()
16885            .filter_map(|l| match l {
16886                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
16887                _ => None,
16888            })
16889            .collect();
16890        assert!(
16891            labels.iter().any(|s| s.contains("Weapons")),
16892            "expected Weapons group: {labels:?}"
16893        );
16894        assert!(labels.iter().any(|s| s.contains("Consumables")));
16895        assert!(labels.iter().any(|s| s.contains("Resources")));
16896
16897        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
16898        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
16899        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
16900        assert!(weapon_pos < consumable_pos);
16901        assert!(consumable_pos < resource_pos);
16902    }
16903
16904    #[test]
16905    fn inventory_tab_cycle_resets_selection() {
16906        let mut state = sample_state();
16907        state.inventory_tab = InventoryTab::OnPerson;
16908        state.inventory_menu_index = 3;
16909        state.inventory_tab = state.inventory_tab.cycle(true);
16910        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
16911        // Client method resets index; enum cycle alone does not — verify cycle labels.
16912        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
16913        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
16914        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
16915        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
16916    }
16917
16918    #[test]
16919    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
16920        assert_eq!(parse_bank_copper_amount(""), Some(0));
16921        assert_eq!(parse_bank_copper_amount("  "), Some(0));
16922        assert_eq!(parse_bank_copper_amount("0"), Some(0));
16923        assert_eq!(parse_bank_copper_amount("250"), Some(250));
16924        assert_eq!(parse_bank_copper_amount("nope"), None);
16925    }
16926
16927    #[test]
16928    fn parse_storage_quantity_blank_and_zero_mean_all() {
16929        assert_eq!(parse_storage_quantity(""), Some(None));
16930        assert_eq!(parse_storage_quantity("  "), Some(None));
16931        assert_eq!(parse_storage_quantity("0"), Some(None));
16932        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
16933        assert_eq!(parse_storage_quantity("nope"), None);
16934    }
16935
16936    #[test]
16937    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
16938        assert!(worker_error_is_hud_noise("path stuck — repathing"));
16939        assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
16940        assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
16941        // Real problem: player may need to place lodging / fix assignment.
16942        assert!(!worker_error_is_hud_noise(
16943            "path stuck — no lodging to reset to"
16944        ));
16945    }
16946
16947    #[test]
16948    fn leaving_building_restores_outdoor_z_bands() {
16949        use flatland_protocol::{InteriorMapView, ZPlatformView};
16950
16951        let mut state = sample_state();
16952        state.z_platforms.clear();
16953        state.z_transitions.clear();
16954        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
16955        state.interior_map = Some(InteriorMapView {
16956            building_id: "broker_hut".into(),
16957            blueprint_id: "broker_hut".into(),
16958            background_color: "#000".into(),
16959            default_floor_color: None,
16960            floor_height_m: 3.0,
16961            z_platforms: vec![ZPlatformView {
16962                id: "floor_0".into(),
16963                z: 0.0,
16964                x0: 0.0,
16965                y0: 0.0,
16966                x1: 8.0,
16967                y1: 8.0,
16968            }],
16969            z_transitions: vec![],
16970            rooms: vec![],
16971            room_doors: vec![],
16972        });
16973        state.sync_interior_map_context();
16974        assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
16975        assert!(state.z_bands_outdoor_backup.is_some());
16976
16977        state.player.as_mut().unwrap().inside_building = None;
16978        state.sync_interior_map_context();
16979        assert!(
16980            state.z_platforms.is_empty(),
16981            "leaving must restore outdoor bands (empty), not leave interior platforms"
16982        );
16983        assert!(state.z_bands_outdoor_backup.is_none());
16984        assert!(state.interior_map.is_none());
16985    }
16986}