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    /// When set, the rename prompt targets a property plot (deed or on-plot).
1268    pub rename_plot_id: Option<uuid::Uuid>,
1269    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1270    pub highlighted_plot_id: Option<uuid::Uuid>,
1271    /// Rename prompt for a hired worker (`n` in workers menu).
1272    pub show_worker_rename: bool,
1273    pub rename_buffer: String,
1274    /// Slot-1 combat target (mirrors server after SetTarget).
1275    pub combat_target: Option<EntityId>,
1276    pub combat_target_label: Option<String>,
1277    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1278    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1279    pub ground_target: Option<(f32, f32, f32)>,
1280    /// Active combat footprints from the server (`plans/39`).
1281    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1282    /// Authored crown property zones (claimable land) — plan 40.
1283    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1284    /// Tax overlays for claim cost premium preview.
1285    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1286    /// Growth / fertility overlays (farming, respawn).
1287    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1288    /// Climate / biome overlays.
1289    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1290    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1291    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1292    /// Claimed property plots near the observer.
1293    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1294    /// Server knobs for client-side claim quotes.
1295    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1296    /// Local claim-footprint editor (not server state).
1297    pub claim_mode: Option<ClaimModeState>,
1298    /// Local relocate ghost for a placed chest/lodging (not server state).
1299    pub relocate_mode: Option<RelocateModeState>,
1300    /// Second `f` within a short window confirms selling this plot back to the crown.
1301    pub sell_plot_confirm: Option<uuid::Uuid>,
1302    /// When `sell_plot_confirm` was armed (for double-tap window).
1303    pub sell_plot_armed_at: Option<Instant>,
1304    /// Choose seed type when planting on owned tilled soil (`f`).
1305    pub show_plant_menu: bool,
1306    pub plant_menu_index: usize,
1307    /// Deed-holder farm access panel (public toggle + allow-list).
1308    pub show_farm_access: bool,
1309    /// Draft name for adding a tenant from the farm-access panel.
1310    pub farm_access_name_draft: String,
1311    /// Public / grant discount draft (bps) while editing farm access.
1312    pub farm_access_discount_bps: u32,
1313    /// Selected row in the farm-access panel (0 = public toggle).
1314    pub farm_access_index: usize,
1315    pub plant_quantity: u32,
1316    pub in_combat: bool,
1317    pub auto_attack: bool,
1318    pub combat_has_los: bool,
1319    pub attack_cd_ticks: u64,
1320    pub gcd_ticks: u64,
1321    pub weapon_ability_id: String,
1322    pub mainhand_template_id: Option<String>,
1323    pub mainhand_label: Option<String>,
1324    pub mainhand_instance_id: Option<uuid::Uuid>,
1325    pub offhand_template_id: Option<String>,
1326    pub offhand_label: Option<String>,
1327    pub offhand_instance_id: Option<uuid::Uuid>,
1328    pub mainhand_hand_slots: u8,
1329    pub defense: Option<flatland_protocol::DefenseHud>,
1330    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1331    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1332    pub carry_mass: f32,
1333    pub carry_mass_max: f32,
1334    pub encumbrance: flatland_protocol::EncumbranceState,
1335    /// Full nested inventory stacks from the server (root only; worn are separate).
1336    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1337    /// Keys stowed on the virtual keychain (zero carry mass).
1338    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1339    /// Whisper stones in the pouch (zero carry mass).
1340    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1341    /// Active status effects on the local player (buff/debuff strip).
1342    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1343    pub combat_target_detail: Option<CombatTargetHud>,
1344    pub cast_progress: Option<CastProgressHud>,
1345    /// Till / plant / other non-combat timed channels on the local player.
1346    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1347    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1348    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1349    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1350    pub blocking_active: bool,
1351    pub max_target_slots: u8,
1352    pub combat_slots: Vec<CombatSlotHud>,
1353    pub rotation_presets: Vec<RotationPreset>,
1354    /// Learned abilities from combat HUD (usable casts).
1355    pub known_abilities: Vec<String>,
1356    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1357    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1358    /// Per-ability mastery from combat HUD (tier / XP).
1359    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1360    /// Server-persisted hotbar bindings (index 0 = key 1).
1361    pub hotbar: Vec<Option<String>>,
1362    /// Max abilities allowed in one rotation (mind score).
1363    pub max_abilities_per_rotation: u8,
1364    pub show_loadout_menu: bool,
1365    pub show_keychain_menu: bool,
1366    pub keychain_menu_index: usize,
1367    pub show_rotation_editor: bool,
1368    /// Selected rotation preset in the loadout menu.
1369    pub loadout_menu_index: usize,
1370    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1371    pub loadout_hotbar_slot: u8,
1372    /// Selected known-ability row in the loadout menu.
1373    pub loadout_ability_index: usize,
1374    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1375    pub loadout_focus_presets: bool,
1376    pub rotation_editor: RotationEditorState,
1377    /// True after a harvest intent is accepted until result/reject/disconnect.
1378    pub harvest_in_progress: bool,
1379    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1380    pub harvest_started_at: Option<Instant>,
1381    /// Craft log deferred until the server acks the craft intent.
1382    pub pending_craft_ack: Option<(u32, String, u32)>,
1383    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1384    pub interactables: Vec<flatland_protocol::InteractableView>,
1385    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1386    pub career: Option<flatland_protocol::PlayerCareerView>,
1387    pub character_sheet_tab: CharacterSheetTab,
1388    pub ledger_period: LedgerPeriod,
1389    pub show_quest_offer: bool,
1390    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1391    pub show_quest_menu: bool,
1392    pub quest_menu_index: usize,
1393    pub quest_withdraw_confirm: bool,
1394    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1395    pub show_workers_menu: bool,
1396    pub workers_menu_index: usize,
1397    /// Workers panel: one-line rows instead of full cards (more on screen).
1398    pub workers_menu_compact: bool,
1399    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1400    /// Public so out-of-crate tests can construct [`GameState`].
1401    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1402    /// Held worker route errors (readable when server clears `last_error` each tick).
1403    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1404    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1405    pub show_worker_give_picker: bool,
1406    pub worker_give_picker_index: usize,
1407    pub worker_give_picker: Option<WorkerGivePicker>,
1408    /// Inventory `g` — pick which nearby worker receives the selected stack.
1409    pub show_worker_give_target_picker: bool,
1410    pub worker_give_target_picker_index: usize,
1411    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1412    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1413    pub show_worker_take_picker: bool,
1414    pub worker_take_picker_index: usize,
1415    pub worker_take_picker: Option<WorkerTakePicker>,
1416    /// Teach-blueprint sheet opened from the workers menu (`t`).
1417    pub show_worker_teach_picker: bool,
1418    pub worker_teach_picker_index: usize,
1419    pub worker_teach_picker: Option<WorkerTeachPicker>,
1420    /// Active harvest-route editor (`h` → `e` on a worker).
1421    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1422    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1423    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1424    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1425    pub attending_worker_instance_id: Option<String>,
1426    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1427    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1428}
1429
1430impl GameState {
1431    pub fn push_log(&mut self, line: impl Into<String>) {
1432        self.logs.push_back(line.into());
1433        while self.logs.len() > MAX_LOG_LINES {
1434            self.logs.pop_front();
1435        }
1436    }
1437
1438    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1439        self.shop_trade_log.push_back(line.into());
1440        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1441            self.shop_trade_log.pop_front();
1442        }
1443    }
1444
1445    pub fn clear_shop_trade_log(&mut self) {
1446        self.shop_trade_log.clear();
1447    }
1448
1449    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1450        if !self.show_shop_menu {
1451            return;
1452        }
1453        let msg = notice.message.trim();
1454        if msg.is_empty() {
1455            return;
1456        }
1457        if notice.coins_delta != 0
1458            || msg.starts_with("Bought ")
1459            || msg.starts_with("Sold ")
1460            || msg.contains("taught you how to craft")
1461            || msg.starts_with("need ")
1462        {
1463            self.push_shop_trade_log(msg);
1464        }
1465    }
1466
1467    pub fn is_alive(&self) -> bool {
1468        self.player
1469            .as_ref()
1470            .and_then(|p| p.vitals)
1471            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1472            .unwrap_or(true)
1473    }
1474
1475    /// Verb menu entries for the current `npc_verb_target`.
1476    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1477        let Some(ref id) = self.npc_verb_target else {
1478            return vec![];
1479        };
1480        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1481            return vec!["Talk"];
1482        };
1483        let role = npc.role.as_str();
1484        if Self::npc_role_is_bank(role) {
1485            return vec!["Bank", "Talk"];
1486        }
1487        if Self::npc_role_is_storage(role) {
1488            return vec!["Storage", "Talk"];
1489        }
1490        if Self::npc_role_is_market(role) {
1491            return vec!["Market", "Talk"];
1492        }
1493        if npc.can_trade || Self::npc_role_can_trade(role) {
1494            vec!["Talk", "Trade"]
1495        } else {
1496            vec!["Talk"]
1497        }
1498    }
1499
1500    fn npc_role_can_trade(role: &str) -> bool {
1501        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1502    }
1503
1504    fn npc_role_is_bank(role: &str) -> bool {
1505        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1506    }
1507
1508    fn npc_role_is_storage(role: &str) -> bool {
1509        role.eq_ignore_ascii_case("storage_manager")
1510    }
1511
1512    fn npc_role_is_market(role: &str) -> bool {
1513        role.eq_ignore_ascii_case("market_clerk")
1514    }
1515
1516    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1517        vec![
1518            "Deposit…",
1519            "Withdraw…",
1520            "Deposit all",
1521            "Withdraw all",
1522            "Transfer…",
1523        ]
1524    }
1525
1526    pub fn storage_menu_options(&self) -> Vec<String> {
1527        let mut opts = vec!["Store…".into(), "Take…".into()];
1528        if let Some(panel) = &self.storage_panel {
1529            for dest in &panel.ship_destinations {
1530                opts.push(format!(
1531                    "Ship → {} ({} cp / {} ticks)",
1532                    dest.label, dest.fee_copper, dest.travel_ticks
1533                ));
1534            }
1535        }
1536        opts
1537    }
1538
1539    /// Loose on-person stacks eligible for town-storage store.
1540    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
1541    /// already outside root inventory.
1542    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1543        let equipped = self.hand_equipped_instance_ids();
1544        self.person_rows()
1545            .into_iter()
1546            .filter(|r| r.depth == 0)
1547            .filter_map(|r| {
1548                let id = r.stack.item_instance_id?;
1549                if equipped.contains(&id) {
1550                    return None;
1551                }
1552                Some(StoragePickOption {
1553                    item_instance_id: id,
1554                    label: storage_stack_label(&r.stack),
1555                    quantity: r.stack.quantity,
1556                    category: r.stack.category.clone().unwrap_or_default(),
1557                })
1558            })
1559            .collect()
1560    }
1561
1562    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
1563    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1564        let mut ids = std::collections::HashSet::new();
1565        if let Some(id) = self.mainhand_instance_id {
1566            ids.insert(id);
1567        } else if let Some(tid) = &self.mainhand_template_id {
1568            if let Some(id) = self
1569                .inventory_stacks
1570                .iter()
1571                .find(|s| &s.template_id == tid)
1572                .and_then(|s| s.item_instance_id)
1573            {
1574                ids.insert(id);
1575            }
1576        }
1577        if let Some(id) = self.offhand_instance_id {
1578            ids.insert(id);
1579        } else if let Some(tid) = &self.offhand_template_id {
1580            if let Some(id) = self
1581                .inventory_stacks
1582                .iter()
1583                .find(|s| {
1584                    &s.template_id == tid
1585                        && s.item_instance_id
1586                            .is_some_and(|iid| !ids.contains(&iid))
1587                })
1588                .and_then(|s| s.item_instance_id)
1589            {
1590                ids.insert(id);
1591            }
1592        }
1593        ids
1594    }
1595
1596    /// Vault stacks eligible for take / ship.
1597    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1598        let Some(panel) = &self.storage_panel else {
1599            return Vec::new();
1600        };
1601        panel
1602            .contents
1603            .iter()
1604            .filter_map(|s| {
1605                let id = s.item_instance_id?;
1606                Some(StoragePickOption {
1607                    item_instance_id: id,
1608                    label: storage_stack_label(s),
1609                    quantity: s.quantity,
1610                    category: s.category.clone().unwrap_or_default(),
1611                })
1612            })
1613            .collect()
1614    }
1615
1616    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1617    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1618        let mut opts = Vec::new();
1619        if !self
1620            .market_list_item_options(&MarketListSourceKind::Person)
1621            .is_empty()
1622        {
1623            opts.push((MarketListSourceKind::Person, "On person".into()));
1624        }
1625        if let Some(panel) = &self.market_panel {
1626            for vault in &panel.list_vaults {
1627                let source = MarketListSourceKind::TownStorage {
1628                    building_id: vault.building_id.clone(),
1629                };
1630                if self.market_list_item_options(&source).is_empty() {
1631                    continue;
1632                }
1633                let label = if vault.building_label.is_empty() {
1634                    format!("Town storage ({})", vault.building_id)
1635                } else {
1636                    format!("Town storage — {}", vault.building_label)
1637                };
1638                opts.push((source, label));
1639            }
1640        }
1641        opts
1642    }
1643
1644    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1645    pub fn market_list_item_options(
1646        &self,
1647        source: &MarketListSourceKind,
1648    ) -> Vec<StoragePickOption> {
1649        let filter = self.market_filter.as_str();
1650        let cat_filter = self.market_category_filter;
1651        let mut opts: Vec<StoragePickOption> = match source {
1652            MarketListSourceKind::Person => {
1653                let equipped = self.hand_equipped_instance_ids();
1654                self.person_rows()
1655                    .into_iter()
1656                    .filter(|r| r.depth == 0)
1657                    .filter(|r| self.stack_is_market_listable(&r.stack))
1658                    .filter_map(|r| {
1659                        let id = r.stack.item_instance_id?;
1660                        if equipped.contains(&id) {
1661                            return None;
1662                        }
1663                        Some(StoragePickOption {
1664                            item_instance_id: id,
1665                            label: storage_stack_label(&r.stack),
1666                            quantity: r.stack.quantity,
1667                            category: r
1668                                .stack
1669                                .category
1670                                .clone()
1671                                .or_else(|| {
1672                                    self.inventory_item_category(&r.stack.template_id)
1673                                        .map(str::to_string)
1674                                })
1675                                .unwrap_or_default(),
1676                        })
1677                    })
1678                    .collect()
1679            }
1680            MarketListSourceKind::TownStorage { building_id } => {
1681                let Some(panel) = &self.market_panel else {
1682                    return Vec::new();
1683                };
1684                let Some(vault) = panel
1685                    .list_vaults
1686                    .iter()
1687                    .find(|v| &v.building_id == building_id)
1688                else {
1689                    return Vec::new();
1690                };
1691                vault
1692                    .contents
1693                    .iter()
1694                    .filter(|s| self.stack_is_market_listable(s))
1695                    .filter_map(|s| {
1696                        let id = s.item_instance_id?;
1697                        Some(StoragePickOption {
1698                            item_instance_id: id,
1699                            label: storage_stack_label(s),
1700                            quantity: s.quantity,
1701                            category: s
1702                                .category
1703                                .clone()
1704                                .or_else(|| {
1705                                    self.inventory_item_category(&s.template_id)
1706                                        .map(str::to_string)
1707                                })
1708                                .unwrap_or_default(),
1709                        })
1710                    })
1711                    .collect()
1712            }
1713        };
1714        opts.retain(|o| {
1715            if !list_label_matches(&o.label, filter) {
1716                return false;
1717            }
1718            if let Some(group) = cat_filter {
1719                inventory_category_group(&o.category).0 == group
1720            } else {
1721                true
1722            }
1723        });
1724        opts
1725    }
1726
1727    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1728        if crate::currency::is_currency(&stack.template_id) {
1729            return false;
1730        }
1731        if let Some(flag) = stack.listable {
1732            return flag;
1733        }
1734        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1735            return hint.listable;
1736        }
1737        let cat = stack
1738            .category
1739            .as_deref()
1740            .or_else(|| self.inventory_item_category(&stack.template_id))
1741            .unwrap_or("");
1742        category_default_listable(cat)
1743    }
1744
1745    /// Category group labels present in the current browse book or list-pick source.
1746    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1747        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1748        match &self.market_ui_mode {
1749            MarketUiMode::ListPick { source, .. } => {
1750                let raw: Vec<_> = match source {
1751                    MarketListSourceKind::Person => self
1752                        .person_rows()
1753                        .into_iter()
1754                        .filter(|r| r.depth == 0)
1755                        .filter(|r| self.stack_is_market_listable(&r.stack))
1756                        .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1757                        .map(|r| {
1758                            r.stack
1759                                .category
1760                                .clone()
1761                                .or_else(|| {
1762                                    self.inventory_item_category(&r.stack.template_id)
1763                                        .map(str::to_string)
1764                                })
1765                                .unwrap_or_default()
1766                        })
1767                        .collect(),
1768                    MarketListSourceKind::TownStorage { building_id } => self
1769                        .market_panel
1770                        .as_ref()
1771                        .and_then(|p| {
1772                            p.list_vaults
1773                                .iter()
1774                                .find(|v| &v.building_id == building_id)
1775                        })
1776                        .map(|vault| {
1777                            vault
1778                                .contents
1779                                .iter()
1780                                .filter(|s| self.stack_is_market_listable(s))
1781                                .filter(|s| {
1782                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
1783                                })
1784                                .map(|s| {
1785                                    s.category
1786                                        .clone()
1787                                        .or_else(|| {
1788                                            self.inventory_item_category(&s.template_id)
1789                                                .map(str::to_string)
1790                                        })
1791                                        .unwrap_or_default()
1792                                })
1793                                .collect::<Vec<_>>()
1794                        })
1795                        .unwrap_or_default(),
1796                };
1797                for category in raw {
1798                    let (label, ord) = inventory_category_group(&category);
1799                    seen.insert(ord, label);
1800                }
1801            }
1802            _ => {
1803                if let Some(panel) = &self.market_panel {
1804                    for listing in &panel.listings {
1805                        if !list_label_matches(&listing.display_name, &self.market_filter)
1806                            && !list_label_matches(&listing.seller_label, &self.market_filter)
1807                        {
1808                            continue;
1809                        }
1810                        let (label, ord) = inventory_category_group(&listing.category);
1811                        seen.insert(ord, label);
1812                    }
1813                }
1814            }
1815        }
1816        seen.into_values().collect()
1817    }
1818
1819    /// Indices into `market_panel.listings` after category + text filter.
1820    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1821        let Some(panel) = &self.market_panel else {
1822            return Vec::new();
1823        };
1824        let filter = self.market_filter.as_str();
1825        let cat_filter = self.market_category_filter;
1826        panel
1827            .listings
1828            .iter()
1829            .enumerate()
1830            .filter(|(_, listing)| {
1831                if !list_label_matches(&listing.display_name, filter)
1832                    && !list_label_matches(&listing.seller_label, filter)
1833                    && !list_label_matches(&listing.template_id, filter)
1834                {
1835                    return false;
1836                }
1837                if let Some(group) = cat_filter {
1838                    inventory_category_group(&listing.category).0 == group
1839                } else {
1840                    true
1841                }
1842            })
1843            .map(|(i, _)| i)
1844            .collect()
1845    }
1846
1847    pub fn clear_harvest_state(&mut self) {
1848        self.harvest_in_progress = false;
1849        self.harvest_started_at = None;
1850    }
1851
1852    fn harvest_state_stale(&self) -> bool {
1853        match self.harvest_started_at {
1854            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1855            None => self.harvest_in_progress,
1856        }
1857    }
1858
1859    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1860        self.player.as_ref().and_then(|p| p.vitals)
1861    }
1862
1863    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1864        let materials_ok = blueprint.inputs.iter().all(|input| {
1865            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1866        });
1867        let tools_ok = blueprint
1868            .required_tools
1869            .iter()
1870            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1871        let station_ok = match blueprint.station.as_deref() {
1872            None | Some("hand") => true,
1873            Some(tag) => self.player_at_station_tag(tag),
1874        };
1875        materials_ok && tools_ok && station_ok
1876    }
1877
1878    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1879        if !self.can_craft_blueprint(blueprint) {
1880            return 0;
1881        }
1882        let mut limit = u32::MAX;
1883        for input in &blueprint.inputs {
1884            if input.quantity == 0 {
1885                continue;
1886            }
1887            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1888            limit = limit.min(have / input.quantity);
1889        }
1890        for tool in &blueprint.required_tools {
1891            if tool.consumed {
1892                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1893                limit = limit.min(have);
1894            }
1895        }
1896        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1897        if CRAFT_STAMINA_COST > 0.0 {
1898            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1899        }
1900        limit
1901    }
1902
1903    pub fn clamp_craft_batch_quantity(&mut self) {
1904        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1905            self.craft_batch_quantity = 1;
1906            return;
1907        };
1908        let max = self.max_craft_batches(bp).max(1);
1909        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1910    }
1911
1912    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1913        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1914            return;
1915        };
1916        let max = self.max_craft_batches(&bp).max(1);
1917        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1918        self.craft_batch_quantity = next as u32;
1919    }
1920
1921    pub fn craft_batch_set_max(&mut self) {
1922        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1923            return;
1924        };
1925        let max = self.max_craft_batches(&bp);
1926        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1927    }
1928
1929    pub fn craft_batch_set_min(&mut self) {
1930        self.craft_batch_quantity = 1;
1931    }
1932
1933    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1934        let preserve_ui = self.show_shop_menu;
1935        let tab = self.shop_tab;
1936        let index = self.shop_menu_index;
1937        let qty = self.shop_quantity;
1938
1939        self.show_shop_menu = true;
1940        self.bank_panel = None;
1941        self.show_craft_menu = false;
1942        self.show_inventory_menu = false;
1943        self.show_stats = false;
1944        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1945            self.npc_verb_target = Some(catalog.npc_id.clone());
1946        }
1947        self.shop_catalog = Some(catalog);
1948
1949        if preserve_ui {
1950            self.shop_tab = tab;
1951            self.shop_menu_index = index;
1952            self.shop_quantity = qty;
1953        } else {
1954            self.shop_tab = ShopTab::Buy;
1955            self.shop_menu_index = 0;
1956            self.shop_quantity = 1;
1957            self.clear_shop_trade_log();
1958        }
1959        self.show_npc_verb_menu = false;
1960        self.clamp_shop_selection();
1961    }
1962
1963    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1964        let same_teller = self
1965            .bank_panel
1966            .as_ref()
1967            .is_some_and(|p| p.npc_id == panel.npc_id);
1968        self.bank_panel = Some(panel);
1969        self.storage_panel = None;
1970        self.market_panel = None;
1971        self.shop_catalog = None;
1972        self.show_shop_menu = false;
1973        self.show_craft_menu = false;
1974        self.show_inventory_menu = false;
1975        self.show_stats = false;
1976        self.show_npc_verb_menu = false;
1977        self.show_npc_chat = false;
1978        self.npc_chat = None;
1979        if !same_teller {
1980            self.bank_menu_index = 0;
1981            self.bank_ui_mode = BankUiMode::Menu;
1982        }
1983        if let Some(panel) = &self.bank_panel {
1984            if self.npc_verb_target.is_none() {
1985                self.npc_verb_target = Some(panel.npc_id.clone());
1986            }
1987        }
1988    }
1989
1990    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1991        let same_manager = self
1992            .storage_panel
1993            .as_ref()
1994            .is_some_and(|p| p.npc_id == panel.npc_id);
1995        self.storage_panel = Some(panel);
1996        self.bank_panel = None;
1997        self.market_panel = None;
1998        self.bank_ui_mode = BankUiMode::Menu;
1999        self.shop_catalog = None;
2000        self.show_shop_menu = false;
2001        self.show_craft_menu = false;
2002        self.show_inventory_menu = false;
2003        self.show_stats = false;
2004        self.show_npc_verb_menu = false;
2005        self.show_npc_chat = false;
2006        self.npc_chat = None;
2007        if !same_manager {
2008            self.storage_menu_index = 0;
2009            self.storage_ui_mode = StorageUiMode::Menu;
2010        } else {
2011            self.clamp_storage_pick_index();
2012        }
2013        if let Some(panel) = &self.storage_panel {
2014            if self.npc_verb_target.is_none() {
2015                self.npc_verb_target = Some(panel.npc_id.clone());
2016            }
2017        }
2018    }
2019
2020    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2021        self.market_panel = Some(panel);
2022        self.bank_panel = None;
2023        self.storage_panel = None;
2024        self.shop_catalog = None;
2025        self.show_shop_menu = false;
2026        self.show_craft_menu = false;
2027        self.show_inventory_menu = false;
2028        self.show_stats = false;
2029        self.show_npc_verb_menu = false;
2030        self.show_npc_chat = false;
2031        self.npc_chat = None;
2032        self.market_menu_index = 0;
2033        self.market_buy_confirm = None;
2034        self.market_ui_mode = MarketUiMode::Browse;
2035        self.market_filter.clear();
2036        self.market_filter_focused = false;
2037        self.market_category_filter = None;
2038        if let Some(panel) = &self.market_panel {
2039            if self.npc_verb_target.is_none() {
2040                self.npc_verb_target = Some(panel.npc_id.clone());
2041            }
2042        }
2043    }
2044
2045    pub fn clear_market_panel(&mut self) {
2046        self.market_panel = None;
2047        self.market_menu_index = 0;
2048        self.market_buy_confirm = None;
2049        self.market_ui_mode = MarketUiMode::Browse;
2050        self.market_filter.clear();
2051        self.market_filter_focused = false;
2052        self.market_category_filter = None;
2053    }
2054
2055    pub fn clear_bank_panel(&mut self) {
2056        self.bank_panel = None;
2057        self.bank_menu_index = 0;
2058        self.bank_ui_mode = BankUiMode::Menu;
2059    }
2060
2061    pub fn clear_storage_panel(&mut self) {
2062        self.storage_panel = None;
2063        self.storage_menu_index = 0;
2064        self.storage_ui_mode = StorageUiMode::Menu;
2065    }
2066
2067    fn clamp_storage_pick_index(&mut self) {
2068        match &self.storage_ui_mode {
2069            StorageUiMode::StorePick { index } => {
2070                let n = self.storage_store_options().len();
2071                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2072                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2073            }
2074            StorageUiMode::TakePick { index } => {
2075                let n = self.storage_vault_options().len();
2076                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2077                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2078            }
2079            StorageUiMode::ShipPick {
2080                dest_building_id,
2081                dest_label,
2082                index,
2083            } => {
2084                let n = self.storage_vault_options().len();
2085                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2086                self.storage_ui_mode = StorageUiMode::ShipPick {
2087                    dest_building_id: dest_building_id.clone(),
2088                    dest_label: dest_label.clone(),
2089                    index: next,
2090                };
2091            }
2092            StorageUiMode::Menu
2093            | StorageUiMode::StoreAmount { .. }
2094            | StorageUiMode::TakeAmount { .. }
2095            | StorageUiMode::ShipAmount { .. } => {}
2096        }
2097    }
2098
2099    pub fn shop_list_len(&self) -> usize {
2100        let Some(catalog) = &self.shop_catalog else {
2101            return 0;
2102        };
2103        match self.shop_tab {
2104            ShopTab::Buy => catalog.sells.len(),
2105            ShopTab::Sell => catalog.buys.len(),
2106        }
2107    }
2108
2109    pub fn shop_menu_move(&mut self, delta: i32) {
2110        let n = self.shop_list_len();
2111        if n == 0 {
2112            return;
2113        }
2114        let idx = self.shop_menu_index as i32;
2115        let next = (idx + delta).rem_euclid(n as i32);
2116        self.shop_menu_index = next as usize;
2117        self.clamp_shop_quantity();
2118    }
2119
2120    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2121        let max = self.shop_quantity_max();
2122        if max == 0 {
2123            self.shop_quantity = 0;
2124            return;
2125        }
2126        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2127        self.shop_quantity = next as u32;
2128    }
2129
2130    pub(crate) fn clamp_shop_selection(&mut self) {
2131        let n = self.shop_list_len();
2132        if n == 0 {
2133            self.shop_menu_index = 0;
2134        } else {
2135            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2136        }
2137        self.clamp_shop_quantity();
2138    }
2139
2140    fn shop_quantity_max(&self) -> u32 {
2141        let Some(catalog) = &self.shop_catalog else {
2142            return 1;
2143        };
2144        match self.shop_tab {
2145            ShopTab::Buy => {
2146                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2147                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2148                        return 1;
2149                    }
2150                }
2151                99
2152            }
2153            ShopTab::Sell => catalog
2154                .buys
2155                .get(self.shop_menu_index)
2156                .map(|l| l.quantity)
2157                .unwrap_or(0),
2158        }
2159    }
2160
2161    pub fn shop_quantity_set_max(&mut self) {
2162        self.shop_quantity = self.shop_quantity_max();
2163    }
2164
2165    pub fn shop_quantity_set_min(&mut self) {
2166        let max = self.shop_quantity_max();
2167        self.shop_quantity = if max == 0 { 0 } else { 1 };
2168    }
2169
2170    fn clamp_shop_quantity(&mut self) {
2171        let max = self.shop_quantity_max();
2172        if max == 0 {
2173            self.shop_quantity = 0;
2174        } else {
2175            self.shop_quantity = self.shop_quantity.max(1).min(max);
2176        }
2177    }
2178
2179    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2180        let Some(id) = self.effective_inside_building() else {
2181            return false;
2182        };
2183        self.buildings
2184            .iter()
2185            .find(|b| b.id == id)
2186            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2187    }
2188
2189    /// Short hint for UI when a recipe cannot be started.
2190    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2191        if self.can_craft_blueprint(blueprint) {
2192            return None;
2193        }
2194        let mut missing = Vec::new();
2195        for input in &blueprint.inputs {
2196            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2197            if have < input.quantity {
2198                let name = self.blueprint_ingredient_label(input);
2199                missing.push(format!("{}×{} (have {have})", input.quantity, name));
2200            }
2201        }
2202        for tool in &blueprint.required_tools {
2203            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2204            if have < 1 {
2205                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2206            }
2207        }
2208        if let Some(station) = blueprint.station.as_deref() {
2209            if station != "hand" && !self.player_at_station_tag(station) {
2210                missing.push(format!("station: {station} (enter building)"));
2211            }
2212        }
2213        if missing.is_empty() {
2214            None
2215        } else {
2216            Some(missing.join(", "))
2217        }
2218    }
2219
2220    pub fn player_entity(&self) -> Option<&EntityState> {
2221        self.player
2222            .as_ref()
2223            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2224    }
2225
2226    /// Apply persisted HUD / workers UI prefs from `client.json`.
2227    pub fn apply_client_ui_prefs(&mut self) {
2228        let cfg = crate::client_config::ClientConfig::load();
2229        if let Some(hidden) = cfg.hud_log_hidden {
2230            self.hud_log_hidden = hidden;
2231        }
2232        if let Some(compact) = cfg.workers_menu_compact {
2233            self.workers_menu_compact = compact;
2234        }
2235    }
2236
2237    pub fn player_position(&self) -> (f32, f32) {
2238        let (x, y, _) = self.player_position_with_z();
2239        (x, y)
2240    }
2241
2242    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2243        if let Some(p) = self.player_entity() {
2244            (
2245                p.transform.position.x,
2246                p.transform.position.y,
2247                p.transform.position.z,
2248            )
2249        } else {
2250            (0.0, 0.0, 0.0)
2251        }
2252    }
2253
2254    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2255        let mut rows: Vec<(String, u32, String)> = self
2256            .inventory
2257            .iter()
2258            .filter(|(_, q)| **q > 0)
2259            .map(|(id, qty)| {
2260                let label = self
2261                    .inventory_hints
2262                    .get(id)
2263                    .map(|h| h.display_name.clone())
2264                    .unwrap_or_else(|| id.clone());
2265                (id.clone(), *qty, label)
2266            })
2267            .collect();
2268        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2269        rows
2270    }
2271
2272    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2273        self.inventory_hints
2274            .get(template_id)
2275            .map(|h| h.category.as_str())
2276            .filter(|c| !c.is_empty())
2277    }
2278
2279    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2280        stack
2281            .props
2282            .get("grants_item_status_effect")
2283            .map(|s| !s.is_empty())
2284            .unwrap_or(false)
2285    }
2286
2287    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2288        stack
2289            .props
2290            .get("grants_item_status_effect")
2291            .map(String::as_str)
2292            .filter(|s| !s.is_empty())
2293    }
2294
2295    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2296        stack
2297            .props
2298            .get("grants_item_status_mode")
2299            .map(String::as_str)
2300            .unwrap_or("on_hit")
2301    }
2302
2303    /// Candidate gear for a grant consumable (inventory + worn).
2304    pub fn grant_target_options(
2305        &self,
2306        grant: &flatland_protocol::ItemStack,
2307    ) -> Vec<GrantTargetOption> {
2308        let mode = Self::grant_mode(grant);
2309        let grant_tags: Vec<&str> = grant
2310            .props
2311            .get("grants_item_status_tags")
2312            .map(|s| {
2313                s.split(',')
2314                    .map(str::trim)
2315                    .filter(|t| !t.is_empty())
2316                    .collect()
2317            })
2318            .unwrap_or_default();
2319        let grant_id = grant.item_instance_id;
2320        let mut out = Vec::new();
2321        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2322            let Some(iid) = stack.item_instance_id else {
2323                return;
2324            };
2325            if Some(iid) == grant_id {
2326                return;
2327            }
2328            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2329                return;
2330            }
2331            if !grant_target_matches_mode(stack, mode) {
2332                return;
2333            }
2334            if !grant_tags_match(stack, &grant_tags) {
2335                return;
2336            }
2337            let name = stack
2338                .display_name
2339                .clone()
2340                .unwrap_or_else(|| stack.template_id.clone());
2341            let bindings = if stack.status_bindings.is_empty() {
2342                String::new()
2343            } else {
2344                format!(
2345                    " · {}",
2346                    stack
2347                        .status_bindings
2348                        .iter()
2349                        .map(|b| b.effect_id.as_str())
2350                        .collect::<Vec<_>>()
2351                        .join(", ")
2352                )
2353            };
2354            out.push(GrantTargetOption {
2355                label: format!("{where_label}: {name}{bindings}"),
2356                target_instance_id: iid,
2357            });
2358        };
2359        fn walk(
2360            stacks: &[flatland_protocol::ItemStack],
2361            where_label: &str,
2362            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2363        ) {
2364            for s in stacks {
2365                push(s, where_label);
2366                if !s.contents.is_empty() {
2367                    let nested = format!(
2368                        "{where_label}/{}",
2369                        s.display_name
2370                            .as_deref()
2371                            .unwrap_or(s.template_id.as_str())
2372                    );
2373                    walk(&s.contents, &nested, push);
2374                }
2375            }
2376        }
2377        walk(&self.inventory_stacks, "Bag", &mut push);
2378        for (slot, stack) in &self.worn {
2379            push(stack, body_slot_label(*slot));
2380            let nest = format!(
2381                "{}/{}",
2382                body_slot_label(*slot),
2383                stack
2384                    .display_name
2385                    .as_deref()
2386                    .unwrap_or(stack.template_id.as_str())
2387            );
2388            walk(&stack.contents, &nest, &mut push);
2389        }
2390        out
2391    }
2392
2393    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2394        self.inventory_hints
2395            .get(template_id)
2396            .and_then(|h| h.base_mass)
2397            .unwrap_or(0.5)
2398    }
2399
2400    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2401        self.inventory_hints
2402            .get(template_id)
2403            .and_then(|h| h.base_volume)
2404            .unwrap_or(1.0)
2405    }
2406
2407    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2408        let unit = stack
2409            .base_mass
2410            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2411        unit * stack.quantity as f32
2412    }
2413
2414    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2415        let unit = stack.base_volume.unwrap_or(1.0);
2416        unit * stack.quantity as f32
2417            + stack
2418                .contents
2419                .iter()
2420                .map(Self::stack_tree_volume)
2421                .sum::<f32>()
2422    }
2423
2424    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2425        contents.iter().map(Self::stack_tree_volume).sum()
2426    }
2427
2428    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2429        self.inventory_hints
2430            .get(template_id)
2431            .and_then(|h| h.capacity_volume)
2432            .filter(|c| *c > 0.0)
2433    }
2434
2435    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2436        stack
2437            .capacity_volume
2438            .filter(|c| *c > 0.0)
2439            .or_else(|| self.template_capacity_volume(&stack.template_id))
2440    }
2441
2442    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2443    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2444        let Some((used, cap)) = self.container_volume_stats(row) else {
2445            return String::new();
2446        };
2447        let free = (cap - used).max(0.0);
2448        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2449    }
2450
2451    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2452        if row.is_chest_shell {
2453            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2454                return None;
2455            };
2456            let chest = self
2457                .placed_containers
2458                .iter()
2459                .find(|c| c.id == *container_id)?;
2460            let cap = self
2461                .stack_capacity_volume(&row.stack)
2462                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2463            let used = if chest.accessible {
2464                Self::contents_used_volume(&chest.contents)
2465            } else {
2466                0.0
2467            };
2468            return Some((used, cap));
2469        }
2470
2471        let cap = self.stack_capacity_volume(&row.stack)?;
2472        let used = Self::contents_used_volume(&row.stack.contents);
2473        Some((used, cap))
2474    }
2475
2476    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2477        if row.is_chest_shell {
2478            return true;
2479        }
2480        if row.is_equip_shell {
2481            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2482        }
2483        self.inventory_item_category(&row.stack.template_id) == Some("container")
2484            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2485    }
2486
2487    fn container_stack_for(
2488        &self,
2489        location: &flatland_protocol::InventoryLocation,
2490        parent_instance_id: Option<uuid::Uuid>,
2491    ) -> Option<flatland_protocol::ItemStack> {
2492        match location {
2493            flatland_protocol::InventoryLocation::Root => {
2494                let pid = parent_instance_id?;
2495                self.find_stack_by_instance(&self.inventory_stacks, pid)
2496            }
2497            flatland_protocol::InventoryLocation::Worn { slot } => {
2498                let worn = self.worn.get(slot)?;
2499                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2500                    Some(worn.clone())
2501                } else {
2502                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2503                }
2504            }
2505            flatland_protocol::InventoryLocation::Placed { container_id } => {
2506                let chest = self
2507                    .placed_containers
2508                    .iter()
2509                    .find(|c| c.id == *container_id)?;
2510                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2511                    Some(flatland_protocol::ItemStack {
2512                        template_id: chest.template_id.clone(),
2513                        quantity: 1,
2514                        item_instance_id: chest.item_instance_id,
2515                        props: Default::default(),
2516                        status_bindings: Vec::new(),
2517                        contents: chest.contents.clone(),
2518                        display_name: Some(chest.display_name.clone()),
2519                        category: Some("container".into()),
2520                        capacity_volume: self
2521                            .inventory_hints
2522                            .get(&chest.template_id)
2523                            .and_then(|h| h.capacity_volume),
2524                        worker_lodging_capacity: chest.worker_lodging_capacity,
2525                        ..Default::default()
2526                    })
2527                } else {
2528                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2529                }
2530            }
2531            flatland_protocol::InventoryLocation::Keychain => None,
2532            flatland_protocol::InventoryLocation::WhisperPouch => None,
2533        }
2534    }
2535
2536    fn find_stack_by_instance(
2537        &self,
2538        stacks: &[flatland_protocol::ItemStack],
2539        instance_id: uuid::Uuid,
2540    ) -> Option<flatland_protocol::ItemStack> {
2541        for stack in stacks {
2542            if stack.item_instance_id == Some(instance_id) {
2543                return Some(stack.clone());
2544            }
2545            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2546                return Some(found);
2547            }
2548        }
2549        None
2550    }
2551
2552    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2553    pub fn max_movable_to(
2554        &self,
2555        template_id: &str,
2556        stack_qty: u32,
2557        from: &flatland_protocol::InventoryLocation,
2558        to: &flatland_protocol::InventoryLocation,
2559        parent_instance_id: Option<uuid::Uuid>,
2560    ) -> u32 {
2561        let unit_vol = self.item_base_volume(template_id);
2562        let unit_mass = self.item_base_mass(template_id);
2563        let mut limit = stack_qty;
2564
2565        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2566            let cap = parent
2567                .capacity_volume
2568                .or_else(|| {
2569                    self.inventory_hints
2570                        .get(&parent.template_id)
2571                        .and_then(|h| h.capacity_volume)
2572                })
2573                .unwrap_or(0.0);
2574            if cap > 0.0 && unit_vol > 0.0 {
2575                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2576                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2577            }
2578        }
2579
2580        let to_person = matches!(
2581            to,
2582            flatland_protocol::InventoryLocation::Root
2583                | flatland_protocol::InventoryLocation::Worn { .. }
2584        );
2585        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2586        if to_person && from_placed && unit_mass > 0.0 {
2587            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2588            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2589                limit = 0;
2590            } else {
2591                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2592            }
2593        }
2594
2595        limit.max(0).min(stack_qty)
2596    }
2597
2598    pub fn move_picker_max_at_selection(&self) -> u32 {
2599        let Some(picker) = &self.move_picker else {
2600            return 1;
2601        };
2602        let Some(opt) = picker.options.get(self.move_picker_index) else {
2603            return picker.stack_quantity;
2604        };
2605        match &opt.kind {
2606            MoveOptionKind::Cancel
2607            | MoveOptionKind::Drop
2608            | MoveOptionKind::Use
2609            | MoveOptionKind::GrantApply
2610            | MoveOptionKind::SellPlotToCrown { .. }
2611            | MoveOptionKind::PickupPlaced { .. }
2612            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2613            MoveOptionKind::Move {
2614                location,
2615                parent_instance_id,
2616            } => self.max_movable_to(
2617                &picker.template_id,
2618                picker.stack_quantity,
2619                &picker.from,
2620                location,
2621                *parent_instance_id,
2622            ),
2623        }
2624    }
2625
2626    pub fn clamp_move_picker_quantity(&mut self) {
2627        let max = self.move_picker_max_at_selection();
2628        if let Some(picker) = &mut self.move_picker {
2629            if max == 0 {
2630                picker.quantity = 1;
2631            } else {
2632                picker.quantity = picker.quantity.clamp(1, max);
2633            }
2634        }
2635    }
2636
2637    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2638        let max = self.move_picker_max_at_selection().max(1);
2639        if let Some(picker) = &mut self.move_picker {
2640            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2641            picker.quantity = next as u32;
2642        }
2643    }
2644
2645    pub fn move_picker_set_quantity_max(&mut self) {
2646        let max = self.move_picker_max_at_selection();
2647        if let Some(picker) = &mut self.move_picker {
2648            picker.quantity = if max == 0 {
2649                1
2650            } else {
2651                max.min(picker.stack_quantity)
2652            };
2653        }
2654    }
2655
2656    pub fn move_picker_set_quantity_min(&mut self) {
2657        if let Some(picker) = &mut self.move_picker {
2658            picker.quantity = 1;
2659        }
2660    }
2661
2662    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2663        if let Some(picker) = &mut self.destroy_picker {
2664            let max = picker.stack_quantity.max(1);
2665            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2666            picker.quantity = next as u32;
2667        }
2668    }
2669
2670    pub fn destroy_picker_set_quantity_max(&mut self) {
2671        if let Some(picker) = &mut self.destroy_picker {
2672            picker.quantity = picker.stack_quantity.max(1);
2673        }
2674    }
2675
2676    pub fn destroy_picker_set_quantity_min(&mut self) {
2677        if let Some(picker) = &mut self.destroy_picker {
2678            picker.quantity = 1;
2679        }
2680    }
2681
2682    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2683        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2684        (have, have >= need)
2685    }
2686
2687    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
2688    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2689        let have = self
2690            .plot_build_offer
2691            .as_ref()
2692            .and_then(|o| {
2693                o.available
2694                    .iter()
2695                    .find(|s| s.template_id == template_id)
2696                    .map(|s| s.quantity)
2697            })
2698            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
2699        (have, have >= need)
2700    }
2701
2702    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2703        self.building_materials
2704            .iter()
2705            .filter(|m| m.can_wall)
2706            .collect()
2707    }
2708
2709    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2710        self.building_materials
2711            .iter()
2712            .filter(|m| m.can_roof)
2713            .collect()
2714    }
2715
2716    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2717        self.plot_build_wall_options()
2718            .get(self.plot_build_wall_index)
2719            .copied()
2720    }
2721
2722    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2723        self.plot_build_roof_options()
2724            .get(self.plot_build_roof_index)
2725            .copied()
2726    }
2727
2728    /// BOM lines for the current wall/roof selection and tilled pad area.
2729    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
2730        let Some(wall) = self.plot_build_selected_wall() else {
2731            return Vec::new();
2732        };
2733        let Some(roof) = self.plot_build_selected_roof() else {
2734            return Vec::new();
2735        };
2736        let area = self
2737            .plot_build_offer
2738            .as_ref()
2739            .filter(|o| o.pad_ok)
2740            .map(|o| o.pad_width_m * o.pad_depth_m)
2741            .unwrap_or(0.0);
2742        if area <= 0.0 {
2743            return Vec::new();
2744        }
2745        let mut map: std::collections::HashMap<String, (String, u32)> =
2746            std::collections::HashMap::new();
2747        for line in &wall.wall_bom {
2748            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2749            if qty == 0 {
2750                continue;
2751            }
2752            let name = if line.display_name.is_empty() {
2753                line.template_id.clone()
2754            } else {
2755                line.display_name.clone()
2756            };
2757            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2758            entry.1 = entry.1.saturating_add(qty);
2759        }
2760        for line in &roof.roof_bom {
2761            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2762            if qty == 0 {
2763                continue;
2764            }
2765            let name = if line.display_name.is_empty() {
2766                line.template_id.clone()
2767            } else {
2768                line.display_name.clone()
2769            };
2770            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2771            entry.1 = entry.1.saturating_add(qty);
2772        }
2773        let mut out: Vec<_> = map
2774            .into_iter()
2775            .map(|(id, (name, qty))| (id, name, qty))
2776            .collect();
2777        out.sort_by(|a, b| a.0.cmp(&b.0));
2778        out
2779    }
2780
2781    pub fn plot_build_duration_secs(&self) -> Option<f32> {
2782        let wall = self.plot_build_selected_wall()?;
2783        let roof = self.plot_build_selected_roof()?;
2784        let offer = self.plot_build_offer.as_ref()?;
2785        if !offer.pad_ok {
2786            return None;
2787        }
2788        let area = offer.pad_width_m * offer.pad_depth_m;
2789        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
2790        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
2791        Some(ticks.max(2.0) / 30.0)
2792    }
2793
2794    pub fn plot_build_can_afford(&self) -> bool {
2795        if self
2796            .plot_build_offer
2797            .as_ref()
2798            .is_none_or(|o| !o.pad_ok)
2799        {
2800            return false;
2801        }
2802        self.plot_build_bom_lines()
2803            .iter()
2804            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
2805    }
2806
2807    pub fn currency_display(&self) -> String {
2808        crate::currency::currency_line(&self.inventory)
2809    }
2810
2811    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2812    pub fn in_shallow_water(&self) -> bool {
2813        let (px, py) = self.player_position();
2814        self.terrain_at(px, py)
2815            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2816    }
2817
2818    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2819        self.terrain_zone_at(x, y).map(|z| z.kind)
2820    }
2821
2822    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2823    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2824        use std::cell::RefCell;
2825
2826        const CHUNK: i32 = 8;
2827        thread_local! {
2828            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2829                RefCell::new(None);
2830        }
2831
2832        let zones = &self.terrain_zones;
2833        if zones.is_empty() {
2834            return None;
2835        }
2836        if zones.len() <= 48 {
2837            return zones
2838                .iter()
2839                .enumerate()
2840                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2841                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2842                .map(|(_, z)| z);
2843        }
2844
2845        let ptr = zones.as_ptr();
2846        let len = zones.len();
2847        INDEX.with(|cell| {
2848            let mut slot = cell.borrow_mut();
2849            let stale = match slot.as_ref() {
2850                Some((p, l, _)) => *p != ptr || *l != len,
2851                None => true,
2852            };
2853            if stale {
2854                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2855                    std::collections::HashMap::new();
2856                for (zi, z) in zones.iter().enumerate() {
2857                    let x0 = z.x0.min(z.x1).floor() as i32;
2858                    let y0 = z.y0.min(z.y1).floor() as i32;
2859                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2860                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2861                    let cx0 = x0.div_euclid(CHUNK);
2862                    let cy0 = y0.div_euclid(CHUNK);
2863                    let cx1 = x1.div_euclid(CHUNK);
2864                    let cy1 = y1.div_euclid(CHUNK);
2865                    for cy in cy0..=cy1 {
2866                        for cx in cx0..=cx1 {
2867                            chunks.entry((cx, cy)).or_default().push(zi);
2868                        }
2869                    }
2870                }
2871                *slot = Some((ptr, len, chunks));
2872            }
2873            let chunks = &slot.as_ref().expect("index").2;
2874            let cx = (x.floor() as i32).div_euclid(CHUNK);
2875            let cy = (y.floor() as i32).div_euclid(CHUNK);
2876            let mut best: Option<(usize, &TerrainZoneView)> = None;
2877            if let Some(list) = chunks.get(&(cx, cy)) {
2878                for &zi in list {
2879                    let Some(z) = zones.get(zi) else { continue };
2880                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2881                        continue;
2882                    }
2883                    best = match best {
2884                        None => Some((zi, z)),
2885                        Some((bi, bz)) => {
2886                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2887                                Some((zi, z))
2888                            } else {
2889                                Some((bi, bz))
2890                            }
2891                        }
2892                    };
2893                }
2894            }
2895            best.map(|(_, z)| z)
2896        })
2897    }
2898
2899    /// Ground elevation from terrain zones (m).
2900    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2901        self.terrain_zone_at(x, y)
2902            .map(|z| z.elevation)
2903            .unwrap_or(0.0)
2904    }
2905
2906    /// Walkable z levels at a map column (terrain + platforms).
2907    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2908        const TOL: f32 = 0.35;
2909        let mut levels = vec![self.elevation_at(x, y)];
2910        for p in &self.z_platforms {
2911            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2912                levels.push(p.z);
2913            }
2914        }
2915        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2916        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2917        levels
2918    }
2919
2920    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2921        const TOL: f32 = 0.35;
2922        self.walkable_levels_at(x, y)
2923            .iter()
2924            .any(|&l| (l - z).abs() <= TOL)
2925    }
2926
2927    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2928        let mut top = self.elevation_at(x, y);
2929        for p in &self.z_platforms {
2930            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2931                top = top.max(p.z);
2932            }
2933        }
2934        top
2935    }
2936
2937    /// Authoritative interior context from the server (`inside_building` flag).
2938    pub fn effective_inside_building(&self) -> Option<String> {
2939        self.player_entity().and_then(|p| p.inside_building.clone())
2940    }
2941
2942    /// True when a placed chest shares the player's outdoor/interior space.
2943    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
2944    /// labels); they must not be drawn or treated as nearby world props.
2945    pub fn placed_container_in_current_space(
2946        &self,
2947        c: &flatland_protocol::PlacedContainerView,
2948    ) -> bool {
2949        match (
2950            self.effective_inside_building().as_deref(),
2951            c.building_id.as_deref(),
2952        ) {
2953            (None, None) => true,
2954            (Some(a), Some(b)) => a == b,
2955            _ => false,
2956        }
2957    }
2958
2959    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2960        self.inventory_stacks = stacks.to_vec();
2961        self.inventory.clear();
2962        self.inventory_hints.clear();
2963        fn walk(
2964            stacks: &[flatland_protocol::ItemStack],
2965            inventory: &mut std::collections::HashMap<String, u32>,
2966            hints: &mut std::collections::HashMap<String, InventoryHint>,
2967        ) {
2968            for stack in stacks {
2969                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2970                if stack.display_name.is_some()
2971                    || stack.category.is_some()
2972                    || stack.base_mass.is_some()
2973                    || stack.base_volume.is_some()
2974                {
2975                    hints.insert(
2976                        stack.template_id.clone(),
2977                        InventoryHint {
2978                            display_name: stack
2979                                .display_name
2980                                .clone()
2981                                .unwrap_or_else(|| stack.template_id.clone()),
2982                            category: stack.category.clone().unwrap_or_default(),
2983                            base_mass: stack.base_mass,
2984                            base_volume: stack.base_volume,
2985                            capacity_volume: stack.capacity_volume,
2986                            stackable: stack.stackable.unwrap_or(true),
2987                            listable: stack.listable.unwrap_or_else(|| {
2988                                category_default_listable(
2989                                    stack.category.as_deref().unwrap_or(""),
2990                                )
2991                            }),
2992                        },
2993                    );
2994                }
2995                walk(&stack.contents, inventory, hints);
2996            }
2997        }
2998        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2999        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
3000        for item in self.worn.values() {
3001            walk(
3002                std::slice::from_ref(item),
3003                &mut self.inventory,
3004                &mut self.inventory_hints,
3005            );
3006        }
3007    }
3008
3009    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
3010    /// inventory UI updates before the next tick snapshot arrives.
3011    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3012        let subtract_items =
3013            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3014        for stack in &notice.inventory_delta {
3015            if stack.quantity == 0 {
3016                continue;
3017            }
3018            if subtract_items {
3019                crate::currency::drain_template_stacks(
3020                    &mut self.inventory_stacks,
3021                    &stack.template_id,
3022                    stack.quantity,
3023                );
3024                continue;
3025            }
3026            let stackable = self
3027                .inventory_hints
3028                .get(&stack.template_id)
3029                .map(|h| h.stackable)
3030                .or(stack.stackable)
3031                .unwrap_or(true);
3032            if stackable {
3033                if let Some(existing) = self
3034                    .inventory_stacks
3035                    .iter_mut()
3036                    .find(|s| s.template_id == stack.template_id)
3037                {
3038                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
3039                    if stack.display_name.is_some() {
3040                        existing.display_name = stack.display_name.clone();
3041                    }
3042                    if stack.category.is_some() {
3043                        existing.category = stack.category.clone();
3044                    }
3045                    continue;
3046                }
3047            }
3048            self.inventory_stacks.push(stack.clone());
3049        }
3050        if notice.coins_delta != 0 {
3051            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3052        }
3053        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3054            let stacks = self.inventory_stacks.clone();
3055            self.sync_inventory_from_stacks(&stacks);
3056        }
3057        self.record_shop_trade_notice(notice);
3058    }
3059
3060    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
3061    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
3062    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
3063    /// iteration alone gives a stable row order.
3064    pub fn worn_rows(&self) -> Vec<InventoryRow> {
3065        let mut rows = Vec::new();
3066        for (slot, item) in &self.worn {
3067            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3068            rows.push(InventoryRow {
3069                depth: 0,
3070                stack: item.clone(),
3071                from: from.clone(),
3072                from_parent_instance_id: None,
3073                is_equip_shell: true,
3074                is_chest_shell: false,
3075                section: InventorySection::Worn,
3076            });
3077            for child in &item.contents {
3078                push_inventory_rows(
3079                    &mut rows,
3080                    1,
3081                    child,
3082                    &from,
3083                    item.item_instance_id,
3084                    InventorySection::Worn,
3085                );
3086            }
3087        }
3088        rows
3089    }
3090
3091    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
3092    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3093        let equipped = self.hand_equipped_instance_ids();
3094        self.inventory_stacks
3095            .iter()
3096            .filter(|s| {
3097                s.item_instance_id
3098                    .is_some_and(|id| !equipped.contains(&id))
3099            })
3100            .collect()
3101    }
3102
3103    /// Root on-person stacks that can be handed to a hired worker.
3104    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3105        let equipped = self.hand_equipped_instance_ids();
3106        self.inventory_stacks
3107            .iter()
3108            .filter_map(|stack| {
3109                let item_instance_id = stack.item_instance_id?;
3110                if equipped.contains(&item_instance_id) {
3111                    return None;
3112                }
3113                let label = stack
3114                    .display_name
3115                    .clone()
3116                    .unwrap_or_else(|| stack.template_id.clone());
3117                let label = if stack.quantity > 1 {
3118                    format!("{label} ×{}", stack.quantity)
3119                } else {
3120                    label
3121                };
3122                Some(WorkerGiveOption {
3123                    item_instance_id,
3124                    label,
3125                    quantity: stack.quantity,
3126                    template_id: stack.template_id.clone(),
3127                })
3128            })
3129            .collect()
3130    }
3131
3132    /// Employer-known blueprints the selected worker does not yet know.
3133    pub fn teachable_blueprint_options(
3134        &self,
3135        worker: &flatland_protocol::HiredWorkerView,
3136    ) -> Vec<WorkerTeachOption> {
3137        let copper = crate::currency::copper_from_counts(&self.inventory);
3138        let mut options: Vec<WorkerTeachOption> = self
3139            .blueprints
3140            .iter()
3141            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3142            .map(|bp| {
3143                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3144                let cost = bp.worker_train_copper;
3145                WorkerTeachOption {
3146                    blueprint_id: bp.id.clone(),
3147                    label: if bp.label.is_empty() {
3148                        bp.id.clone()
3149                    } else {
3150                        bp.label.clone()
3151                    },
3152                    cost_copper: cost,
3153                    min_level,
3154                    worker_level: worker.level,
3155                    can_afford: copper >= cost,
3156                    level_ok: worker.level >= min_level,
3157                }
3158            })
3159            .collect();
3160        options.sort_by(|a, b| a.label.cmp(&b.label));
3161        options
3162    }
3163
3164    /// Loose on-person inventory (not worn, not inside a placed chest).
3165    /// Root stacks are ordered by category group; nested contents stay under their parent.
3166    pub fn person_rows(&self) -> Vec<InventoryRow> {
3167        self.person_rows_filtered("")
3168    }
3169
3170    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3171        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3172        roots.sort_by(|a, b| {
3173            let ca = a
3174                .category
3175                .as_deref()
3176                .or_else(|| self.inventory_item_category(&a.template_id))
3177                .unwrap_or("");
3178            let cb = b
3179                .category
3180                .as_deref()
3181                .or_else(|| self.inventory_item_category(&b.template_id))
3182                .unwrap_or("");
3183            let ga = inventory_category_group(ca).1;
3184            let gb = inventory_category_group(cb).1;
3185            ga.cmp(&gb).then_with(|| {
3186                let na = a
3187                    .display_name
3188                    .as_deref()
3189                    .unwrap_or(a.template_id.as_str());
3190                let nb = b
3191                    .display_name
3192                    .as_deref()
3193                    .unwrap_or(b.template_id.as_str());
3194                na.cmp(nb)
3195            })
3196        });
3197        let mut rows = Vec::new();
3198        for stack in roots {
3199            push_inventory_rows_filtered(
3200                &mut rows,
3201                0,
3202                stack,
3203                &flatland_protocol::InventoryLocation::Root,
3204                None,
3205                InventorySection::Person,
3206                filter,
3207            );
3208        }
3209        rows
3210    }
3211
3212    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3213        if filter.is_empty() {
3214            return self.worn_rows();
3215        }
3216        let mut rows = Vec::new();
3217        for (slot, item) in &self.worn {
3218            if !stack_matches_filter(item, filter) {
3219                continue;
3220            }
3221            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3222            let self_hit = {
3223                let f = filter.to_ascii_lowercase();
3224                let name = item
3225                    .display_name
3226                    .as_deref()
3227                    .unwrap_or("")
3228                    .to_ascii_lowercase();
3229                let tid = item.template_id.to_ascii_lowercase();
3230                name.contains(&f) || tid.contains(&f)
3231            };
3232            rows.push(InventoryRow {
3233                depth: 0,
3234                stack: item.clone(),
3235                from: from.clone(),
3236                from_parent_instance_id: None,
3237                is_equip_shell: true,
3238                is_chest_shell: false,
3239                section: InventorySection::Worn,
3240            });
3241            for child in &item.contents {
3242                if self_hit || stack_matches_filter(child, filter) {
3243                    push_inventory_rows_filtered(
3244                        &mut rows,
3245                        1,
3246                        child,
3247                        &from,
3248                        item.item_instance_id,
3249                        InventorySection::Worn,
3250                        if self_hit { "" } else { filter },
3251                    );
3252                }
3253            }
3254        }
3255        rows
3256    }
3257
3258    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3259    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3260        let mut rows = self.worn_rows();
3261        rows.extend(self.person_rows());
3262        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3263    }
3264
3265    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3266    /// populated when `accessible` — this is what makes a chest's contents
3267    /// disappear the moment you walk away or it's locked without your key.
3268    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3269        let (px, py) = self.player_position();
3270        let mut list: Vec<NearbyContainer> = self
3271            .placed_containers
3272            .iter()
3273            .filter(|c| self.placed_container_in_current_space(c))
3274            .filter_map(|c| {
3275                let distance_m = (c.x - px).hypot(c.y - py);
3276                if distance_m > CONTAINER_RANGE_M {
3277                    return None;
3278                }
3279                let mut rows = Vec::new();
3280                let from = flatland_protocol::InventoryLocation::Placed {
3281                    container_id: c.id.clone(),
3282                };
3283                rows.push(InventoryRow {
3284                    depth: 0,
3285                    stack: flatland_protocol::ItemStack {
3286                        template_id: c.template_id.clone(),
3287                        quantity: 1,
3288                        item_instance_id: c.item_instance_id,
3289                        props: Default::default(),
3290                        status_bindings: Vec::new(),
3291                        contents: Vec::new(),
3292                        display_name: Some(c.display_name.clone()),
3293                        category: Some("container".into()),
3294                        capacity_volume: c.capacity_volume,
3295                        worker_lodging_capacity: c.worker_lodging_capacity,
3296                        ..Default::default()
3297                    },
3298                    from: from.clone(),
3299                    from_parent_instance_id: None,
3300                    is_equip_shell: false,
3301                    is_chest_shell: true,
3302                    section: InventorySection::Nearby,
3303                });
3304                if c.accessible {
3305                    for child in &c.contents {
3306                        push_inventory_rows(
3307                            &mut rows,
3308                            1,
3309                            child,
3310                            &from,
3311                            c.item_instance_id,
3312                            InventorySection::Nearby,
3313                        );
3314                    }
3315                }
3316                Some(NearbyContainer {
3317                    view: c.clone(),
3318                    distance_m,
3319                    rows,
3320                })
3321            })
3322            .collect();
3323        list.sort_by(|a, b| {
3324            a.distance_m
3325                .partial_cmp(&b.distance_m)
3326                .unwrap_or(std::cmp::Ordering::Equal)
3327        });
3328        list
3329    }
3330
3331    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3332    pub fn nearest_placed_container(
3333        &self,
3334        max_dist: f32,
3335    ) -> Option<flatland_protocol::PlacedContainerView> {
3336        let (px, py) = self.player_position();
3337        self.placed_containers
3338            .iter()
3339            .filter(|c| self.placed_container_in_current_space(c))
3340            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3341            .min_by(|a, b| {
3342                let da = (a.x - px).hypot(a.y - py);
3343                let db = (b.x - px).hypot(b.y - py);
3344                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3345            })
3346            .cloned()
3347    }
3348
3349    /// Selectable rows for the **active inventory tab** (and current filter).
3350    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3351    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3352        let filter = self.inventory_filter.as_str();
3353        match self.inventory_tab {
3354            InventoryTab::OnPerson => {
3355                let mut rows = self.worn_rows_filtered(filter);
3356                rows.extend(self.person_rows_filtered(filter));
3357                rows
3358            }
3359            InventoryTab::Nearby => {
3360                let mut rows = Vec::new();
3361                for nc in self.nearby_containers() {
3362                    if filter.is_empty() {
3363                        rows.extend(nc.rows);
3364                        continue;
3365                    }
3366                    let shell = nc.rows.first().cloned();
3367                    let contents: Vec<_> = nc
3368                        .rows
3369                        .iter()
3370                        .skip(1)
3371                        .filter(|r| stack_matches_filter(&r.stack, filter))
3372                        .cloned()
3373                        .collect();
3374                    let shell_hit = shell
3375                        .as_ref()
3376                        .map(|s| stack_matches_filter(&s.stack, filter))
3377                        .unwrap_or(false);
3378                    if shell_hit || !contents.is_empty() {
3379                        if let Some(s) = shell {
3380                            rows.push(s);
3381                        }
3382                        if shell_hit {
3383                            rows.extend(nc.rows.into_iter().skip(1));
3384                        } else {
3385                            rows.extend(contents);
3386                        }
3387                    }
3388                }
3389                rows
3390            }
3391        }
3392    }
3393
3394    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3395        self.inventory_selectable_rows()
3396            .into_iter()
3397            .nth(self.inventory_menu_index)
3398    }
3399
3400    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3401        let cat = self
3402            .inventory_item_category(&row.stack.template_id)
3403            .unwrap_or("");
3404        if cat == "key" {
3405            self.key_inventory_label(&row.stack)
3406        } else {
3407            row.stack
3408                .display_name
3409                .clone()
3410                .unwrap_or_else(|| row.stack.template_id.clone())
3411        }
3412    }
3413
3414    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3415    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3416        let bindings = format_status_bindings_suffix(
3417            &row.stack.status_bindings,
3418            self.tick,
3419            DEFAULT_TICK_HZ,
3420        );
3421        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3422            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3423            let mode = Self::grant_mode(&row.stack);
3424            format!(" [grant {effect} · {mode} — e apply]")
3425        } else {
3426            String::new()
3427        };
3428        let qty = if row.stack.quantity > 1 {
3429            format!(" ×{}", row.stack.quantity)
3430        } else {
3431            String::new()
3432        };
3433        let worn_slot = if row.is_equip_shell {
3434            match row.from {
3435                flatland_protocol::InventoryLocation::Worn { slot } => {
3436                    format!(" ({})", body_slot_label(slot))
3437                }
3438                _ => String::new(),
3439            }
3440        } else {
3441            String::new()
3442        };
3443        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3444    }
3445
3446    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3447        (
3448            row.stack.template_id.clone(),
3449            self.inventory_row_base_label(row),
3450            self.inventory_row_visible_mod_signature(row),
3451        )
3452    }
3453
3454    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3455    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3456        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3457        for row in self.inventory_selectable_rows() {
3458            if row.stack.item_instance_id.is_none() {
3459                continue;
3460            }
3461            let key = self.inventory_row_instance_identity_key(&row);
3462            *counts.entry(key).or_default() += 1;
3463        }
3464        counts
3465            .into_iter()
3466            .filter(|(_, n)| *n > 1)
3467            .map(|(k, _)| k)
3468            .collect()
3469    }
3470
3471    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3472        let hex: String = id
3473            .as_simple()
3474            .to_string()
3475            .chars()
3476            .filter(|c| c.is_ascii_hexdigit())
3477            .collect();
3478        let short = if hex.len() >= 4 {
3479            &hex[hex.len() - 4..]
3480        } else {
3481            hex.as_str()
3482        };
3483        format!("Instance {id} (#{short})")
3484    }
3485
3486    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3487    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3488        let cat = self
3489            .inventory_item_category(&row.stack.template_id)
3490            .unwrap_or("");
3491        let label = self.inventory_row_base_label(row);
3492        let hint: String = if row.is_equip_shell {
3493            " [worn — Enter to unequip]".into()
3494        } else if row.is_chest_shell {
3495            let (locked, lodging_note) = match &row.from {
3496                flatland_protocol::InventoryLocation::Placed { container_id } => {
3497                    let locked = self
3498                        .placed_containers
3499                        .iter()
3500                        .find(|c| c.id == *container_id)
3501                        .map(|c| c.locked)
3502                        .unwrap_or(false);
3503                    let lodging_note = self
3504                        .lodging_occupancy_label(container_id)
3505                        .map(|who| format!(" [lodging: {who}]"))
3506                        .unwrap_or_default();
3507                    (locked, lodging_note)
3508                }
3509                _ => (false, String::new()),
3510            };
3511            if locked {
3512                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3513            } else {
3514                format!(" [Enter pick up · l lock]{lodging_note}")
3515            }
3516        } else if cat == "key" {
3517            self.key_inventory_hint(&row.stack)
3518        } else {
3519            match cat {
3520                "weapon" => " [weapon]".into(),
3521                "container" => " [bag/chest/belt]".into(),
3522                "lodging" => " [worker lodging]".into(),
3523                "armor" => " [armor]".into(),
3524                _ => String::new(),
3525            }
3526        };
3527        let qty = if row.stack.quantity > 1 {
3528            format!(" ×{}", row.stack.quantity)
3529        } else {
3530            String::new()
3531        };
3532        let bindings = format_status_bindings_suffix(
3533            &row.stack.status_bindings,
3534            self.tick,
3535            DEFAULT_TICK_HZ,
3536        );
3537        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3538            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3539            let mode = Self::grant_mode(&row.stack);
3540            format!(" [grant {effect} · {mode} — e apply]")
3541        } else {
3542            String::new()
3543        };
3544        let mass = self.stack_mass(&row.stack);
3545        let mass_kg = (mass >= 0.05).then_some(mass);
3546        let mass_str = mass_kg
3547            .map(|m| format!("  {m:.1} kg"))
3548            .unwrap_or_default();
3549        let volume = self.container_volume_stats(row);
3550        let vol_str = self.container_volume_label(row);
3551
3552        let mut title = label.clone();
3553        title.push_str(&qty);
3554        if row.is_equip_shell {
3555            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3556                title.push_str(&format!(" ({})", body_slot_label(slot)));
3557            }
3558        }
3559
3560        InventoryRowView {
3561            depth: row.depth,
3562            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3563            title: format!("{title}{grant_hint}{bindings}"),
3564            mass_kg,
3565            volume,
3566            instance_tooltip: None,
3567        }
3568    }
3569
3570    fn push_browser_item(
3571        &self,
3572        lines: &mut Vec<InventoryBrowserLine>,
3573        row: &InventoryRow,
3574        global_idx: &mut usize,
3575        target: usize,
3576        highlight: bool,
3577        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3578    ) {
3579        let mut view = self.format_inventory_row(row);
3580        if let Some(id) = row.stack.item_instance_id {
3581            let key = self.inventory_row_instance_identity_key(row);
3582            if ambiguous_instance_keys.contains(&key) {
3583                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3584            }
3585        }
3586        lines.push(InventoryBrowserLine::Item {
3587            selectable_index: *global_idx,
3588            selected: highlight && *global_idx == target,
3589            depth: view.depth,
3590            text: view.text,
3591            title: view.title,
3592            mass_kg: view.mass_kg,
3593            volume: view.volume,
3594            instance_tooltip: view.instance_tooltip,
3595        });
3596        *global_idx += 1;
3597    }
3598
3599    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3600    /// `selectable_index` matching `inventory_menu_index`.
3601    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3602        let mut lines = Vec::new();
3603        let target = self.inventory_menu_index;
3604        let highlight = !self.show_move_picker && !self.show_grant_picker;
3605        let filter = self.inventory_filter.as_str();
3606        let mut global_idx = 0usize;
3607        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3608
3609        match self.inventory_tab {
3610            InventoryTab::OnPerson => {
3611                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3612                let worn = self.worn_rows_filtered(filter);
3613                if worn.is_empty() {
3614                    lines.push(InventoryBrowserLine::Hint(
3615                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3616                    ));
3617                } else {
3618                    for row in &worn {
3619                        if row.is_equip_shell {
3620                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3621                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3622                                    "  {}:",
3623                                    body_slot_label(slot)
3624                                )));
3625                            }
3626                        }
3627                        self.push_browser_item(
3628                            &mut lines,
3629                            row,
3630                            &mut global_idx,
3631                            target,
3632                            highlight,
3633                            &ambiguous_instance_keys,
3634                        );
3635                    }
3636                }
3637
3638                lines.push(InventoryBrowserLine::Blank);
3639                lines.push(InventoryBrowserLine::Section(
3640                    "— On you (loose, not worn) —".into(),
3641                ));
3642                let person = self.person_rows_filtered(filter);
3643                if person.is_empty() {
3644                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3645                } else {
3646                    let mut last_group: Option<&'static str> = None;
3647                    for row in &person {
3648                        if row.depth == 0 {
3649                            let cat = row
3650                                .stack
3651                                .category
3652                                .as_deref()
3653                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3654                                .unwrap_or("");
3655                            let (group, _) = inventory_category_group(cat);
3656                            if last_group != Some(group) {
3657                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3658                                    "  {group}"
3659                                )));
3660                                last_group = Some(group);
3661                            }
3662                        }
3663                        self.push_browser_item(
3664                            &mut lines,
3665                            row,
3666                            &mut global_idx,
3667                            target,
3668                            highlight,
3669                            &ambiguous_instance_keys,
3670                        );
3671                    }
3672                }
3673            }
3674            InventoryTab::Nearby => {
3675                let nearby = self.nearby_containers();
3676                if nearby.is_empty() {
3677                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3678                    lines.push(InventoryBrowserLine::Hint(
3679                        "  (none within reach — walk up to a chest)".into(),
3680                    ));
3681                    lines.push(InventoryBrowserLine::Hint(
3682                        "  Select an on-person item, then m / Enter → move into chest.".into(),
3683                    ));
3684                } else {
3685                    let mut any_visible = false;
3686                    for nc in &nearby {
3687                        let shell = nc.rows.first();
3688                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
3689                            nc.rows.iter().skip(1).collect()
3690                        } else {
3691                            let shell_hit = shell
3692                                .map(|s| {
3693                                    let f = filter.to_ascii_lowercase();
3694                                    let name = s
3695                                        .stack
3696                                        .display_name
3697                                        .as_deref()
3698                                        .unwrap_or("")
3699                                        .to_ascii_lowercase();
3700                                    let tid = s.stack.template_id.to_ascii_lowercase();
3701                                    name.contains(&f) || tid.contains(&f)
3702                                })
3703                                .unwrap_or(false);
3704                            if shell_hit {
3705                                nc.rows.iter().skip(1).collect()
3706                            } else {
3707                                nc.rows
3708                                    .iter()
3709                                    .skip(1)
3710                                    .filter(|r| stack_matches_filter(&r.stack, filter))
3711                                    .collect()
3712                            }
3713                        };
3714                        let shell_visible = filter.is_empty()
3715                            || shell
3716                                .map(|s| stack_matches_filter(&s.stack, filter))
3717                                .unwrap_or(false)
3718                            || !contents.is_empty();
3719                        if !shell_visible && shell.is_some() {
3720                            continue;
3721                        }
3722                        any_visible = true;
3723                        lines.push(InventoryBrowserLine::Blank);
3724                        let lock_note = if nc.view.locked && nc.view.accessible {
3725                            "  unlocked with your key"
3726                        } else if nc.view.locked {
3727                            "  locked"
3728                        } else {
3729                            ""
3730                        };
3731                        lines.push(InventoryBrowserLine::Section(format!(
3732                            "— {} ({:.0}m away){lock_note} —",
3733                            nc.view.display_name, nc.distance_m
3734                        )));
3735                        if !nc.view.accessible {
3736                            lines.push(InventoryBrowserLine::Hint(
3737                                "  locked — need the matching key (l to try)".into(),
3738                            ));
3739                        } else if nc.rows.is_empty() {
3740                            lines.push(InventoryBrowserLine::Hint(
3741                                "  (empty — switch to On person, select an item, m to move in)"
3742                                    .into(),
3743                            ));
3744                        } else if let Some(shell_row) = shell {
3745                            self.push_browser_item(
3746                                &mut lines,
3747                                shell_row,
3748                                &mut global_idx,
3749                                target,
3750                                highlight,
3751                                &ambiguous_instance_keys,
3752                            );
3753                            for row in contents {
3754                                self.push_browser_item(
3755                                    &mut lines,
3756                                    row,
3757                                    &mut global_idx,
3758                                    target,
3759                                    highlight,
3760                                    &ambiguous_instance_keys,
3761                                );
3762                            }
3763                        }
3764                    }
3765                    if !any_visible {
3766                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3767                        lines.push(InventoryBrowserLine::Hint(
3768                            "  (no matching items — clear filter with Esc)".into(),
3769                        ));
3770                    }
3771                }
3772            }
3773        }
3774        lines
3775    }
3776
3777    /// Destinations for picking up a placed chest/crate into inventory.
3778    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3779        let mut opts = Vec::new();
3780        opts.push(MoveOption {
3781            label: "Relocate…".into(),
3782            kind: MoveOptionKind::RelocatePlaced {
3783                container_id: container_id.to_string(),
3784            },
3785        });
3786        opts.push(MoveOption {
3787            label: "On your person (loose)".into(),
3788            kind: MoveOptionKind::PickupPlaced {
3789                container_id: container_id.to_string(),
3790                nest_location: flatland_protocol::InventoryLocation::Root,
3791                nest_parent_instance_id: None,
3792            },
3793        });
3794        for (slot, item) in &self.worn {
3795            if item.category.as_deref() != Some("container") {
3796                continue;
3797            }
3798            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3799                continue;
3800            }
3801            let Some(parent_id) = item.item_instance_id else {
3802                continue;
3803            };
3804            let shell_name = item
3805                .display_name
3806                .clone()
3807                .unwrap_or_else(|| item.template_id.clone());
3808            opts.push(MoveOption {
3809                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3810                kind: MoveOptionKind::PickupPlaced {
3811                    container_id: container_id.to_string(),
3812                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3813                    nest_parent_instance_id: Some(parent_id),
3814                },
3815            });
3816            // Nested pouches inside the worn bag.
3817            Self::append_chest_pickup_nested(
3818                &mut opts,
3819                container_id,
3820                flatland_protocol::InventoryLocation::Worn { slot: *slot },
3821                item,
3822                &format!("in {shell_name}"),
3823            );
3824        }
3825        opts.push(MoveOption {
3826            label: "Cancel".into(),
3827            kind: MoveOptionKind::Cancel,
3828        });
3829        opts
3830    }
3831
3832    fn append_chest_pickup_nested(
3833        opts: &mut Vec<MoveOption>,
3834        container_id: &str,
3835        location: flatland_protocol::InventoryLocation,
3836        parent: &flatland_protocol::ItemStack,
3837        context: &str,
3838    ) {
3839        for child in &parent.contents {
3840            if child.category.as_deref() != Some("container") {
3841                continue;
3842            }
3843            if !Self::is_volume_container_stack(child) {
3844                continue;
3845            }
3846            // Skip placeable nested chests — relocating into another chest is not useful.
3847            if child.world_placeable == Some(true) {
3848                continue;
3849            }
3850            let Some(child_id) = child.item_instance_id else {
3851                continue;
3852            };
3853            let name = child
3854                .display_name
3855                .clone()
3856                .unwrap_or_else(|| child.template_id.clone());
3857            opts.push(MoveOption {
3858                label: format!("{name} ({context})"),
3859                kind: MoveOptionKind::PickupPlaced {
3860                    container_id: container_id.to_string(),
3861                    nest_location: location.clone(),
3862                    nest_parent_instance_id: Some(child_id),
3863                },
3864            });
3865            Self::append_chest_pickup_nested(
3866                opts,
3867                container_id,
3868                location.clone(),
3869                child,
3870                &format!("in {name}"),
3871            );
3872        }
3873    }
3874
3875    /// Build the "move to…" destination list for an item currently at `from`.
3876    pub fn move_destinations_for(
3877        &self,
3878        from: &flatland_protocol::InventoryLocation,
3879        from_parent_instance_id: Option<uuid::Uuid>,
3880        moving_instance_id: Option<uuid::Uuid>,
3881        moving_template_id: &str,
3882    ) -> Vec<MoveOption> {
3883        let mut opts = Vec::new();
3884        if *from != flatland_protocol::InventoryLocation::Root {
3885            opts.push(MoveOption {
3886                label: "On your person (loose)".into(),
3887                kind: MoveOptionKind::Move {
3888                    location: flatland_protocol::InventoryLocation::Root,
3889                    parent_instance_id: None,
3890                },
3891            });
3892        }
3893        for (slot, item) in &self.worn {
3894            if item.category.as_deref() != Some("container") {
3895                continue;
3896            }
3897            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3898            let shell_name = item
3899                .display_name
3900                .clone()
3901                .unwrap_or_else(|| item.template_id.clone());
3902
3903            // Backpack and other worn volume containers — store directly inside the shell.
3904            if *slot != BodySlot::Waist
3905                && item.item_instance_id != moving_instance_id
3906                && Self::is_volume_container_stack(item)
3907            {
3908                Self::push_move_destination(
3909                    &mut opts,
3910                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3911                    location.clone(),
3912                    item.item_instance_id,
3913                    from,
3914                    from_parent_instance_id,
3915                );
3916            }
3917
3918            // Belt loops only accept pouch attachments — not loose materials.
3919            if *slot == BodySlot::Waist
3920                && Self::attaches_to_belt_loop(moving_template_id)
3921                && item.item_instance_id != moving_instance_id
3922            {
3923                Self::push_move_destination(
3924                    &mut opts,
3925                    format!("{shell_name} (belt loop)"),
3926                    location.clone(),
3927                    item.item_instance_id,
3928                    from,
3929                    from_parent_instance_id,
3930                );
3931            }
3932
3933            let context = if *slot == BodySlot::Waist {
3934                format!("on {shell_name}")
3935            } else {
3936                format!("in {shell_name}")
3937            };
3938            Self::append_nested_container_destinations(
3939                &mut opts,
3940                location,
3941                item,
3942                &context,
3943                from,
3944                from_parent_instance_id,
3945                moving_instance_id,
3946            );
3947        }
3948        for nc in self.nearby_containers() {
3949            if !nc.view.accessible {
3950                continue;
3951            }
3952            let location = flatland_protocol::InventoryLocation::Placed {
3953                container_id: nc.view.id.clone(),
3954            };
3955            Self::push_move_destination(
3956                &mut opts,
3957                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3958                location,
3959                nc.view.item_instance_id,
3960                from,
3961                from_parent_instance_id,
3962            );
3963        }
3964        let allow_drop = moving_instance_id
3965            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
3966            .unwrap_or(true)
3967            && moving_instance_id
3968                .and_then(|id| self.stack_for_instance(id))
3969                .map(|stack| {
3970                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3971                })
3972                .unwrap_or(
3973                    moving_template_id != KEY_TEMPLATE
3974                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
3975                );
3976        if allow_drop {
3977            opts.push(MoveOption {
3978                label: "Drop on the ground".into(),
3979                kind: MoveOptionKind::Drop,
3980            });
3981        }
3982        opts.push(MoveOption {
3983            label: "Cancel".into(),
3984            kind: MoveOptionKind::Cancel,
3985        });
3986        opts
3987    }
3988
3989    fn is_same_container_dest(
3990        dest_location: &flatland_protocol::InventoryLocation,
3991        dest_parent: Option<uuid::Uuid>,
3992        from: &flatland_protocol::InventoryLocation,
3993        from_parent: Option<uuid::Uuid>,
3994    ) -> bool {
3995        dest_location == from && dest_parent == from_parent
3996    }
3997
3998    fn push_move_destination(
3999        opts: &mut Vec<MoveOption>,
4000        label: String,
4001        location: flatland_protocol::InventoryLocation,
4002        parent_instance_id: Option<uuid::Uuid>,
4003        from: &flatland_protocol::InventoryLocation,
4004        from_parent_instance_id: Option<uuid::Uuid>,
4005    ) {
4006        if Self::is_same_container_dest(
4007            &location,
4008            parent_instance_id,
4009            from,
4010            from_parent_instance_id,
4011        ) {
4012            return;
4013        }
4014        opts.push(MoveOption {
4015            label,
4016            kind: MoveOptionKind::Move {
4017                location,
4018                parent_instance_id,
4019            },
4020        });
4021    }
4022
4023    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4024        stack.capacity_volume.is_some_and(|c| c > 0.0)
4025    }
4026
4027    fn attaches_to_belt_loop(template_id: &str) -> bool {
4028        matches!(template_id, "leather_pouch" | "dimensional_pouch")
4029    }
4030
4031    fn append_nested_container_destinations(
4032        opts: &mut Vec<MoveOption>,
4033        location: flatland_protocol::InventoryLocation,
4034        container: &flatland_protocol::ItemStack,
4035        context: &str,
4036        from: &flatland_protocol::InventoryLocation,
4037        from_parent_instance_id: Option<uuid::Uuid>,
4038        moving_instance_id: Option<uuid::Uuid>,
4039    ) {
4040        for child in &container.contents {
4041            if Self::is_volume_container_stack(child)
4042                && child.item_instance_id != moving_instance_id
4043            {
4044                let name = child
4045                    .display_name
4046                    .clone()
4047                    .unwrap_or_else(|| child.template_id.clone());
4048                Self::push_move_destination(
4049                    opts,
4050                    format!("{name} ({context})"),
4051                    location.clone(),
4052                    child.item_instance_id,
4053                    from,
4054                    from_parent_instance_id,
4055                );
4056            }
4057            let nested_context = format!(
4058                "in {}",
4059                child.display_name.as_deref().unwrap_or(&child.template_id)
4060            );
4061            Self::append_nested_container_destinations(
4062                opts,
4063                location.clone(),
4064                child,
4065                &nested_context,
4066                from,
4067                from_parent_instance_id,
4068                moving_instance_id,
4069            );
4070        }
4071    }
4072
4073    fn clamp_inventory_indices(&mut self) {
4074        let n = self.inventory_selectable_rows().len();
4075        self.inventory_menu_index = if n == 0 {
4076            0
4077        } else {
4078            self.inventory_menu_index.min(n - 1)
4079        };
4080        if let Some(picker) = &self.move_picker {
4081            let pn = picker.options.len();
4082            self.move_picker_index = if pn == 0 {
4083                0
4084            } else {
4085                self.move_picker_index.min(pn - 1)
4086            };
4087        }
4088    }
4089
4090    /// Drop interior map layers whenever the player is outdoors.
4091    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
4092    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
4093    /// force `build_grid` into a full-map z pass on every click until client restart.
4094    fn sync_interior_map_context(&mut self) {
4095        if self.effective_inside_building().is_none() {
4096            self.interior_map = None;
4097            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4098                self.z_platforms = platforms;
4099                self.z_transitions = transitions;
4100            }
4101            return;
4102        }
4103        self.sync_interior_z_bands();
4104    }
4105
4106    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
4107    fn sync_interior_z_bands(&mut self) {
4108        if self.effective_inside_building().is_some() {
4109            if let Some(map) = &self.interior_map {
4110                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4111                    if self.z_bands_outdoor_backup.is_none() {
4112                        self.z_bands_outdoor_backup = Some((
4113                            std::mem::take(&mut self.z_platforms),
4114                            std::mem::take(&mut self.z_transitions),
4115                        ));
4116                    }
4117                    self.z_platforms = map.z_platforms.clone();
4118                    self.z_transitions = map.z_transitions.clone();
4119                }
4120            }
4121        }
4122    }
4123
4124    fn apply_snapshot_fields(
4125        &mut self,
4126        snapshot: &flatland_protocol::Snapshot,
4127        entity_id: EntityId,
4128    ) {
4129        self.tick = snapshot.tick;
4130        self.chunk_rev = snapshot.chunk_rev;
4131        self.content_rev = snapshot.content_rev;
4132        self.publish_rev = snapshot.publish_rev;
4133        self.resource_nodes = snapshot.resource_nodes.clone();
4134        self.ground_drops = snapshot.ground_drops.clone();
4135        self.placed_containers = snapshot.placed_containers.clone();
4136        self.world_x0 = snapshot.world_x0;
4137        self.world_y0 = snapshot.world_y0;
4138        self.world_width_m = snapshot.world_width_m;
4139        self.world_height_m = snapshot.world_height_m;
4140        self.world_clock = snapshot.world_clock;
4141        self.terrain_zones = snapshot.terrain_zones.clone();
4142        self.z_platforms = snapshot.z_platforms.clone();
4143        self.z_transitions = snapshot.z_transitions.clone();
4144        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
4145        self.z_bands_outdoor_backup = None;
4146        self.buildings = snapshot.buildings.clone();
4147        self.doors = snapshot.doors.clone();
4148        self.interior_map = snapshot.interior_map.clone();
4149        self.npcs = snapshot.npcs.clone();
4150        self.blueprints = snapshot.blueprints.clone();
4151        self.building_materials = snapshot.building_materials.clone();
4152        self.sync_inventory_from_stacks(&snapshot.inventory);
4153        self.player = snapshot
4154            .entities
4155            .iter()
4156            .find(|e| e.id == entity_id)
4157            .cloned();
4158        self.entities = snapshot.entities.clone();
4159        self.quest_log = snapshot.quest_log.clone();
4160        self.apply_hired_workers(snapshot.hired_workers.clone());
4161        self.interactables = snapshot.interactables.clone();
4162        self.ledger = snapshot.ledger.clone();
4163        self.career = snapshot.career.clone();
4164        self.combat_fx = snapshot.combat_fx.clone();
4165        self.property_zones = snapshot.property_zones.clone();
4166        self.tax_zones = snapshot.tax_zones.clone();
4167        self.growth_zones = snapshot.growth_zones.clone();
4168        self.biome_zones = snapshot.biome_zones.clone();
4169        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4170        self.property_plots = snapshot.property_plots.clone();
4171        self.property_plot_settings = snapshot.property_plot_settings.clone();
4172        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
4173        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
4174        if self.effective_inside_building().is_some() {
4175            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4176        }
4177        self.sync_interior_map_context();
4178        self.refresh_whisper_range();
4179    }
4180
4181    /// Re-derive inventory selection state after a snapshot/tick — chests that
4182    /// went out of range or got locked simply vanish from the row list, and the
4183    /// move picker (if any) closes once its item is no longer reachable.
4184    fn refresh_inventory_ui(&mut self) {
4185        if let Some(picker) = &self.move_picker {
4186            let instance_id = picker.item_instance_id;
4187            let still_exists = self
4188                .inventory_selectable_rows()
4189                .iter()
4190                .any(|r| r.stack.item_instance_id == Some(instance_id));
4191            if !still_exists {
4192                self.move_picker = None;
4193                self.show_move_picker = false;
4194            }
4195        }
4196        if let Some(picker) = &self.destroy_picker {
4197            let instance_id = picker.item_instance_id;
4198            let still_exists = self
4199                .inventory_selectable_rows()
4200                .iter()
4201                .any(|r| r.stack.item_instance_id == Some(instance_id));
4202            if !still_exists {
4203                self.destroy_picker = None;
4204                self.show_destroy_picker = false;
4205                self.destroy_confirm_pending = false;
4206            }
4207        }
4208        self.clamp_inventory_indices();
4209    }
4210
4211    /// Replace the hired-worker list from a snapshot/delta.
4212    ///
4213    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
4214    /// selected worker (and its step line) does not jump when the server rebuilds
4215    /// the list.
4216    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4217        let selected_id = self
4218            .hired_workers
4219            .get(self.workers_menu_index)
4220            .map(|w| w.instance_id.clone());
4221        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4222        let now = Instant::now();
4223        for w in &workers {
4224            let prev_err = self
4225                .hired_workers
4226                .iter()
4227                .find(|p| p.instance_id == w.instance_id)
4228                .and_then(|p| p.last_error.as_deref());
4229            let new_err = w.last_error.as_deref();
4230            if new_err != prev_err {
4231                if let Some(err) = new_err {
4232                    if !worker_error_is_transient(err) {
4233                        self.push_log(format!("Worker {}: {err}", w.label));
4234                    }
4235                }
4236            }
4237        }
4238        let mut next_display = BTreeMap::new();
4239        let mut next_errors = BTreeMap::new();
4240        for w in &workers {
4241            let mut sticky = self
4242                .worker_step_display
4243                .remove(&w.instance_id)
4244                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4245            sticky.observe(&w.step_label, now);
4246            next_display.insert(w.instance_id.clone(), sticky);
4247
4248            let mut err_sticky = self
4249                .worker_error_display
4250                .remove(&w.instance_id)
4251                .unwrap_or_default();
4252            err_sticky.observe(w.last_error.as_deref(), now);
4253            if err_sticky.shown(now).is_some() {
4254                next_errors.insert(w.instance_id.clone(), err_sticky);
4255            }
4256        }
4257        self.worker_step_display = next_display;
4258        self.worker_error_display = next_errors;
4259        self.hired_workers = workers;
4260        self.sync_worker_take_picker_from_hired();
4261        if let Some(id) = selected_id {
4262            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4263                self.workers_menu_index = idx;
4264                return;
4265            }
4266        }
4267        if self.workers_menu_index >= self.hired_workers.len() {
4268            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4269        }
4270    }
4271
4272    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4273    fn sync_worker_take_picker_from_hired(&mut self) {
4274        if !self.show_worker_take_picker {
4275            return;
4276        }
4277        let Some(picker) = self.worker_take_picker.clone() else {
4278            return;
4279        };
4280        let Some(worker) = self
4281            .hired_workers
4282            .iter()
4283            .find(|w| w.instance_id == picker.worker_instance_id)
4284            .cloned()
4285        else {
4286            self.show_worker_take_picker = false;
4287            self.worker_take_picker = None;
4288            self.worker_take_picker_index = 0;
4289            return;
4290        };
4291        let options: Vec<WorkerGiveOption> = worker
4292            .inventory
4293            .iter()
4294            .filter_map(|stack| {
4295                let item_instance_id = stack.item_instance_id?;
4296                let label = stack
4297                    .display_name
4298                    .clone()
4299                    .unwrap_or_else(|| stack.template_id.clone());
4300                let label = if stack.quantity > 1 {
4301                    format!("{label} ×{}", stack.quantity)
4302                } else {
4303                    label
4304                };
4305                Some(WorkerGiveOption {
4306                    item_instance_id,
4307                    label,
4308                    quantity: stack.quantity,
4309                    template_id: stack.template_id.clone(),
4310                })
4311            })
4312            .collect();
4313        if options.is_empty() {
4314            self.show_worker_take_picker = false;
4315            self.worker_take_picker = None;
4316            self.worker_take_picker_index = 0;
4317            return;
4318        }
4319        let prev_id = picker
4320            .options
4321            .get(self.worker_take_picker_index)
4322            .map(|o| o.item_instance_id);
4323        let idx = prev_id
4324            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4325            .unwrap_or(0)
4326            .min(options.len().saturating_sub(1));
4327        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4328        let quantity = picker.quantity.clamp(1, max_qty);
4329        self.worker_take_picker_index = idx;
4330        self.worker_take_picker = Some(WorkerTakePicker {
4331            worker_instance_id: picker.worker_instance_id,
4332            worker_label: picker.worker_label,
4333            options,
4334            quantity,
4335        });
4336    }
4337
4338    /// Held coarse step label for the workers menu (`step:` line).
4339    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4340        self.worker_step_display
4341            .get(worker_instance_id)
4342            .map(|s| s.shown.as_str())
4343            .or_else(|| {
4344                self.hired_workers
4345                    .iter()
4346                    .find(|w| w.instance_id == worker_instance_id)
4347                    .map(|w| w.step_label.as_str())
4348            })
4349            .unwrap_or("")
4350    }
4351
4352    /// Held error line for workers UI (detail + compact).
4353    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4354        let now = Instant::now();
4355        self.worker_error_display
4356            .get(worker_instance_id)
4357            .and_then(|s| s.shown(now))
4358            .or_else(|| {
4359                self.hired_workers
4360                    .iter()
4361                    .find(|w| w.instance_id == worker_instance_id)
4362                    .and_then(|w| w.last_error.as_deref())
4363                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4364            })
4365            .filter(|e| !worker_error_is_hud_noise(e))
4366    }
4367
4368    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4369        self.in_combat = combat.in_combat;
4370        self.auto_attack = combat.auto_attack;
4371        self.combat_has_los = combat.has_los;
4372        self.attack_cd_ticks = combat.attack_cd_ticks;
4373        self.gcd_ticks = combat.gcd_ticks;
4374        self.weapon_ability_id = combat.ability_id.clone();
4375        self.mainhand_template_id = combat.mainhand_template_id.clone();
4376        self.mainhand_label = combat.mainhand_label.clone();
4377        self.mainhand_instance_id = combat.mainhand_instance_id;
4378        self.offhand_template_id = combat.offhand_template_id.clone();
4379        self.offhand_label = combat.offhand_label.clone();
4380        self.offhand_instance_id = combat.offhand_instance_id;
4381        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4382            1
4383        } else {
4384            combat.mainhand_hand_slots
4385        };
4386        self.defense = combat.defense.clone();
4387        self.worn = combat.worn.iter().cloned().collect();
4388        self.carry_mass = combat.carry_mass;
4389        self.carry_mass_max = combat.carry_mass_max;
4390        self.encumbrance = combat.encumbrance;
4391        self.cast_progress = combat.cast.clone();
4392        self.timed_channel = combat.timed_channel.clone();
4393        self.plot_build_offer = combat.plot_build.clone();
4394        self.ability_cooldowns = combat.ability_cooldowns.clone();
4395        self.blocking_active = combat.blocking_active;
4396        self.max_target_slots = combat.max_target_slots.max(1);
4397        self.combat_slots = combat.slots.clone();
4398        self.rotation_presets = combat.rotation_presets.clone();
4399        self.known_abilities = combat.known_abilities.clone();
4400        self.ability_meta = combat
4401            .ability_meta
4402            .iter()
4403            .cloned()
4404            .map(|meta| (meta.id.clone(), meta))
4405            .collect();
4406        self.ability_mastery = combat
4407            .ability_mastery
4408            .iter()
4409            .cloned()
4410            .map(|row| (row.ability_id.clone(), row))
4411            .collect();
4412        self.hotbar = combat.hotbar.clone();
4413        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4414        self.keychain_stacks = combat.keychain.clone();
4415        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4416        self.combat_target_detail = combat.target.clone();
4417        self.statuses = combat.statuses.clone();
4418        self.combat_target = combat.target_entity_id;
4419        if combat.progression_xp_base > 0.0 {
4420            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4421                baseline_display: combat.progression_baseline,
4422                xp_base: combat.progression_xp_base,
4423                xp_growth: combat.progression_xp_growth,
4424            });
4425        }
4426        if let Some(xp) = &combat.progression_xp {
4427            if let Some(player) = &mut self.player {
4428                player.progression_xp = Some(xp.clone());
4429                if let Some(attrs) = combat.attributes {
4430                    player.attributes = Some(attrs);
4431                }
4432                if let Some(skills) = &combat.skills {
4433                    player.skills = Some(skills.clone());
4434                }
4435            }
4436        }
4437        if let Some(label) = &combat.target_label {
4438            self.combat_target_label = Some(label.clone());
4439        } else if let Some(id) = combat.target_entity_id {
4440            self.combat_target_label = self
4441                .entities
4442                .iter()
4443                .find(|e| e.id == id)
4444                .map(|e| e.label.clone())
4445                .or_else(|| self.combat_target_label.clone());
4446        }
4447        self.refresh_inventory_ui();
4448    }
4449
4450    /// Entity id assigned to a combat slot (from server HUD).
4451    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4452        self.combat_slots
4453            .iter()
4454            .find(|s| s.slot_index == slot)
4455            .and_then(|s| s.target_entity_id)
4456            .or_else(|| if slot == 1 { self.combat_target } else { None })
4457    }
4458
4459    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4460    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4461        self.ability_meta
4462            .get(ability_id)
4463            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4464            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4465            // the server validates aim_mode.
4466            .unwrap_or(self.ground_target.is_some())
4467    }
4468
4469    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4470    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4471        self.ability_meta
4472            .get(ability_id)
4473            .map(|meta| meta.aim_mode == "ground")
4474            .unwrap_or(false)
4475    }
4476
4477    /// True when the ability may be placed in an auto-rotation preset.
4478    /// Missing meta (pre-sync) defaults to eligible; server still validates.
4479    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4480        self.ability_meta
4481            .get(ability_id)
4482            .map(|meta| meta.auto_rotation_eligible)
4483            .unwrap_or(true)
4484    }
4485
4486    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4487    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4488        self.ground_target = Some((x, y, 0.0));
4489    }
4490
4491    /// Clear the local free-aim ground target.
4492    pub fn clear_ground_target(&mut self) {
4493        self.ground_target = None;
4494    }
4495
4496    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4497    /// May be an ability id or `item:<template_id>` consumable binding.
4498    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4499        if !(1..=9).contains(&slot_1_to_9) {
4500            return None;
4501        }
4502        self.hotbar
4503            .get((slot_1_to_9 - 1) as usize)
4504            .and_then(|a| a.as_deref())
4505            .filter(|id| !id.is_empty())
4506    }
4507
4508    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4509    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4510        let binding = self.hotbar_ability(slot_1_to_9)?;
4511        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4512            let name = self
4513                .inventory_hints
4514                .get(template_id)
4515                .map(|h| h.display_name.as_str())
4516                .unwrap_or(template_id);
4517            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4518            Some(format!("{name}×{qty}"))
4519        } else {
4520            Some(binding.to_string())
4521        }
4522    }
4523
4524    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4525    pub fn loadout_ability_choices(&self) -> Vec<String> {
4526        let mut out = self.known_abilities.clone();
4527        let weapon = self.weapon_ability_id.trim();
4528        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4529            out.push(weapon.to_string());
4530        }
4531        out
4532    }
4533
4534    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4535    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4536        let mut out = Vec::new();
4537        for ability in self.loadout_ability_choices() {
4538            let meta = if ability == self.weapon_ability_id {
4539                Some("weapon".into())
4540            } else {
4541                None
4542            };
4543            out.push(LoadoutHotbarChoice {
4544                binding: ability.clone(),
4545                label: ability,
4546                meta,
4547            });
4548        }
4549        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4550        for stack in &self.inventory_stacks {
4551            if Self::stack_is_item_grant(stack) {
4552                continue;
4553            }
4554            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4555                continue;
4556            }
4557            let qty = stack.quantity.max(1);
4558            if let Some((_, _, existing)) = consumables
4559                .iter_mut()
4560                .find(|(id, _, _)| id == &stack.template_id)
4561            {
4562                *existing = existing.saturating_add(qty);
4563            } else {
4564                let label = stack
4565                    .display_name
4566                    .clone()
4567                    .or_else(|| {
4568                        self.inventory_hints
4569                            .get(&stack.template_id)
4570                            .map(|h| h.display_name.clone())
4571                    })
4572                    .unwrap_or_else(|| stack.template_id.clone());
4573                consumables.push((stack.template_id.clone(), label, qty));
4574            }
4575        }
4576        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4577        for (template_id, label, qty) in consumables {
4578            out.push(LoadoutHotbarChoice {
4579                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4580                label: format!("{label} ×{qty}"),
4581                meta: Some("use".into()),
4582            });
4583        }
4584        out
4585    }
4586
4587    /// Hostile wildlife / monsters for T1 (`Tab`).
4588    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4589        self.combat_candidates()
4590    }
4591
4592    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4593    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4594        let (px, py) = self.player_position();
4595        let dist = |id: EntityId| {
4596            self.entities
4597                .iter()
4598                .find(|e| e.id == id)
4599                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4600                .unwrap_or(f32::MAX)
4601        };
4602
4603        let mut allies = Vec::new();
4604        // Self first — heal_touch on T2.
4605        if let Some(me) = self.player.as_ref() {
4606            let alive = me
4607                .vitals
4608                .as_ref()
4609                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4610                .unwrap_or(true);
4611            if alive {
4612                allies.push((self.entity_id, "Yourself".into()));
4613            }
4614        }
4615        for entity in &self.entities {
4616            if entity.id == self.entity_id {
4617                continue;
4618            }
4619            if entity.vitals.is_some() {
4620                let alive = entity
4621                    .vitals
4622                    .as_ref()
4623                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4624                    .unwrap_or(true);
4625                if alive {
4626                    allies.push((entity.id, entity.label.clone()));
4627                }
4628            }
4629        }
4630        allies.sort_by(|(a, _), (b, _)| {
4631            if *a == self.entity_id {
4632                return std::cmp::Ordering::Less;
4633            }
4634            if *b == self.entity_id {
4635                return std::cmp::Ordering::Greater;
4636            }
4637            dist(*a)
4638                .partial_cmp(&dist(*b))
4639                .unwrap_or(std::cmp::Ordering::Equal)
4640        });
4641
4642        let mut monsters = self.combat_candidates();
4643        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4644        allies.into_iter().chain(monsters).collect()
4645    }
4646
4647    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4648        match slot_index {
4649            2 => self.t2_candidates(),
4650            _ => self.t1_candidates(),
4651        }
4652    }
4653
4654    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
4655    pub fn pick_combat_target_at(
4656        &self,
4657        wx: f32,
4658        wy: f32,
4659        slot_index: u8,
4660        radius_m: f32,
4661    ) -> Option<(EntityId, String)> {
4662        let mut best: Option<(f32, EntityId, String)> = None;
4663        for (id, label) in self.candidates_for_slot(slot_index) {
4664            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4665                // NPCs may only appear in NpcView — fall back to npc list coords.
4666                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4667                    let d = distance(wx, wy, npc.x, npc.y);
4668                    if d <= radius_m {
4669                        best = match best {
4670                            Some((bd, _, _)) if bd <= d => best,
4671                            _ => Some((d, id, label)),
4672                        };
4673                    }
4674                }
4675                continue;
4676            };
4677            let d = distance(
4678                wx,
4679                wy,
4680                entity.transform.position.x,
4681                entity.transform.position.y,
4682            );
4683            if d <= radius_m {
4684                best = match best {
4685                    Some((bd, _, _)) if bd <= d => best,
4686                    _ => Some((d, id, label)),
4687                };
4688            }
4689        }
4690        best.map(|(_, id, label)| (id, label))
4691    }
4692
4693    /// Full client reset after Welcome (initial connect or reconnect).
4694    pub(crate) fn restore_from_welcome(
4695        &mut self,
4696        session_id: SessionId,
4697        entity_id: EntityId,
4698        snapshot: &flatland_protocol::Snapshot,
4699    ) {
4700        self.clear_harvest_state();
4701        self.disconnect_reason = None;
4702        self.show_stats = false;
4703        self.show_craft_menu = false;
4704        self.show_shop_menu = false;
4705        self.shop_catalog = None;
4706        self.show_inventory_menu = false;
4707        self.session_id = session_id;
4708        self.entity_id = entity_id;
4709        self.connected = true;
4710        self.apply_snapshot_fields(snapshot, entity_id);
4711        if let Some(combat) = &snapshot.combat {
4712            self.apply_combat_hud(combat);
4713            let stacks = self.inventory_stacks.clone();
4714            self.sync_inventory_from_stacks(&stacks);
4715        }
4716    }
4717
4718    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4719        self.tick = delta.tick;
4720        self.world_clock = delta.world_clock;
4721
4722        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
4723        if delta.entities.is_empty() {
4724            self.ground_drops = delta.ground_drops.clone();
4725            self.combat_fx = delta.combat_fx.clone();
4726            self.property_plots = delta.property_plots.clone();
4727            self.apply_terrain_overlays(&delta.terrain_overlays);
4728            if let Some(combat) = &delta.combat {
4729                self.apply_combat_hud(combat);
4730                let stacks = self.inventory_stacks.clone();
4731                self.sync_inventory_from_stacks(&stacks);
4732            }
4733            // Peer vanished from AOI — drop directed whisper.
4734            self.refresh_whisper_range();
4735            return;
4736        }
4737        if !delta.buildings.is_empty() {
4738            self.buildings = delta.buildings.clone();
4739        }
4740        if !delta.blueprints.is_empty() {
4741            self.blueprints = delta.blueprints.clone();
4742        }
4743        if !delta.building_materials.is_empty() {
4744            self.building_materials = delta.building_materials.clone();
4745        }
4746        self.sync_inventory_from_stacks(&delta.inventory);
4747
4748        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4749            self.player = Some(updated.clone());
4750        }
4751        self.entities = delta.entities.clone();
4752        if self.player.is_none() {
4753            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4754        }
4755
4756        self.sync_interior_map_context();
4757
4758        // Static world layers: ticks often omit these (indoors, or unchanged).
4759        // Never wipe the welcome snapshot with an empty vec — except when indoors,
4760        // where empty means "no nodes in this building" (clear outdoor leftovers).
4761        if !delta.resource_nodes.is_empty() {
4762            self.resource_nodes = delta.resource_nodes.clone();
4763        } else if delta.interior_map.is_some()
4764            || self.effective_inside_building().is_some()
4765        {
4766            self.resource_nodes = delta.resource_nodes.clone();
4767        }
4768        self.ground_drops = delta.ground_drops.clone();
4769        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
4770        self.placed_containers = delta.placed_containers.clone();
4771        if !delta.doors.is_empty() {
4772            self.doors = delta.doors.clone();
4773        }
4774        if self.effective_inside_building().is_some() {
4775            if let Some(map) = &delta.interior_map {
4776                self.interior_map = Some(map.clone());
4777            }
4778        } else {
4779            self.interior_map = None;
4780        }
4781        self.sync_interior_z_bands();
4782        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
4783        self.npcs = delta.npcs.clone();
4784        if !delta.quest_log.is_empty() {
4785            self.quest_log = delta.quest_log.clone();
4786        }
4787        self.apply_hired_workers(delta.hired_workers.clone());
4788        if !delta.interactables.is_empty() {
4789            self.interactables = delta.interactables.clone();
4790        }
4791        if delta.ledger.is_some() {
4792            self.ledger = delta.ledger.clone();
4793        }
4794        if delta.career.is_some() {
4795            self.career = delta.career.clone();
4796        }
4797        self.combat_fx = delta.combat_fx.clone();
4798        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
4799        if !delta.property_plots.is_empty() {
4800            self.property_plots = delta.property_plots.clone();
4801        }
4802        self.apply_terrain_overlays(&delta.terrain_overlays);
4803        if let Some(combat) = &delta.combat {
4804            self.apply_combat_hud(combat);
4805            let stacks = self.inventory_stacks.clone();
4806            self.sync_inventory_from_stacks(&stacks);
4807        } else {
4808            self.refresh_inventory_ui();
4809        }
4810        self.refresh_whisper_range();
4811    }
4812
4813    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
4814    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
4815    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4816        self.terrain_zones
4817            .retain(|z| !z.id.starts_with("rt:"));
4818        self.terrain_zones.extend(overlays.iter().cloned());
4819    }
4820
4821    /// End directed whisper when the peer is farther than interact range (or missing).
4822    /// Each client runs this locally so both players drop whisper mode when either walks away.
4823    fn refresh_whisper_range(&mut self) {
4824        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4825            return;
4826        };
4827        let (px, py) = self.player_position();
4828        let in_range = self.entities.iter().any(|e| {
4829            e.id == peer
4830                && distance(
4831                    px,
4832                    py,
4833                    e.transform.position.x,
4834                    e.transform.position.y,
4835                ) <= INTERACTION_RADIUS_M
4836        });
4837        if !in_range {
4838            self.social_chat.cancel_whisper_out_of_range();
4839        }
4840    }
4841
4842    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
4843    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4844        let (px, py) = self.player_position();
4845        let mut out = Vec::new();
4846        for npc in &self.npcs {
4847            let Some(eid) = npc.entity_id else {
4848                continue;
4849            };
4850            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4851            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4852            if alive && has_hp {
4853                out.push((eid, npc.label.clone()));
4854            }
4855        }
4856        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4857            let dist = |id: EntityId| {
4858                self.entities
4859                    .iter()
4860                    .find(|e| e.id == id)
4861                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4862                    .unwrap_or(f32::MAX)
4863            };
4864            dist(*a_id)
4865                .partial_cmp(&dist(*b_id))
4866                .unwrap_or(std::cmp::Ordering::Equal)
4867                .then_with(|| a_label.cmp(b_label))
4868                .then_with(|| a_id.cmp(b_id))
4869        });
4870        out
4871    }
4872
4873    pub fn refresh_combat_target_label(&mut self) {
4874        let Some(id) = self.combat_target else {
4875            return;
4876        };
4877        if let Some((_, label)) = self
4878            .combat_candidates()
4879            .into_iter()
4880            .find(|(eid, _)| *eid == id)
4881        {
4882            self.combat_target_label = Some(label);
4883        } else if let Some(label) = self
4884            .entities
4885            .iter()
4886            .find(|e| e.id == id)
4887            .map(|e| e.label.clone())
4888        {
4889            self.combat_target_label = Some(label);
4890        }
4891    }
4892
4893    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4894        self.quest_log
4895            .iter()
4896            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4897            .collect()
4898    }
4899
4900    /// True when the observer has at least one free lodging slot (max workers = beds).
4901    pub fn has_worker_lodging(&self) -> bool {
4902        self.free_worker_lodging_slots() > 0
4903    }
4904
4905    /// Owned lodging capacity minus currently hired workers.
4906    pub fn free_worker_lodging_slots(&self) -> i64 {
4907        let slots: u32 = self
4908            .placed_containers
4909            .iter()
4910            .filter(|c| match (self.character_id, c.owner_character_id) {
4911                (Some(me), Some(owner)) => me == owner,
4912                (Some(_), None) => false,
4913                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4914            })
4915            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4916            .sum();
4917        let used = self.hired_workers.len() as u32;
4918        slots as i64 - used as i64
4919    }
4920
4921    /// Display names of hired workers assigned to this lodging container.
4922    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4923        let mut names: Vec<String> = self
4924            .hired_workers
4925            .iter()
4926            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4927            .map(|w| w.label.clone())
4928            .collect();
4929        names.sort();
4930        names
4931    }
4932
4933    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
4934    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4935        let is_lodging = self
4936            .placed_containers
4937            .iter()
4938            .find(|c| c.id == container_id)
4939            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4940        if !is_lodging {
4941            return None;
4942        }
4943        let names = self.lodging_occupant_labels(container_id);
4944        Some(if names.is_empty() {
4945            "vacant".into()
4946        } else {
4947            names.join(", ")
4948        })
4949    }
4950
4951    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4952        self.quest_log
4953            .iter()
4954            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4955            .or_else(|| {
4956                self.quest_log
4957                    .iter()
4958                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4959            })
4960    }
4961
4962    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
4963    pub fn nearby_lockable_door(&self) -> bool {
4964        let (px, py) = self.player_position();
4965        self.doors
4966            .iter()
4967            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= 3.5)
4968    }
4969
4970    /// True when an open player-house door is in range for Enter-to-go-inside.
4971    pub fn nearby_open_player_door(&self) -> bool {
4972        if self.effective_inside_building().is_some() {
4973            return false;
4974        }
4975        let (px, py) = self.player_position();
4976        self.doors.iter().any(|d| {
4977            if !d.open || d.locked {
4978                return false;
4979            }
4980            let player_house = self
4981                .buildings
4982                .iter()
4983                .find(|b| b.id == d.building_id)
4984                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
4985            player_house && (d.x - px).hypot(d.y - py) <= 3.5
4986        })
4987    }
4988
4989    /// True when inside a player house near the exterior portal (Enter exits freely).
4990    pub fn nearby_player_exit_door(&self) -> bool {
4991        let Some(bid) = self.effective_inside_building() else {
4992            return false;
4993        };
4994        let (px, py) = self.player_position();
4995        self.doors.iter().any(|d| {
4996            if d.building_id != bid || d.portal.is_none() {
4997                return false;
4998            }
4999            let player_house = self
5000                .buildings
5001                .iter()
5002                .find(|b| b.id == d.building_id)
5003                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5004            player_house && (d.x - px).hypot(d.y - py) <= 1.5
5005        })
5006    }
5007
5008    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
5009    pub fn nearest_interact_target(&self) -> Option<String> {
5010        let (px, py) = self.player_position();
5011        let inside = self.effective_inside_building();
5012
5013        #[derive(Clone, Copy, PartialEq, Eq)]
5014        enum Kind {
5015            Player,
5016            Npc,
5017            HiredWorker,
5018            QuestBoard,
5019            ExitDoor,
5020            EnterDoor,
5021            Well,
5022            Water,
5023        }
5024
5025        fn kind_priority(kind: Kind) -> u8 {
5026            match kind {
5027                Kind::Player => 0,
5028                Kind::Npc => 0,
5029                Kind::HiredWorker => 0,
5030                Kind::QuestBoard => 1,
5031                Kind::ExitDoor => 2,
5032                Kind::EnterDoor => 3,
5033                Kind::Well => 4,
5034                Kind::Water => 5,
5035            }
5036        }
5037
5038        let mut best: Option<(f32, Kind, String)> = None;
5039
5040        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5041            if dist > max {
5042                return;
5043            }
5044            let replace = match best {
5045                None => true,
5046                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5047                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5048                    kind_priority(kind) < kind_priority(bk)
5049                }
5050                _ => false,
5051            };
5052            if replace {
5053                best = Some((dist, kind, id));
5054            }
5055        };
5056
5057        for npc in &self.npcs {
5058            consider(
5059                distance(px, py, npc.x, npc.y),
5060                INTERACTION_RADIUS_M,
5061                Kind::Npc,
5062                npc.id.clone(),
5063            );
5064        }
5065
5066        for worker in &self.hired_workers {
5067            consider(
5068                distance(px, py, worker.x, worker.y),
5069                INTERACTION_RADIUS_M,
5070                Kind::HiredWorker,
5071                worker.instance_id.clone(),
5072            );
5073        }
5074
5075        for entity in &self.entities {
5076            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
5077            {
5078                continue;
5079            }
5080            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
5081            if self
5082                .hired_workers
5083                .iter()
5084                .any(|w| w.entity_id == entity.id)
5085            {
5086                continue;
5087            }
5088            consider(
5089                distance(
5090                    px,
5091                    py,
5092                    entity.transform.position.x,
5093                    entity.transform.position.y,
5094                ),
5095                INTERACTION_RADIUS_M,
5096                Kind::Player,
5097                entity.id.to_string(),
5098            );
5099        }
5100
5101        for door in &self.doors {
5102            if let Some(ref bid) = inside {
5103                if door.building_id != *bid {
5104                    continue;
5105                }
5106                let is_exit = door.portal.is_some();
5107                let max = if is_exit {
5108                    INTERACTION_RADIUS_M
5109                } else {
5110                    DOOR_INTERACTION_RADIUS_M
5111                };
5112                let kind = if is_exit {
5113                    Kind::ExitDoor
5114                } else {
5115                    Kind::EnterDoor
5116                };
5117                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5118                continue;
5119            }
5120            consider(
5121                distance(px, py, door.x, door.y),
5122                DOOR_INTERACTION_RADIUS_M,
5123                Kind::EnterDoor,
5124                door.id.clone(),
5125            );
5126        }
5127
5128        if inside.is_none() {
5129            for inter in &self.interactables {
5130                if inter.kind == "quest_board" {
5131                    consider(
5132                        distance(px, py, inter.x, inter.y),
5133                        QUEST_BOARD_INTERACTION_RADIUS_M,
5134                        Kind::QuestBoard,
5135                        inter.id.clone(),
5136                    );
5137                }
5138            }
5139            for building in &self.buildings {
5140                if !building.tags.iter().any(|t| t == "well") {
5141                    continue;
5142                }
5143                consider(
5144                    distance(px, py, building.x, building.y),
5145                    INTERACTION_RADIUS_M,
5146                    Kind::Well,
5147                    building.id.clone(),
5148                );
5149            }
5150            if self.in_shallow_water() {
5151                consider(
5152                    0.0,
5153                    INTERACTION_RADIUS_M,
5154                    Kind::Water,
5155                    "water_source".into(),
5156                );
5157            }
5158        }
5159
5160        best.map(|(_, _, id)| id)
5161    }
5162
5163    /// Nearest quest board and distance (any distance), for out-of-range feedback.
5164    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5165        if self.effective_inside_building().is_some() {
5166            return None;
5167        }
5168        let (px, py) = self.player_position();
5169        self.interactables
5170            .iter()
5171            .filter(|i| i.kind == "quest_board")
5172            .map(|i| {
5173                let label = if i.label.is_empty() {
5174                    "Quest board".to_string()
5175                } else {
5176                    i.label.clone()
5177                };
5178                (label, distance(px, py, i.x, i.y))
5179            })
5180            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5181    }
5182
5183    /// Human-readable template name from synced catalog hints.
5184    pub fn template_display_name(&self, template_id: &str) -> String {
5185        self.inventory_hints
5186            .get(template_id)
5187            .map(|h| h.display_name.clone())
5188            .filter(|n| !n.is_empty())
5189            .unwrap_or_else(|| humanize_template_id(template_id))
5190    }
5191
5192    /// Prefer blueprint-view display name, then inventory hints / humanized id.
5193    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5194        if !display_name.is_empty() {
5195            display_name.to_string()
5196        } else {
5197            self.template_display_name(template_id)
5198        }
5199    }
5200
5201    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5202        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5203    }
5204
5205    pub fn blueprint_ingredient_label(
5206        &self,
5207        input: &flatland_protocol::BlueprintIngredientView,
5208    ) -> String {
5209        self.blueprint_item_label(&input.template_id, &input.display_name)
5210    }
5211
5212    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5213        self.blueprint_item_label(&tool.item, &tool.display_name)
5214    }
5215
5216    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
5217    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5218        use crate::worker_route_editor::{
5219            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5220        };
5221        let lodging = self
5222            .worker_route_editor
5223            .as_ref()
5224            .and_then(|ed| ed.lodging_container_id.as_deref());
5225        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5226            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5227            None => node_candidates_stable(&self.resource_nodes),
5228        }
5229    }
5230
5231    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5232        if dist_m.is_nan() {
5233            return "—".into();
5234        }
5235        let from_bed = self
5236            .worker_route_editor
5237            .as_ref()
5238            .and_then(|ed| ed.lodging_container_id.as_deref())
5239            .and_then(|id| {
5240                self.placed_containers
5241                    .iter()
5242                    .find(|c| c.id == id)
5243                    .map(|c| c.display_name.clone())
5244            });
5245        match from_bed {
5246            Some(bed) => format!("{dist_m:.0}m from {bed}"),
5247            None => format!("{dist_m:.0}m"),
5248        }
5249    }
5250
5251    /// Chest label for the location panel — generic type unless this player owns it.
5252    pub fn placed_container_public_label(
5253        &self,
5254        c: &flatland_protocol::PlacedContainerView,
5255    ) -> String {
5256        let is_owner = match (self.character_id, c.owner_character_id) {
5257            (Some(me), Some(owner)) => me == owner,
5258            _ => false,
5259        };
5260        if is_owner {
5261            c.display_name.clone()
5262        } else {
5263            self.template_display_name(&c.template_id)
5264        }
5265    }
5266
5267    /// Keys on person and stowed on the keychain for the keychain overlay.
5268    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5269        let mut out = Vec::new();
5270        for stack in &self.inventory_stacks {
5271            if stack.template_id == KEY_TEMPLATE {
5272                out.push(KeychainEntry {
5273                    stack: stack.clone(),
5274                    stowed: false,
5275                });
5276            }
5277        }
5278        for stack in &self.keychain_stacks {
5279            if stack.template_id == KEY_TEMPLATE {
5280                out.push(KeychainEntry {
5281                    stack: stack.clone(),
5282                    stowed: true,
5283                });
5284            }
5285        }
5286        out
5287    }
5288
5289    /// Display name of the chest a `container_key` opens, when known on the client.
5290    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5291        if stack.template_id != KEY_TEMPLATE {
5292            return None;
5293        }
5294        if let Some(name) = stack
5295            .props
5296            .get(PROP_OPENS_CONTAINER_NAME)
5297            .filter(|n| !n.is_empty())
5298        {
5299            return Some(name.clone());
5300        }
5301        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5302        self.container_name_for_lock_id(opens)
5303    }
5304
5305    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
5306    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5307        if stack.template_id == KEY_TEMPLATE {
5308            self.template_display_name(KEY_TEMPLATE)
5309        } else {
5310            stack
5311                .display_name
5312                .clone()
5313                .unwrap_or_else(|| stack.template_id.clone())
5314        }
5315    }
5316
5317    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
5318    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5319        if stack.template_id != KEY_TEMPLATE {
5320            return String::new();
5321        }
5322        match self.key_pair_chest_label(stack) {
5323            Some(chest) if self.key_drop_blocked(stack) => {
5324                format!(" [key for {chest} — can't drop while locked]")
5325            }
5326            Some(chest) => format!(" [key for {chest}]"),
5327            None => " [key — unpaired]".into(),
5328        }
5329    }
5330
5331    /// Resolve a lock id to a container label (placed chest or one still on your person).
5332    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5333        for c in &self.placed_containers {
5334            if c.lock_id.as_deref() == Some(lock) {
5335                return Some(c.display_name.clone());
5336            }
5337        }
5338        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5339            self.worn
5340                .values()
5341                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5342        })
5343    }
5344
5345    /// Keys cannot be dropped while their paired chest is locked.
5346    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5347        if stack.template_id != KEY_TEMPLATE {
5348            return false;
5349        }
5350        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5351            return false;
5352        };
5353        for c in &self.placed_containers {
5354            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5355                return true;
5356            }
5357        }
5358        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5359            return true;
5360        }
5361        self.worn
5362            .values()
5363            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5364    }
5365
5366    /// Property deeds cannot be dropped or destroyed (store or trade only).
5367    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5368        stack.template_id == PROPERTY_DEED_TEMPLATE
5369    }
5370
5371    pub fn is_property_deed_template(template_id: &str) -> bool {
5372        template_id == PROPERTY_DEED_TEMPLATE
5373    }
5374
5375    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5376        stack
5377            .props
5378            .get("plot_id")
5379            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5380    }
5381
5382    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5383    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5384        let (px, py) = self.player_position();
5385        let (cx, cy) = self.farm_plot_cell_under_player()?;
5386        let tx = cx as f32 + 0.5;
5387        let ty = cy as f32 + 0.5;
5388        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5389            return None;
5390        }
5391        let kind = self
5392            .terrain_at(tx, ty)
5393            .or_else(|| self.terrain_at(px, py));
5394        if kind == Some(TerrainKindView::Tilled) {
5395            return None;
5396        }
5397        if matches!(
5398            kind,
5399            Some(TerrainKindView::ShallowWater)
5400                | Some(TerrainKindView::DeepWater)
5401                | Some(TerrainKindView::Rock)
5402        ) {
5403            return None;
5404        }
5405        Some((tx, ty))
5406    }
5407
5408    fn container_name_in_stacks(
5409        stacks: &[flatland_protocol::ItemStack],
5410        lock: &str,
5411    ) -> Option<String> {
5412        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5413            for s in stacks {
5414                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5415                    return Some(GameState::stack_container_label(s));
5416                }
5417                if let Some(name) = walk(&s.contents, lock) {
5418                    return Some(name);
5419                }
5420            }
5421            None
5422        }
5423        walk(stacks, lock)
5424    }
5425
5426    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5427        stack
5428            .props
5429            .get(PROP_CUSTOM_NAME)
5430            .cloned()
5431            .or_else(|| stack.display_name.clone())
5432            .unwrap_or_else(|| stack.template_id.clone())
5433    }
5434
5435    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5436        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5437            for s in stacks {
5438                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5439                    return true;
5440                }
5441                if walk(&s.contents, lock) {
5442                    return true;
5443                }
5444            }
5445            false
5446        }
5447        walk(stacks, lock)
5448    }
5449
5450    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5451        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5452            return Some(stack.clone());
5453        }
5454        for worn in self.worn.values() {
5455            if worn.item_instance_id == Some(instance_id) {
5456                return Some(worn.clone());
5457            }
5458            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5459                return Some(stack.clone());
5460            }
5461        }
5462        None
5463    }
5464
5465    /// Highest-`z_order` property zone covering `(x, y)`.
5466    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5467        self.property_zones
5468            .iter()
5469            .enumerate()
5470            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5471            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5472            .map(|(_, z)| z)
5473    }
5474
5475    /// Highest-`z_order` tax zone covering `(x, y)`.
5476    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5477        self.tax_zones
5478            .iter()
5479            .enumerate()
5480            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5481            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5482            .map(|(_, z)| z)
5483    }
5484
5485    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5486    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5487        let mut max_bps = 0u32;
5488        let mut y = y0 + 0.5;
5489        while y < y1 {
5490            let mut x = x0 + 0.5;
5491            while x < x1 {
5492                if let Some(tz) = self.tax_zone_at(x, y) {
5493                    max_bps = max_bps.max(tz.rate_bps);
5494                }
5495                x += 1.0;
5496            }
5497            y += 1.0;
5498        }
5499        max_bps
5500    }
5501
5502    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5503    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5504        let mode = self.claim_mode.as_ref()?;
5505        let w = mode.width_m.max(1) as f32;
5506        let h = mode.height_m.max(1) as f32;
5507        Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5508    }
5509
5510    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5511    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5512        let mode = self.relocate_mode.as_ref()?;
5513        let x0 = mode.cursor_x.floor();
5514        let y0 = mode.cursor_y.floor();
5515        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5516    }
5517
5518    /// Client quote matching server `quote_plot`:
5519    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5520    pub fn claim_quote(
5521        &self,
5522    ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5523        let mode = self.claim_mode.as_ref()?;
5524        let zone = self
5525            .property_zones
5526            .iter()
5527            .find(|z| z.id == mode.zone_id)?;
5528        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5529        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5530        let zone_area = zone_view_area_m2(zone).max(1.0);
5531        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5532        let weight = self
5533            .property_plot_settings
5534            .as_ref()
5535            .map(|s| s.tax_premium_weight)
5536            .unwrap_or(0.5)
5537            .max(0.0);
5538        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5539        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5540        let purchase = ((zone.crown_price_copper as f64)
5541            * (area_frac as f64)
5542            * (premium as f64))
5543            .ceil()
5544            .max(0.0) as u64;
5545        let upkeep = if zone.upkeep_copper_per_day == 0 {
5546            0
5547        } else {
5548            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5549                .ceil()
5550                .max(1.0) as u64
5551        };
5552        let copper = crate::currency::copper_from_counts(&self.inventory);
5553        let can_afford = copper >= purchase;
5554        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5555        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5556    }
5557
5558    fn validate_claim_footprint(
5559        &self,
5560        zone: &flatland_protocol::PropertyZoneView,
5561        x0: f32,
5562        y0: f32,
5563        x1: f32,
5564        y1: f32,
5565        area: f32,
5566    ) -> (bool, String) {
5567        let min_area = self
5568            .property_plot_settings
5569            .as_ref()
5570            .map(|s| s.min_plot_area_m2)
5571            .unwrap_or(4.0);
5572        if area + f32::EPSILON < min_area {
5573            return (false, "plot too small".into());
5574        }
5575        if zone.max_area_m2.is_some_and(|m| area > m) {
5576            return (false, "plot exceeds max area".into());
5577        }
5578        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5579            return (false, "plot must lie inside the property zone".into());
5580        }
5581        if self.property_plots.iter().any(|p| {
5582            rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5583        }) {
5584            return (false, "plot overlaps an existing claim".into());
5585        }
5586        (true, String::new())
5587    }
5588
5589    /// Standing in a property zone on a free cell (not already claimed).
5590    pub fn free_property_zone_under_player(
5591        &self,
5592    ) -> Option<&flatland_protocol::PropertyZoneView> {
5593        let (px, py) = self.player_position();
5594        let zone = self.property_zone_at(px, py)?;
5595        if self
5596            .property_plots
5597            .iter()
5598            .any(|p| point_in_plot(px, py, p))
5599        {
5600            return None;
5601        }
5602        Some(zone)
5603    }
5604
5605    /// Own plot covering the player (`is_mine`).
5606    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5607        let (px, py) = self.player_position();
5608        self.property_plots
5609            .iter()
5610            .find(|p| p.is_mine && point_in_plot(px, py, p))
5611    }
5612
5613    /// Plot under the player that they may farm (deed, grant, or public).
5614    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5615        let (px, py) = self.player_position();
5616        self.property_plots
5617            .iter()
5618            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5619    }
5620
5621    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5622    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5623        if self.farmable_plot_under_player().is_none() {
5624            return None;
5625        }
5626        let (px, py) = self.player_position();
5627        Some((px.floor() as i32, py.floor() as i32))
5628    }
5629
5630    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5631        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5632        self.resource_nodes.iter().any(|n| {
5633            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5634            ncx == cx && ncy == cy
5635                || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5636        })
5637    }
5638
5639    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5640        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5641        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5642            || self
5643                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5644                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5645        if !tilled {
5646            return false;
5647        }
5648        !self.resource_node_occupies_farm_cell(cx, cy)
5649    }
5650
5651    /// Empty tilled soil on the cell the player is standing on (no crop node).
5652    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5653        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5654            return false;
5655        };
5656        self.free_tilled_plant_slot_at(cx, cy)
5657    }
5658
5659    /// True when a free tilled cell (no resource node) is within interact range.
5660    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5661        let (px, py) = self.player_position();
5662        for dy in -2..=2 {
5663            for dx in -2..=2 {
5664                let cx = px.floor() as i32 + dx;
5665                let cy = py.floor() as i32 + dy;
5666                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5667                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5668                    continue;
5669                }
5670                if self.free_tilled_plant_slot_at(cx, cy) {
5671                    return true;
5672                }
5673            }
5674        }
5675        false
5676    }
5677
5678    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5679        stack.quantity > 0
5680            && (stack.props.contains_key("seed_for")
5681                || stack.template_id.ends_with("_seed")
5682                || stack.template_id == "potato_seed"
5683                || stack.template_id == "carrot_seed")
5684    }
5685
5686    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
5687    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5688        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5689        fn walk(
5690            stacks: &[flatland_protocol::ItemStack],
5691            counts: &mut std::collections::HashMap<String, u32>,
5692        ) {
5693            for s in stacks {
5694                if GameState::stack_is_farm_seed(s) {
5695                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5696                }
5697                walk(&s.contents, counts);
5698            }
5699        }
5700        walk(&self.inventory_stacks, &mut counts);
5701        for worn in self.worn.values() {
5702            walk(std::slice::from_ref(worn), &mut counts);
5703        }
5704        let mut out: Vec<_> = counts
5705            .into_iter()
5706            .map(|(template_id, quantity)| {
5707                let label = self
5708                    .inventory_hints
5709                    .get(&template_id)
5710                    .map(|h| h.display_name.clone())
5711                    .filter(|n| !n.trim().is_empty())
5712                    .unwrap_or_else(|| humanize_template_id(&template_id));
5713                (template_id, quantity, label)
5714            })
5715            .collect();
5716        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5717        out
5718    }
5719
5720    /// First farm seed template in inventory (root + nested bags).
5721    pub fn first_farm_seed_template(&self) -> Option<String> {
5722        self.farm_seed_entries()
5723            .into_iter()
5724            .next()
5725            .map(|(id, _, _)| id)
5726    }
5727
5728    pub fn clamp_plant_menu(&mut self) {
5729        let n = self.farm_seed_entries().len();
5730        if n == 0 {
5731            self.plant_menu_index = 0;
5732            self.plant_quantity = 1;
5733            return;
5734        }
5735        self.plant_menu_index = self.plant_menu_index.min(n - 1);
5736        let max_qty = self
5737            .farm_seed_entries()
5738            .get(self.plant_menu_index)
5739            .map(|(_, q, _)| *q)
5740            .unwrap_or(1)
5741            .max(1);
5742        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5743    }
5744
5745    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5746        let entries = self.farm_seed_entries();
5747        let (id, max, label) = entries.get(self.plant_menu_index)?;
5748        let qty = self.plant_quantity.min(*max).max(1);
5749        Some((id.clone(), qty, label.clone()))
5750    }
5751
5752    /// Terrain, interactables, and map objects near the player for the HUD location panel.
5753    pub fn location_context_lines(&self) -> Vec<ContextLine> {
5754        let (px, py) = self.player_position();
5755        let inside = self.effective_inside_building();
5756        let mut lines = Vec::new();
5757
5758        if let Some(kind) = self.terrain_at(px, py) {
5759            lines.push(ContextLine {
5760                on_top: true,
5761                text: format!("Terrain: {}", terrain_kind_label(kind)),
5762            });
5763        }
5764
5765        if let Some(id) = inside.as_ref() {
5766            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5767                lines.push(ContextLine {
5768                    on_top: true,
5769                    text: format!("Inside: {}", b.label),
5770                });
5771            }
5772        }
5773
5774        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5775
5776        for node in &self.resource_nodes {
5777            if node.id.starts_with("preview:") {
5778                continue;
5779            }
5780            let dist = distance(px, py, node.x, node.y);
5781            if dist > NEARBY_SCAN_M {
5782                continue;
5783            }
5784            let on_top = dist <= ON_TOP_RADIUS_M;
5785            let prefix = if on_top { "On" } else { "Near" };
5786            let name = resource_node_near_display_label(&node.label);
5787            let action = resource_node_near_action_suffix(node);
5788            nearby.push((
5789                dist,
5790                ContextLine {
5791                    on_top,
5792                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5793                },
5794            ));
5795        }
5796
5797        for drop in &self.ground_drops {
5798            let dist = distance(px, py, drop.x, drop.y);
5799            if dist > INTERACTION_RADIUS_M {
5800                continue;
5801            }
5802            let on_top = dist <= ON_TOP_RADIUS_M;
5803            let name = self.template_display_name(&drop.template_id);
5804            let prefix = if on_top { "On" } else { "Near" };
5805            let qty = if drop.quantity > 1 {
5806                format!(" ×{}", drop.quantity)
5807            } else {
5808                String::new()
5809            };
5810            nearby.push((
5811                dist,
5812                ContextLine {
5813                    on_top,
5814                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5815                },
5816            ));
5817        }
5818
5819        for c in &self.placed_containers {
5820            if !self.placed_container_in_current_space(c) {
5821                continue;
5822            }
5823            let dist = distance(px, py, c.x, c.y);
5824            if dist > CONTAINER_RANGE_M {
5825                continue;
5826            }
5827            let on_top = dist <= ON_TOP_RADIUS_M;
5828            let name = self.placed_container_public_label(c);
5829            let lock = if c.locked { " [locked]" } else { "" };
5830            let prefix = if on_top { "On" } else { "Near" };
5831            nearby.push((
5832                dist,
5833                ContextLine {
5834                    on_top,
5835                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5836                },
5837            ));
5838        }
5839
5840        for npc in &self.npcs {
5841            let dist = distance(px, py, npc.x, npc.y);
5842            if dist > NEARBY_SCAN_M {
5843                continue;
5844            }
5845            let on_top = dist <= ON_TOP_RADIUS_M;
5846            let prefix = if on_top { "On" } else { "Near" };
5847            nearby.push((
5848                dist,
5849                ContextLine {
5850                    on_top,
5851                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5852                },
5853            ));
5854        }
5855
5856        for door in &self.doors {
5857            let dist = distance(px, py, door.x, door.y);
5858            if dist > DOOR_INTERACTION_RADIUS_M {
5859                continue;
5860            }
5861            let building = self
5862                .buildings
5863                .iter()
5864                .find(|b| b.id == door.building_id)
5865                .map(|b| b.label.as_str())
5866                .unwrap_or(door.building_id.as_str());
5867            let player_house = self
5868                .buildings
5869                .iter()
5870                .find(|b| b.id == door.building_id)
5871                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5872            let action = if inside.is_some() && door.portal.is_some() {
5873                if player_house {
5874                    if door.locked {
5875                        "locked — l unlock · Enter exit".to_string()
5876                    } else if door.open {
5877                        "close · Enter exit · l lock".to_string()
5878                    } else {
5879                        "open · Enter exit · l lock".to_string()
5880                    }
5881                } else {
5882                    "exit".to_string()
5883                }
5884            } else if player_house {
5885                if door.locked {
5886                    "locked — l unlock".to_string()
5887                } else if door.open {
5888                    "close · Enter go inside · l lock".to_string()
5889                } else {
5890                    "open · l lock".to_string()
5891                }
5892            } else {
5893                "enter".to_string()
5894            };
5895            nearby.push((
5896                dist,
5897                ContextLine {
5898                    on_top: dist <= ON_TOP_RADIUS_M,
5899                    text: format!("{building} door ({dist:.1}m) — f {action}"),
5900                },
5901            ));
5902        }
5903
5904        if inside.is_none() {
5905            for inter in &self.interactables {
5906                if inter.kind != "quest_board" {
5907                    continue;
5908                }
5909                let dist = distance(px, py, inter.x, inter.y);
5910                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5911                    continue;
5912                }
5913                let on_top = dist <= ON_TOP_RADIUS_M;
5914                let prefix = if on_top { "On" } else { "Near" };
5915                let label = if inter.label.is_empty() {
5916                    "Quest board".to_string()
5917                } else {
5918                    inter.label.clone()
5919                };
5920                nearby.push((
5921                    dist,
5922                    ContextLine {
5923                        on_top,
5924                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5925                    },
5926                ));
5927            }
5928        }
5929
5930        if self.in_shallow_water() {
5931            let already = self
5932                .terrain_at(px, py)
5933                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5934            if !already {
5935                nearby.push((
5936                    0.0,
5937                    ContextLine {
5938                        on_top: true,
5939                        text: "Shallow water — f fill bottle".into(),
5940                    },
5941                ));
5942            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5943                line.text.push_str(" — f fill bottle");
5944            }
5945        }
5946
5947        if self.claim_mode.is_some() {
5948            nearby.push((
5949                0.0,
5950                ContextLine {
5951                    on_top: true,
5952                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5953                        .into(),
5954                },
5955            ));
5956        } else if let Some(plot) = self.my_plot_under_player() {
5957            let name = plot_public_label(plot);
5958            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5959                format!("{name} — f again to sell to crown")
5960            } else {
5961                format!(
5962                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
5963                )
5964            };
5965            nearby.push((
5966                0.0,
5967                ContextLine {
5968                    on_top: true,
5969                    text: prompt,
5970                },
5971            ));
5972        } else if let Some(plot) = self.farmable_plot_under_player() {
5973            let name = plot_public_label(plot);
5974            let disc = if plot.farm_public {
5975                plot.public_tax_discount_bps / 100
5976            } else {
5977                plot.farm_allow
5978                    .iter()
5979                    .find(|g| Some(g.character_id) == self.character_id)
5980                    .map(|g| g.tax_discount_bps / 100)
5981                    .unwrap_or(0)
5982            };
5983            nearby.push((
5984                0.0,
5985                ContextLine {
5986                    on_top: true,
5987                    text: format!(
5988                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
5989                    ),
5990                },
5991            ));
5992        } else if let Some(zone) = self.free_property_zone_under_player() {
5993            let label = zone
5994                .label
5995                .as_deref()
5996                .filter(|s| !s.trim().is_empty())
5997                .unwrap_or(zone.id.as_str());
5998            nearby.push((
5999                0.0,
6000                ContextLine {
6001                    on_top: true,
6002                    text: format!("Claimable land: {label} — k buy plot"),
6003                },
6004            ));
6005        }
6006
6007        for entity in &self.entities {
6008            if entity.id == self.entity_id {
6009                continue;
6010            }
6011            let dist = distance(
6012                px,
6013                py,
6014                entity.transform.position.x,
6015                entity.transform.position.y,
6016            );
6017            if dist > NEARBY_SCAN_M {
6018                continue;
6019            }
6020            let label = if entity.label.is_empty() {
6021                format!("entity {}", entity.id)
6022            } else {
6023                entity.label.clone()
6024            };
6025            nearby.push((
6026                dist,
6027                ContextLine {
6028                    on_top: dist <= ON_TOP_RADIUS_M,
6029                    text: format!("Near: {label} ({dist:.1}m)"),
6030                },
6031            ));
6032        }
6033
6034        nearby.sort_by(|a, b| {
6035            a.0.partial_cmp(&b.0)
6036                .unwrap_or(std::cmp::Ordering::Equal)
6037                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6038        });
6039        lines.extend(nearby.into_iter().map(|(_, l)| l));
6040
6041        if lines.is_empty() {
6042            lines.push(ContextLine {
6043                on_top: false,
6044                text: "(nothing notable nearby)".into(),
6045            });
6046        }
6047
6048        lines
6049    }
6050}
6051
6052/// HUD line for the location / nearby panel.
6053#[derive(Debug, Clone)]
6054pub struct ContextLine {
6055    pub on_top: bool,
6056    pub text: String,
6057}
6058
6059const ON_TOP_RADIUS_M: f32 = 0.65;
6060const NEARBY_SCAN_M: f32 = 5.0;
6061
6062/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
6063pub fn resource_node_near_display_label(label: &str) -> String {
6064    label
6065        .strip_suffix(" (growing)")
6066        .unwrap_or(label)
6067        .to_string()
6068}
6069
6070fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6071    let t = label.trim();
6072    if t.is_empty() || t == id {
6073        return true;
6074    }
6075    let lower = t.to_ascii_lowercase();
6076    if lower.contains("_copy") {
6077        return true;
6078    }
6079    false
6080}
6081
6082fn humanize_item_template_label(template: &str) -> String {
6083    let base = template.rsplit('/').next().unwrap_or(template).trim();
6084    if base.is_empty() {
6085        return "Resource".into();
6086    }
6087    let stripped = base
6088        .strip_prefix("crop-")
6089        .or_else(|| base.strip_prefix("crop_"))
6090        .unwrap_or(base);
6091    stripped
6092        .split(|c: char| c == '-' || c == '_')
6093        .filter(|p| !p.is_empty())
6094        .map(|p| {
6095            let mut chars = p.chars();
6096            match chars.next() {
6097                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6098                None => String::new(),
6099            }
6100        })
6101        .collect::<Vec<_>>()
6102        .join(" ")
6103}
6104
6105/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
6106pub fn resource_node_id_suffix(id: &str) -> String {
6107    let chars: Vec<char> = id
6108        .chars()
6109        .rev()
6110        .filter(|c| c.is_ascii_alphanumeric())
6111        .take(4)
6112        .collect();
6113    chars.into_iter().rev().collect()
6114}
6115
6116/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
6117pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6118    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6119}
6120
6121pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6122    let cleaned = resource_node_near_display_label(label);
6123    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6124        cleaned
6125    } else if !item_template.trim().is_empty() {
6126        humanize_item_template_label(item_template)
6127    } else {
6128        id.to_string()
6129    };
6130    let suffix = resource_node_id_suffix(id);
6131    if suffix.is_empty() {
6132        friendly
6133    } else {
6134        format!("{friendly} ({suffix})")
6135    }
6136}
6137
6138/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
6139pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6140    use flatland_protocol::ResourceNodeState;
6141    if node.harvest_off {
6142        return " (decorative)".to_string();
6143    }
6144    if let Some(p) = node.growth_progress {
6145        if p < 1.0 - f32::EPSILON {
6146            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6147            return format!(" (growing, {pct}%)");
6148        }
6149        return " — f harvest".to_string();
6150    }
6151    match node.state {
6152        ResourceNodeState::Available => " — f harvest".to_string(),
6153        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6154        ResourceNodeState::Cooldown => " (depleted)".to_string(),
6155    }
6156}
6157
6158fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6159    use flatland_protocol::TerrainKindView;
6160    match kind {
6161        TerrainKindView::Grass => "Grass",
6162        TerrainKindView::Dirt => "Dirt",
6163        TerrainKindView::Tilled => "Tilled",
6164        TerrainKindView::Desert => "Desert",
6165        TerrainKindView::Hill => "Hills",
6166        TerrainKindView::Bog => "Bog",
6167        TerrainKindView::Beach => "Beach",
6168        TerrainKindView::ShallowWater => "Shallow water",
6169        TerrainKindView::DeepWater => "Deep water",
6170        TerrainKindView::Trail => "Trail",
6171        TerrainKindView::Road => "Road",
6172        TerrainKindView::Rock => "Rock",
6173    }
6174}
6175
6176fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6177    crate::world_zones::zone_rects_contain(rects, x, y)
6178}
6179
6180fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6181    zone.rects
6182        .iter()
6183        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6184        .sum()
6185}
6186
6187fn claim_rect_fully_inside_zone(
6188    zone: &flatland_protocol::PropertyZoneView,
6189    x0: f32,
6190    y0: f32,
6191    x1: f32,
6192    y1: f32,
6193) -> bool {
6194    let mut y = y0 + 0.5;
6195    while y < y1 {
6196        let mut x = x0 + 0.5;
6197        while x < x1 {
6198            if !zone_rects_contain(&zone.rects, x, y) {
6199                return false;
6200            }
6201            x += 1.0;
6202        }
6203        y += 1.0;
6204    }
6205    true
6206}
6207
6208fn rects_overlap_half_open(
6209    ax0: f32,
6210    ay0: f32,
6211    ax1: f32,
6212    ay1: f32,
6213    bx0: f32,
6214    by0: f32,
6215    bx1: f32,
6216    by1: f32,
6217) -> bool {
6218    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6219}
6220
6221fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6222    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6223}
6224
6225fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6226    plot_public_label(p)
6227}
6228
6229/// Canonical plot name: `{owner} — {zone} — {label}`.
6230pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6231    let zone = p
6232        .zone_label
6233        .as_deref()
6234        .filter(|s| !s.trim().is_empty())
6235        .unwrap_or_else(|| {
6236            if p.property_zone_id.is_empty() {
6237                "Homestead"
6238            } else {
6239                p.property_zone_id.as_str()
6240            }
6241        });
6242    let label = if p.label.trim().is_empty() {
6243        if p.plot_code.trim().is_empty() {
6244            p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6245        } else {
6246            p.plot_code.clone()
6247        }
6248    } else {
6249        p.label.clone()
6250    };
6251    match p.owner_label.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
6252        Some(owner) => format!("{owner} — {zone} — {label}"),
6253        None => format!("{zone} — {label}"),
6254    }
6255}
6256
6257/// Match `flatland_sim::economy_zones::snap_claim_rect`.
6258fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6259    let a = x0.min(x1).floor();
6260    let b = y0.min(y1).floor();
6261    let mut c = x0.max(x1).ceil();
6262    let mut d = y0.max(y1).ceil();
6263    if (c - a) < 1.0 {
6264        c = a + 1.0;
6265    }
6266    if (d - b) < 1.0 {
6267        d = b + 1.0;
6268    }
6269    (a, b, c, d)
6270}
6271
6272fn humanize_template_id(template_id: &str) -> String {
6273    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
6274    if looks_like_template_uuid(template_id) {
6275        return "Unknown item".into();
6276    }
6277    template_id
6278        .split('_')
6279        .map(|word| {
6280            let mut chars = word.chars();
6281            match chars.next() {
6282                None => String::new(),
6283                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6284            }
6285        })
6286        .collect::<Vec<_>>()
6287        .join(" ")
6288}
6289
6290fn looks_like_template_uuid(template_id: &str) -> bool {
6291    let bytes = template_id.as_bytes();
6292    if bytes.len() != 36 {
6293        return false;
6294    }
6295    let is_hex = |b: u8| b.is_ascii_hexdigit();
6296    let groups = [8usize, 4, 4, 4, 12];
6297    let mut i = 0;
6298    for (gi, &len) in groups.iter().enumerate() {
6299        if gi > 0 {
6300            if bytes.get(i) != Some(&b'-') {
6301                return false;
6302            }
6303            i += 1;
6304        }
6305        for _ in 0..len {
6306            if !bytes.get(i).copied().is_some_and(is_hex) {
6307                return false;
6308            }
6309            i += 1;
6310        }
6311    }
6312    true
6313}
6314
6315/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
6316const HARVEST_RANGE_M: f32 = 1.5;
6317
6318pub struct GameClient<S: PlayConnection> {
6319    session: S,
6320    seq: Seq,
6321    pub state: GameState,
6322    last_move_forward: f32,
6323    last_move_strafe: f32,
6324}
6325
6326impl<S: PlayConnection> GameClient<S> {
6327    pub fn new(session: S) -> Self {
6328        let session_id = session.session_id();
6329        let entity_id = session.entity_id();
6330        let mut client = Self {
6331            session,
6332            seq: 0,
6333            last_move_forward: 0.0,
6334            last_move_strafe: 0.0,
6335            state: GameState {
6336                session_id,
6337                entity_id,
6338                character_id: None,
6339                tick: 0,
6340                chunk_rev: 0,
6341                content_rev: 0,
6342                publish_rev: 0,
6343                entities: Vec::new(),
6344                player: None,
6345                resource_nodes: Vec::new(),
6346                ground_drops: Vec::new(),
6347                placed_containers: Vec::new(),
6348                buildings: Vec::new(),
6349                doors: Vec::new(),
6350                interior_map: None,
6351                npcs: Vec::new(),
6352                blueprints: Vec::new(),
6353                building_materials: Vec::new(),
6354                world_x0: 0.0,
6355                world_y0: 0.0,
6356                world_width_m: 0.0,
6357                world_height_m: 0.0,
6358                terrain_zones: Vec::new(),
6359                z_platforms: Vec::new(),
6360                z_transitions: Vec::new(),
6361                z_bands_outdoor_backup: None,
6362                world_clock: flatland_protocol::WorldClock::default(),
6363                inventory: std::collections::HashMap::new(),
6364                inventory_hints: std::collections::HashMap::new(),
6365                logs: VecDeque::new(),
6366                intents_sent: 0,
6367                ticks_received: 0,
6368                connected: false,
6369                disconnect_reason: None,
6370                show_stats: false,
6371            hud_log_hidden: false,
6372                show_equip_menu: false,
6373                equip_menu_index: 0,
6374                show_craft_menu: false,
6375                show_plot_build_menu: false,
6376                plot_build_focus_wall: true,
6377                plot_build_wall_index: 0,
6378                plot_build_roof_index: 0,
6379                craft_menu_index: 0,
6380                craft_batch_quantity: 1,
6381                show_shop_menu: false,
6382                shop_catalog: None,
6383                bank_panel: None,
6384                bank_menu_index: 0,
6385                bank_ui_mode: BankUiMode::Menu,
6386                storage_panel: None,
6387                market_panel: None,
6388                market_menu_index: 0,
6389                market_filter: String::new(),
6390                market_filter_focused: false,
6391                market_category_filter: None,
6392                market_buy_confirm: None,
6393                market_ui_mode: MarketUiMode::Browse,
6394                storage_menu_index: 0,
6395                storage_ui_mode: StorageUiMode::Menu,
6396                shop_tab: ShopTab::default(),
6397                shop_menu_index: 0,
6398                shop_quantity: 1,
6399                shop_trade_log: VecDeque::new(),
6400                show_npc_verb_menu: false,
6401                npc_verb_target: None,
6402                npc_verb_index: 0,
6403                player_verbs: crate::social::PlayerVerbState::default(),
6404                social_chat: crate::social::SocialChatState::default(),
6405                trade_ui: crate::social::TradeUiState::default(),
6406                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6407                show_npc_chat: false,
6408                npc_chat: None,
6409                show_inventory_menu: false,
6410                inventory_menu_index: 0,
6411                inventory_tab: InventoryTab::OnPerson,
6412                inventory_filter: String::new(),
6413                inventory_filter_focused: false,
6414                show_move_picker: false,
6415                show_rename_prompt: false,
6416                rename_plot_id: None,
6417                highlighted_plot_id: None,
6418                show_worker_rename: false,
6419                rename_buffer: String::new(),
6420                move_picker_index: 0,
6421                move_picker: None,
6422                show_grant_picker: false,
6423                grant_picker_index: 0,
6424                grant_picker: None,
6425                show_destroy_picker: false,
6426                destroy_confirm_pending: false,
6427                destroy_picker: None,
6428                combat_target: None,
6429                combat_target_label: None,
6430                ground_target: None,
6431                combat_fx: Vec::new(),
6432                property_zones: Vec::new(),
6433                tax_zones: Vec::new(),
6434                growth_zones: Vec::new(),
6435                biome_zones: Vec::new(),
6436                terrain_kind_nav: Vec::new(),
6437                property_plots: Vec::new(),
6438                property_plot_settings: None,
6439                claim_mode: None,
6440                relocate_mode: None,
6441                sell_plot_confirm: None,
6442                sell_plot_armed_at: None,
6443                show_plant_menu: false,
6444                plant_menu_index: 0,
6445                show_farm_access: false,
6446                farm_access_name_draft: String::new(),
6447                farm_access_discount_bps: 0,
6448                farm_access_index: 0,
6449                plant_quantity: 1,
6450                in_combat: false,
6451                auto_attack: true,
6452                combat_has_los: false,
6453                attack_cd_ticks: 0,
6454                gcd_ticks: 0,
6455                weapon_ability_id: "unarmed".into(),
6456                mainhand_template_id: None,
6457                mainhand_label: None,
6458                mainhand_instance_id: None,
6459                offhand_template_id: None,
6460                offhand_label: None,
6461                offhand_instance_id: None,
6462                mainhand_hand_slots: 1,
6463                defense: None,
6464                worn: BTreeMap::new(),
6465                carry_mass: 0.0,
6466                carry_mass_max: 0.0,
6467                encumbrance: flatland_protocol::EncumbranceState::Light,
6468                inventory_stacks: Vec::new(),
6469                keychain_stacks: Vec::new(),
6470            whisper_pouch_stacks: Vec::new(),
6471                combat_target_detail: None,
6472                statuses: Vec::new(),
6473                cast_progress: None,
6474                timed_channel: None,
6475                plot_build_offer: None,
6476                ability_cooldowns: Vec::new(),
6477                blocking_active: false,
6478                max_target_slots: 1,
6479                combat_slots: Vec::new(),
6480                rotation_presets: Vec::new(),
6481                known_abilities: Vec::new(),
6482                ability_meta: std::collections::HashMap::new(),
6483                ability_mastery: std::collections::HashMap::new(),
6484                hotbar: vec![None; 9],
6485                max_abilities_per_rotation: 0,
6486                show_loadout_menu: false,
6487                show_keychain_menu: false,
6488                keychain_menu_index: 0,
6489                show_rotation_editor: false,
6490                loadout_menu_index: 0,
6491                loadout_hotbar_slot: 1,
6492                loadout_ability_index: 0,
6493                loadout_focus_presets: false,
6494                rotation_editor: RotationEditorState::default(),
6495                harvest_in_progress: false,
6496                harvest_started_at: None,
6497                pending_craft_ack: None,
6498                pending_worker_job_ack: None,
6499                attending_worker_instance_id: None,
6500                quest_log: Vec::new(),
6501                interactables: Vec::new(),
6502                ledger: None,
6503                career: None,
6504                character_sheet_tab: CharacterSheetTab::Character,
6505                ledger_period: LedgerPeriod::Day,
6506                show_quest_offer: false,
6507                pending_quest_offer: None,
6508                show_quest_menu: false,
6509                quest_menu_index: 0,
6510                quest_withdraw_confirm: false,
6511                hired_workers: Vec::new(),
6512                show_workers_menu: false,
6513                workers_menu_index: 0,
6514                workers_menu_compact: false,
6515                worker_step_display: BTreeMap::new(),
6516                worker_error_display: BTreeMap::new(),
6517                show_worker_give_picker: false,
6518                worker_give_picker_index: 0,
6519                worker_give_picker: None,
6520                show_worker_give_target_picker: false,
6521                worker_give_target_picker_index: 0,
6522                worker_give_target_picker: None,
6523                show_worker_take_picker: false,
6524                worker_take_picker_index: 0,
6525                worker_take_picker: None,
6526                show_worker_teach_picker: false,
6527                worker_teach_picker_index: 0,
6528                worker_teach_picker: None,
6529                worker_route_editor: None,
6530                progression_curve: None,
6531            },
6532        };
6533        client.state.apply_client_ui_prefs();
6534        client
6535    }
6536
6537    pub fn entity_id(&self) -> EntityId {
6538        self.state.entity_id
6539    }
6540
6541    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6542        if self.state.connected {
6543            return Ok(());
6544        }
6545
6546        loop {
6547            match self.session.next_event().await {
6548                Some(SessionEvent::Welcome {
6549                    session_id,
6550                    entity_id,
6551                    snapshot,
6552                }) => {
6553                    self.state
6554                        .restore_from_welcome(session_id, entity_id, &snapshot);
6555                    self.state.apply_client_ui_prefs();
6556                    self.state.push_log(format!(
6557                        "Connected — session {session_id}, entity {entity_id}"
6558                    ));
6559                    return Ok(());
6560                }
6561                Some(SessionEvent::Disconnected { .. }) => {
6562                    anyhow::bail!("disconnected before welcome");
6563                }
6564                Some(_) => continue,
6565                None => anyhow::bail!("session closed before welcome"),
6566            }
6567        }
6568    }
6569
6570    /// Drain all pending server events (non-blocking).
6571    pub fn drain_events(&mut self) {
6572        while let Some(event) = self.session.try_next_event() {
6573            if self.handle_event_sync(event).is_err() {
6574                break;
6575            }
6576        }
6577    }
6578
6579    /// Wait for the next server event.
6580    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6581        self.session.next_event().await
6582    }
6583
6584    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6585        self.handle_event_sync(event)
6586    }
6587
6588    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6589        match event {
6590            SessionEvent::Welcome {
6591                session_id,
6592                entity_id,
6593                snapshot,
6594            } => {
6595                let resumed = self.state.connected;
6596                self.state
6597                    .restore_from_welcome(session_id, entity_id, &snapshot);
6598                if resumed {
6599                    self.state.push_log(format!(
6600                        "Session restored — session {session_id}, entity {entity_id}"
6601                    ));
6602                }
6603            }
6604            SessionEvent::ContentUpdated { snapshot } => {
6605                self.state
6606                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6607                self.state.push_log(format!(
6608                    "World updated (content rev {})",
6609                    snapshot.content_rev
6610                ));
6611            }
6612            SessionEvent::Tick(delta) => {
6613                self.state.apply_tick_fields(&delta, self.state.entity_id);
6614                self.state.ticks_received += 1;
6615            }
6616            SessionEvent::IntentAck {
6617                entity_id,
6618                seq,
6619                tick,
6620            } => {
6621                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6622                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6623                    if *craft_seq == seq {
6624                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6625                        if batches > 1 {
6626                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6627                        } else {
6628                            self.state.push_log(format!("Crafting {label}…"));
6629                        }
6630                    }
6631                }
6632                if self
6633                    .state
6634                    .pending_worker_job_ack
6635                    .as_ref()
6636                    .is_some_and(|p| p.seq == seq)
6637                {
6638                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6639                    if pending.idle {
6640                        self.state.push_log(format!(
6641                            "Route cleared for {} — worker idle",
6642                            pending.worker_label
6643                        ));
6644                    } else {
6645                        self.state.push_log(format!(
6646                            "Route saved for {} — {} stop(s), job loop active",
6647                            pending.worker_label, pending.stop_count
6648                        ));
6649                    }
6650                    if self
6651                        .state
6652                        .worker_route_editor
6653                        .as_ref()
6654                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6655                    {
6656                        self.close_worker_route_editor();
6657                    }
6658                }
6659            }
6660            SessionEvent::Chat(msg) => {
6661                let label = match msg.channel {
6662                    flatland_protocol::ChatChannel::Nearby => "nearby",
6663                    flatland_protocol::ChatChannel::Direct => "speak",
6664                    flatland_protocol::ChatChannel::Whisper => "whisper",
6665                    flatland_protocol::ChatChannel::WhisperStone => "stone",
6666                };
6667                let clarity = match msg.clarity {
6668                    flatland_protocol::ChatClarity::Clear => "",
6669                    flatland_protocol::ChatClarity::Partial => "~",
6670                    flatland_protocol::ChatClarity::Heavy => "…",
6671                };
6672                self.state.push_log(format!(
6673                    "[{label}{clarity}] {}: {}",
6674                    msg.from_name, msg.text
6675                ));
6676                let now_ms = std::time::SystemTime::now()
6677                    .duration_since(std::time::UNIX_EPOCH)
6678                    .map(|d| d.as_millis() as u64)
6679                    .unwrap_or(0);
6680                self.state
6681                    .social_chat
6682                    .note_speech(&msg, self.state.entity_id, now_ms);
6683                self.state
6684                    .social_chat
6685                    .push(crate::social::ChatLogEntry::from_message(
6686                        msg,
6687                        self.state.entity_id,
6688                    ));
6689            }
6690            SessionEvent::TradeOpened(panel) => {
6691                self.state.social_chat.pending_trade = None;
6692                let peer = panel.peer_name.clone();
6693                self.state.trade_ui.open(panel);
6694                self.state
6695                    .social_chat
6696                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6697                self.state
6698                    .social_chat
6699                    .push_cue(crate::social::AudioCue::TradeOpened);
6700            }
6701            SessionEvent::TradeClosed { reason } => {
6702                self.state.push_log(reason.clone());
6703                self.state.social_chat.push_system(reason);
6704                self.state.trade_ui.close();
6705            }
6706            SessionEvent::HarvestResult(result) => {
6707                self.state.clear_harvest_state();
6708                crate::harvest_trace!(
6709                    entity_id = self.state.entity_id,
6710                    node_id = %result.node_id,
6711                    template = %result.item_template,
6712                    quantity = result.quantity,
6713                    client_tick = self.state.tick,
6714                    "client applied harvest result"
6715                );
6716                let msg = if result.quantity == 0 {
6717                    format!(
6718                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6719                        result.item_template
6720                    )
6721                } else {
6722                    format!(
6723                        "Harvested {} x{} (on the ground — press P to pick up)",
6724                        result.item_template, result.quantity
6725                    )
6726                };
6727                self.state.push_log(msg);
6728            }
6729            SessionEvent::CraftResult(result) => {
6730                for stack in &result.consumed {
6731                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6732                        *qty = qty.saturating_sub(stack.quantity);
6733                        if *qty == 0 {
6734                            self.state.inventory.remove(&stack.template_id);
6735                        }
6736                    }
6737                }
6738                for stack in &result.outputs {
6739                    *self
6740                        .state
6741                        .inventory
6742                        .entry(stack.template_id.clone())
6743                        .or_insert(0) += stack.quantity;
6744                }
6745                if let Some(output) = result.outputs.first() {
6746                    if result.batch_total > 1 {
6747                        self.state.push_log(format!(
6748                            "Crafted {} x{} ({}/{})",
6749                            output.template_id,
6750                            output.quantity,
6751                            result.batch_index,
6752                            result.batch_total
6753                        ));
6754                    } else {
6755                        self.state.push_log(format!(
6756                            "Crafted {} x{}",
6757                            output.template_id, output.quantity
6758                        ));
6759                    }
6760                } else {
6761                    self.state
6762                        .push_log(format!("Craft finished: {}", result.blueprint_id));
6763                }
6764            }
6765            SessionEvent::Death(notice) => {
6766                self.state.clear_harvest_state();
6767                self.state.push_log(notice.message.clone());
6768                self.state.push_log(format!(
6769                    "Respawned at ({:.1}, {:.1})",
6770                    notice.respawn_x, notice.respawn_y
6771                ));
6772            }
6773            SessionEvent::Interaction(notice) => {
6774                if notice.message.starts_with("Harvest failed:") {
6775                    self.state.clear_harvest_state();
6776                }
6777                if notice.message.starts_with("Can't do that:") {
6778                    self.state.pending_craft_ack = None;
6779                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
6780                        if let Some(w) = self
6781                            .state
6782                            .hired_workers
6783                            .iter_mut()
6784                            .find(|w| w.instance_id == pending.worker_instance_id)
6785                        {
6786                            w.route = pending.prev_route;
6787                            w.mode = pending.prev_mode;
6788                            w.step_label = pending.prev_step_label;
6789                            w.last_error = pending.prev_last_error;
6790                        }
6791                        let reason = notice
6792                            .message
6793                            .strip_prefix("Can't do that:")
6794                            .unwrap_or(&notice.message)
6795                            .trim();
6796                        self.state.push_log(format!(
6797                            "Route save failed for {}: {reason}",
6798                            pending.worker_label
6799                        ));
6800                    }
6801                    let reason = notice
6802                        .message
6803                        .strip_prefix("Can't do that:")
6804                        .unwrap_or(&notice.message)
6805                        .trim();
6806                    if reason.contains("already tilled") {
6807                        if let Some(plot) = self.state.my_plot_under_player() {
6808                            self.state.sell_plot_confirm = Some(plot.plot_id);
6809                            self.state.sell_plot_armed_at = Some(Instant::now());
6810                        }
6811                    }
6812                }
6813                if notice.message.starts_with("Cast failed:") {
6814                    self.state.cast_progress = None;
6815                }
6816                if notice.message.contains("slain the") {
6817                    self.state.combat_target = None;
6818                    self.state.combat_target_label = None;
6819                }
6820                // Inbound trade request → inline Y/N in the CHAT column (no popup).
6821                if notice.message.contains("wants to trade") {
6822                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6823                        let from_name = notice
6824                            .message
6825                            .split(" wants to trade")
6826                            .next()
6827                            .unwrap_or("Player")
6828                            .to_string();
6829                        self.state.social_chat.pending_trade =
6830                            Some(crate::social::PendingTradeRequest {
6831                                from_entity,
6832                                from_name: from_name.clone(),
6833                            });
6834                        self.state.social_chat.push_system(format!(
6835                            "{from_name} wants to trade — [Y] accept · [N] decline"
6836                        ));
6837                        self.state
6838                            .social_chat
6839                            .push_cue(crate::social::AudioCue::TradeOffer);
6840                    }
6841                }
6842                if notice.message.starts_with("trade request declined") {
6843                    self.state
6844                        .social_chat
6845                        .push_system(notice.message.clone());
6846                    self.state
6847                        .social_chat
6848                        .push_cue(crate::social::AudioCue::TradeDeclined);
6849                }
6850                self.state.apply_interaction_notice(&notice);
6851                self.state.push_log(notice.message.clone());
6852            }
6853            SessionEvent::ShopOpened(catalog) => {
6854                self.state.apply_shop_catalog(catalog);
6855            }
6856            SessionEvent::BankOpened(panel) => {
6857                self.state.apply_bank_panel(panel);
6858            }
6859            SessionEvent::StorageOpened(panel) => {
6860                self.state.apply_storage_panel(panel);
6861            }
6862            SessionEvent::MarketOpened(panel) => {
6863                self.state.apply_market_panel(panel);
6864            }
6865            SessionEvent::NpcTalkOpened(opened) => {
6866                self.state.show_npc_verb_menu = false;
6867                if self.state.npc_verb_target.is_none() {
6868                    self.state.npc_verb_target = Some(opened.npc_id.clone());
6869                }
6870                let label = opened.npc_label.clone();
6871                let banner = if !opened.trade_allowed {
6872                    Some("Trade is unavailable right now.".to_string())
6873                } else {
6874                    None
6875                };
6876                self.state.show_npc_chat = true;
6877                self.state.npc_chat = Some(NpcChatState {
6878                    npc_id: opened.npc_id,
6879                    npc_label: opened.npc_label,
6880                    lines: if opened.greeting.is_empty() {
6881                        vec![]
6882                    } else {
6883                        vec![format!("{label}: {}", opened.greeting)]
6884                    },
6885                    input: String::new(),
6886                    pending: opened.greeting.is_empty(),
6887                    talk_depth: opened.talk_depth,
6888                    trade_allowed: opened.trade_allowed,
6889                    banner,
6890                });
6891            }
6892            SessionEvent::NpcTalkPending(_) => {
6893                if let Some(chat) = self.state.npc_chat.as_mut() {
6894                    chat.pending = true;
6895                }
6896            }
6897            SessionEvent::NpcTalkReply(reply) => {
6898                if let Some(chat) = self.state.npc_chat.as_mut() {
6899                    if chat.npc_id == reply.npc_id {
6900                        chat.pending = false;
6901                        if reply.trade_disabled {
6902                            chat.trade_allowed = false;
6903                            chat.banner = Some("Trade is unavailable right now.".to_string());
6904                        }
6905                        if reply.wind_down {
6906                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6907                            if chat.banner.is_none() {
6908                                chat.banner =
6909                                    Some("They're wrapping up — keep it brief.".to_string());
6910                            }
6911                        }
6912                        chat.lines
6913                            .push(format!("{}: {}", chat.npc_label, reply.line));
6914                    }
6915                }
6916            }
6917            SessionEvent::NpcTalkClosed(closed) => {
6918                if self
6919                    .state
6920                    .npc_chat
6921                    .as_ref()
6922                    .is_some_and(|c| c.npc_id == closed.npc_id)
6923                {
6924                    self.state.show_npc_chat = false;
6925                    self.state.npc_chat = None;
6926                }
6927            }
6928            SessionEvent::NpcTalkError(err) => {
6929                self.state.push_log(format!("Talk failed: {}", err.reason));
6930                if let Some(chat) = self.state.npc_chat.as_mut() {
6931                    chat.pending = false;
6932                }
6933            }
6934            SessionEvent::UseResult(result) => {
6935                // Inventory count hint only — the Interaction notice already logs
6936                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
6937                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6938                    *qty = qty.saturating_sub(1);
6939                    if *qty == 0 {
6940                        self.state.inventory.remove(&result.template_id);
6941                    }
6942                }
6943            }
6944            SessionEvent::QuestOffer(offer) => {
6945                self.state.pending_quest_offer = Some(offer.clone());
6946                self.state.show_quest_offer = true;
6947                self.state
6948                    .push_log(format!("Quest offered: {}", offer.title));
6949            }
6950            SessionEvent::QuestAccepted(notice) => {
6951                self.state.show_quest_offer = false;
6952                self.state.pending_quest_offer = None;
6953                self.state.push_log(notice.message);
6954            }
6955            SessionEvent::QuestWithdrawn(notice) => {
6956                self.state.show_quest_menu = false;
6957                self.state.quest_withdraw_confirm = false;
6958                self.state.push_log(notice.message);
6959            }
6960            SessionEvent::QuestStepCompleted(notice) => {
6961                self.state.push_log(notice.message);
6962            }
6963            SessionEvent::QuestCompleted(notice) => {
6964                self.state.push_log(notice.message);
6965            }
6966            SessionEvent::Disconnected { reason } => {
6967                self.state.clear_harvest_state();
6968                self.state.connected = false;
6969                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6970                if let Some(r) = &self.state.disconnect_reason {
6971                    self.state.push_log(format!("Disconnected: {r}"));
6972                } else {
6973                    self.state.push_log("Disconnected from server");
6974                }
6975            }
6976        }
6977        Ok(())
6978    }
6979
6980    pub fn is_connected(&self) -> bool {
6981        self.state.connected
6982    }
6983
6984    pub fn close_overlays(&mut self) {
6985        self.state.show_stats = false;
6986        self.state.show_craft_menu = false;
6987        self.state.show_plot_build_menu = false;
6988        self.state.show_shop_menu = false;
6989        self.state.shop_catalog = None;
6990        self.state.show_npc_verb_menu = false;
6991        self.state.npc_verb_target = None;
6992        self.state.show_npc_chat = false;
6993        self.state.npc_chat = None;
6994        self.state.show_inventory_menu = false;
6995        self.state.show_loadout_menu = false;
6996        self.state.show_rotation_editor = false;
6997        self.state.rotation_editor.reset();
6998        self.state.show_rename_prompt = false;
6999        self.state.show_worker_rename = false;
7000        self.state.rename_buffer.clear();
7001        self.state.show_move_picker = false;
7002        self.state.move_picker = None;
7003        self.state.show_destroy_picker = false;
7004        self.state.destroy_confirm_pending = false;
7005        self.state.destroy_picker = None;
7006        self.state.show_quest_offer = false;
7007        self.state.pending_quest_offer = None;
7008        self.state.show_quest_menu = false;
7009        self.state.quest_withdraw_confirm = false;
7010        self.state.show_workers_menu = false;
7011        self.close_worker_give_picker();
7012        self.close_worker_give_target_picker();
7013        self.close_worker_take_picker();
7014        self.close_worker_teach_picker();
7015        self.state.worker_route_editor = None;
7016        self.state.claim_mode = None;
7017        self.state.relocate_mode = None;
7018        self.state.sell_plot_confirm = None;
7019        self.state.sell_plot_armed_at = None;
7020        self.close_farm_access_panel();
7021        if self.state.show_plant_menu {
7022            self.close_plant_menu();
7023        }
7024    }
7025
7026    /// Esc / back — pop one UI layer instead of closing every overlay at once.
7027    pub fn back_on_esc(&mut self) -> bool {
7028        if self.state.social_chat.composer_open() {
7029            self.state.social_chat.close_composer();
7030            return true;
7031        }
7032        if self.state.player_verbs.open {
7033            self.state.player_verbs.close();
7034            return true;
7035        }
7036        if self.state.whisper_pouch_ui.open {
7037            self.state.whisper_pouch_ui.open = false;
7038            return true;
7039        }
7040        if self.state.trade_ui.panel.is_some() {
7041            // async cancel — caller should prefer trade_cancel; close UI optimistically
7042            self.state.trade_ui.close();
7043            return true;
7044        }
7045        if self.state.show_rename_prompt {
7046            self.cancel_rename_prompt();
7047            return true;
7048        }
7049        if self.state.show_worker_rename {
7050            self.cancel_worker_rename();
7051            return true;
7052        }
7053        if self.state.show_destroy_picker {
7054            if self.state.destroy_confirm_pending {
7055                self.cancel_destroy_confirm();
7056            } else {
7057                self.close_destroy_picker();
7058            }
7059            return true;
7060        }
7061        if self.state.claim_mode.is_some() {
7062            self.cancel_claim_mode();
7063            return true;
7064        }
7065        if self.state.relocate_mode.is_some() {
7066            self.cancel_relocate_mode();
7067            return true;
7068        }
7069        if self.state.show_plant_menu {
7070            self.close_plant_menu();
7071            return true;
7072        }
7073        if self.state.show_farm_access {
7074            self.close_farm_access_panel();
7075            return true;
7076        }
7077        if self.state.sell_plot_confirm.is_some() {
7078            self.state.sell_plot_confirm = None;
7079            self.state.sell_plot_armed_at = None;
7080            self.state.push_log("Sell cancelled");
7081            return true;
7082        }
7083        if self.state.show_move_picker {
7084            self.close_move_picker();
7085            return true;
7086        }
7087        if self.state.show_rotation_editor {
7088            match self.state.rotation_editor.mode {
7089                RotationEditorMode::List => {
7090                    self.state.show_rotation_editor = false;
7091                    self.state.rotation_editor.reset();
7092                }
7093                RotationEditorMode::EditLabel => {
7094                    self.state.rotation_editor.label_buffer.clear();
7095                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7096                }
7097                RotationEditorMode::PickAbility => {
7098                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7099                }
7100                RotationEditorMode::EditSequence => {
7101                    self.state.rotation_editor.draft = None;
7102                    self.state.rotation_editor.mode = RotationEditorMode::List;
7103                }
7104            }
7105            return true;
7106        }
7107        if self.state.show_inventory_menu {
7108            self.close_inventory_menu();
7109            return true;
7110        }
7111        if self.state.show_craft_menu {
7112            self.close_craft_menu();
7113            return true;
7114        }
7115        if self.state.show_plot_build_menu {
7116            self.close_plot_build_menu();
7117            return true;
7118        }
7119        if self.state.show_keychain_menu {
7120            self.close_keychain_menu();
7121            return true;
7122        }
7123        if self.state.show_quest_offer {
7124            self.quest_offer_decline();
7125            return true;
7126        }
7127        if self.state.show_shop_menu {
7128            // Caller must await shop close (npc_interaction_back / play-loop).
7129            return false;
7130        }
7131        if self.state.bank_panel.is_some() {
7132            return false;
7133        }
7134        if self.state.storage_panel.is_some() {
7135            return false;
7136        }
7137        if self.state.market_panel.is_some() {
7138            return false;
7139        }
7140        if self.state.show_npc_chat {
7141            // play.rs calls npc_interaction_back().await on Esc
7142            return false;
7143        }
7144        if self.state.show_npc_verb_menu {
7145            self.state.show_npc_verb_menu = false;
7146            self.state.npc_verb_target = None;
7147            return true;
7148        }
7149        if self.state.show_quest_menu {
7150            if self.state.quest_withdraw_confirm {
7151                self.state.quest_withdraw_confirm = false;
7152            } else {
7153                self.state.show_quest_menu = false;
7154            }
7155            return true;
7156        }
7157        if self.state.worker_route_editor.is_some() {
7158            // Pop one sheet level; only close the editor at the root stop list.
7159            if self.re_at_root_sheet() {
7160                let reopen = self.state.attending_worker_instance_id.clone();
7161                self.close_worker_route_editor();
7162                if let Some(id) = reopen {
7163                    if let Some(idx) = self
7164                        .state
7165                        .hired_workers
7166                        .iter()
7167                        .position(|w| w.instance_id == id)
7168                    {
7169                        self.state.workers_menu_index = idx;
7170                        self.state.show_workers_menu = true;
7171                    }
7172                }
7173            } else {
7174                self.re_sheet_back();
7175            }
7176            return true;
7177        }
7178        if self.state.show_worker_give_picker {
7179            self.close_worker_give_picker();
7180            return true;
7181        }
7182        if self.state.show_worker_give_target_picker {
7183            self.close_worker_give_target_picker();
7184            return true;
7185        }
7186        if self.state.show_worker_take_picker {
7187            self.close_worker_take_picker();
7188            return true;
7189        }
7190        if self.state.show_worker_teach_picker {
7191            self.close_worker_teach_picker();
7192            return true;
7193        }
7194        if self.state.show_workers_menu {
7195            self.close_workers_menu_ui();
7196            return true;
7197        }
7198        if self.state.show_loadout_menu {
7199            self.state.show_loadout_menu = false;
7200            return true;
7201        }
7202        if self.state.show_stats {
7203            self.state.show_stats = false;
7204            return true;
7205        }
7206        if self.state.show_equip_menu {
7207            self.state.show_equip_menu = false;
7208            return true;
7209        }
7210        false
7211    }
7212
7213    pub fn toggle_stats(&mut self) {
7214        self.state.show_stats = !self.state.show_stats;
7215        if self.state.show_stats {
7216            self.state.character_sheet_tab = CharacterSheetTab::Character;
7217            self.state.show_craft_menu = false;
7218            self.state.show_shop_menu = false;
7219            self.state.shop_catalog = None;
7220            self.state.show_inventory_menu = false;
7221            self.state.show_equip_menu = false;
7222        }
7223    }
7224
7225    pub fn toggle_equip_menu(&mut self) {
7226        self.state.show_equip_menu = !self.state.show_equip_menu;
7227        if self.state.show_equip_menu {
7228            self.state.show_stats = false;
7229            self.state.show_craft_menu = false;
7230            self.state.show_shop_menu = false;
7231            self.state.shop_catalog = None;
7232            self.state.show_inventory_menu = false;
7233            self.state.show_loadout_menu = false;
7234        }
7235    }
7236
7237    pub fn cycle_character_sheet_tab(&mut self) {
7238        if self.state.show_stats {
7239            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7240        }
7241    }
7242
7243    pub fn set_ledger_period_digit(&mut self, c: char) {
7244        if self.state.show_stats {
7245            if let Some(p) = LedgerPeriod::from_digit(c) {
7246                self.state.ledger_period = p;
7247                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7248            }
7249        }
7250    }
7251
7252    pub fn cycle_ledger_period(&mut self) {
7253        if self.state.show_stats
7254            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
7255        {
7256            self.state.ledger_period = self.state.ledger_period.cycle();
7257        }
7258    }
7259
7260    pub fn open_inventory_menu(&mut self) {
7261        self.state.show_inventory_menu = true;
7262        self.state.show_craft_menu = false;
7263        self.state.show_shop_menu = false;
7264        self.state.shop_catalog = None;
7265        self.state.show_stats = false;
7266        self.state.show_move_picker = false;
7267        self.state.move_picker = None;
7268        self.state.show_destroy_picker = false;
7269        self.state.destroy_confirm_pending = false;
7270        self.state.destroy_picker = None;
7271        self.state.show_rename_prompt = false;
7272        self.state.rename_plot_id = None;
7273        self.state.rename_buffer.clear();
7274        self.state.inventory_filter_focused = false;
7275        self.state.clamp_inventory_indices();
7276    }
7277
7278    pub fn close_inventory_menu(&mut self) {
7279        self.state.show_inventory_menu = false;
7280        self.state.show_move_picker = false;
7281        self.state.move_picker = None;
7282        self.close_grant_picker();
7283        self.state.show_destroy_picker = false;
7284        self.state.destroy_confirm_pending = false;
7285        self.state.destroy_picker = None;
7286        self.state.show_rename_prompt = false;
7287        self.state.rename_plot_id = None;
7288        self.state.rename_buffer.clear();
7289        self.state.inventory_filter_focused = false;
7290    }
7291
7292    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7293        let Some(row) = self.state.inventory_selected_row() else {
7294            anyhow::bail!("inventory empty");
7295        };
7296        if GameState::is_property_deed_template(&row.stack.template_id) {
7297            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7298                anyhow::bail!("deed has no plot id");
7299            };
7300            let label = self
7301                .state
7302                .property_plots
7303                .iter()
7304                .find(|p| p.plot_id == plot_id)
7305                .map(|p| {
7306                    if p.label.trim().is_empty() {
7307                        p.plot_code.clone()
7308                    } else {
7309                        p.label.clone()
7310                    }
7311                })
7312                .unwrap_or_else(|| {
7313                    row.stack
7314                        .display_name
7315                        .clone()
7316                        .unwrap_or_else(|| "plot".into())
7317                });
7318            self.state.rename_buffer = label;
7319            self.state.rename_plot_id = Some(plot_id);
7320            self.state.highlighted_plot_id = Some(plot_id);
7321            self.state.show_rename_prompt = true;
7322            self.state.show_worker_rename = false;
7323            self.state.show_move_picker = false;
7324            self.state.show_destroy_picker = false;
7325            self.state.destroy_confirm_pending = false;
7326            return Ok(());
7327        }
7328        if !self.state.row_is_renameable_container(&row) {
7329            anyhow::bail!("only storage containers or deeds can be renamed");
7330        }
7331        let current = row
7332            .stack
7333            .display_name
7334            .clone()
7335            .unwrap_or_else(|| row.stack.template_id.clone());
7336        self.state.rename_buffer = current;
7337        self.state.rename_plot_id = None;
7338        self.state.show_rename_prompt = true;
7339        self.state.show_worker_rename = false;
7340        self.state.show_move_picker = false;
7341        self.state.show_destroy_picker = false;
7342        self.state.destroy_confirm_pending = false;
7343        Ok(())
7344    }
7345
7346    /// Rename the owned plot under the player's feet (`n` in world).
7347    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7348        let Some(plot) = self.state.my_plot_under_player().cloned() else {
7349            anyhow::bail!("stand on your plot to rename it");
7350        };
7351        let label = if plot.label.trim().is_empty() {
7352            plot.plot_code.clone()
7353        } else {
7354            plot.label.clone()
7355        };
7356        self.state.rename_buffer = label;
7357        self.state.rename_plot_id = Some(plot.plot_id);
7358        self.state.highlighted_plot_id = Some(plot.plot_id);
7359        self.state.show_rename_prompt = true;
7360        self.state.show_worker_rename = false;
7361        Ok(())
7362    }
7363
7364    pub fn cancel_rename_prompt(&mut self) {
7365        self.state.show_rename_prompt = false;
7366        self.state.rename_plot_id = None;
7367        self.state.rename_buffer.clear();
7368    }
7369
7370    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7371        let name = self.state.rename_buffer.trim().to_string();
7372        if name.is_empty() {
7373            anyhow::bail!("name cannot be empty");
7374        }
7375        if let Some(plot_id) = self.state.rename_plot_id {
7376            if name.chars().count() > 48 {
7377                anyhow::bail!("label must be 1–48 characters");
7378            }
7379            self.seq += 1;
7380            self.session
7381                .submit_intent(Intent::RenamePropertyPlot {
7382                    entity_id: self.state.entity_id,
7383                    plot_id,
7384                    label: name,
7385                    seq: self.seq,
7386                })
7387                .await?;
7388            self.state.intents_sent += 1;
7389            self.state.show_rename_prompt = false;
7390            self.state.rename_plot_id = None;
7391            self.state.rename_buffer.clear();
7392            return Ok(());
7393        }
7394        if name.chars().count() > 32 {
7395            anyhow::bail!("name must be 1–32 characters");
7396        }
7397        let Some(row) = self.state.inventory_selected_row() else {
7398            anyhow::bail!("inventory empty");
7399        };
7400        let Some(instance_id) = row.stack.item_instance_id else {
7401            anyhow::bail!("item has no instance id");
7402        };
7403        self.seq += 1;
7404        self.session
7405            .submit_intent(Intent::RenameContainer {
7406                entity_id: self.state.entity_id,
7407                item_instance_id: instance_id,
7408                location: row.from.clone(),
7409                name,
7410                seq: self.seq,
7411            })
7412            .await?;
7413        self.state.intents_sent += 1;
7414        self.state.show_rename_prompt = false;
7415        self.state.rename_buffer.clear();
7416        Ok(())
7417    }
7418
7419    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7420        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7421            anyhow::bail!("no worker selected");
7422        };
7423        self.state.rename_buffer = worker.label.clone();
7424        self.state.show_worker_rename = true;
7425        self.state.show_rename_prompt = false;
7426        Ok(())
7427    }
7428
7429    pub fn cancel_worker_rename(&mut self) {
7430        self.state.show_worker_rename = false;
7431        self.state.rename_buffer.clear();
7432    }
7433
7434    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7435        let name = self.state.rename_buffer.trim().to_string();
7436        if name.is_empty() {
7437            anyhow::bail!("name cannot be empty");
7438        }
7439        if name.chars().count() > 32 {
7440            anyhow::bail!("name must be 1–32 characters");
7441        }
7442        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7443            anyhow::bail!("no worker selected");
7444        };
7445        let worker_instance_id = worker.instance_id.clone();
7446        self.seq += 1;
7447        self.session
7448            .submit_intent(Intent::RenameHiredWorker {
7449                entity_id: self.state.entity_id,
7450                worker_instance_id: worker_instance_id.clone(),
7451                name: name.clone(),
7452                seq: self.seq,
7453            })
7454            .await?;
7455        self.state.intents_sent += 1;
7456        if let Some(w) = self
7457            .state
7458            .hired_workers
7459            .iter_mut()
7460            .find(|w| w.instance_id == worker_instance_id)
7461        {
7462            w.label = name.clone();
7463        }
7464        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7465            if ed.worker_instance_id == worker_instance_id {
7466                ed.worker_label = name.clone();
7467            }
7468        }
7469        self.state.show_worker_rename = false;
7470        self.state.rename_buffer.clear();
7471        self.state.push_log(format!("Renamed worker to \"{name}\""));
7472        Ok(())
7473    }
7474
7475    pub fn toggle_inventory_menu(&mut self) {
7476        if self.state.show_inventory_menu {
7477            self.close_inventory_menu();
7478        } else {
7479            self.open_inventory_menu();
7480        }
7481    }
7482
7483    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
7484    pub fn inventory_menu_move(&mut self, delta: i32) {
7485        if self.state.show_grant_picker {
7486            let Some(picker) = self.state.grant_picker.as_ref() else {
7487                return;
7488            };
7489            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7490            let filter = picker.filter.clone();
7491            let n = labels.len();
7492            if n == 0 {
7493                return;
7494            }
7495            self.state.grant_picker_index = step_filtered_index(
7496                self.state.grant_picker_index,
7497                delta,
7498                n,
7499                |i| list_label_matches(&labels[i], &filter),
7500            );
7501            return;
7502        }
7503        if self.state.show_move_picker {
7504            let Some(picker) = self.state.move_picker.as_ref() else {
7505                return;
7506            };
7507            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7508            let filter = picker.filter.clone();
7509            let n = labels.len();
7510            if n == 0 {
7511                return;
7512            }
7513            self.state.move_picker_index = step_filtered_index(
7514                self.state.move_picker_index,
7515                delta,
7516                n,
7517                |i| list_label_matches(&labels[i], &filter),
7518            );
7519            self.state.clamp_move_picker_quantity();
7520            return;
7521        }
7522        let n = self.state.inventory_selectable_rows().len();
7523        if n == 0 {
7524            return;
7525        }
7526        let idx = self.state.inventory_menu_index as i32;
7527        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7528    }
7529
7530    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
7531    pub fn inventory_menu_page(&mut self, pages: i32) {
7532        if self.state.show_grant_picker {
7533            let Some(picker) = self.state.grant_picker.as_ref() else {
7534                return;
7535            };
7536            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7537            let filter = picker.filter.clone();
7538            let n = labels.len();
7539            self.state.grant_picker_index = page_filtered_index(
7540                self.state.grant_picker_index,
7541                pages,
7542                n,
7543                |i| list_label_matches(&labels[i], &filter),
7544            );
7545            return;
7546        }
7547        if self.state.show_move_picker {
7548            let Some(picker) = self.state.move_picker.as_ref() else {
7549                return;
7550            };
7551            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7552            let filter = picker.filter.clone();
7553            let n = labels.len();
7554            self.state.move_picker_index = page_filtered_index(
7555                self.state.move_picker_index,
7556                pages,
7557                n,
7558                |i| list_label_matches(&labels[i], &filter),
7559            );
7560            self.state.clamp_move_picker_quantity();
7561            return;
7562        }
7563        let n = self.state.inventory_selectable_rows().len();
7564        self.state.inventory_menu_index =
7565            page_list_index(self.state.inventory_menu_index, pages, n);
7566    }
7567
7568    pub fn cycle_inventory_tab(&mut self, forward: bool) {
7569        if self.state.show_move_picker
7570            || self.state.show_grant_picker
7571            || self.state.show_destroy_picker
7572            || self.state.show_rename_prompt
7573            || self.state.inventory_filter_focused
7574        {
7575            return;
7576        }
7577        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7578        self.state.inventory_menu_index = 0;
7579        self.state.clamp_inventory_indices();
7580    }
7581
7582    pub fn focus_inventory_filter(&mut self) {
7583        if self.state.show_grant_picker {
7584            if let Some(p) = self.state.grant_picker.as_mut() {
7585                p.filter_focused = true;
7586            }
7587            return;
7588        }
7589        if self.state.show_move_picker {
7590            if let Some(p) = self.state.move_picker.as_mut() {
7591                p.filter_focused = true;
7592            }
7593            return;
7594        }
7595        self.state.inventory_filter_focused = true;
7596    }
7597
7598    pub fn set_inventory_filter(&mut self, filter: String) {
7599        self.state.inventory_filter = filter;
7600        self.state.inventory_menu_index = 0;
7601        self.state.clamp_inventory_indices();
7602    }
7603
7604    pub fn append_inventory_filter_char(&mut self, ch: char) {
7605        if ch.is_control() {
7606            return;
7607        }
7608        if self.state.show_grant_picker {
7609            if let Some(p) = self.state.grant_picker.as_mut() {
7610                if p.filter_focused {
7611                    p.filter.push(ch);
7612                    self.state.grant_picker_index = 0;
7613                }
7614            }
7615            return;
7616        }
7617        if self.state.show_move_picker {
7618            if let Some(p) = self.state.move_picker.as_mut() {
7619                if p.filter_focused {
7620                    p.filter.push(ch);
7621                    self.state.move_picker_index = 0;
7622                    self.state.clamp_move_picker_quantity();
7623                }
7624            }
7625            return;
7626        }
7627        if !self.state.inventory_filter_focused {
7628            return;
7629        }
7630        self.state.inventory_filter.push(ch);
7631        self.state.inventory_menu_index = 0;
7632        self.state.clamp_inventory_indices();
7633    }
7634
7635    pub fn inventory_filter_backspace(&mut self) {
7636        if self.state.show_grant_picker {
7637            if let Some(p) = self.state.grant_picker.as_mut() {
7638                if p.filter_focused {
7639                    p.filter.pop();
7640                    self.state.grant_picker_index = 0;
7641                }
7642            }
7643            return;
7644        }
7645        if self.state.show_move_picker {
7646            if let Some(p) = self.state.move_picker.as_mut() {
7647                if p.filter_focused {
7648                    p.filter.pop();
7649                    self.state.move_picker_index = 0;
7650                    self.state.clamp_move_picker_quantity();
7651                }
7652            }
7653            return;
7654        }
7655        if !self.state.inventory_filter_focused {
7656            return;
7657        }
7658        self.state.inventory_filter.pop();
7659        self.state.inventory_menu_index = 0;
7660        self.state.clamp_inventory_indices();
7661    }
7662
7663    /// Esc while filter focused: clear filter then blur. Returns true if handled.
7664    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7665        if self.state.show_grant_picker {
7666            if let Some(p) = self.state.grant_picker.as_mut() {
7667                if p.filter_focused {
7668                    if !p.filter.is_empty() {
7669                        p.filter.clear();
7670                        self.state.grant_picker_index = 0;
7671                    } else {
7672                        p.filter_focused = false;
7673                    }
7674                    return true;
7675                }
7676                if !p.filter.is_empty() {
7677                    p.filter.clear();
7678                    self.state.grant_picker_index = 0;
7679                    return true;
7680                }
7681            }
7682            return false;
7683        }
7684        if self.state.show_move_picker {
7685            if let Some(p) = self.state.move_picker.as_mut() {
7686                if p.filter_focused {
7687                    if !p.filter.is_empty() {
7688                        p.filter.clear();
7689                        self.state.move_picker_index = 0;
7690                        self.state.clamp_move_picker_quantity();
7691                    } else {
7692                        p.filter_focused = false;
7693                    }
7694                    return true;
7695                }
7696                if !p.filter.is_empty() {
7697                    p.filter.clear();
7698                    self.state.move_picker_index = 0;
7699                    self.state.clamp_move_picker_quantity();
7700                    return true;
7701                }
7702            }
7703            return false;
7704        }
7705        if self.state.inventory_filter_focused {
7706            if !self.state.inventory_filter.is_empty() {
7707                self.state.inventory_filter.clear();
7708                self.state.inventory_menu_index = 0;
7709                self.state.clamp_inventory_indices();
7710            } else {
7711                self.state.inventory_filter_focused = false;
7712            }
7713            return true;
7714        }
7715        if !self.state.inventory_filter.is_empty() {
7716            self.state.inventory_filter.clear();
7717            self.state.inventory_menu_index = 0;
7718            self.state.clamp_inventory_indices();
7719            return true;
7720        }
7721        false
7722    }
7723
7724    pub fn craft_menu_page(&mut self, pages: i32) {
7725        let n = self.state.blueprints.len();
7726        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7727        self.state.clamp_craft_batch_quantity();
7728    }
7729
7730    pub fn shop_menu_page(&mut self, pages: i32) {
7731        let n = self.state.shop_list_len();
7732        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7733        self.state.clamp_shop_quantity();
7734    }
7735
7736    pub fn workers_menu_page(&mut self, pages: i32) {
7737        let n = self.state.hired_workers.len();
7738        self.state.workers_menu_index =
7739            page_list_index(self.state.workers_menu_index, pages, n);
7740    }
7741
7742    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
7743    /// chest — or fall back to the "move to…" destination picker (including
7744    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
7745    /// Placed chest shells open a pick-up destination picker (`l` still locks).
7746    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7747        if self.state.show_destroy_picker {
7748            if self.state.destroy_confirm_pending {
7749                return self.confirm_destroy_item().await;
7750            }
7751            return self.request_destroy_confirm();
7752        }
7753        if self.state.show_grant_picker {
7754            return self.confirm_grant_picker().await;
7755        }
7756        if self.state.show_move_picker {
7757            return self.confirm_move_picker().await;
7758        }
7759        let Some(row) = self.state.inventory_selected_row() else {
7760            anyhow::bail!("inventory empty");
7761        };
7762        if row.is_equip_shell {
7763            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7764                anyhow::bail!("not a worn item");
7765            };
7766            return self.equip_worn(slot, None).await;
7767        }
7768        if row.is_chest_shell {
7769            return self.open_chest_pickup_picker();
7770        }
7771        let template_id = row.stack.template_id.clone();
7772        let instance_id = row.stack.item_instance_id;
7773        let category = self.state.inventory_item_category(&template_id);
7774        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7775
7776        if category == Some("weapon") {
7777            return self.equip_mainhand(Some(template_id)).await;
7778        }
7779        if category == Some("lodging") && on_person {
7780            if let Some(inst) = instance_id {
7781                return self.place_container(inst).await;
7782            }
7783        }
7784        if (category == Some("container") || category == Some("armor")) && on_person {
7785            if let Some(inst) = instance_id {
7786                let world_placeable = row.stack.world_placeable == Some(true)
7787                    || template_id.contains("chest");
7788                if world_placeable {
7789                    return self.place_container(inst).await;
7790                }
7791                // Pouches no longer equip directly — they clip onto a worn belt's loops
7792                // instead, so fall through to the move picker (offers "belt loop" when a
7793                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
7794                if let Some(slot) = guess_body_slot(&template_id) {
7795                    return self.equip_worn(slot, Some(inst)).await;
7796                }
7797            }
7798        }
7799        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
7800        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
7801        // instead of guessing.
7802        self.open_move_picker()
7803    }
7804
7805    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
7806    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7807        let Some(row) = self.state.inventory_selected_row() else {
7808            anyhow::bail!("inventory empty");
7809        };
7810        if row.from != flatland_protocol::InventoryLocation::Root {
7811            anyhow::bail!("select a consumable on your person");
7812        }
7813        if GameState::stack_is_item_grant(&row.stack) {
7814            return self.open_grant_target_picker();
7815        }
7816        if GameState::is_property_deed_template(&row.stack.template_id) {
7817            return self.open_move_picker();
7818        }
7819        let category = self
7820            .state
7821            .inventory_item_category(&row.stack.template_id);
7822        if category != Some("consumable") {
7823            anyhow::bail!("selected item is not consumable");
7824        }
7825        self.use_item(&row.stack.template_id).await
7826    }
7827
7828    /// Open picker: apply selected grant item onto inventory / worn gear.
7829    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7830        let Some(row) = self.state.inventory_selected_row() else {
7831            anyhow::bail!("inventory empty");
7832        };
7833        if row.from != flatland_protocol::InventoryLocation::Root {
7834            anyhow::bail!("select a grant item on your person");
7835        }
7836        if !GameState::stack_is_item_grant(&row.stack) {
7837            anyhow::bail!("selected item does not grant onto gear");
7838        }
7839        let Some(grant_instance_id) = row.stack.item_instance_id else {
7840            anyhow::bail!("grant has no instance id");
7841        };
7842        let effect_id = GameState::grant_effect_id(&row.stack)
7843            .unwrap_or("?")
7844            .to_string();
7845        let mode = GameState::grant_mode(&row.stack).to_string();
7846        let options = self.state.grant_target_options(&row.stack);
7847        if options.is_empty() {
7848            anyhow::bail!("no valid gear to apply {effect_id} to");
7849        }
7850        let grant_label = row
7851            .stack
7852            .display_name
7853            .clone()
7854            .unwrap_or_else(|| row.stack.template_id.clone());
7855        self.state.show_grant_picker = true;
7856        self.state.grant_picker_index = 0;
7857        self.state.grant_picker = Some(GrantTargetPicker {
7858            grant_instance_id,
7859            grant_label,
7860            effect_id,
7861            mode,
7862            options,
7863            filter: String::new(),
7864            filter_focused: false,
7865        });
7866        Ok(())
7867    }
7868
7869    pub fn close_grant_picker(&mut self) {
7870        self.state.show_grant_picker = false;
7871        self.state.grant_picker = None;
7872        self.state.grant_picker_index = 0;
7873    }
7874
7875    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7876        let Some(picker) = self.state.grant_picker.clone() else {
7877            self.close_grant_picker();
7878            return Ok(());
7879        };
7880        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7881            self.close_grant_picker();
7882            return Ok(());
7883        };
7884        self.close_grant_picker();
7885        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7886            .await?;
7887        self.state.push_log(format!(
7888            "Applying {} onto {}…",
7889            picker.effect_id, opt.label
7890        ));
7891        Ok(())
7892    }
7893
7894    /// `m`: always open the "move to…" picker for the selected item, even for
7895    /// weapons/wearables that Enter would otherwise equip/wear directly.
7896    /// Placed chests open the pick-up destination picker instead.
7897    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7898        let Some(row) = self.state.inventory_selected_row() else {
7899            anyhow::bail!("inventory empty");
7900        };
7901        if row.is_equip_shell {
7902            anyhow::bail!("this is a worn bag — press Enter to unequip it");
7903        }
7904        if row.is_chest_shell {
7905            return self.open_chest_pickup_picker();
7906        }
7907        let Some(instance_id) = row.stack.item_instance_id else {
7908            anyhow::bail!("item has no instance id");
7909        };
7910        let mut options = self.state.move_destinations_for(
7911            &row.from,
7912            row.from_parent_instance_id,
7913            row.stack.item_instance_id,
7914            &row.stack.template_id,
7915        );
7916        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7917        let category = self.state.inventory_item_category(&row.stack.template_id);
7918        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7919            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7920                options.insert(
7921                    0,
7922                    MoveOption {
7923                        label: "Sell plot to crown…".into(),
7924                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
7925                    },
7926                );
7927            }
7928        }
7929        if on_person && category == Some("consumable") {
7930            if GameState::stack_is_item_grant(&row.stack) {
7931                options.insert(
7932                    0,
7933                    MoveOption {
7934                        label: "Apply onto gear…".into(),
7935                        kind: MoveOptionKind::GrantApply,
7936                    },
7937                );
7938            } else {
7939                options.insert(
7940                    0,
7941                    MoveOption {
7942                        label: "Use (eat / drink)".into(),
7943                        kind: MoveOptionKind::Use,
7944                    },
7945                );
7946            }
7947        }
7948        let item_label = row
7949            .stack
7950            .display_name
7951            .clone()
7952            .unwrap_or_else(|| row.stack.template_id.clone());
7953        // Default to 1 so withdrawing from storage is partial unless the player
7954        // presses `a` for max fit (full stack when the destination allows it).
7955        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7956        self.state.move_picker = Some(MovePicker {
7957            item_instance_id: instance_id,
7958            from: row.from,
7959            item_label,
7960            template_id: row.stack.template_id.clone(),
7961            stack_quantity: row.stack.quantity,
7962            quantity: initial_qty.max(1),
7963            options,
7964            filter: String::new(),
7965            filter_focused: false,
7966        });
7967        self.state.move_picker_index = 0;
7968        self.state.show_move_picker = true;
7969        self.state.show_destroy_picker = false;
7970        self.state.destroy_confirm_pending = false;
7971        self.state.destroy_picker = None;
7972        self.state.clamp_move_picker_quantity();
7973        Ok(())
7974    }
7975
7976    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
7977    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7978        let Some(row) = self.state.inventory_selected_row() else {
7979            anyhow::bail!("inventory empty");
7980        };
7981        if !row.is_chest_shell {
7982            anyhow::bail!("not a placed chest");
7983        }
7984        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7985            anyhow::bail!("not a placed chest");
7986        };
7987        let Some(instance_id) = row.stack.item_instance_id else {
7988            anyhow::bail!("chest has no instance id");
7989        };
7990        let chest = self
7991            .state
7992            .placed_containers
7993            .iter()
7994            .find(|c| c.id == *container_id)
7995            .cloned()
7996            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7997        let (px, py) = self.state.player_position();
7998        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7999            anyhow::bail!("too far from {}", chest.display_name);
8000        }
8001        if chest.locked && !chest.accessible {
8002            anyhow::bail!(
8003                "need the matching key for {} before picking it up",
8004                chest.display_name
8005            );
8006        }
8007        let options = self.state.chest_pickup_destinations(container_id);
8008        let item_label = row
8009            .stack
8010            .display_name
8011            .clone()
8012            .unwrap_or_else(|| row.stack.template_id.clone());
8013        self.state.move_picker = Some(MovePicker {
8014            item_instance_id: instance_id,
8015            from: row.from.clone(),
8016            item_label,
8017            template_id: row.stack.template_id.clone(),
8018            stack_quantity: 1,
8019            quantity: 1,
8020            options,
8021            filter: String::new(),
8022            filter_focused: false,
8023        });
8024        self.state.move_picker_index = 0;
8025        self.state.show_move_picker = true;
8026        self.state.show_destroy_picker = false;
8027        self.state.destroy_confirm_pending = false;
8028        self.state.destroy_picker = None;
8029        Ok(())
8030    }
8031
8032    pub fn close_move_picker(&mut self) {
8033        self.state.show_move_picker = false;
8034        self.state.move_picker = None;
8035    }
8036
8037    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8038        self.state.move_picker_adjust_quantity(delta);
8039    }
8040
8041    pub fn move_picker_set_quantity_max(&mut self) {
8042        self.state.move_picker_set_quantity_max();
8043    }
8044
8045    pub fn move_picker_set_quantity_min(&mut self) {
8046        self.state.move_picker_set_quantity_min();
8047    }
8048
8049    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8050        self.state.destroy_picker_adjust_quantity(delta);
8051    }
8052
8053    pub fn destroy_picker_set_quantity_max(&mut self) {
8054        self.state.destroy_picker_set_quantity_max();
8055    }
8056
8057    pub fn destroy_picker_set_quantity_min(&mut self) {
8058        self.state.destroy_picker_set_quantity_min();
8059    }
8060
8061    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8062        let Some(picker) = self.state.move_picker.clone() else {
8063            self.close_move_picker();
8064            return Ok(());
8065        };
8066        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8067            self.close_move_picker();
8068            return Ok(());
8069        };
8070        match option.kind {
8071            MoveOptionKind::Cancel => {
8072                self.close_move_picker();
8073            }
8074            MoveOptionKind::Use => {
8075                self.close_move_picker();
8076                self.use_item(&picker.template_id).await?;
8077            }
8078            MoveOptionKind::GrantApply => {
8079                self.close_move_picker();
8080                self.open_grant_target_picker()?;
8081            }
8082            MoveOptionKind::SellPlotToCrown { plot_id } => {
8083                self.close_move_picker();
8084                self.confirm_sell_plot_to_crown(plot_id).await?;
8085            }
8086            MoveOptionKind::RelocatePlaced { container_id } => {
8087                self.close_move_picker();
8088                self.state.show_inventory_menu = false;
8089                self.begin_relocate_container(&container_id)?;
8090            }
8091            MoveOptionKind::Drop => {
8092                self.close_move_picker();
8093                if self
8094                    .state
8095                    .hand_equipped_instance_ids()
8096                    .contains(&picker.item_instance_id)
8097                {
8098                    anyhow::bail!("unequip that item first");
8099                }
8100                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8101                    if self.state.deed_bound(&stack) {
8102                        anyhow::bail!(
8103                            "cannot drop a property deed — store it or trade it to another player"
8104                        );
8105                    }
8106                    if self.state.key_drop_blocked(&stack) {
8107                        anyhow::bail!("cannot drop the key while its chest is locked");
8108                    }
8109                }
8110                self.drop_item(picker.item_instance_id, picker.from).await?;
8111                self.state
8112                    .push_log(format!("Dropped {}", picker.item_label));
8113            }
8114            MoveOptionKind::PickupPlaced {
8115                container_id,
8116                nest_location,
8117                nest_parent_instance_id,
8118            } => {
8119                self.close_move_picker();
8120                self.pickup_container(container_id.clone()).await?;
8121                let nest_into_bag = nest_parent_instance_id.is_some()
8122                    || !matches!(
8123                        nest_location,
8124                        flatland_protocol::InventoryLocation::Root
8125                    );
8126                if nest_into_bag {
8127                    self.move_item(
8128                        picker.item_instance_id,
8129                        flatland_protocol::InventoryLocation::Root,
8130                        nest_location,
8131                        nest_parent_instance_id,
8132                        None,
8133                    )
8134                    .await?;
8135                    self.state
8136                        .push_log(format!("Picked up {} into bag", picker.item_label));
8137                } else {
8138                    self.state
8139                        .push_log(format!("Picked up {}", picker.item_label));
8140                }
8141            }
8142            MoveOptionKind::Move {
8143                location,
8144                parent_instance_id,
8145            } => {
8146                self.close_move_picker();
8147                let qty = if picker.quantity >= picker.stack_quantity {
8148                    None
8149                } else {
8150                    Some(picker.quantity)
8151                };
8152                self.move_item(
8153                    picker.item_instance_id,
8154                    picker.from,
8155                    location,
8156                    parent_instance_id,
8157                    qty,
8158                )
8159                .await?;
8160                let moved = qty.unwrap_or(picker.stack_quantity);
8161                if moved >= picker.stack_quantity {
8162                    self.state.push_log(format!("Moved {}", picker.item_label));
8163                } else {
8164                    self.state.push_log(format!(
8165                        "Moved {} ×{} of {}",
8166                        picker.item_label, moved, picker.stack_quantity
8167                    ));
8168                }
8169            }
8170        }
8171        Ok(())
8172    }
8173
8174    /// `d`: drop the selected item on the ground immediately (no picker).
8175    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8176        let Some(row) = self.state.inventory_selected_row() else {
8177            anyhow::bail!("inventory empty");
8178        };
8179        if row.is_equip_shell {
8180            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8181        }
8182        if row.is_chest_shell {
8183            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8184        }
8185        let Some(inst) = row.stack.item_instance_id else {
8186            anyhow::bail!("item has no instance id");
8187        };
8188        if self.state.hand_equipped_instance_ids().contains(&inst) {
8189            anyhow::bail!("unequip that item first");
8190        }
8191        if self.state.deed_bound(&row.stack) {
8192            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8193        }
8194        if self.state.key_drop_blocked(&row.stack) {
8195            anyhow::bail!("cannot drop the key while its chest is locked");
8196        }
8197        let label = row
8198            .stack
8199            .display_name
8200            .clone()
8201            .unwrap_or_else(|| row.stack.template_id.clone());
8202        self.drop_item(inst, row.from).await?;
8203        self.state.push_log(format!("Dropped {label}"));
8204        Ok(())
8205    }
8206
8207    pub async fn drop_item(
8208        &mut self,
8209        item_instance_id: uuid::Uuid,
8210        from: flatland_protocol::InventoryLocation,
8211    ) -> anyhow::Result<()> {
8212        self.seq += 1;
8213        self.session
8214            .submit_intent(Intent::DropItem {
8215                entity_id: self.state.entity_id,
8216                item_instance_id,
8217                from,
8218                seq: self.seq,
8219            })
8220            .await?;
8221        self.state.intents_sent += 1;
8222        Ok(())
8223    }
8224
8225    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
8226    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8227        let Some(row) = self.state.inventory_selected_row() else {
8228            anyhow::bail!("inventory empty");
8229        };
8230        if row.is_equip_shell {
8231            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8232        }
8233        if row.is_chest_shell {
8234            anyhow::bail!("can't destroy a placed chest from the inventory list");
8235        }
8236        let Some(instance_id) = row.stack.item_instance_id else {
8237            anyhow::bail!("item has no instance id");
8238        };
8239        if self.state.hand_equipped_instance_ids().contains(&instance_id) {
8240            anyhow::bail!("unequip that item first");
8241        }
8242        if self.state.deed_bound(&row.stack) {
8243            anyhow::bail!(
8244                "cannot destroy a property deed — store it or trade it to another player"
8245            );
8246        }
8247        if self.state.key_drop_blocked(&row.stack) {
8248            anyhow::bail!("cannot destroy the key while its chest is locked");
8249        }
8250        let item_label = row
8251            .stack
8252            .display_name
8253            .clone()
8254            .unwrap_or_else(|| row.stack.template_id.clone());
8255        self.state.destroy_picker = Some(DestroyPicker {
8256            item_instance_id: instance_id,
8257            from: row.from,
8258            item_label,
8259            stack_quantity: row.stack.quantity,
8260            quantity: row.stack.quantity,
8261        });
8262        self.state.destroy_confirm_pending = false;
8263        self.state.show_destroy_picker = true;
8264        self.state.show_move_picker = false;
8265        self.state.move_picker = None;
8266        Ok(())
8267    }
8268
8269    pub fn close_destroy_picker(&mut self) {
8270        self.state.show_destroy_picker = false;
8271        self.state.destroy_confirm_pending = false;
8272        self.state.destroy_picker = None;
8273    }
8274
8275    pub fn cancel_destroy_confirm(&mut self) {
8276        self.state.destroy_confirm_pending = false;
8277    }
8278
8279    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8280        if self.state.destroy_picker.is_none() {
8281            self.close_destroy_picker();
8282            return Ok(());
8283        }
8284        self.state.destroy_confirm_pending = true;
8285        Ok(())
8286    }
8287
8288    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8289        let Some(picker) = self.state.destroy_picker.clone() else {
8290            self.close_destroy_picker();
8291            return Ok(());
8292        };
8293        let qty = if picker.quantity >= picker.stack_quantity {
8294            None
8295        } else {
8296            Some(picker.quantity)
8297        };
8298        self.destroy_item(picker.item_instance_id, picker.from, qty)
8299            .await?;
8300        let destroyed = qty.unwrap_or(picker.stack_quantity);
8301        if destroyed >= picker.stack_quantity {
8302            self.state
8303                .push_log(format!("Destroyed {}", picker.item_label));
8304        } else {
8305            self.state.push_log(format!(
8306                "Destroyed {} ×{} of {}",
8307                picker.item_label, destroyed, picker.stack_quantity
8308            ));
8309        }
8310        self.close_destroy_picker();
8311        Ok(())
8312    }
8313
8314    pub async fn destroy_item(
8315        &mut self,
8316        item_instance_id: uuid::Uuid,
8317        from: flatland_protocol::InventoryLocation,
8318        quantity: Option<u32>,
8319    ) -> anyhow::Result<()> {
8320        self.seq += 1;
8321        self.session
8322            .submit_intent(Intent::DestroyItem {
8323                entity_id: self.state.entity_id,
8324                item_instance_id,
8325                from,
8326                quantity,
8327                seq: self.seq,
8328            })
8329            .await?;
8330        self.state.intents_sent += 1;
8331        Ok(())
8332    }
8333
8334    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
8335    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8336        if let Some(row) = self.state.inventory_selected_row() {
8337            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8338                return self.toggle_placed_chest_lock(container_id).await;
8339            }
8340        }
8341        self.toggle_nearby_chest_lock().await
8342    }
8343
8344    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8345        let chest = self
8346            .state
8347            .placed_containers
8348            .iter()
8349            .find(|c| c.id == container_id)
8350            .cloned()
8351            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8352        let (px, py) = self.state.player_position();
8353        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8354            anyhow::bail!("too far from {}", chest.display_name);
8355        }
8356        if !chest.accessible && chest.locked {
8357            anyhow::bail!(
8358                "need the matching key for {} (each crafted chest has its own key)",
8359                chest.display_name
8360            );
8361        }
8362        let lock = !chest.locked;
8363        self.set_container_locked(
8364            flatland_protocol::InventoryLocation::Placed {
8365                container_id: chest.id.clone(),
8366            },
8367            lock,
8368        )
8369        .await?;
8370        self.state.push_log(if lock {
8371            format!("Locked {}", chest.display_name)
8372        } else {
8373            format!("Unlocked {}", chest.display_name)
8374        });
8375        Ok(())
8376    }
8377
8378    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
8379    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8380        let chest = self
8381            .state
8382            .nearest_placed_container(CONTAINER_RANGE_M)
8383            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8384        self.toggle_placed_chest_lock(&chest.id).await
8385    }
8386
8387    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8388        self.equip_mainhand(None).await
8389    }
8390
8391    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8392        if !self.state.is_alive() {
8393            anyhow::bail!("you are dead");
8394        }
8395        self.seq += 1;
8396        self.session
8397            .submit_intent(Intent::EquipOffhand {
8398                entity_id: self.state.entity_id,
8399                template_id,
8400                instance_id: None,
8401                seq: self.seq,
8402            })
8403            .await?;
8404        self.state.intents_sent += 1;
8405        Ok(())
8406    }
8407
8408    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8409        self.equip_offhand(None).await
8410    }
8411
8412    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8413        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8414        for slot in slots {
8415            self.equip_worn(slot, None).await?;
8416        }
8417        Ok(())
8418    }
8419
8420    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8421        let (px, py) = self.state.player_position();
8422        let nearest = self
8423            .state
8424            .placed_containers
8425            .iter()
8426            .min_by(|a, b| {
8427                let da = (a.x - px).hypot(a.y - py);
8428                let db = (b.x - px).hypot(b.y - py);
8429                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8430            })
8431            .cloned();
8432        let Some(chest) = nearest else {
8433            anyhow::bail!("no chest nearby");
8434        };
8435        if (chest.x - px).hypot(chest.y - py) > 2.0 {
8436            anyhow::bail!("too far from chest");
8437        }
8438        self.pickup_container(chest.id).await
8439    }
8440
8441    pub async fn equip_worn(
8442        &mut self,
8443        slot: BodySlot,
8444        instance_id: Option<uuid::Uuid>,
8445    ) -> anyhow::Result<()> {
8446        self.seq += 1;
8447        self.session
8448            .submit_intent(Intent::EquipWorn {
8449                entity_id: self.state.entity_id,
8450                slot,
8451                instance_id,
8452                seq: self.seq,
8453            })
8454            .await?;
8455        self.state.intents_sent += 1;
8456        Ok(())
8457    }
8458
8459    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8460        self.seq += 1;
8461        self.session
8462            .submit_intent(Intent::PlaceContainer {
8463                entity_id: self.state.entity_id,
8464                item_instance_id,
8465                seq: self.seq,
8466            })
8467            .await?;
8468        self.state.intents_sent += 1;
8469        Ok(())
8470    }
8471
8472    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8473        self.seq += 1;
8474        self.session
8475            .submit_intent(Intent::PickupContainer {
8476                entity_id: self.state.entity_id,
8477                container_id,
8478                seq: self.seq,
8479            })
8480            .await?;
8481        self.state.intents_sent += 1;
8482        Ok(())
8483    }
8484
8485    pub async fn move_item(
8486        &mut self,
8487        item_instance_id: uuid::Uuid,
8488        from: flatland_protocol::InventoryLocation,
8489        to: flatland_protocol::InventoryLocation,
8490        to_parent_instance_id: Option<uuid::Uuid>,
8491        quantity: Option<u32>,
8492    ) -> anyhow::Result<()> {
8493        self.seq += 1;
8494        self.session
8495            .submit_intent(Intent::MoveItem {
8496                entity_id: self.state.entity_id,
8497                item_instance_id,
8498                from,
8499                to,
8500                to_parent_instance_id,
8501                quantity,
8502                seq: self.seq,
8503            })
8504            .await?;
8505        self.state.intents_sent += 1;
8506        Ok(())
8507    }
8508
8509    pub async fn set_container_locked(
8510        &mut self,
8511        location: flatland_protocol::InventoryLocation,
8512        locked: bool,
8513    ) -> anyhow::Result<()> {
8514        self.seq += 1;
8515        self.session
8516            .submit_intent(Intent::SetContainerLocked {
8517                entity_id: self.state.entity_id,
8518                location,
8519                locked,
8520                seq: self.seq,
8521            })
8522            .await?;
8523        self.state.intents_sent += 1;
8524        Ok(())
8525    }
8526
8527    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8528        if !self.state.is_alive() {
8529            anyhow::bail!("you are dead");
8530        }
8531        self.seq += 1;
8532        self.session
8533            .submit_intent(Intent::Use {
8534                entity_id: self.state.entity_id,
8535                template_id: template_id.to_string(),
8536                seq: self.seq,
8537            })
8538            .await?;
8539        self.state.intents_sent += 1;
8540        Ok(())
8541    }
8542
8543    /// Bind a grant consumable onto a specific item instance (unique gear status).
8544    pub async fn use_grant(
8545        &mut self,
8546        grant_instance_id: uuid::Uuid,
8547        target_instance_id: uuid::Uuid,
8548    ) -> anyhow::Result<()> {
8549        if !self.state.is_alive() {
8550            anyhow::bail!("you are dead");
8551        }
8552        self.seq += 1;
8553        self.session
8554            .submit_intent(Intent::UseGrant {
8555                entity_id: self.state.entity_id,
8556                grant_instance_id,
8557                target_instance_id,
8558                seq: self.seq,
8559            })
8560            .await?;
8561        self.state.intents_sent += 1;
8562        Ok(())
8563    }
8564
8565    pub fn open_craft_menu(&mut self) {
8566        self.state.show_craft_menu = true;
8567        self.state.show_shop_menu = false;
8568        self.state.shop_catalog = None;
8569        self.state.show_stats = false;
8570        self.state.show_inventory_menu = false;
8571        if self.state.blueprints.is_empty() {
8572            self.state.craft_menu_index = 0;
8573            self.state.craft_batch_quantity = 1;
8574            return;
8575        }
8576        self.state.craft_menu_index = self
8577            .state
8578            .craft_menu_index
8579            .min(self.state.blueprints.len() - 1);
8580        if let Some(idx) = self
8581            .state
8582            .blueprints
8583            .iter()
8584            .position(|bp| self.state.can_craft_blueprint(bp))
8585        {
8586            self.state.craft_menu_index = idx;
8587        }
8588        self.state.clamp_craft_batch_quantity();
8589    }
8590
8591    pub fn close_craft_menu(&mut self) {
8592        self.state.show_craft_menu = false;
8593    }
8594
8595    pub fn toggle_keychain_menu(&mut self) {
8596        if self.state.show_keychain_menu {
8597            self.close_keychain_menu();
8598        } else {
8599            self.state.show_keychain_menu = true;
8600            self.state.show_craft_menu = false;
8601            self.state.show_shop_menu = false;
8602            self.state.show_inventory_menu = false;
8603            let n = self.state.keychain_entries().len();
8604            if n == 0 {
8605                self.state.keychain_menu_index = 0;
8606            } else {
8607                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8608            }
8609        }
8610    }
8611
8612    pub fn close_keychain_menu(&mut self) {
8613        self.state.show_keychain_menu = false;
8614    }
8615
8616    pub fn keychain_menu_move(&mut self, delta: i32) {
8617        let n = self.state.keychain_entries().len();
8618        if n == 0 {
8619            self.state.keychain_menu_index = 0;
8620            return;
8621        }
8622        let idx = self.state.keychain_menu_index as i32 + delta;
8623        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8624    }
8625
8626    pub fn keychain_menu_page(&mut self, pages: i32) {
8627        let n = self.state.keychain_entries().len();
8628        self.state.keychain_menu_index =
8629            page_list_index(self.state.keychain_menu_index, pages, n);
8630    }
8631
8632    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8633        if !self.state.is_alive() {
8634            anyhow::bail!("you are dead");
8635        }
8636        let entries = self.state.keychain_entries();
8637        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8638            anyhow::bail!("nothing selected");
8639        };
8640        let Some(instance_id) = entry.stack.item_instance_id else {
8641            anyhow::bail!("key has no instance id");
8642        };
8643        if entry.stowed {
8644            self.move_item(
8645                instance_id,
8646                flatland_protocol::InventoryLocation::Keychain,
8647                flatland_protocol::InventoryLocation::Root,
8648                None,
8649                Some(1),
8650            )
8651            .await
8652        } else {
8653            self.move_item(
8654                instance_id,
8655                flatland_protocol::InventoryLocation::Root,
8656                flatland_protocol::InventoryLocation::Keychain,
8657                None,
8658                Some(1),
8659            )
8660            .await
8661        }
8662    }
8663
8664    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8665        let npc_id = self
8666            .state
8667            .shop_catalog
8668            .as_ref()
8669            .map(|c| c.npc_id.clone());
8670        self.state.show_shop_menu = false;
8671        self.state.shop_catalog = None;
8672        self.state.clear_shop_trade_log();
8673        if let Some(npc_id) = npc_id {
8674            self.seq += 1;
8675            self.session
8676                .submit_intent(Intent::ShopClose {
8677                    entity_id: self.state.entity_id,
8678                    npc_id,
8679                    seq: self.seq,
8680                })
8681                .await?;
8682            self.state.intents_sent += 1;
8683        }
8684        Ok(())
8685    }
8686
8687    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8688        let Some(panel) = self.state.bank_panel.clone() else {
8689            return Ok(());
8690        };
8691        self.seq += 1;
8692        self.session
8693            .submit_intent(Intent::BankDeposit {
8694                entity_id: self.state.entity_id,
8695                npc_id: panel.npc_id,
8696                amount_copper,
8697                seq: self.seq,
8698            })
8699            .await?;
8700        self.state.intents_sent += 1;
8701        Ok(())
8702    }
8703
8704    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8705        let Some(panel) = self.state.bank_panel.clone() else {
8706            return Ok(());
8707        };
8708        self.seq += 1;
8709        self.session
8710            .submit_intent(Intent::BankWithdraw {
8711                entity_id: self.state.entity_id,
8712                npc_id: panel.npc_id,
8713                amount_copper,
8714                seq: self.seq,
8715            })
8716            .await?;
8717        self.state.intents_sent += 1;
8718        Ok(())
8719    }
8720
8721    pub async fn bank_transfer(
8722        &mut self,
8723        to_character_id: Option<uuid::Uuid>,
8724        to_name: String,
8725        amount_copper: u64,
8726    ) -> anyhow::Result<()> {
8727        let Some(panel) = self.state.bank_panel.clone() else {
8728            return Ok(());
8729        };
8730        self.seq += 1;
8731        self.session
8732            .submit_intent(Intent::BankTransfer {
8733                entity_id: self.state.entity_id,
8734                npc_id: panel.npc_id,
8735                to_character_id,
8736                to_name,
8737                amount_copper,
8738                seq: self.seq,
8739            })
8740            .await?;
8741        self.state.intents_sent += 1;
8742        Ok(())
8743    }
8744
8745    pub fn bank_menu_move(&mut self, delta: i32) {
8746        let n = self.state.bank_menu_options().len();
8747        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8748            return;
8749        }
8750        let idx = self.state.bank_menu_index as i32;
8751        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8752    }
8753
8754    pub fn storage_menu_move(&mut self, delta: i32) {
8755        let n = self.state.storage_menu_options().len();
8756        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8757            return;
8758        }
8759        let idx = self.state.storage_menu_index as i32;
8760        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8761    }
8762
8763    pub fn storage_pick_move(&mut self, delta: i32) {
8764        let n = match &self.state.storage_ui_mode {
8765            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8766            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8767                self.state.storage_vault_options().len()
8768            }
8769            StorageUiMode::Menu
8770            | StorageUiMode::StoreAmount { .. }
8771            | StorageUiMode::TakeAmount { .. }
8772            | StorageUiMode::ShipAmount { .. } => 0,
8773        };
8774        if n == 0 {
8775            return;
8776        }
8777        match &mut self.state.storage_ui_mode {
8778            StorageUiMode::StorePick { index }
8779            | StorageUiMode::TakePick { index }
8780            | StorageUiMode::ShipPick { index, .. } => {
8781                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8782            }
8783            StorageUiMode::Menu
8784            | StorageUiMode::StoreAmount { .. }
8785            | StorageUiMode::TakeAmount { .. }
8786            | StorageUiMode::ShipAmount { .. } => {}
8787        }
8788    }
8789
8790    pub fn storage_ui_back(&mut self) {
8791        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8792            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8793                index: *pick_index,
8794            },
8795            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8796                index: *pick_index,
8797            },
8798            StorageUiMode::ShipAmount {
8799                dest_building_id,
8800                dest_label,
8801                pick_index,
8802                ..
8803            } => StorageUiMode::ShipPick {
8804                dest_building_id: dest_building_id.clone(),
8805                dest_label: dest_label.clone(),
8806                index: *pick_index,
8807            },
8808            StorageUiMode::StorePick { .. }
8809            | StorageUiMode::TakePick { .. }
8810            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8811            StorageUiMode::Menu => StorageUiMode::Menu,
8812        };
8813    }
8814
8815    pub fn storage_amount_append_char(&mut self, c: char) {
8816        match &mut self.state.storage_ui_mode {
8817            StorageUiMode::StoreAmount { input, .. }
8818            | StorageUiMode::TakeAmount { input, .. }
8819            | StorageUiMode::ShipAmount { input, .. } => {
8820                if c.is_ascii_digit() && input.len() < 8 {
8821                    input.push(c);
8822                }
8823            }
8824            _ => {}
8825        }
8826    }
8827
8828    pub fn storage_amount_backspace(&mut self) {
8829        match &mut self.state.storage_ui_mode {
8830            StorageUiMode::StoreAmount { input, .. }
8831            | StorageUiMode::TakeAmount { input, .. }
8832            | StorageUiMode::ShipAmount { input, .. } => {
8833                input.pop();
8834            }
8835            _ => {}
8836        }
8837    }
8838
8839    pub fn storage_ui_typing(&self) -> bool {
8840        matches!(
8841            self.state.storage_ui_mode,
8842            StorageUiMode::StoreAmount { .. }
8843                | StorageUiMode::TakeAmount { .. }
8844                | StorageUiMode::ShipAmount { .. }
8845        )
8846    }
8847
8848    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8849        match self.state.storage_ui_mode.clone() {
8850            StorageUiMode::Menu => {
8851                let index = self.state.storage_menu_index;
8852                match index {
8853                    0 => {
8854                        let opts = self.state.storage_store_options();
8855                        if opts.is_empty() {
8856                            self.state.push_log("Nothing loose to store.");
8857                            return Ok(());
8858                        }
8859                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8860                    }
8861                    1 => {
8862                        let opts = self.state.storage_vault_options();
8863                        if opts.is_empty() {
8864                            self.state.push_log("Vault is empty.");
8865                            return Ok(());
8866                        }
8867                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8868                    }
8869                    n => {
8870                        let dest = self
8871                            .state
8872                            .storage_panel
8873                            .as_ref()
8874                            .and_then(|p| p.ship_destinations.get(n - 2))
8875                            .cloned();
8876                        let Some(dest) = dest else {
8877                            return Ok(());
8878                        };
8879                        let opts = self.state.storage_vault_options();
8880                        if opts.is_empty() {
8881                            self.state
8882                                .push_log("Vault is empty — nothing to ship.");
8883                            return Ok(());
8884                        }
8885                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
8886                            dest_building_id: dest.building_id,
8887                            dest_label: dest.label,
8888                            index: 0,
8889                        };
8890                    }
8891                }
8892            }
8893            StorageUiMode::StorePick { index } => {
8894                let opts = self.state.storage_store_options();
8895                let Some(opt) = opts.get(index) else {
8896                    self.state.push_log("Nothing loose to store.");
8897                    self.state.storage_ui_mode = StorageUiMode::Menu;
8898                    return Ok(());
8899                };
8900                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8901                    pick_index: index,
8902                    item_instance_id: opt.item_instance_id,
8903                    label: opt.label.clone(),
8904                    max_qty: opt.quantity.max(1),
8905                    input: String::new(),
8906                };
8907            }
8908            StorageUiMode::TakePick { index } => {
8909                let opts = self.state.storage_vault_options();
8910                let Some(opt) = opts.get(index) else {
8911                    self.state.push_log("Vault is empty.");
8912                    self.state.storage_ui_mode = StorageUiMode::Menu;
8913                    return Ok(());
8914                };
8915                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8916                    pick_index: index,
8917                    item_instance_id: opt.item_instance_id,
8918                    label: opt.label.clone(),
8919                    max_qty: opt.quantity.max(1),
8920                    input: String::new(),
8921                };
8922            }
8923            StorageUiMode::ShipPick {
8924                dest_building_id,
8925                dest_label,
8926                index,
8927            } => {
8928                let opts = self.state.storage_vault_options();
8929                let Some(opt) = opts.get(index) else {
8930                    self.state
8931                        .push_log("Vault is empty — nothing to ship.");
8932                    self.state.storage_ui_mode = StorageUiMode::Menu;
8933                    return Ok(());
8934                };
8935                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8936                    dest_building_id,
8937                    dest_label,
8938                    pick_index: index,
8939                    item_instance_id: opt.item_instance_id,
8940                    label: opt.label.clone(),
8941                    max_qty: opt.quantity.max(1),
8942                    input: String::new(),
8943                };
8944            }
8945            StorageUiMode::StoreAmount {
8946                item_instance_id,
8947                max_qty,
8948                input,
8949                ..
8950            } => {
8951                let Some(qty) = parse_storage_quantity(&input) else {
8952                    self.state
8953                        .push_log("Enter a quantity (blank or 0 = all).");
8954                    return Ok(());
8955                };
8956                let qty = qty.map(|n| n.min(max_qty).max(1));
8957                self.storage_store(item_instance_id, qty).await?;
8958                self.state.storage_ui_mode = StorageUiMode::Menu;
8959            }
8960            StorageUiMode::TakeAmount {
8961                item_instance_id,
8962                max_qty,
8963                input,
8964                ..
8965            } => {
8966                let Some(qty) = parse_storage_quantity(&input) else {
8967                    self.state
8968                        .push_log("Enter a quantity (blank or 0 = all).");
8969                    return Ok(());
8970                };
8971                let qty = qty.map(|n| n.min(max_qty).max(1));
8972                self.storage_take(item_instance_id, qty).await?;
8973                self.state.storage_ui_mode = StorageUiMode::Menu;
8974            }
8975            StorageUiMode::ShipAmount {
8976                dest_building_id,
8977                item_instance_id,
8978                max_qty,
8979                input,
8980                ..
8981            } => {
8982                let Some(qty) = parse_storage_quantity(&input) else {
8983                    self.state
8984                        .push_log("Enter a quantity (blank or 0 = all).");
8985                    return Ok(());
8986                };
8987                let qty = qty.map(|n| n.min(max_qty).max(1));
8988                self.storage_ship(dest_building_id, item_instance_id, qty)
8989                    .await?;
8990                self.state.storage_ui_mode = StorageUiMode::Menu;
8991            }
8992        }
8993        Ok(())
8994    }
8995
8996    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8997        match self.state.bank_ui_mode.clone() {
8998            BankUiMode::Menu => {
8999                let choice = self
9000                    .state
9001                    .bank_menu_options()
9002                    .get(self.state.bank_menu_index)
9003                    .copied()
9004                    .unwrap_or("Deposit…");
9005                match choice {
9006                    "Withdraw…" => {
9007                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9008                            input: String::new(),
9009                        };
9010                    }
9011                    "Deposit all" => self.bank_deposit(0).await?,
9012                    "Withdraw all" => self.bank_withdraw(0).await?,
9013                    "Transfer…" => {
9014                        self.state.bank_ui_mode = BankUiMode::TransferName {
9015                            input: String::new(),
9016                        };
9017                    }
9018                    _ => {
9019                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
9020                            input: String::new(),
9021                        };
9022                    }
9023                }
9024            }
9025            BankUiMode::DepositAmount { input } => {
9026                let Some(amount) = parse_bank_copper_amount(&input) else {
9027                    self.state
9028                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
9029                    return Ok(());
9030                };
9031                self.bank_deposit(amount).await?;
9032                self.state.bank_ui_mode = BankUiMode::Menu;
9033            }
9034            BankUiMode::WithdrawAmount { input } => {
9035                let Some(amount) = parse_bank_copper_amount(&input) else {
9036                    self.state
9037                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
9038                    return Ok(());
9039                };
9040                self.bank_withdraw(amount).await?;
9041                self.state.bank_ui_mode = BankUiMode::Menu;
9042            }
9043            BankUiMode::TransferName { input } => {
9044                let name = input.trim().to_string();
9045                if name.is_empty() {
9046                    self.state.push_log("Enter the recipient character name.");
9047                    return Ok(());
9048                }
9049                self.state.bank_ui_mode = BankUiMode::TransferAmount {
9050                    to_name: name,
9051                    input: String::new(),
9052                };
9053            }
9054            BankUiMode::TransferAmount { to_name, input } => {
9055                let amount: u64 = match input.trim().parse() {
9056                    Ok(v) if v > 0 => v,
9057                    _ => {
9058                        self.state
9059                            .push_log("Enter a positive copper amount to transfer.");
9060                        return Ok(());
9061                    }
9062                };
9063                self.bank_transfer(None, to_name, amount).await?;
9064                self.state.bank_ui_mode = BankUiMode::Menu;
9065            }
9066        }
9067        Ok(())
9068    }
9069
9070    pub fn bank_transfer_back(&mut self) {
9071        match &self.state.bank_ui_mode {
9072            BankUiMode::TransferAmount { to_name, .. } => {
9073                self.state.bank_ui_mode = BankUiMode::TransferName {
9074                    input: to_name.clone(),
9075                };
9076            }
9077            BankUiMode::TransferName { .. }
9078            | BankUiMode::DepositAmount { .. }
9079            | BankUiMode::WithdrawAmount { .. } => {
9080                self.state.bank_ui_mode = BankUiMode::Menu;
9081            }
9082            BankUiMode::Menu => {}
9083        }
9084    }
9085
9086    pub fn bank_transfer_append_char(&mut self, c: char) {
9087        match &mut self.state.bank_ui_mode {
9088            BankUiMode::TransferName { input } => {
9089                if input.len() < 32 && !c.is_control() {
9090                    input.push(c);
9091                }
9092            }
9093            BankUiMode::DepositAmount { input }
9094            | BankUiMode::WithdrawAmount { input }
9095            | BankUiMode::TransferAmount { input, .. } => {
9096                if c.is_ascii_digit() && input.len() < 12 {
9097                    input.push(c);
9098                }
9099            }
9100            BankUiMode::Menu => {}
9101        }
9102    }
9103
9104    pub fn bank_transfer_backspace(&mut self) {
9105        match &mut self.state.bank_ui_mode {
9106            BankUiMode::TransferName { input }
9107            | BankUiMode::DepositAmount { input }
9108            | BankUiMode::WithdrawAmount { input }
9109            | BankUiMode::TransferAmount { input, .. } => {
9110                input.pop();
9111            }
9112            BankUiMode::Menu => {}
9113        }
9114    }
9115
9116    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9117        let npc_id = self
9118            .state
9119            .bank_panel
9120            .as_ref()
9121            .map(|p| p.npc_id.clone());
9122        self.state.clear_bank_panel();
9123        if let Some(npc_id) = npc_id {
9124            self.seq += 1;
9125            self.session
9126                .submit_intent(Intent::BankClose {
9127                    entity_id: self.state.entity_id,
9128                    npc_id,
9129                    seq: self.seq,
9130                })
9131                .await?;
9132            self.state.intents_sent += 1;
9133        }
9134        Ok(())
9135    }
9136
9137    pub async fn storage_store(
9138        &mut self,
9139        item_instance_id: uuid::Uuid,
9140        quantity: Option<u32>,
9141    ) -> anyhow::Result<()> {
9142        let Some(panel) = self.state.storage_panel.clone() else {
9143            return Ok(());
9144        };
9145        self.seq += 1;
9146        self.session
9147            .submit_intent(Intent::StorageStore {
9148                entity_id: self.state.entity_id,
9149                npc_id: panel.npc_id,
9150                item_instance_id,
9151                quantity,
9152                seq: self.seq,
9153            })
9154            .await?;
9155        self.state.intents_sent += 1;
9156        Ok(())
9157    }
9158
9159    pub async fn storage_take(
9160        &mut self,
9161        item_instance_id: uuid::Uuid,
9162        quantity: Option<u32>,
9163    ) -> anyhow::Result<()> {
9164        let Some(panel) = self.state.storage_panel.clone() else {
9165            return Ok(());
9166        };
9167        self.seq += 1;
9168        self.session
9169            .submit_intent(Intent::StorageTake {
9170                entity_id: self.state.entity_id,
9171                npc_id: panel.npc_id,
9172                item_instance_id,
9173                quantity,
9174                seq: self.seq,
9175            })
9176            .await?;
9177        self.state.intents_sent += 1;
9178        Ok(())
9179    }
9180
9181    pub async fn storage_ship(
9182        &mut self,
9183        dest_building_id: String,
9184        item_instance_id: uuid::Uuid,
9185        quantity: Option<u32>,
9186    ) -> anyhow::Result<()> {
9187        let Some(panel) = self.state.storage_panel.clone() else {
9188            return Ok(());
9189        };
9190        self.seq += 1;
9191        self.session
9192            .submit_intent(Intent::StorageShip {
9193                entity_id: self.state.entity_id,
9194                npc_id: panel.npc_id,
9195                dest_building_id,
9196                item_instance_id,
9197                quantity,
9198                seq: self.seq,
9199            })
9200            .await?;
9201        self.state.intents_sent += 1;
9202        Ok(())
9203    }
9204
9205    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9206        let npc_id = self
9207            .state
9208            .storage_panel
9209            .as_ref()
9210            .map(|p| p.npc_id.clone());
9211        self.state.clear_storage_panel();
9212        if let Some(npc_id) = npc_id {
9213            self.seq += 1;
9214            self.session
9215                .submit_intent(Intent::StorageClose {
9216                    entity_id: self.state.entity_id,
9217                    npc_id,
9218                    seq: self.seq,
9219                })
9220                .await?;
9221            self.state.intents_sent += 1;
9222        }
9223        Ok(())
9224    }
9225
9226    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9227        let npc_id = self
9228            .state
9229            .market_panel
9230            .as_ref()
9231            .map(|p| p.npc_id.clone());
9232        self.state.clear_market_panel();
9233        if let Some(npc_id) = npc_id {
9234            self.seq += 1;
9235            self.session
9236                .submit_intent(Intent::MarketClose {
9237                    entity_id: self.state.entity_id,
9238                    npc_id,
9239                    seq: self.seq,
9240                })
9241                .await?;
9242            self.state.intents_sent += 1;
9243        }
9244        Ok(())
9245    }
9246
9247    pub fn market_move_selection(&mut self, delta: i32) {
9248        let indices = self.state.market_filtered_listing_indices();
9249        let n = indices.len();
9250        if n == 0 {
9251            self.state.market_menu_index = 0;
9252            return;
9253        }
9254        let cur = self.state.market_menu_index as i32;
9255        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9256    }
9257
9258    pub fn market_page_selection(&mut self, pages: i32) {
9259        let indices = self.state.market_filtered_listing_indices();
9260        let n = indices.len();
9261        if n == 0 {
9262            self.state.market_menu_index = 0;
9263            return;
9264        }
9265        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9266    }
9267
9268    pub fn market_list_page(&mut self, pages: i32) {
9269        match &self.state.market_ui_mode {
9270            MarketUiMode::ListSource { index } => {
9271                let n = self.state.market_list_source_options().len();
9272                if n == 0 {
9273                    return;
9274                }
9275                let next = page_list_index(*index, pages, n);
9276                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9277            }
9278            MarketUiMode::ListPricingMode { index, .. } => {
9279                let next = page_list_index(*index, pages, 2);
9280                if let MarketUiMode::ListPricingMode { index, .. } =
9281                    &mut self.state.market_ui_mode
9282                {
9283                    *index = next;
9284                }
9285            }
9286            MarketUiMode::ListPick { source, index } => {
9287                let opts = self.state.market_list_item_options(source);
9288                let n = opts.len();
9289                if n == 0 {
9290                    return;
9291                }
9292                let next = page_list_index(*index, pages, n);
9293                self.state.market_ui_mode = MarketUiMode::ListPick {
9294                    source: source.clone(),
9295                    index: next,
9296                };
9297            }
9298            _ => {}
9299        }
9300    }
9301
9302    pub fn market_cycle_category(&mut self, delta: i32) {
9303        let groups = self.state.market_available_category_groups();
9304        // All + groups
9305        let mut labels: Vec<Option<&'static str>> = vec![None];
9306        labels.extend(groups.into_iter().map(Some));
9307        let n = labels.len() as i32;
9308        let cur = labels
9309            .iter()
9310            .position(|g| *g == self.state.market_category_filter)
9311            .unwrap_or(0) as i32;
9312        let next = (cur + delta).rem_euclid(n) as usize;
9313        self.state.market_category_filter = labels[next];
9314        self.state.market_menu_index = 0;
9315        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9316            let source = source.clone();
9317            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9318        }
9319    }
9320
9321    pub fn focus_market_filter(&mut self) {
9322        self.state.market_filter_focused = true;
9323    }
9324
9325    pub fn append_market_filter_char(&mut self, ch: char) {
9326        if !self.state.market_filter_focused {
9327            return;
9328        }
9329        if ch.is_control() {
9330            return;
9331        }
9332        if self.state.market_filter.len() < 48 {
9333            self.state.market_filter.push(ch);
9334            self.state.market_menu_index = 0;
9335            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9336                let source = source.clone();
9337                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9338            }
9339        }
9340    }
9341
9342    pub fn market_filter_backspace(&mut self) {
9343        if !self.state.market_filter_focused {
9344            return;
9345        }
9346        self.state.market_filter.pop();
9347        self.state.market_menu_index = 0;
9348        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9349            let source = source.clone();
9350            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9351        }
9352    }
9353
9354    /// Clears filter text, blurs search, or returns false if already idle.
9355    pub fn clear_or_blur_market_filter(&mut self) -> bool {
9356        if self.state.market_filter_focused {
9357            if !self.state.market_filter.is_empty() {
9358                self.state.market_filter.clear();
9359                self.state.market_menu_index = 0;
9360                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9361                    let source = source.clone();
9362                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9363                }
9364                return true;
9365            }
9366            self.state.market_filter_focused = false;
9367            return true;
9368        }
9369        if !self.state.market_filter.is_empty() {
9370            self.state.market_filter.clear();
9371            self.state.market_menu_index = 0;
9372            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9373                let source = source.clone();
9374                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9375            }
9376            return true;
9377        }
9378        false
9379    }
9380
9381    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9382        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9383            return self.market_confirm_buy(listing_id, qty).await;
9384        }
9385        let Some(panel) = self.state.market_panel.clone() else {
9386            return Ok(());
9387        };
9388        let indices = self.state.market_filtered_listing_indices();
9389        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9390            return Ok(());
9391        };
9392        let Some(listing) = panel.listings.get(raw_idx) else {
9393            return Ok(());
9394        };
9395        if listing.mine {
9396            self.seq += 1;
9397            self.session
9398                .submit_intent(Intent::MarketDelist {
9399                    entity_id: self.state.entity_id,
9400                    npc_id: panel.npc_id.clone(),
9401                    listing_id: listing.listing_id,
9402                    dest: flatland_protocol::GoodsLocation::Person,
9403                    seq: self.seq,
9404                })
9405                .await?;
9406            self.state.intents_sent += 1;
9407            return Ok(());
9408        }
9409        if listing.npc_price {
9410            self.state
9411                .push_log("NPC-price listings are bought by merchants only.");
9412            return Ok(());
9413        }
9414        let qty = 1u32.min(listing.quantity).max(1);
9415        let line = listing.unit_price_copper.saturating_mul(qty as u64);
9416        self.state.market_buy_confirm = Some((
9417            listing.listing_id,
9418            qty,
9419            listing.unit_price_copper,
9420            line,
9421            listing.display_name.clone(),
9422        ));
9423        Ok(())
9424    }
9425
9426    pub async fn market_confirm_buy(
9427        &mut self,
9428        listing_id: uuid::Uuid,
9429        quantity: u32,
9430    ) -> anyhow::Result<()> {
9431        let Some(panel) = self.state.market_panel.clone() else {
9432            self.state.market_buy_confirm = None;
9433            return Ok(());
9434        };
9435        self.state.market_buy_confirm = None;
9436        self.seq += 1;
9437        self.session
9438            .submit_intent(Intent::MarketBuy {
9439                entity_id: self.state.entity_id,
9440                npc_id: panel.npc_id,
9441                listing_id,
9442                quantity,
9443                dest: flatland_protocol::GoodsLocation::Person,
9444                seq: self.seq,
9445            })
9446            .await?;
9447        self.state.intents_sent += 1;
9448        Ok(())
9449    }
9450
9451    /// Begin the list wizard (`l` in market browse).
9452    pub fn market_begin_list(&mut self) {
9453        if self.state.market_panel.is_none() {
9454            return;
9455        }
9456        let sources = self.state.market_list_source_options();
9457        if sources.is_empty() {
9458            self.state.push_log("Nothing to list from.");
9459            return;
9460        }
9461        // One source (person only) → skip straight to item pick when it has stacks.
9462        if sources.len() == 1 {
9463            let (source, _) = sources[0].clone();
9464            let opts = self.state.market_list_item_options(&source);
9465            if opts.is_empty() {
9466                self.state.push_log("Nothing loose to list.");
9467                return;
9468            }
9469            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9470            self.state.market_buy_confirm = None;
9471            return;
9472        }
9473        self.state.market_buy_confirm = None;
9474        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9475    }
9476
9477    pub fn market_ui_back(&mut self) {
9478        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9479            MarketUiMode::Browse => MarketUiMode::Browse,
9480            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9481            MarketUiMode::ListPick { .. } => {
9482                if self.state.market_list_source_options().len() <= 1 {
9483                    MarketUiMode::Browse
9484                } else {
9485                    MarketUiMode::ListSource { index: 0 }
9486                }
9487            }
9488            MarketUiMode::ListAmount {
9489                source,
9490                pick_index,
9491                ..
9492            } => MarketUiMode::ListPick {
9493                source,
9494                index: pick_index,
9495            },
9496            MarketUiMode::ListPricingMode {
9497                source,
9498                item_instance_id,
9499                label,
9500                max_qty,
9501                quantity,
9502                ..
9503            } => {
9504                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9505                MarketUiMode::ListAmount {
9506                    source,
9507                    pick_index: 0,
9508                    item_instance_id,
9509                    label,
9510                    max_qty,
9511                    input,
9512                }
9513            }
9514            MarketUiMode::ListPrice {
9515                source,
9516                item_instance_id,
9517                label,
9518                max_qty,
9519                quantity,
9520                ..
9521            } => MarketUiMode::ListPricingMode {
9522                source,
9523                item_instance_id,
9524                label,
9525                quantity,
9526                max_qty,
9527                index: 1,
9528            },
9529        };
9530    }
9531
9532    pub fn market_list_move(&mut self, delta: i32) {
9533        match &self.state.market_ui_mode {
9534            MarketUiMode::ListSource { index } => {
9535                let n = self.state.market_list_source_options().len();
9536                if n == 0 {
9537                    return;
9538                }
9539                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9540                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9541            }
9542            MarketUiMode::ListPricingMode { index, .. } => {
9543                let next = (*index as i32 + delta).rem_euclid(2) as usize;
9544                if let MarketUiMode::ListPricingMode { index, .. } =
9545                    &mut self.state.market_ui_mode
9546                {
9547                    *index = next;
9548                }
9549            }
9550            MarketUiMode::ListPick { source, index } => {
9551                let opts = self.state.market_list_item_options(source);
9552                let n = opts.len();
9553                if n == 0 {
9554                    return;
9555                }
9556                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9557                self.state.market_ui_mode = MarketUiMode::ListPick {
9558                    source: source.clone(),
9559                    index: next,
9560                };
9561            }
9562            _ => {}
9563        }
9564    }
9565
9566    pub fn market_list_amount_append_char(&mut self, c: char) {
9567        if !c.is_ascii_digit() {
9568            return;
9569        }
9570        match &mut self.state.market_ui_mode {
9571            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9572                if input.len() < 12 {
9573                    input.push(c);
9574                }
9575            }
9576            _ => {}
9577        }
9578    }
9579
9580    pub fn market_list_amount_backspace(&mut self) {
9581        match &mut self.state.market_ui_mode {
9582            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9583                input.pop();
9584            }
9585            _ => {}
9586        }
9587    }
9588
9589    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9590        match self.state.market_ui_mode.clone() {
9591            MarketUiMode::Browse => Ok(()),
9592            MarketUiMode::ListSource { index } => {
9593                let sources = self.state.market_list_source_options();
9594                let Some((source, _)) = sources.get(index).cloned() else {
9595                    return Ok(());
9596                };
9597                let opts = self.state.market_list_item_options(&source);
9598                if opts.is_empty() {
9599                    self.state.push_log("Nothing to list from that source.");
9600                    return Ok(());
9601                }
9602                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9603                Ok(())
9604            }
9605            MarketUiMode::ListPick { source, index } => {
9606                let opts = self.state.market_list_item_options(&source);
9607                let Some(opt) = opts.get(index) else {
9608                    self.state.push_log("Nothing to list.");
9609                    self.state.market_ui_mode = MarketUiMode::Browse;
9610                    return Ok(());
9611                };
9612                self.state.market_ui_mode = MarketUiMode::ListAmount {
9613                    source,
9614                    pick_index: index,
9615                    item_instance_id: opt.item_instance_id,
9616                    label: opt.label.clone(),
9617                    max_qty: opt.quantity.max(1),
9618                    input: String::new(),
9619                };
9620                Ok(())
9621            }
9622            MarketUiMode::ListAmount {
9623                source,
9624                item_instance_id,
9625                label,
9626                max_qty,
9627                input,
9628                ..
9629            } => {
9630                let Some(qty_opt) = parse_storage_quantity(&input) else {
9631                    self.state.push_log("Enter a quantity (blank = all).");
9632                    return Ok(());
9633                };
9634                if let Some(q) = qty_opt {
9635                    if q > max_qty {
9636                        self.state
9637                            .push_log(format!("Only {max_qty} available."));
9638                        return Ok(());
9639                    }
9640                }
9641                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9642                    source,
9643                    item_instance_id,
9644                    label,
9645                    quantity: qty_opt,
9646                    max_qty,
9647                    index: 0,
9648                };
9649                Ok(())
9650            }
9651            MarketUiMode::ListPricingMode {
9652                source,
9653                item_instance_id,
9654                label,
9655                quantity,
9656                max_qty,
9657                index,
9658            } => {
9659                if index == 0 {
9660                    return self
9661                        .submit_market_list_intent(
9662                            source,
9663                            item_instance_id,
9664                            quantity,
9665                            0,
9666                            true,
9667                            &label,
9668                        )
9669                        .await;
9670                }
9671                self.state.market_ui_mode = MarketUiMode::ListPrice {
9672                    source,
9673                    item_instance_id,
9674                    label,
9675                    quantity,
9676                    max_qty,
9677                    input: String::new(),
9678                };
9679                Ok(())
9680            }
9681            MarketUiMode::ListPrice {
9682                source,
9683                item_instance_id,
9684                label,
9685                quantity,
9686                input,
9687                ..
9688            } => {
9689                let price = input.trim().parse::<u64>().unwrap_or(0);
9690                if price == 0 {
9691                    self.state.push_log("Enter a unit price of at least 1 copper.");
9692                    return Ok(());
9693                }
9694                self.submit_market_list_intent(
9695                    source,
9696                    item_instance_id,
9697                    quantity,
9698                    price,
9699                    false,
9700                    &label,
9701                )
9702                .await
9703            }
9704        }
9705    }
9706
9707    async fn submit_market_list_intent(
9708        &mut self,
9709        source: MarketListSourceKind,
9710        item_instance_id: uuid::Uuid,
9711        quantity: Option<u32>,
9712        unit_price_copper: u64,
9713        npc_price: bool,
9714        label: &str,
9715    ) -> anyhow::Result<()> {
9716        let Some(panel) = self.state.market_panel.clone() else {
9717            self.state.market_ui_mode = MarketUiMode::Browse;
9718            return Ok(());
9719        };
9720        let goods = match source {
9721            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9722            MarketListSourceKind::TownStorage { building_id } => {
9723                flatland_protocol::GoodsLocation::TownStorage { building_id }
9724            }
9725        };
9726        self.seq += 1;
9727        self.session
9728            .submit_intent(Intent::MarketList {
9729                entity_id: self.state.entity_id,
9730                npc_id: panel.npc_id,
9731                source: goods,
9732                item_instance_id,
9733                quantity,
9734                unit_price_copper,
9735                npc_price,
9736                seq: self.seq,
9737            })
9738            .await?;
9739        self.state.intents_sent += 1;
9740        if npc_price {
9741            self.state.push_log(format!("Listing {label} at NPC price…"));
9742        } else {
9743            self.state
9744                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
9745        }
9746        self.state.market_ui_mode = MarketUiMode::Browse;
9747        Ok(())
9748    }
9749
9750    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
9751    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9752        let return_to_verbs = self.state.npc_verb_target.is_some();
9753        self.close_shop_menu().await?;
9754        if return_to_verbs {
9755            self.state.show_npc_verb_menu = true;
9756        }
9757        Ok(())
9758    }
9759
9760    pub fn shop_tab_toggle(&mut self) {
9761        self.state.shop_tab = match self.state.shop_tab {
9762            ShopTab::Buy => ShopTab::Sell,
9763            ShopTab::Sell => ShopTab::Buy,
9764        };
9765        self.state.shop_menu_index = 0;
9766        if self.state.shop_tab == ShopTab::Sell {
9767            self.state.shop_quantity_set_max();
9768        }
9769        self.state.clamp_shop_selection();
9770    }
9771
9772    pub fn shop_menu_move(&mut self, delta: i32) {
9773        self.state.shop_menu_move(delta);
9774    }
9775
9776    pub fn shop_quantity_adjust(&mut self, delta: i32) {
9777        self.state.shop_quantity_adjust(delta);
9778    }
9779
9780    pub fn shop_quantity_set_max(&mut self) {
9781        self.state.shop_quantity_set_max();
9782    }
9783
9784    pub fn shop_quantity_set_min(&mut self) {
9785        self.state.shop_quantity_set_min();
9786    }
9787
9788    pub fn toggle_quest_menu(&mut self) {
9789        self.state.show_quest_menu = !self.state.show_quest_menu;
9790        if self.state.show_quest_menu {
9791            self.state.quest_menu_index = 0;
9792            self.state.quest_withdraw_confirm = false;
9793            self.state.show_workers_menu = false;
9794        }
9795    }
9796
9797    pub fn toggle_workers_menu(&mut self) {
9798        if self.state.show_workers_menu {
9799            self.close_workers_menu_ui();
9800        } else {
9801            self.state.show_workers_menu = true;
9802            self.state.workers_menu_index = 0;
9803            self.state.show_quest_menu = false;
9804            self.close_worker_give_picker();
9805            self.close_worker_give_target_picker();
9806            self.close_worker_take_picker();
9807            self.close_worker_teach_picker();
9808            self.cancel_worker_rename();
9809        }
9810    }
9811
9812    /// Close workers UI layers without network (caller should release attend).
9813    pub fn close_workers_menu_ui(&mut self) {
9814        self.state.show_workers_menu = false;
9815        self.close_worker_give_picker();
9816        self.close_worker_give_target_picker();
9817        self.close_worker_take_picker();
9818        self.close_worker_teach_picker();
9819        self.cancel_worker_rename();
9820    }
9821
9822    /// Open the workers menu focused on `instance_id` and pause that worker's job.
9823    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9824        let Some(idx) = self
9825            .state
9826            .hired_workers
9827            .iter()
9828            .position(|w| w.instance_id == instance_id)
9829        else {
9830            anyhow::bail!("worker not found");
9831        };
9832        let label = self.state.hired_workers[idx].label.clone();
9833        self.state.show_workers_menu = true;
9834        self.state.workers_menu_index = idx;
9835        self.state.show_quest_menu = false;
9836        self.close_worker_give_picker();
9837        self.close_worker_give_target_picker();
9838        self.close_worker_take_picker();
9839        self.close_worker_teach_picker();
9840        self.cancel_worker_rename();
9841        self.set_worker_attending(instance_id, true).await?;
9842        self.state
9843            .push_log(format!("Managing {label} — job paused while menu is open"));
9844        Ok(())
9845    }
9846
9847    /// Close workers menu and release any attend pause.
9848    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9849        self.close_workers_menu_ui();
9850        self.release_worker_attend().await
9851    }
9852
9853    async fn set_worker_attending(
9854        &mut self,
9855        instance_id: &str,
9856        attending: bool,
9857    ) -> anyhow::Result<()> {
9858        if attending {
9859            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9860                return Ok(());
9861            }
9862            // Switch target: release previous first.
9863            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9864                if prev != instance_id {
9865                    self.send_attend_hired_worker(&prev, false).await?;
9866                }
9867            }
9868            self.send_attend_hired_worker(instance_id, true).await?;
9869            self.state.attending_worker_instance_id = Some(instance_id.to_string());
9870        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9871            self.send_attend_hired_worker(instance_id, false).await?;
9872            self.state.attending_worker_instance_id = None;
9873        }
9874        Ok(())
9875    }
9876
9877    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9878        let Some(id) = self.state.attending_worker_instance_id.take() else {
9879            return Ok(());
9880        };
9881        self.send_attend_hired_worker(&id, false).await
9882    }
9883
9884    async fn send_attend_hired_worker(
9885        &mut self,
9886        worker_instance_id: &str,
9887        attending: bool,
9888    ) -> anyhow::Result<()> {
9889        self.seq += 1;
9890        self.session
9891            .submit_intent(Intent::AttendHiredWorker {
9892                entity_id: self.state.entity_id,
9893                worker_instance_id: worker_instance_id.to_string(),
9894                attending,
9895                seq: self.seq,
9896            })
9897            .await?;
9898        self.state.intents_sent += 1;
9899        Ok(())
9900    }
9901
9902    pub fn workers_menu_move(&mut self, delta: i32) {
9903        let n = self.state.hired_workers.len();
9904        if n == 0 {
9905            return;
9906        }
9907        let idx = self.state.workers_menu_index as i32;
9908        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9909    }
9910
9911    pub fn toggle_workers_menu_compact(&mut self) {
9912        self.state.workers_menu_compact = !self.state.workers_menu_compact;
9913        let mut cfg = crate::client_config::ClientConfig::load();
9914        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9915    }
9916
9917    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9918        let Some(worker) = self
9919            .state
9920            .hired_workers
9921            .get(self.state.workers_menu_index)
9922            .cloned()
9923        else {
9924            anyhow::bail!("no worker selected");
9925        };
9926        self.seq += 1;
9927        self.session
9928            .submit_intent(Intent::DismissWorker {
9929                entity_id: self.state.entity_id,
9930                worker_instance_id: worker.instance_id.clone(),
9931                seq: self.seq,
9932            })
9933            .await?;
9934        self.state.intents_sent += 1;
9935        self.state
9936            .hired_workers
9937            .retain(|w| w.instance_id != worker.instance_id);
9938        if self.state.workers_menu_index >= self.state.hired_workers.len() {
9939            self.state.workers_menu_index = self
9940                .state
9941                .hired_workers
9942                .len()
9943                .saturating_sub(1);
9944        }
9945        self.state.push_log(format!("Dismissed {}", worker.label));
9946        Ok(())
9947    }
9948
9949    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9950        let Some(worker) = self
9951            .state
9952            .hired_workers
9953            .get(self.state.workers_menu_index)
9954            .cloned()
9955        else {
9956            anyhow::bail!("no worker selected");
9957        };
9958        let mode = match worker.mode {
9959            flatland_protocol::WorkerModeView::Companion => "job_loop",
9960            flatland_protocol::WorkerModeView::JobLoop => "idle",
9961            flatland_protocol::WorkerModeView::Idle => "companion",
9962        };
9963        self.seq += 1;
9964        self.session
9965            .submit_intent(Intent::SetWorkerMode {
9966                entity_id: self.state.entity_id,
9967                worker_instance_id: worker.instance_id,
9968                mode: mode.into(),
9969                seq: self.seq,
9970            })
9971            .await?;
9972        self.state.intents_sent += 1;
9973        Ok(())
9974    }
9975
9976    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9977        if self.state.hired_workers.is_empty() {
9978            return self.hire_worker_laborer().await;
9979        }
9980        self.workers_toggle_mode_selected().await
9981    }
9982
9983    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
9984    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
9985    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9986        let row = self
9987            .state
9988            .inventory_selected_row()
9989            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9990            .clone();
9991        if row.from != flatland_protocol::InventoryLocation::Root {
9992            anyhow::bail!("select a carried item to give");
9993        }
9994        let Some(instance_id) = row.stack.item_instance_id else {
9995            anyhow::bail!("that stack can't be given");
9996        };
9997        let options = self.nearby_worker_give_targets();
9998        if options.is_empty() {
9999            anyhow::bail!(
10000                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10001            );
10002        }
10003        let item_label = row
10004            .stack
10005            .display_name
10006            .as_deref()
10007            .unwrap_or(&row.stack.template_id)
10008            .to_string();
10009        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10010            item_instance_id: instance_id,
10011            item_label,
10012            quantity: None,
10013            options,
10014        });
10015        self.state.worker_give_target_picker_index = 0;
10016        self.state.show_worker_give_target_picker = true;
10017        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
10018        self.state.show_inventory_menu = false;
10019        Ok(())
10020    }
10021
10022    /// Hired workers within give/take range, nearest first.
10023    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10024        let (px, py, _) = self.state.player_position_with_z();
10025        let mut options: Vec<WorkerGiveTargetOption> = self
10026            .state
10027            .hired_workers
10028            .iter()
10029            .filter_map(|w| {
10030                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10031                if dist > WORKER_GIVE_RANGE_M {
10032                    return None;
10033                }
10034                Some(WorkerGiveTargetOption {
10035                    instance_id: w.instance_id.clone(),
10036                    label: w.label.clone(),
10037                    distance_m: dist,
10038                })
10039            })
10040            .collect();
10041        options.sort_by(|a, b| {
10042            a.distance_m
10043                .partial_cmp(&b.distance_m)
10044                .unwrap_or(std::cmp::Ordering::Equal)
10045        });
10046        options
10047    }
10048
10049    pub fn close_worker_give_target_picker(&mut self) {
10050        self.state.show_worker_give_target_picker = false;
10051        self.state.worker_give_target_picker = None;
10052        self.state.worker_give_target_picker_index = 0;
10053    }
10054
10055    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10056        let Some(picker) = &self.state.worker_give_target_picker else {
10057            return;
10058        };
10059        let n = picker.options.len();
10060        if n == 0 {
10061            return;
10062        }
10063        let idx = self.state.worker_give_target_picker_index as i32;
10064        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10065    }
10066
10067    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10068        let Some(picker) = self.state.worker_give_target_picker.clone() else {
10069            anyhow::bail!("give target picker not open");
10070        };
10071        let Some(opt) = picker
10072            .options
10073            .get(self.state.worker_give_target_picker_index)
10074            .cloned()
10075        else {
10076            anyhow::bail!("no worker selected");
10077        };
10078        let Some(worker) = self
10079            .state
10080            .hired_workers
10081            .iter()
10082            .find(|w| w.instance_id == opt.instance_id)
10083            .cloned()
10084        else {
10085            self.close_worker_give_target_picker();
10086            anyhow::bail!("worker no longer hired");
10087        };
10088        self.give_item_to_worker(
10089            &worker.instance_id,
10090            &worker.label,
10091            worker.x,
10092            worker.y,
10093            picker.item_instance_id,
10094            &picker.item_label,
10095            picker.quantity,
10096        )
10097        .await?;
10098        self.close_worker_give_target_picker();
10099        Ok(())
10100    }
10101
10102    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
10103    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10104        self.open_worker_give_target_picker()
10105    }
10106
10107    /// Open the workers-menu give picker for the selected worker (must be in range).
10108    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10109        let Some(worker) = self
10110            .state
10111            .hired_workers
10112            .get(self.state.workers_menu_index)
10113            .cloned()
10114        else {
10115            anyhow::bail!("select a hired worker first");
10116        };
10117        let (px, py, _) = self.state.player_position_with_z();
10118        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10119        if dist > WORKER_GIVE_RANGE_M {
10120            anyhow::bail!(
10121                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10122                worker.label
10123            );
10124        }
10125        let options = self.state.giveable_inventory_options();
10126        if options.is_empty() {
10127            anyhow::bail!("nothing in inventory to give");
10128        }
10129        self.state.worker_give_picker = Some(WorkerGivePicker {
10130            worker_instance_id: worker.instance_id,
10131            worker_label: worker.label,
10132            options,
10133        });
10134        self.state.worker_give_picker_index = 0;
10135        self.state.show_worker_give_picker = true;
10136        Ok(())
10137    }
10138
10139    pub fn close_worker_give_picker(&mut self) {
10140        self.state.show_worker_give_picker = false;
10141        self.state.worker_give_picker = None;
10142        self.state.worker_give_picker_index = 0;
10143    }
10144
10145    pub fn worker_give_picker_move(&mut self, delta: i32) {
10146        let Some(picker) = &self.state.worker_give_picker else {
10147            return;
10148        };
10149        let n = picker.options.len();
10150        if n == 0 {
10151            return;
10152        }
10153        let idx = self.state.worker_give_picker_index as i32;
10154        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10155    }
10156
10157    /// Confirm the selected row in the workers-menu give picker.
10158    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10159        let Some(picker) = self.state.worker_give_picker.clone() else {
10160            anyhow::bail!("give picker not open");
10161        };
10162        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
10163            anyhow::bail!("no item selected");
10164        };
10165        let Some(worker) = self
10166            .state
10167            .hired_workers
10168            .iter()
10169            .find(|w| w.instance_id == picker.worker_instance_id)
10170            .cloned()
10171        else {
10172            self.close_worker_give_picker();
10173            anyhow::bail!("worker no longer hired");
10174        };
10175        self.give_item_to_worker(
10176            &worker.instance_id,
10177            &worker.label,
10178            worker.x,
10179            worker.y,
10180            opt.item_instance_id,
10181            &opt.label,
10182            None,
10183        )
10184        .await?;
10185        // Refresh options (stack may be gone / reduced) or close when empty.
10186        let options = self.state.giveable_inventory_options();
10187        if options.is_empty() {
10188            self.close_worker_give_picker();
10189        } else {
10190            self.state.worker_give_picker = Some(WorkerGivePicker {
10191                worker_instance_id: picker.worker_instance_id,
10192                worker_label: picker.worker_label,
10193                options,
10194            });
10195            if self.state.worker_give_picker_index
10196                >= self
10197                    .state
10198                    .worker_give_picker
10199                    .as_ref()
10200                    .map(|p| p.options.len())
10201                    .unwrap_or(0)
10202            {
10203                self.state.worker_give_picker_index = self
10204                    .state
10205                    .worker_give_picker
10206                    .as_ref()
10207                    .map(|p| p.options.len().saturating_sub(1))
10208                    .unwrap_or(0);
10209            }
10210        }
10211        Ok(())
10212    }
10213
10214    /// Open the workers-menu teach picker for the selected worker (must be in range).
10215    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10216        let Some(worker) = self
10217            .state
10218            .hired_workers
10219            .get(self.state.workers_menu_index)
10220            .cloned()
10221        else {
10222            anyhow::bail!("select a hired worker first");
10223        };
10224        let (px, py, _) = self.state.player_position_with_z();
10225        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10226        if dist > WORKER_GIVE_RANGE_M {
10227            anyhow::bail!(
10228                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10229                worker.label
10230            );
10231        }
10232        let options = self.state.teachable_blueprint_options(&worker);
10233        if options.is_empty() {
10234            anyhow::bail!("no recipes you know that {} still needs", worker.label);
10235        }
10236        self.state.worker_teach_picker = Some(WorkerTeachPicker {
10237            worker_instance_id: worker.instance_id,
10238            worker_label: worker.label,
10239            worker_level: worker.level,
10240            options,
10241        });
10242        self.state.worker_teach_picker_index = 0;
10243        self.state.show_worker_teach_picker = true;
10244        Ok(())
10245    }
10246
10247    pub fn close_worker_teach_picker(&mut self) {
10248        self.state.show_worker_teach_picker = false;
10249        self.state.worker_teach_picker = None;
10250        self.state.worker_teach_picker_index = 0;
10251    }
10252
10253    pub fn worker_teach_picker_move(&mut self, delta: i32) {
10254        let Some(picker) = &self.state.worker_teach_picker else {
10255            return;
10256        };
10257        let n = picker.options.len();
10258        if n == 0 {
10259            return;
10260        }
10261        let idx = self.state.worker_teach_picker_index as i32;
10262        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10263    }
10264
10265    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10266        let Some(picker) = self.state.worker_teach_picker.clone() else {
10267            anyhow::bail!("teach picker not open");
10268        };
10269        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
10270            anyhow::bail!("nothing selected");
10271        };
10272        if !opt.level_ok {
10273            anyhow::bail!(
10274                "{} needs level {} (is level {})",
10275                picker.worker_label,
10276                opt.min_level,
10277                opt.worker_level
10278            );
10279        }
10280        if !opt.can_afford {
10281            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10282        }
10283        let Some(worker) = self
10284            .state
10285            .hired_workers
10286            .iter()
10287            .find(|w| w.instance_id == picker.worker_instance_id)
10288            .cloned()
10289        else {
10290            anyhow::bail!("worker gone");
10291        };
10292        let (px, py, _) = self.state.player_position_with_z();
10293        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10294        if dist > WORKER_GIVE_RANGE_M {
10295            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10296        }
10297        self.seq += 1;
10298        self.session
10299            .submit_intent(Intent::TeachWorkerBlueprint {
10300                entity_id: self.state.entity_id,
10301                worker_instance_id: picker.worker_instance_id.clone(),
10302                blueprint_id: opt.blueprint_id.clone(),
10303                seq: self.seq,
10304            })
10305            .await?;
10306        self.state.intents_sent += 1;
10307        self.state.push_log(format!(
10308            "Teaching {} to {} ({} cp)",
10309            opt.label, picker.worker_label, opt.cost_copper
10310        ));
10311        self.close_worker_teach_picker();
10312        Ok(())
10313    }
10314
10315    async fn give_item_to_worker(
10316        &mut self,
10317        worker_instance_id: &str,
10318        worker_label: &str,
10319        worker_x: f32,
10320        worker_y: f32,
10321        item_instance_id: uuid::Uuid,
10322        item_label: &str,
10323        quantity: Option<u32>,
10324    ) -> anyhow::Result<()> {
10325        let (px, py, _) = self.state.player_position_with_z();
10326        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10327        if dist > WORKER_GIVE_RANGE_M {
10328            anyhow::bail!("worker {worker_label} too far — stand next to them");
10329        }
10330        self.seq += 1;
10331        self.session
10332            .submit_intent(Intent::GiveWorkerItem {
10333                entity_id: self.state.entity_id,
10334                worker_instance_id: worker_instance_id.to_string(),
10335                item_instance_id,
10336                quantity,
10337                seq: self.seq,
10338            })
10339            .await?;
10340        self.state.intents_sent += 1;
10341        self.state
10342            .push_log(format!("Gave {item_label} to {worker_label}"));
10343        Ok(())
10344    }
10345
10346    /// Open the workers-menu take picker for the selected worker (must be in range).
10347    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10348        let Some(worker) = self
10349            .state
10350            .hired_workers
10351            .get(self.state.workers_menu_index)
10352            .cloned()
10353        else {
10354            anyhow::bail!("select a hired worker first");
10355        };
10356        let (px, py, _) = self.state.player_position_with_z();
10357        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10358        if dist > WORKER_GIVE_RANGE_M {
10359            anyhow::bail!(
10360                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10361                worker.label
10362            );
10363        }
10364        let options = Self::worker_inventory_options(&worker);
10365        if options.is_empty() {
10366            anyhow::bail!("{} isn't carrying anything", worker.label);
10367        }
10368        let initial_qty = options
10369            .first()
10370            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10371            .unwrap_or(1);
10372        self.state.worker_take_picker = Some(WorkerTakePicker {
10373            worker_instance_id: worker.instance_id,
10374            worker_label: worker.label,
10375            options,
10376            quantity: initial_qty,
10377        });
10378        self.state.worker_take_picker_index = 0;
10379        self.state.show_worker_take_picker = true;
10380        Ok(())
10381    }
10382
10383    fn worker_inventory_options(
10384        worker: &flatland_protocol::HiredWorkerView,
10385    ) -> Vec<WorkerGiveOption> {
10386        worker
10387            .inventory
10388            .iter()
10389            .filter_map(|stack| {
10390                let item_instance_id = stack.item_instance_id?;
10391                let label = stack
10392                    .display_name
10393                    .clone()
10394                    .unwrap_or_else(|| stack.template_id.clone());
10395                let label = if stack.quantity > 1 {
10396                    format!("{label} ×{}", stack.quantity)
10397                } else {
10398                    label
10399                };
10400                Some(WorkerGiveOption {
10401                    item_instance_id,
10402                    label,
10403                    quantity: stack.quantity,
10404                    template_id: stack.template_id.clone(),
10405                })
10406            })
10407            .collect()
10408    }
10409
10410    pub fn close_worker_take_picker(&mut self) {
10411        self.state.show_worker_take_picker = false;
10412        self.state.worker_take_picker = None;
10413        self.state.worker_take_picker_index = 0;
10414    }
10415
10416    pub fn worker_take_picker_move(&mut self, delta: i32) {
10417        let Some(picker) = &self.state.worker_take_picker else {
10418            return;
10419        };
10420        let n = picker.options.len();
10421        if n == 0 {
10422            return;
10423        }
10424        let idx = self.state.worker_take_picker_index as i32;
10425        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10426        self.clamp_worker_take_quantity();
10427    }
10428
10429    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10430        let Some(picker) = &mut self.state.worker_take_picker else {
10431            return;
10432        };
10433        let max = picker
10434            .options
10435            .get(self.state.worker_take_picker_index)
10436            .map(|o| o.quantity.max(1))
10437            .unwrap_or(1);
10438        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10439        picker.quantity = next as u32;
10440    }
10441
10442    pub fn worker_take_picker_set_quantity_max(&mut self) {
10443        let Some(picker) = &mut self.state.worker_take_picker else {
10444            return;
10445        };
10446        let max = picker
10447            .options
10448            .get(self.state.worker_take_picker_index)
10449            .map(|o| o.quantity.max(1))
10450            .unwrap_or(1);
10451        picker.quantity = max;
10452    }
10453
10454    pub fn worker_take_picker_set_quantity_min(&mut self) {
10455        let Some(picker) = &mut self.state.worker_take_picker else {
10456            return;
10457        };
10458        picker.quantity = 1;
10459        self.clamp_worker_take_quantity();
10460    }
10461
10462    fn clamp_worker_take_quantity(&mut self) {
10463        let Some(picker) = &mut self.state.worker_take_picker else {
10464            return;
10465        };
10466        let max = picker
10467            .options
10468            .get(self.state.worker_take_picker_index)
10469            .map(|o| o.quantity.max(1))
10470            .unwrap_or(1);
10471        if picker.quantity == 0 || picker.quantity > max {
10472            picker.quantity = if max > 1 { 1 } else { max };
10473        }
10474    }
10475
10476    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10477        let Some(picker) = self.state.worker_take_picker.clone() else {
10478            anyhow::bail!("take picker not open");
10479        };
10480        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
10481            anyhow::bail!("no item selected");
10482        };
10483        let Some(worker) = self
10484            .state
10485            .hired_workers
10486            .iter()
10487            .find(|w| w.instance_id == picker.worker_instance_id)
10488            .cloned()
10489        else {
10490            self.close_worker_take_picker();
10491            anyhow::bail!("worker no longer hired");
10492        };
10493        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10494        let intent_qty = if qty >= opt.quantity {
10495            None
10496        } else {
10497            Some(qty)
10498        };
10499        self.take_item_from_worker(
10500            &worker.instance_id,
10501            &worker.label,
10502            worker.x,
10503            worker.y,
10504            opt.item_instance_id,
10505            &opt.label,
10506            intent_qty,
10507        )
10508        .await?;
10509        // Leave the picker open — server Interaction / hired-workers sync refresh
10510        // options. Do not optimistic-strip stacks (rejects used to look like success).
10511        Ok(())
10512    }
10513
10514    async fn take_item_from_worker(
10515        &mut self,
10516        worker_instance_id: &str,
10517        worker_label: &str,
10518        worker_x: f32,
10519        worker_y: f32,
10520        item_instance_id: uuid::Uuid,
10521        item_label: &str,
10522        quantity: Option<u32>,
10523    ) -> anyhow::Result<()> {
10524        let (px, py, _) = self.state.player_position_with_z();
10525        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10526        if dist > WORKER_GIVE_RANGE_M {
10527            anyhow::bail!("worker {worker_label} too far — stand next to them");
10528        }
10529        self.seq += 1;
10530        self.session
10531            .submit_intent(Intent::TakeWorkerItem {
10532                entity_id: self.state.entity_id,
10533                worker_instance_id: worker_instance_id.to_string(),
10534                item_instance_id,
10535                quantity,
10536                seq: self.seq,
10537            })
10538            .await?;
10539        self.state.intents_sent += 1;
10540        let qty_note = quantity
10541            .map(|q| format!(" ×{q}"))
10542            .unwrap_or_default();
10543        self.state
10544            .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
10545        Ok(())
10546    }
10547
10548    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
10549        if !self.state.has_worker_lodging() {
10550            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
10551        }
10552        self.seq += 1;
10553        self.session
10554            .submit_intent(Intent::HireWorker {
10555                entity_id: self.state.entity_id,
10556                def_id: "worker_laborer".into(),
10557                wage_copper_per_interval: 8,
10558                lodging_container_id: None,
10559                job_yaml: None,
10560                seq: self.seq,
10561            })
10562            .await?;
10563        self.state.intents_sent += 1;
10564        Ok(())
10565    }
10566
10567    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
10568        let Some(worker) = self
10569            .state
10570            .hired_workers
10571            .get(self.state.workers_menu_index)
10572            .cloned()
10573        else {
10574            anyhow::bail!("select a hired worker first");
10575        };
10576        let lodging = worker.lodging_container_id.clone().or_else(|| {
10577            crate::worker_route_editor::owned_lodging_container_ids(
10578                &self.state.placed_containers,
10579                self.state.character_id,
10580            )
10581            .into_iter()
10582            .next()
10583            .map(|(id, _)| id)
10584        });
10585        let label = worker.label.clone();
10586        let editor = if let Some(route) = &worker.route {
10587            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
10588                worker.instance_id,
10589                worker.label,
10590                route,
10591                lodging,
10592            )
10593        } else {
10594            crate::worker_route_editor::WorkerRouteEditorState::new(
10595                worker.instance_id,
10596                worker.label,
10597                lodging,
10598            )
10599        };
10600        self.state.worker_route_editor = Some(editor);
10601        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10602            if let Some(collapsed) =
10603                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
10604            {
10605                ed.panel_collapsed = collapsed;
10606            }
10607        }
10608        self.state.show_workers_menu = false;
10609        self.state.push_log(format!(
10610            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
10611        ));
10612        Ok(())
10613    }
10614
10615    pub fn close_worker_route_editor(&mut self) {
10616        self.state.worker_route_editor = None;
10617    }
10618
10619    pub fn worker_route_editor_toggle_panel(&mut self) {
10620        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10621            ed.toggle_panel_collapsed();
10622            let collapsed = ed.panel_collapsed;
10623            let mut cfg = crate::client_config::ClientConfig::load();
10624            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
10625        }
10626    }
10627
10628    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
10629        let n = {
10630            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10631                return;
10632            };
10633            ed.append_waypoint(x, y, z);
10634            ed.stop_count()
10635        };
10636        self.state
10637            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
10638    }
10639
10640    // ---- picker candidate snapshots ----------------------------------
10641
10642    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
10643        let (px, py, _) = self.state.player_position_with_z();
10644        let inside = self.state.effective_inside_building();
10645        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
10646            &self.state.placed_containers,
10647            &self.state.buildings,
10648            self.state.character_id,
10649            px,
10650            py,
10651            &self.state.hired_workers,
10652            inside.as_deref(),
10653        )
10654    }
10655
10656    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
10657        self.state.route_editor_node_candidates()
10658    }
10659
10660    fn re_open_harvest_picker(
10661        &mut self,
10662        index: usize,
10663        picked: std::collections::BTreeSet<String>,
10664    ) {
10665        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
10666        let nodes = self.state.route_editor_node_candidates();
10667        let index = if nodes.is_empty() {
10668            ROUTE_PICKER_DONE_ROW
10669        } else {
10670            index.max(1).min(nodes.len())
10671        };
10672        self.re_open_sheet(S::HarvestPicker {
10673            index,
10674            picked,
10675            nodes,
10676        });
10677    }
10678
10679    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10680        let (px, py, _) = self.state.player_position_with_z();
10681        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10682    }
10683
10684    fn re_template_candidates(&self) -> Vec<String> {
10685        let mut extra = Vec::new();
10686        if let Some(ed) = self.state.worker_route_editor.as_ref() {
10687            for stop in &ed.stops {
10688                match stop {
10689                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
10690                        filter: Some(filter),
10691                        ..
10692                    } => extra.extend(filter.iter().cloned()),
10693                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10694                        extra.push(template.clone());
10695                    }
10696                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10697                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10698                            extra.push(bp.output.clone());
10699                            for input in &bp.inputs {
10700                                extra.push(input.template_id.clone());
10701                            }
10702                        }
10703                    }
10704                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10705                        for it in items {
10706                            extra.push(it.template.clone());
10707                        }
10708                    }
10709                    _ => {}
10710                }
10711            }
10712            // Worker-known recipe outputs even if the player hasn't learned them yet.
10713            if let Some(worker) = self
10714                .state
10715                .hired_workers
10716                .iter()
10717                .find(|w| w.instance_id == ed.worker_instance_id)
10718            {
10719                for recipe in &worker.known_blueprint_ids {
10720                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10721                        extra.push(bp.output.clone());
10722                    }
10723                }
10724            }
10725        }
10726        crate::worker_route_editor::route_item_template_candidates(
10727            &self.state.placed_containers,
10728            self.state.character_id,
10729            &self.state.inventory,
10730            &self.state.blueprints,
10731            &self.state.resource_nodes,
10732            &extra,
10733        )
10734    }
10735
10736    fn re_blueprint_ids(&self) -> Vec<String> {
10737        let worker_known: Option<&[String]> = self
10738            .state
10739            .worker_route_editor
10740            .as_ref()
10741            .and_then(|ed| {
10742                self.state
10743                    .hired_workers
10744                    .iter()
10745                    .find(|w| w.instance_id == ed.worker_instance_id)
10746            })
10747            .map(|w| w.known_blueprint_ids.as_slice());
10748        crate::worker_route_editor::worker_craft_blueprint_ids(
10749            &self.state.blueprints,
10750            worker_known,
10751        )
10752    }
10753
10754    fn re_bed_candidates(&self) -> Vec<(String, String)> {
10755        crate::worker_route_editor::owned_lodging_container_ids(
10756            &self.state.placed_containers,
10757            self.state.character_id,
10758        )
10759    }
10760
10761    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10762        self.state
10763            .placed_containers
10764            .iter()
10765            .find(|c| c.id == container_id)
10766            .map(|c| c.contents.clone())
10767            .unwrap_or_default()
10768    }
10769
10770    // ---- sheet navigation (`plans/33`) --------------------------------
10771
10772    fn re_sheet_supports_filter(&self) -> bool {
10773        use crate::worker_route_editor::RouteEditorSheet as S;
10774        self.state
10775            .worker_route_editor
10776            .as_ref()
10777            .is_some_and(|ed| {
10778                matches!(
10779                    ed.sheet,
10780                    S::HarvestPicker { .. }
10781                        | S::SellItem { .. }
10782                        | S::DepositFilter { .. }
10783                        | S::WithdrawItems { .. }
10784                        | S::WithdrawContainers { .. }
10785                        | S::DepositContainers { .. }
10786                        | S::SellNpcs { .. }
10787                        | S::CraftBlueprint { .. }
10788                        | S::BedPicker { .. }
10789                )
10790            })
10791    }
10792
10793    /// Whether a sheet row is shown under the current `/` filter.
10794    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10795        use crate::worker_route_editor::{
10796            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10797            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10798        };
10799        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10800            return false;
10801        };
10802        let filter = &ed.sheet_filter;
10803        match &ed.sheet {
10804            S::HarvestPicker { nodes, .. } => {
10805                harvest_picker_row_matches(nodes, row, filter)
10806            }
10807            S::SellItem { templates, .. } => {
10808                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10809                    return true;
10810                }
10811                let slot = row.saturating_sub(2);
10812                templates.get(slot).is_some_and(|t| {
10813                    let label = self.state.template_display_name(t);
10814                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10815                })
10816            }
10817            S::DepositFilter { rows, .. } => {
10818                if row >= rows.len() {
10819                    return true;
10820                }
10821                rows.get(row).is_some_and(|(t, _)| {
10822                    let label = self.state.template_display_name(t);
10823                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10824                })
10825            }
10826            S::WithdrawItems { lines, .. } => {
10827                if row >= lines.len() {
10828                    return true;
10829                }
10830                lines.get(row).is_some_and(|l| {
10831                    let label = self.state.template_display_name(&l.template);
10832                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10833                })
10834            }
10835            S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10836                .re_container_candidates()
10837                .get(row)
10838                .is_some_and(|c| {
10839                    list_filter_row_matches(
10840                        filter,
10841                        Some(c.dist),
10842                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10843                    )
10844                }),
10845            S::SellNpcs { .. } => {
10846                if row == 0 {
10847                    return true;
10848                }
10849                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10850                    list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10851                })
10852            }
10853            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10854                let label = self
10855                    .state
10856                    .blueprints
10857                    .iter()
10858                    .find(|b| &b.id == id)
10859                    .map(|b| {
10860                        if b.label.is_empty() {
10861                            id.as_str()
10862                        } else {
10863                            b.label.as_str()
10864                        }
10865                    })
10866                    .unwrap_or(id.as_str());
10867                list_filter_row_matches(filter, None, &[id.as_str(), label])
10868            }),
10869            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10870                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10871            }),
10872            _ => true,
10873        }
10874    }
10875
10876    fn re_sheet_clamp_index(&mut self) {
10877        let count = self.re_sheet_row_count();
10878        if count == 0 {
10879            return;
10880        }
10881        let cur = self.re_sheet_index();
10882        if self.re_sheet_row_visible(cur) {
10883            return;
10884        }
10885        for offset in 1..count {
10886            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10887                self.re_sheet_set_index(cur + offset);
10888                return;
10889            }
10890            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10891                self.re_sheet_set_index(cur - offset);
10892                return;
10893            }
10894        }
10895    }
10896
10897    fn re_sheet_set_index(&mut self, index: usize) {
10898        use crate::worker_route_editor::RouteEditorSheet as S;
10899        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10900            return;
10901        };
10902        match &mut ed.sheet {
10903            S::AddMenu { index: slot }
10904            | S::WaypointMenu { index: slot }
10905            | S::HarvestPicker { index: slot, .. }
10906            | S::WithdrawContainers { index: slot }
10907            | S::DepositContainers { index: slot }
10908            | S::SellNpcs { index: slot }
10909            | S::CraftBlueprint { index: slot }
10910            | S::BedPicker { index: slot }
10911            | S::FarmPlotPicker { index: slot, .. }
10912            | S::FarmPlantSeed { index: slot, .. }
10913            | S::WithdrawItems { index: slot, .. }
10914            | S::DepositFilter { index: slot, .. }
10915            | S::SellItem { index: slot, .. } => *slot = index,
10916            _ => {}
10917        }
10918    }
10919
10920    pub fn re_focus_sheet_filter(&mut self) {
10921        if !self.re_sheet_supports_filter() {
10922            return;
10923        }
10924        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10925            ed.sheet_filter_focused = true;
10926        }
10927    }
10928
10929    pub fn re_blur_sheet_filter_keep_text(&mut self) {
10930        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10931            return;
10932        };
10933        if !ed.sheet_filter_focused {
10934            return;
10935        }
10936        ed.sheet_filter_focused = false;
10937        self.re_sheet_clamp_index();
10938    }
10939
10940    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10941        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10942            return false;
10943        };
10944        if ed.sheet_filter_focused {
10945            ed.sheet_filter_focused = false;
10946            self.re_sheet_clamp_index();
10947            return true;
10948        }
10949        if !ed.sheet_filter.is_empty() {
10950            ed.sheet_filter.clear();
10951            self.re_sheet_clamp_index();
10952            return true;
10953        }
10954        false
10955    }
10956
10957    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10958        if ch.is_control() {
10959            return;
10960        }
10961        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10962            return;
10963        };
10964        if !ed.sheet_filter_focused {
10965            return;
10966        }
10967        ed.sheet_filter.push(ch);
10968        self.re_sheet_set_index(0);
10969        self.re_sheet_clamp_index();
10970    }
10971
10972    pub fn re_sheet_filter_backspace(&mut self) {
10973        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10974            return;
10975        };
10976        if !ed.sheet_filter_focused {
10977            return;
10978        }
10979        ed.sheet_filter.pop();
10980        self.re_sheet_set_index(0);
10981        self.re_sheet_clamp_index();
10982    }
10983
10984    /// Row count of the current sheet (for cursor wrapping).
10985    pub fn re_sheet_row_count(&self) -> usize {
10986        use crate::worker_route_editor::{
10987            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10988        };
10989        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10990            return 0;
10991        };
10992        match &ed.sheet {
10993            S::Stops => ed.stops.len(),
10994            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10995            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10996            S::WaypointMapPick => 0,
10997            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10998            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10999                self.re_container_candidates().len()
11000            }
11001            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
11002            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
11003            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
11004            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
11005            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11006            S::WaitEntry { .. } => 1,
11007            S::BedPicker { .. } => self.re_bed_candidates().len(),
11008            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11009            S::FarmPlantSeed { seeds, .. } => seeds.len(),
11010        }
11011    }
11012
11013    /// Current sheet cursor index (0 for sheets without one).
11014    pub fn re_sheet_index(&self) -> usize {
11015        use crate::worker_route_editor::RouteEditorSheet as S;
11016        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11017            return 0;
11018        };
11019        match &ed.sheet {
11020            S::AddMenu { index }
11021            | S::WaypointMenu { index }
11022            | S::HarvestPicker { index, .. }
11023            | S::WithdrawContainers { index }
11024            | S::DepositContainers { index }
11025            | S::SellNpcs { index }
11026            | S::CraftBlueprint { index }
11027            | S::BedPicker { index }
11028            | S::FarmPlotPicker { index, .. }
11029            | S::FarmPlantSeed { index, .. }
11030            | S::WithdrawItems { index, .. }
11031            | S::DepositFilter { index, .. }
11032            | S::SellItem { index, .. } => *index,
11033            _ => 0,
11034        }
11035    }
11036
11037    /// Move the current sheet's cursor, wrapping within its rows.
11038    pub fn re_sheet_move(&mut self, delta: i32) {
11039        let count = self.re_sheet_row_count();
11040        if count == 0 {
11041            return;
11042        }
11043        let cur = self.re_sheet_index();
11044        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11045        self.re_sheet_set_index(next);
11046    }
11047
11048    pub fn re_sheet_page(&mut self, pages: i32) {
11049        let count = self.re_sheet_row_count();
11050        if count == 0 {
11051            return;
11052        }
11053        let cur = self.re_sheet_index();
11054        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11055        self.re_sheet_set_index(next);
11056    }
11057
11058    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
11059    pub fn re_sheet_adjust(&mut self, delta: i32) {
11060        use crate::worker_route_editor::RouteEditorSheet as S;
11061        let index = self.re_sheet_index();
11062        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11063            return;
11064        };
11065        match &mut ed.sheet {
11066            S::WithdrawItems { lines, .. } => {
11067                if let Some(line) = lines.get_mut(index) {
11068                    line.adjust_qty(delta);
11069                }
11070            }
11071            S::WaitEntry { ticks } => {
11072                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11073            }
11074            _ => {}
11075        }
11076    }
11077
11078    pub fn re_sheet_back(&mut self) {
11079        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11080            return;
11081        };
11082        use crate::worker_route_editor::RouteEditorSheet as S;
11083        let was_editing = ed.editing_index.is_some();
11084        let from_top_picker = matches!(
11085            ed.sheet,
11086            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11087        );
11088        ed.sheet_back();
11089        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11090            // Chest retarget commits on pick; Esc here just ends the edit sheet.
11091            self.state
11092                .push_log("Route: left edit sheet — press s to save current stops".to_string());
11093        }
11094    }
11095
11096    /// True when the editor is at its root sheet (Esc should close it).
11097    pub fn re_at_root_sheet(&self) -> bool {
11098        self.state
11099            .worker_route_editor
11100            .as_ref()
11101            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
11102    }
11103
11104    pub fn re_open_add_menu(&mut self) {
11105        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11106            ed.open_add_menu();
11107        }
11108    }
11109
11110    pub fn re_open_bed_picker(&mut self) {
11111        let beds = self.re_bed_candidates();
11112        if beds.is_empty() {
11113            self.state
11114                .push_log("Route: place a camp bed first".to_string());
11115            return;
11116        }
11117        let current = self
11118            .state
11119            .worker_route_editor
11120            .as_ref()
11121            .and_then(|ed| ed.lodging_container_id.clone());
11122        let index = current
11123            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11124            .unwrap_or(0);
11125        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11126    }
11127
11128    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11129        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11130            ed.open_sheet(sheet);
11131        }
11132    }
11133
11134    /// Confirm a sheet's stop and log the outcome.
11135    fn re_confirm_stop(
11136        &mut self,
11137        stop: crate::worker_route_editor::WorkerRouteStop,
11138        what: String,
11139    ) {
11140        let appended = self
11141            .state
11142            .worker_route_editor
11143            .as_mut()
11144            .is_some_and(|ed| ed.confirm_stop(stop));
11145        if appended {
11146            self.state.push_log(format!("Route: + {what}"));
11147        } else {
11148            self.state
11149                .push_log(format!("Route: {what} already in route — selected it"));
11150        }
11151    }
11152
11153    fn re_open_withdraw_items(&mut self, container_id: String) {
11154        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11155        let contents = self.re_container_contents(&container_id);
11156        // When editing, keep the stop's item picks even if the player retargets
11157        // to a different (possibly empty) chest — otherwise Done has nothing to
11158        // confirm and the edit appears to "revert".
11159        let existing = self
11160            .state
11161            .worker_route_editor
11162            .as_ref()
11163            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11164            .and_then(|stop| match stop {
11165                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11166                _ => None,
11167            })
11168            .unwrap_or_default();
11169        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11170        // Commit the new chest immediately while editing so Esc-before-Done still
11171        // keeps the retarget (items update only when Done is pressed).
11172        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11173            let _ = ed.retarget_withdraw_container(container_id.clone());
11174        }
11175        self.re_open_sheet(S::WithdrawItems {
11176            container_id,
11177            lines,
11178            index: 0,
11179        });
11180    }
11181
11182    fn re_withdraw_items_activate(&mut self, index: usize) {
11183        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11184        enum Outcome {
11185            Cycled,
11186            Confirmed(String),
11187            Empty,
11188        }
11189        let outcome = {
11190            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11191                return;
11192            };
11193            let S::WithdrawItems {
11194                container_id,
11195                lines,
11196                index: sheet_index,
11197            } = &mut ed.sheet
11198            else {
11199                return;
11200            };
11201            *sheet_index = index;
11202            if index < lines.len() {
11203                lines[index].cycle();
11204                Outcome::Cycled
11205            } else {
11206                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11207                if items.is_empty() {
11208                    Outcome::Empty
11209                } else {
11210                    let stop = WorkerRouteStop::WithdrawFrom {
11211                        container_id: container_id.clone(),
11212                        items,
11213                    };
11214                    let summary = stop.summary();
11215                    ed.confirm_stop(stop);
11216                    Outcome::Confirmed(summary)
11217                }
11218            }
11219        };
11220        match outcome {
11221            Outcome::Cycled => {}
11222            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11223            Outcome::Empty => self
11224                .state
11225                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
11226        }
11227    }
11228
11229    fn re_open_deposit_filter(&mut self, container_id: String) {
11230        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11231        // Preserve filter when retargeting the deposit chest while editing.
11232        let existing_filter = self
11233            .state
11234            .worker_route_editor
11235            .as_ref()
11236            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11237            .and_then(|stop| match stop {
11238                WorkerRouteStop::DepositAt { filter, .. } => {
11239                    Some(filter.clone().unwrap_or_default())
11240                }
11241                _ => None,
11242            });
11243        let mut candidates = self.re_template_candidates();
11244        if let Some(ref chosen) = existing_filter {
11245            for t in chosen {
11246                if !candidates.iter().any(|c| c == t) {
11247                    candidates.push(t.clone());
11248                }
11249            }
11250            candidates.sort();
11251            candidates.dedup();
11252        }
11253        let rows: Vec<(String, bool)> = match existing_filter {
11254            Some(chosen) => candidates
11255                .iter()
11256                .map(|t| (t.clone(), chosen.contains(t)))
11257                .collect(),
11258            None => candidates.into_iter().map(|t| (t, false)).collect(),
11259        };
11260        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11261            let _ = ed.retarget_deposit_container(container_id.clone());
11262        }
11263        self.re_open_sheet(S::DepositFilter {
11264            container_id,
11265            rows,
11266            index: 0,
11267        });
11268    }
11269
11270    fn re_deposit_filter_activate(&mut self, index: usize) {
11271        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11272        let mut confirmed: Option<String> = None;
11273        {
11274            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11275                return;
11276            };
11277            let S::DepositFilter {
11278                container_id,
11279                rows,
11280                index: sheet_index,
11281            } = &mut ed.sheet
11282            else {
11283                return;
11284            };
11285            *sheet_index = index;
11286            if index < rows.len() {
11287                rows[index].1 = !rows[index].1;
11288            } else {
11289                // "Done" row.
11290                let chosen: Vec<String> = rows
11291                    .iter()
11292                    .filter(|(_, on)| *on)
11293                    .map(|(t, _)| t.clone())
11294                    .collect();
11295                let filter = if chosen.is_empty() { None } else { Some(chosen) };
11296                let stop = WorkerRouteStop::DepositAt {
11297                    container_id: container_id.clone(),
11298                    filter,
11299                };
11300                confirmed = Some(stop.summary());
11301                ed.confirm_stop(stop);
11302            }
11303        }
11304        if let Some(what) = confirmed {
11305            self.state.push_log(format!("Route: + {what}"));
11306        }
11307    }
11308
11309    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11310        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11311        let templates = self.re_template_candidates();
11312        if templates.is_empty() {
11313            self.state.push_log(
11314                "Route: no item templates available — learn a craft recipe or place a harvest node first"
11315                    .to_string(),
11316            );
11317            return;
11318        }
11319        // Prefill when editing an existing sell stop.
11320        let (pre_npc, pre_template, pre_all) = self
11321            .state
11322            .worker_route_editor
11323            .as_ref()
11324            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11325            .and_then(|stop| match stop {
11326                WorkerRouteStop::TradeWith {
11327                    npc_id,
11328                    template,
11329                    sell_all,
11330                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11331                _ => None,
11332            })
11333            .unwrap_or((None, None, true));
11334        let npc_id = npc_id.or(pre_npc);
11335        let mut picked = std::collections::BTreeSet::new();
11336        if let Some(t) = pre_template {
11337            picked.insert(t);
11338        }
11339        self.re_open_sheet(S::SellItem {
11340            npc_id,
11341            templates,
11342            index: if picked.is_empty() {
11343                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11344            } else {
11345                2
11346            },
11347            sell_all: pre_all,
11348            picked,
11349        });
11350    }
11351
11352    fn re_sell_item_activate(&mut self, index: usize) {
11353        use crate::worker_route_editor::{
11354            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11355        };
11356        let mut batch_log: Option<String> = None;
11357        {
11358            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11359                return;
11360            };
11361            let S::SellItem {
11362                npc_id,
11363                templates,
11364                index: sheet_index,
11365                sell_all,
11366                picked,
11367            } = &mut ed.sheet
11368            else {
11369                return;
11370            };
11371            *sheet_index = index;
11372            if index == ROUTE_PICKER_DONE_ROW {
11373                if picked.is_empty() {
11374                    batch_log = Some(
11375                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
11376                    );
11377                } else {
11378                    let picks: Vec<String> = picked.iter().cloned().collect();
11379                    let npc = npc_id.clone();
11380                    let all = *sell_all;
11381                    let added = ed.confirm_trade_picks(npc, &picks, all);
11382                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
11383                }
11384            } else if index == SELL_ITEM_TOGGLE_ROW {
11385                *sell_all = !*sell_all;
11386            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11387                if picked.contains(template) {
11388                    picked.remove(template);
11389                } else {
11390                    picked.insert(template.clone());
11391                }
11392            }
11393        }
11394        if let Some(msg) = batch_log {
11395            self.state.push_log(msg);
11396        }
11397    }
11398
11399    /// Enter on the stop list: open the selected stop's sheet, prefilled.
11400    pub fn re_edit_selected_stop(&mut self) {
11401        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11402        let Some(stop) = self
11403            .state
11404            .worker_route_editor
11405            .as_ref()
11406            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11407        else {
11408            self.state
11409                .push_log("Route: no stop selected — press a to add one".to_string());
11410            return;
11411        };
11412        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11413            ed.begin_edit_selected();
11414        }
11415        match stop {
11416            WorkerRouteStop::Waypoint { .. } => {
11417                self.re_open_sheet(S::WaypointMenu { index: 0 });
11418            }
11419            WorkerRouteStop::HarvestNode { node_id } => {
11420                let nodes = self.state.route_editor_node_candidates();
11421                if nodes.is_empty() {
11422                    self.re_cancel_edit();
11423                    self.state
11424                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
11425                } else {
11426                    let mut picked = std::collections::BTreeSet::new();
11427                    picked.insert(node_id.clone());
11428                    let index = nodes
11429                        .iter()
11430                        .position(|n| n.id == node_id)
11431                        .map(|i| i + 1)
11432                        .unwrap_or(1);
11433                    self.re_open_harvest_picker(index, picked);
11434                }
11435            }
11436            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11437                // Open the container picker so the player can retarget storage
11438                // (previously jumped straight to items on the same chest).
11439                let containers = self.re_container_candidates();
11440                if containers.is_empty() {
11441                    self.re_cancel_edit();
11442                    self.state
11443                        .push_log("Route: place a storage chest first".to_string());
11444                } else {
11445                    let index = containers
11446                        .iter()
11447                        .position(|c| c.id == container_id)
11448                        .unwrap_or(0);
11449                    self.re_open_sheet(S::WithdrawContainers { index });
11450                }
11451            }
11452            WorkerRouteStop::DepositAt { container_id, .. } => {
11453                let containers = self.re_container_candidates();
11454                if containers.is_empty() {
11455                    self.re_cancel_edit();
11456                    self.state
11457                        .push_log("Route: place a storage chest first".to_string());
11458                } else {
11459                    let index = containers
11460                        .iter()
11461                        .position(|c| c.id == container_id)
11462                        .unwrap_or(0);
11463                    self.re_open_sheet(S::DepositContainers { index });
11464                }
11465            }
11466            WorkerRouteStop::TradeWith { npc_id, .. } => {
11467                let npcs = self.re_npc_candidates();
11468                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
11469                let index = npc_id
11470                    .as_ref()
11471                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11472                    .unwrap_or(0);
11473                self.re_open_sheet(S::SellNpcs { index });
11474            }
11475            WorkerRouteStop::CraftAt { blueprint, .. } => {
11476                let bps = self.re_blueprint_ids();
11477                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11478                if bps.is_empty() {
11479                    self.re_cancel_edit();
11480                    self.state
11481                        .push_log("Route: no known blueprints to retarget".to_string());
11482                } else {
11483                    self.re_open_sheet(S::CraftBlueprint { index });
11484                }
11485            }
11486            WorkerRouteStop::CultivatePlot { .. } => {
11487                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
11488            }
11489            WorkerRouteStop::PlantPlot { .. } => {
11490                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11491            }
11492            WorkerRouteStop::HarvestPlot { .. } => {
11493                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
11494            }
11495            WorkerRouteStop::RestIfNeeded => {
11496                self.re_cancel_edit();
11497                self.state
11498                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
11499            }
11500            WorkerRouteStop::Wait { wait_ticks } => {
11501                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
11502            }
11503        }
11504    }
11505
11506    fn re_cancel_edit(&mut self) {
11507        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11508            ed.editing_index = None;
11509        }
11510    }
11511
11512    // ---- mouse clicks inside the overlay ------------------------------
11513
11514    pub fn worker_route_editor_ui_click(
11515        &mut self,
11516        click: crate::worker_route_editor::RouteEditorClick,
11517    ) {
11518        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
11519        match click {
11520            RouteEditorClick::SelectStop(i) => {
11521                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11522                    ed.sheet = S::Stops;
11523                    ed.select_stop(i);
11524                }
11525            }
11526            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
11527            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
11528            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
11529        }
11530    }
11531
11532    /// Activate (Enter / click) a row of the current sheet.
11533    pub fn re_sheet_row_activate(&mut self, row: usize) {
11534        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11535        let Some(sheet) = self
11536            .state
11537            .worker_route_editor
11538            .as_ref()
11539            .map(|ed| ed.sheet.clone())
11540        else {
11541            return;
11542        };
11543        match sheet {
11544            S::Stops => {
11545                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11546                    ed.select_stop(row);
11547                }
11548            }
11549            S::AddMenu { .. } => match row {
11550                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
11551                1 => {
11552                    if self.re_node_candidates().is_empty() {
11553                        self.state
11554                            .push_log("Route: no harvestable nodes visible in this region".to_string());
11555                    } else {
11556                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
11557                    }
11558                }
11559                2 | 3 => {
11560                    if self.re_container_candidates().is_empty() {
11561                        self.state
11562                            .push_log("Route: place a storage chest first".to_string());
11563                    } else if row == 2 {
11564                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
11565                    } else {
11566                        self.re_open_sheet(S::DepositContainers { index: 0 });
11567                    }
11568                }
11569                4 => {
11570                    if self.re_template_candidates().is_empty() {
11571                        self.state.push_log(
11572                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
11573                                .to_string(),
11574                        );
11575                    } else {
11576                        self.re_open_sheet(S::SellNpcs { index: 0 });
11577                    }
11578                }
11579                5 => {
11580                    if self.re_blueprint_ids().is_empty() {
11581                        self.state.push_log(
11582                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
11583                                .to_string(),
11584                        );
11585                    } else {
11586                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
11587                    }
11588                }
11589                6 => self.re_confirm_stop(
11590                    WorkerRouteStop::RestIfNeeded,
11591                    "rest at lodging (if needed)".into(),
11592                ),
11593                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
11594                8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
11595                9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
11596                10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
11597                _ => {}
11598            },
11599            S::WaypointMenu { .. } => match row {
11600                0 => {
11601                    let (x, y, z) = self.state.player_position_with_z();
11602                    let stop = WorkerRouteStop::Waypoint { x, y, z };
11603                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11604                }
11605                1 => {
11606                    self.re_open_sheet(S::WaypointMapPick);
11607                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
11608                }
11609                _ => {}
11610            },
11611            S::HarvestPicker { .. } => {
11612                let mut log: Option<String> = None;
11613                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11614                    let S::HarvestPicker {
11615                        index: sheet_index,
11616                        picked,
11617                        nodes,
11618                    } = &mut ed.sheet
11619                    else {
11620                        return;
11621                    };
11622                    *sheet_index = row;
11623                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
11624                        if picked.is_empty() {
11625                            log = Some(
11626                                "Route: pick at least one node (Space toggles, Done confirms)"
11627                                    .into(),
11628                            );
11629                        } else {
11630                            let ids: Vec<String> = picked.iter().cloned().collect();
11631                            let added = ed.confirm_harvest_picks(&ids);
11632                            log = Some(format!("Route: + {added} harvest stop(s)"));
11633                        }
11634                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
11635                        if picked.contains(&n.id) {
11636                            picked.remove(&n.id);
11637                        } else {
11638                            picked.insert(n.id.clone());
11639                        }
11640                    }
11641                }
11642                if let Some(msg) = log {
11643                    self.state.push_log(msg);
11644                }
11645            }
11646            S::WithdrawContainers { .. } => {
11647                let containers = self.re_container_candidates();
11648                if let Some(c) = containers.get(row) {
11649                    let id = c.id.clone();
11650                    self.re_open_withdraw_items(id);
11651                }
11652            }
11653            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
11654            S::DepositContainers { .. } => {
11655                let containers = self.re_container_candidates();
11656                if let Some(c) = containers.get(row) {
11657                    let id = c.id.clone();
11658                    self.re_open_deposit_filter(id);
11659                }
11660            }
11661            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
11662            S::SellNpcs { .. } => {
11663                let npcs = self.re_npc_candidates();
11664                let npc_id = if row == 0 {
11665                    None
11666                } else {
11667                    npcs.get(row - 1).map(|n| n.id.clone())
11668                };
11669                if row == 0 || npc_id.is_some() {
11670                    self.re_open_sell_item(npc_id);
11671                }
11672            }
11673            S::SellItem { .. } => self.re_sell_item_activate(row),
11674            S::CraftBlueprint { .. } => {
11675                let bps = self.re_blueprint_ids();
11676                if let Some(bp) = bps.get(row) {
11677                    let stop = WorkerRouteStop::CraftAt {
11678                        device: "hand".into(),
11679                        blueprint: bp.clone(),
11680                        qty: None,
11681                    };
11682                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11683                }
11684            }
11685            S::WaitEntry { ticks } => {
11686                let stop = WorkerRouteStop::Wait {
11687                    wait_ticks: ticks,
11688                };
11689                self.re_confirm_stop(stop, format!("wait {ticks}t"));
11690            }
11691            S::BedPicker { .. } => {
11692                let beds = self.re_bed_candidates();
11693                if let Some((id, name)) = beds.get(row) {
11694                    let (id, name) = (id.clone(), name.clone());
11695                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11696                        ed.lodging_container_id = Some(id.clone());
11697                        ed.sheet = S::Stops;
11698                    }
11699                    self.state
11700                        .push_log(format!("Route: rest bed set to {name}"));
11701                }
11702            }
11703            S::FarmPlotPicker { action, .. } => {
11704                let plots = self.re_farm_plot_candidates();
11705                let Some(plot) = plots.get(row).cloned() else {
11706                    return;
11707                };
11708                match action {
11709                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
11710                        let label = plot_route_label(&plot);
11711                        self.re_confirm_stop(
11712                            WorkerRouteStop::CultivatePlot {
11713                                plot_id: plot.plot_id,
11714                            },
11715                            format!("cultivate {label}"),
11716                        );
11717                    }
11718                    crate::worker_route_editor::FarmPlotAction::Harvest => {
11719                        let label = plot_route_label(&plot);
11720                        self.re_confirm_stop(
11721                            WorkerRouteStop::HarvestPlot {
11722                                plot_id: plot.plot_id,
11723                            },
11724                            format!("harvest {label}"),
11725                        );
11726                    }
11727                    crate::worker_route_editor::FarmPlotAction::Plant => {
11728                        let seeds = self.re_farm_seed_candidates();
11729                        if seeds.is_empty() {
11730                            self.state.push_log(
11731                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11732                            );
11733                            return;
11734                        }
11735                        self.re_open_sheet(S::FarmPlantSeed {
11736                            plot_id: plot.plot_id,
11737                            seeds,
11738                            index: 0,
11739                        });
11740                    }
11741                }
11742            }
11743            S::FarmPlantSeed { plot_id, seeds, .. } => {
11744                if let Some(seed) = seeds.get(row).cloned() {
11745                    self.re_confirm_stop(
11746                        WorkerRouteStop::PlantPlot {
11747                            plot_id,
11748                            seed_template: seed.clone(),
11749                        },
11750                        format!("plant {seed}"),
11751                    );
11752                }
11753            }
11754            S::WaypointMapPick => {}
11755        }
11756    }
11757
11758    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11759        use crate::worker_route_editor::RouteEditorSheet as S;
11760        if self.re_farm_plot_candidates().is_empty() {
11761            self.state.push_log(
11762                "Route: no farmable plots visible — claim land or get farm access first",
11763            );
11764            return;
11765        }
11766        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11767    }
11768
11769    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11770        self.state
11771            .property_plots
11772            .iter()
11773            .filter(|p| p.is_mine || p.may_farm)
11774            .cloned()
11775            .collect()
11776    }
11777
11778    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
11779    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
11780    /// and known crop seeds so routes can withdraw-then-plant.
11781    fn re_farm_seed_candidates(&self) -> Vec<String> {
11782        let mut set = std::collections::BTreeSet::new();
11783        let looks_like_seed = |id: &str| {
11784            id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11785        };
11786        for (id, _, _) in self.state.farm_seed_entries() {
11787            set.insert(id);
11788        }
11789        for c in &self.state.placed_containers {
11790            let mine = match (self.state.character_id, c.owner_character_id) {
11791                (Some(a), Some(b)) => a == b,
11792                _ => false,
11793            };
11794            if !mine {
11795                continue;
11796            }
11797            for s in &c.contents {
11798                if s.quantity > 0
11799                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11800                {
11801                    set.insert(s.template_id.clone());
11802                }
11803            }
11804        }
11805        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11806            for stop in &ed.stops {
11807                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11808                {
11809                    for it in items {
11810                        if looks_like_seed(&it.template) {
11811                            set.insert(it.template.clone());
11812                        }
11813                    }
11814                }
11815                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11816                    seed_template, ..
11817                } = stop
11818                {
11819                    if !seed_template.is_empty() {
11820                        set.insert(seed_template.clone());
11821                    }
11822                }
11823            }
11824        }
11825        for id in self.state.inventory_hints.keys() {
11826            if looks_like_seed(id) {
11827                set.insert(id.clone());
11828            }
11829        }
11830        // Authored crop seeds (always offer so withdraw-then-plant routes work).
11831        for id in ["potato_seed", "carrot_seed"] {
11832            set.insert(id.to_string());
11833        }
11834        set.into_iter().collect()
11835    }
11836
11837    // ---- map clicks (sheet-scoped accelerators) ------------------------
11838
11839    /// Map click while the route editor is open. When a picker sheet is open
11840    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
11841    /// container, NPC clicks the merchant, node clicks the harvest target);
11842    /// otherwise the click quick-adds the nearest target (context-aware).
11843    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11844        use crate::worker_route_editor as wre;
11845        use wre::RouteEditorSheet as S;
11846        if self.state.worker_route_editor.is_none() {
11847            return;
11848        }
11849        let sheet = self
11850            .state
11851            .worker_route_editor
11852            .as_ref()
11853            .map(|ed| ed.sheet.clone())
11854            .unwrap_or(S::Stops);
11855        match sheet {
11856            S::WaypointMapPick => {
11857                let (_, _, z) = self.state.player_position_with_z();
11858                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11859                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11860                // Stay in map-pick mode when adding (not editing) for fast pathing.
11861                let editing = self
11862                    .state
11863                    .worker_route_editor
11864                    .as_ref()
11865                    .is_some_and(|ed| ed.editing_index.is_some());
11866                if !editing {
11867                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11868                        ed.sheet = S::WaypointMapPick;
11869                    }
11870                }
11871            }
11872            S::HarvestPicker { .. } => {
11873                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11874                    let mut log: Option<String> = None;
11875                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11876                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11877                            return;
11878                        };
11879                        let selected = if picked.contains(&node.id) {
11880                            picked.remove(&node.id);
11881                            false
11882                        } else {
11883                            picked.insert(node.id.clone());
11884                            true
11885                        };
11886                        log = Some(format!(
11887                            "Route: {} {}",
11888                            if selected { "selected" } else { "deselected" },
11889                            resource_node_route_label(node)
11890                        ));
11891                    }
11892                    if let Some(msg) = log {
11893                        self.state.push_log(msg);
11894                    }
11895                }
11896            }
11897            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11898                // Chest click picks (or switches) the withdraw source.
11899                let inside = self.state.effective_inside_building();
11900                if let Some(cid) = wre::pick_storage_container_at(
11901                    &self.state.placed_containers,
11902                    self.state.character_id,
11903                    x,
11904                    y,
11905                    inside.as_deref(),
11906                ) {
11907                    self.re_open_withdraw_items(cid);
11908                }
11909            }
11910            S::DepositContainers { .. } | S::DepositFilter { .. } => {
11911                let inside = self.state.effective_inside_building();
11912                if let Some(cid) = wre::pick_storage_container_at(
11913                    &self.state.placed_containers,
11914                    self.state.character_id,
11915                    x,
11916                    y,
11917                    inside.as_deref(),
11918                ) {
11919                    self.re_open_deposit_filter(cid);
11920                }
11921            }
11922            S::SellNpcs { .. } => {
11923                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11924                    self.re_open_sell_item(Some(npc_id));
11925                }
11926            }
11927            S::SellItem { .. } => {
11928                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11929                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11930                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11931                            *slot = Some(npc_id.clone());
11932                        }
11933                    }
11934                    self.state
11935                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11936                }
11937            }
11938            // Stop list / other sheets: context-aware quick add.
11939            _ => self.worker_route_editor_quick_add_click(x, y),
11940        }
11941    }
11942
11943    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
11944    /// node wins; duplicates select the existing stop; a click on a merchant
11945    /// while a sell stop is selected pins it.
11946    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11947        use crate::worker_route_editor as wre;
11948        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11949            let dx = ax - bx;
11950            let dy = ay - by;
11951            (dx * dx + dy * dy).sqrt()
11952        };
11953
11954        // 1. Retarget the selected stop when the click names its target:
11955        //    sell stop → pin the merchant; withdraw stop → set the source chest.
11956        let selected_stop_kind = self
11957            .state
11958            .worker_route_editor
11959            .as_ref()
11960            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11961            .map(|s| match s {
11962                wre::WorkerRouteStop::TradeWith { .. } => 1,
11963                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11964                _ => 0,
11965            })
11966            .unwrap_or(0);
11967        if selected_stop_kind == 1 {
11968            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11969                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11970                    ed.set_selected_trade_npc(npc_id.clone());
11971                }
11972                self.state
11973                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11974                return;
11975            }
11976        }
11977        if selected_stop_kind == 2 {
11978            let inside = self.state.effective_inside_building();
11979            if let Some(cid) = wre::pick_storage_container_at(
11980                &self.state.placed_containers,
11981                self.state.character_id,
11982                x,
11983                y,
11984                inside.as_deref(),
11985            ) {
11986                let name = self
11987                    .state
11988                    .placed_containers
11989                    .iter()
11990                    .find(|c| c.id == cid)
11991                    .map(|c| c.display_name.clone())
11992                    .unwrap_or_else(|| "container".into());
11993                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11994                    ed.set_selected_withdraw_container(cid.clone());
11995                }
11996                self.state
11997                    .push_log(format!("Route: withdraw source → {name}"));
11998                return;
11999            }
12000        }
12001
12002        // 2. Nearest candidate of any kind wins. Category order is the
12003        //    tie-break for exact overlaps (bed over chest over merchant over node).
12004        enum Target {
12005            Bed(String),
12006            Container(String),
12007            Npc(String, String),
12008            Node(String, String),
12009        }
12010        let mut best: Option<(f32, u8, Target)> = None;
12011        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12012            let better = match best {
12013                None => true,
12014                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
12015            };
12016            if better {
12017                *best = Some((d, rank, t));
12018            }
12019        };
12020        let inside = self.state.effective_inside_building();
12021        if let Some(bed_id) = wre::pick_lodging_container_at(
12022            &self.state.placed_containers,
12023            self.state.character_id,
12024            x,
12025            y,
12026            inside.as_deref(),
12027        ) {
12028            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12029                // A bed that's already the rest bed doubles as a storage chest —
12030                // a second click on it means "deposit here", not "set bed again".
12031                let already_bed = self
12032                    .state
12033                    .worker_route_editor
12034                    .as_ref()
12035                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
12036                if already_bed {
12037                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
12038                } else {
12039                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12040                }
12041            }
12042        }
12043        if let Some(cid) = wre::pick_storage_container_at(
12044            &self.state.placed_containers,
12045            self.state.character_id,
12046            x,
12047            y,
12048            inside.as_deref(),
12049        ) {
12050            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12051                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12052            }
12053        }
12054        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12055            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12056                consider(
12057                    dist(x, y, n.x, n.y),
12058                    2,
12059                    Target::Npc(npc_id, label),
12060                    &mut best,
12061                );
12062            }
12063        }
12064        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12065            let d = dist(x, y, node.x, node.y);
12066            let label = resource_node_route_label(node);
12067            consider(
12068                d,
12069                3,
12070                Target::Node(node.id.clone(), label),
12071                &mut best,
12072            );
12073        }
12074
12075        match best.map(|(_, _, t)| t) {
12076            Some(Target::Bed(bed_id)) => {
12077                let name = self
12078                    .state
12079                    .placed_containers
12080                    .iter()
12081                    .find(|c| c.id == bed_id)
12082                    .map(|c| c.display_name.clone())
12083                    .unwrap_or_else(|| "camp bed".into());
12084                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12085                    ed.lodging_container_id = Some(bed_id.clone());
12086                }
12087                self.state
12088                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12089            }
12090            Some(Target::Container(cid)) => {
12091                let name = self
12092                    .state
12093                    .placed_containers
12094                    .iter()
12095                    .find(|c| c.id == cid)
12096                    .map(|c| c.display_name.clone())
12097                    .unwrap_or_else(|| "container".into());
12098                let added = self
12099                    .state
12100                    .worker_route_editor
12101                    .as_mut()
12102                    .is_some_and(|ed| ed.append_deposit_at(&cid));
12103                if added {
12104                    self.state
12105                        .push_log(format!("Route: + deposit at {name} ({cid})"));
12106                } else {
12107                    self.state.push_log(format!(
12108                        "Route: {name} already in route — selected it (d to remove)"
12109                    ));
12110                }
12111            }
12112            Some(Target::Npc(npc_id, label)) => {
12113                // Quick-add sell: first storage template (the full sell flow
12114                // lives in the a → Sell sheet).
12115                let template = self.re_template_candidates().into_iter().next();
12116                let Some(template) = template else {
12117                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
12118                    return;
12119                };
12120                let added = self
12121                    .state
12122                    .worker_route_editor
12123                    .as_mut()
12124                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
12125                if added {
12126                    self.state
12127                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12128                } else {
12129                    self.state.push_log(format!(
12130                        "Route: {label} already sells {template} — selected it (d to remove)"
12131                    ));
12132                }
12133            }
12134            Some(Target::Node(id, label)) => {
12135                let added = self
12136                    .state
12137                    .worker_route_editor
12138                    .as_mut()
12139                    .is_some_and(|ed| ed.append_harvest_node(&id));
12140                if added {
12141                    self.state
12142                        .push_log(format!("Route: + harvest node {label}"));
12143                } else {
12144                    self.state.push_log(format!(
12145                        "Route: {label} already in route — selected it (d to remove)"
12146                    ));
12147                }
12148            }
12149            None => {}
12150        }
12151    }
12152
12153    pub fn worker_route_editor_select(&mut self, delta: i32) {
12154        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12155            return;
12156        };
12157        if ed.stops.is_empty() {
12158            return;
12159        }
12160        let n = ed.stops.len() as i32;
12161        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12162        ed.selected_stop_index = next;
12163    }
12164
12165    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12166        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12167            return;
12168        };
12169        if delta < 0 {
12170            ed.move_selected_up();
12171        } else if delta > 0 {
12172            ed.move_selected_down();
12173        }
12174    }
12175
12176    pub fn worker_route_editor_delete_selected(&mut self) {
12177        let removed = self
12178            .state
12179            .worker_route_editor
12180            .as_mut()
12181            .is_some_and(|ed| {
12182                let before = ed.stop_count();
12183                ed.remove_selected_stop();
12184                ed.stop_count() < before
12185            });
12186        if removed {
12187            self.state.push_log("Route: removed selected stop");
12188        }
12189    }
12190
12191    /// Clear all stops. Saving afterwards parks the worker in idle mode
12192    /// instead of leaving it in a broken job loop.
12193    pub fn worker_route_editor_clear_stops(&mut self) {
12194        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12195            return;
12196        };
12197        if ed.stops.is_empty() {
12198            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
12199            return;
12200        }
12201        ed.stops.clear();
12202        ed.selected_stop_index = 0;
12203        self.state
12204            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
12205    }
12206
12207    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12208        if self.state.pending_worker_job_ack.is_some() {
12209            anyhow::bail!("route save still pending — wait for server ack");
12210        }
12211        let Some(ed) = self.state.worker_route_editor.clone() else {
12212            anyhow::bail!("route editor not open");
12213        };
12214        // Empty route = stand down: park the worker in idle mode rather than
12215        // erroring out or leaving it marching a ghost loop.
12216        let (job_yaml, idle) = if ed.stops.is_empty() {
12217            (ed.build_idle_job_yaml(), true)
12218        } else {
12219            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12220        };
12221        let worker_id = ed.worker_instance_id.clone();
12222        let route_view = if idle {
12223            None
12224        } else {
12225            Some(ed.to_route_view())
12226        };
12227        let mode = if idle {
12228            flatland_protocol::WorkerModeView::Idle
12229        } else {
12230            flatland_protocol::WorkerModeView::JobLoop
12231        };
12232        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12233            .state
12234            .hired_workers
12235            .iter()
12236            .find(|w| w.instance_id == worker_id)
12237            .map(|w| {
12238                (
12239                    w.route.clone(),
12240                    w.mode,
12241                    w.step_label.clone(),
12242                    w.last_error.clone(),
12243                )
12244            })
12245            .unwrap_or((
12246                None,
12247                flatland_protocol::WorkerModeView::Idle,
12248                String::new(),
12249                None,
12250            ));
12251        self.seq += 1;
12252        let seq = self.seq;
12253        self.session
12254            .submit_intent(Intent::SetWorkerJob {
12255                entity_id: self.state.entity_id,
12256                worker_instance_id: worker_id.clone(),
12257                job_yaml,
12258                seq,
12259            })
12260            .await?;
12261        self.state.intents_sent += 1;
12262        if let Some(w) = self
12263            .state
12264            .hired_workers
12265            .iter_mut()
12266            .find(|w| w.instance_id == worker_id)
12267        {
12268            w.route = route_view;
12269            w.mode = mode;
12270            w.last_error = None;
12271            if idle {
12272                w.step_label.clear();
12273                w.route_stop_index = None;
12274            }
12275        }
12276        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12277            seq,
12278            worker_instance_id: worker_id,
12279            worker_label: ed.worker_label.clone(),
12280            idle,
12281            stop_count: ed.stops.len(),
12282            prev_route,
12283            prev_mode,
12284            prev_step_label,
12285            prev_last_error,
12286        });
12287        self.state.push_log(format!(
12288            "Route: saving for {}… (waiting for server)",
12289            ed.worker_label
12290        ));
12291        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
12292        Ok(())
12293    }
12294    pub fn quest_menu_move(&mut self, delta: i32) {
12295        let n = self.state.active_quest_entries().len();
12296        if n == 0 {
12297            return;
12298        }
12299        let idx = self.state.quest_menu_index as i32;
12300        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12301    }
12302
12303    pub fn quest_menu_page(&mut self, pages: i32) {
12304        let n = self.state.active_quest_entries().len();
12305        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12306    }
12307
12308    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12309        let Some(offer) = self.state.pending_quest_offer.clone() else {
12310            anyhow::bail!("no quest offer");
12311        };
12312        self.seq += 1;
12313        let seq = self.seq;
12314        self.session
12315            .submit_intent(Intent::AcceptQuest {
12316                entity_id: self.state.entity_id,
12317                quest_id: offer.quest_id,
12318                seq,
12319            })
12320            .await?;
12321        self.state.intents_sent += 1;
12322        Ok(())
12323    }
12324
12325    pub fn quest_offer_decline(&mut self) {
12326        self.state.show_quest_offer = false;
12327        self.state.pending_quest_offer = None;
12328        if !self.state.show_npc_chat
12329            && !self.state.show_shop_menu
12330            && self.state.npc_verb_target.is_some()
12331        {
12332            self.state.show_npc_verb_menu = true;
12333        }
12334    }
12335
12336    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12337        if !self.state.show_quest_menu {
12338            return Ok(());
12339        }
12340        let active: Vec<_> = self
12341            .state
12342            .active_quest_entries()
12343            .into_iter()
12344            .cloned()
12345            .collect();
12346        let Some(entry) = active.get(self.state.quest_menu_index) else {
12347            return Ok(());
12348        };
12349        if self.state.quest_withdraw_confirm {
12350            if !entry.can_withdraw {
12351                anyhow::bail!("quest cannot be withdrawn");
12352            }
12353            self.seq += 1;
12354            let seq = self.seq;
12355            self.session
12356                .submit_intent(Intent::WithdrawQuest {
12357                    entity_id: self.state.entity_id,
12358                    quest_id: entry.quest_id.clone(),
12359                    seq,
12360                })
12361                .await?;
12362            self.state.intents_sent += 1;
12363            self.state.quest_withdraw_confirm = false;
12364            return Ok(());
12365        }
12366        self.seq += 1;
12367        let seq = self.seq;
12368        self.session
12369            .submit_intent(Intent::TrackQuest {
12370                entity_id: self.state.entity_id,
12371                quest_id: entry.quest_id.clone(),
12372                seq,
12373            })
12374            .await?;
12375        self.state.intents_sent += 1;
12376        Ok(())
12377    }
12378
12379    pub fn quest_request_withdraw(&mut self) {
12380        if self.state.show_quest_menu {
12381            self.state.quest_withdraw_confirm = true;
12382        }
12383    }
12384
12385    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12386        if !self.state.is_alive() {
12387            anyhow::bail!("you are dead");
12388        }
12389        let Some(catalog) = self.state.shop_catalog.clone() else {
12390            anyhow::bail!("no shop open");
12391        };
12392        self.seq += 1;
12393        let seq = self.seq;
12394        match self.state.shop_tab {
12395            ShopTab::Buy => {
12396                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12397                    anyhow::bail!("nothing selected");
12398                };
12399                if offer.already_owned {
12400                    anyhow::bail!("already owned");
12401                }
12402                self.session
12403                    .submit_intent(Intent::ShopBuy {
12404                        entity_id: self.state.entity_id,
12405                        npc_id: catalog.npc_id.clone(),
12406                        offer_id: offer.offer_id.clone(),
12407                        quantity: self.state.shop_quantity,
12408                        seq,
12409                    })
12410                    .await?;
12411            }
12412            ShopTab::Sell => {
12413                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12414                    anyhow::bail!("nothing to sell");
12415                };
12416                if line.quantity == 0 {
12417                    anyhow::bail!("you have no {}", line.label);
12418                }
12419                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12420                self.session
12421                    .submit_intent(Intent::ShopSell {
12422                        entity_id: self.state.entity_id,
12423                        npc_id: catalog.npc_id.clone(),
12424                        template_id: line.template_id.clone(),
12425                        quantity,
12426                        seq,
12427                    })
12428                    .await?;
12429            }
12430        }
12431        self.state.intents_sent += 1;
12432        Ok(())
12433    }
12434
12435    pub fn craft_menu_move(&mut self, delta: i32) {
12436        let n = self.state.blueprints.len();
12437        if n == 0 {
12438            return;
12439        }
12440        let idx = self.state.craft_menu_index as i32;
12441        let next = (idx + delta).rem_euclid(n as i32);
12442        self.state.craft_menu_index = next as usize;
12443        self.state.clamp_craft_batch_quantity();
12444    }
12445
12446    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12447        self.state.craft_batch_adjust_quantity(delta);
12448    }
12449
12450    pub fn craft_batch_set_max(&mut self) {
12451        self.state.craft_batch_set_max();
12452    }
12453
12454    pub fn craft_batch_set_min(&mut self) {
12455        self.state.craft_batch_set_min();
12456    }
12457
12458    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12459        let Some(blueprint) = self
12460            .state
12461            .blueprints
12462            .get(self.state.craft_menu_index)
12463            .cloned()
12464        else {
12465            anyhow::bail!("no blueprints known");
12466        };
12467        if !self.state.can_craft_blueprint(&blueprint) {
12468            let hint = self
12469                .state
12470                .craft_missing_hint(&blueprint)
12471                .unwrap_or_else(|| "missing materials".into());
12472            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12473        }
12474        let count = self.state.craft_batch_quantity;
12475        let max = self.state.max_craft_batches(&blueprint);
12476        if max == 0 {
12477            anyhow::bail!("cannot craft {}", blueprint.label);
12478        }
12479        let batches = count.min(max);
12480        self.craft(&blueprint.id, Some(batches)).await?;
12481        self.state.show_craft_menu = false;
12482        Ok(())
12483    }
12484
12485    pub async fn move_by(
12486        &mut self,
12487        forward: f32,
12488        strafe: f32,
12489        vertical: f32,
12490        sprint: bool,
12491    ) -> anyhow::Result<()> {
12492        if !self.state.is_alive() {
12493            anyhow::bail!("you are dead");
12494        }
12495        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
12496            self.last_move_forward = forward;
12497            self.last_move_strafe = strafe;
12498        }
12499        self.seq += 1;
12500        self.session
12501            .submit_intent(Intent::Move {
12502                entity_id: self.state.entity_id,
12503                forward,
12504                strafe,
12505                vertical,
12506                sprint,
12507                seq: self.seq,
12508            })
12509            .await?;
12510        self.state.intents_sent += 1;
12511        Ok(())
12512    }
12513
12514    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
12515        if !self.state.connected {
12516            crate::harvest_trace!("harvest_nearest rejected: not connected");
12517            anyhow::bail!("not connected");
12518        }
12519        if !self.state.is_alive() {
12520            crate::harvest_trace!("harvest_nearest rejected: player dead");
12521            anyhow::bail!("you are dead");
12522        }
12523        if self.state.harvest_in_progress {
12524            if self.state.harvest_state_stale() {
12525                self.state.clear_harvest_state();
12526            } else {
12527                anyhow::bail!("already harvesting");
12528            }
12529        }
12530        let (px, py) = self
12531            .state
12532            .player
12533            .as_ref()
12534            .map(|p| (p.transform.position.x, p.transform.position.y))
12535            .unwrap_or((0.0, 0.0));
12536
12537        let available = self
12538            .state
12539            .resource_nodes
12540            .iter()
12541            .filter(|n| !n.harvest_off)
12542            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12543            .count();
12544        let node_id = self
12545            .state
12546            .resource_nodes
12547            .iter()
12548            .filter(|n| !n.harvest_off)
12549            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12550            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
12551            .min_by(|a, b| {
12552                let da = distance(px, py, a.x, a.y);
12553                let db = distance(px, py, b.x, b.y);
12554                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
12555            })
12556            .map(|n| n.id.clone());
12557
12558        let Some(node_id) = node_id else {
12559            let has_loot = self
12560                .state
12561                .ground_drops
12562                .iter()
12563                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12564            if has_loot {
12565                return self.pickup_nearest().await;
12566            }
12567            anyhow::bail!(
12568                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
12569            );
12570        };
12571
12572        self.seq += 1;
12573        let seq = self.seq;
12574        crate::harvest_trace!(
12575            entity_id = self.state.entity_id,
12576            node_id = %node_id,
12577            seq,
12578            px,
12579            py,
12580            available_nodes = available,
12581            "submitting harvest intent"
12582        );
12583        self.session
12584            .submit_intent(Intent::Harvest {
12585                entity_id: self.state.entity_id,
12586                node_id,
12587                seq,
12588            })
12589            .await?;
12590        self.state.intents_sent += 1;
12591        self.state.harvest_in_progress = true;
12592        self.state.harvest_started_at = Some(Instant::now());
12593        self.state.push_log("Harvesting…");
12594        crate::harvest_trace!(
12595            entity_id = self.state.entity_id,
12596            seq,
12597            "harvest intent queued to session"
12598        );
12599        Ok(())
12600    }
12601
12602    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
12603        if !self.state.is_alive() {
12604            anyhow::bail!("you are dead");
12605        }
12606        let blueprint_id = self
12607            .state
12608            .blueprints
12609            .iter()
12610            .find(|bp| self.state.can_craft_blueprint(bp))
12611            .map(|bp| bp.id.clone())
12612            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
12613        self.craft(&blueprint_id, None).await
12614    }
12615
12616    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
12617        if !self.state.is_alive() {
12618            anyhow::bail!("you are dead");
12619        }
12620        self.seq += 1;
12621        self.session
12622            .submit_intent(Intent::Craft {
12623                entity_id: self.state.entity_id,
12624                blueprint_id: blueprint_id.to_string(),
12625                count,
12626                seq: self.seq,
12627            })
12628            .await?;
12629        self.state.intents_sent += 1;
12630        let (label, batches) = self
12631            .state
12632            .blueprints
12633            .iter()
12634            .find(|b| b.id == blueprint_id)
12635            .map(|b| {
12636                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
12637                (b.label.as_str(), n)
12638            })
12639            .unwrap_or((blueprint_id, count.unwrap_or(1)));
12640        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
12641        Ok(())
12642    }
12643
12644    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
12645        if !self.state.is_alive() {
12646            anyhow::bail!("you are dead");
12647        }
12648        let target_id = match self.state.nearest_interact_target() {
12649            Some(id) => id,
12650            None => {
12651                anyhow::bail!("nothing to interact with nearby");
12652            }
12653        };
12654        if self.state.npcs.iter().any(|n| n.id == target_id) {
12655            self.state.show_npc_verb_menu = true;
12656            self.state.npc_verb_target = Some(target_id);
12657            self.state.npc_verb_index = 0;
12658            return Ok(());
12659        }
12660        if self
12661            .state
12662            .hired_workers
12663            .iter()
12664            .any(|w| w.instance_id == target_id)
12665        {
12666            return self.open_workers_menu_for(&target_id).await;
12667        }
12668        if let Ok(peer_id) = target_id.parse::<EntityId>() {
12669            if self
12670                .state
12671                .hired_workers
12672                .iter()
12673                .any(|w| w.entity_id == peer_id)
12674            {
12675                if let Some(w) = self
12676                    .state
12677                    .hired_workers
12678                    .iter()
12679                    .find(|w| w.entity_id == peer_id)
12680                {
12681                    let id = w.instance_id.clone();
12682                    return self.open_workers_menu_for(&id).await;
12683                }
12684            }
12685            if let Some(entity) = self
12686                .state
12687                .entities
12688                .iter()
12689                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
12690            {
12691                self.state
12692                    .player_verbs
12693                    .open_for(peer_id, &entity.label);
12694                return Ok(());
12695            }
12696        }
12697        self.seq += 1;
12698        self.session
12699            .submit_intent(Intent::Interact {
12700                entity_id: self.state.entity_id,
12701                target_id: target_id.clone(),
12702                seq: self.seq,
12703            })
12704            .await?;
12705        self.state.intents_sent += 1;
12706        Ok(())
12707    }
12708
12709    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
12710    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12711        if !self.state.is_alive() {
12712            anyhow::bail!("you are dead");
12713        }
12714        let (px, py) = self.state.player_position();
12715        let has_loot = self
12716            .state
12717            .ground_drops
12718            .iter()
12719            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12720        if has_loot {
12721            return self.pickup_nearest().await;
12722        }
12723        if self
12724            .state
12725            .placed_containers
12726            .iter()
12727            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12728        {
12729            return self.pickup_nearest_container().await;
12730        }
12731
12732        if let Some(plot) = self.state.my_plot_under_player().cloned() {
12733            // Double-tap f within 1.2s sells; otherwise farm the plot.
12734            const SELL_WINDOW: Duration = Duration::from_millis(1200);
12735            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12736                && self
12737                    .state
12738                    .sell_plot_armed_at
12739                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12740            if sell_armed {
12741                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12742            }
12743            self.state.sell_plot_confirm = None;
12744            self.state.sell_plot_armed_at = None;
12745
12746            // Prefer farm actions on your own land unless an NPC/player/door/board
12747            // is in interact range (those still win).
12748            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12749                self.state.npcs.iter().any(|n| n.id == id)
12750                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12751                    || self.state.doors.iter().any(|d| d.id == id)
12752                    || self.state.interactables.iter().any(|i| {
12753                        i.id == id
12754                            && matches!(
12755                                i.kind.as_str(),
12756                                "quest_board" | "well" | "exit" | "enter"
12757                            )
12758                    })
12759                    || id.parse::<EntityId>().is_ok_and(|eid| {
12760                        self.state
12761                            .entities
12762                            .iter()
12763                            .any(|e| e.id == eid && e.id != self.state.entity_id)
12764                    })
12765            });
12766            if !blocking_interact {
12767                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
12768                match self.harvest_nearest().await {
12769                    Ok(()) => return Ok(()),
12770                    Err(err) => {
12771                        let msg = err.to_string();
12772                        if !(msg.contains("no harvestable")
12773                            || msg.contains("press p")
12774                            || msg.contains("press f")
12775                            || msg.contains("nothing"))
12776                        {
12777                            return Err(err);
12778                        }
12779                    }
12780                }
12781                return Ok(());
12782            }
12783        }
12784        if self.state.nearest_interact_target().is_some() {
12785            return self.interact_nearest().await;
12786        }
12787        // Prefer a clear "move closer" when a board is visible but out of reach,
12788        // instead of silently falling through to harvest.
12789        if let Some((label, dist)) = self.state.nearest_quest_board() {
12790            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12791                anyhow::bail!(
12792                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12793                );
12794            }
12795        }
12796
12797        match self.harvest_nearest().await {
12798            Ok(()) => Ok(()),
12799            Err(err) => {
12800                let msg = err.to_string();
12801                if msg.contains("no harvestable")
12802                    || msg.contains("press p")
12803                    || msg.contains("press f")
12804                {
12805                    anyhow::bail!(
12806                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12807                    );
12808                }
12809                Err(err)
12810            }
12811        }
12812    }
12813
12814    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
12815    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12816        if !self.state.is_alive() {
12817            anyhow::bail!("you are dead");
12818        }
12819        if self.state.claim_mode.is_some() {
12820            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12821        }
12822        let zone = self
12823            .state
12824            .free_property_zone_under_player()
12825            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12826        let zone_id = zone.id.clone();
12827        let label = zone
12828            .label
12829            .as_deref()
12830            .filter(|s| !s.trim().is_empty())
12831            .unwrap_or(zone.id.as_str())
12832            .to_string();
12833        self.enter_claim_mode(&zone_id);
12834        self.state
12835            .push_log(format!(
12836                "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12837            ));
12838        Ok(())
12839    }
12840
12841    /// Enter claim mode over a property zone (default 4×4 or min size).
12842    pub fn enter_claim_mode(&mut self, zone_id: &str) {
12843        let Some(zone) = self
12844            .state
12845            .property_zones
12846            .iter()
12847            .find(|z| z.id == zone_id)
12848            .cloned()
12849        else {
12850            self.state.push_log("unknown property zone");
12851            return;
12852        };
12853        self.state.sell_plot_confirm = None;
12854        self.state.sell_plot_armed_at = None;
12855        let min_area = self
12856            .state
12857            .property_plot_settings
12858            .as_ref()
12859            .map(|s| s.min_plot_area_m2)
12860            .unwrap_or(4.0)
12861            .max(1.0);
12862        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12863        let side = 4u32.max(min_side);
12864        let (px, py) = self.state.player_position();
12865        let anchor_x = px.floor();
12866        let anchor_y = py.floor();
12867        self.state.claim_mode = Some(ClaimModeState {
12868            zone_id: zone.id.clone(),
12869            width_m: side,
12870            height_m: side,
12871            anchor_x,
12872            anchor_y,
12873        });
12874        let label = zone
12875            .label
12876            .as_deref()
12877            .filter(|s| !s.trim().is_empty())
12878            .unwrap_or(zone.id.as_str());
12879        self.state.push_log(format!(
12880            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12881        ));
12882    }
12883
12884    pub fn cancel_claim_mode(&mut self) {
12885        if self.state.claim_mode.take().is_some() {
12886            self.state.push_log("Claim cancelled");
12887        }
12888    }
12889
12890    /// Enter relocate mode for a placed container (1×1 ghost).
12891    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12892        if !self.state.is_alive() {
12893            anyhow::bail!("you are dead");
12894        }
12895        if self.state.relocate_mode.is_some() {
12896            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12897        }
12898        if self.state.claim_mode.is_some() {
12899            anyhow::bail!("finish or cancel claim mode first");
12900        }
12901        let chest = self
12902            .state
12903            .placed_containers
12904            .iter()
12905            .find(|c| c.id == container_id)
12906            .cloned()
12907            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12908        let (px, py) = self.state.player_position();
12909        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12910            anyhow::bail!("too far from {}", chest.display_name);
12911        }
12912        if chest.locked && !chest.accessible {
12913            anyhow::bail!(
12914                "need the matching key for {} before moving it",
12915                chest.display_name
12916            );
12917        }
12918        let label = if chest.display_name.trim().is_empty() {
12919            chest.template_id.clone()
12920        } else {
12921            chest.display_name.clone()
12922        };
12923        self.state.relocate_mode = Some(RelocateModeState {
12924            container_id: chest.id.clone(),
12925            label: label.clone(),
12926            cursor_x: chest.x.floor() + 0.5,
12927            cursor_y: chest.y.floor() + 0.5,
12928        });
12929        self.state.push_log(format!(
12930            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12931        ));
12932        Ok(())
12933    }
12934
12935    /// World `Shift+m`: relocate nearest owned/accessible placed container.
12936    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12937        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12938            anyhow::bail!("no chest nearby to relocate");
12939        };
12940        if chest.locked && !chest.accessible {
12941            anyhow::bail!(
12942                "need the matching key for {} before moving it",
12943                chest.display_name
12944            );
12945        }
12946        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
12947        // Server enforces ownership; client starts on nearest accessible chest in range.
12948        self.begin_relocate_container(&chest.id)
12949    }
12950
12951    pub fn cancel_relocate_mode(&mut self) {
12952        if self.state.relocate_mode.take().is_some() {
12953            self.state.push_log("Relocate cancelled");
12954        }
12955    }
12956
12957    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12958        let Some(mode) = self.state.relocate_mode.as_mut() else {
12959            return;
12960        };
12961        let max_x = self.state.world_width_m.max(1.0);
12962        let max_y = self.state.world_height_m.max(1.0);
12963        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12964        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12965        mode.cursor_x = nx.floor() + 0.5;
12966        mode.cursor_y = ny.floor() + 0.5;
12967    }
12968
12969    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12970        let Some(mode) = self.state.relocate_mode.as_mut() else {
12971            return;
12972        };
12973        let max_x = self.state.world_width_m.max(1.0);
12974        let max_y = self.state.world_height_m.max(1.0);
12975        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12976        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12977    }
12978
12979    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12980        if !self.state.is_alive() {
12981            anyhow::bail!("you are dead");
12982        }
12983        let Some(mode) = self.state.relocate_mode.clone() else {
12984            anyhow::bail!("not relocating");
12985        };
12986        let (px, py) = self.state.player_position();
12987        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12988        if dist > 8.0 {
12989            anyhow::bail!("destination too far (max 8 m)");
12990        }
12991        self.seq += 1;
12992        self.session
12993            .submit_intent(Intent::MovePlacedContainer {
12994                entity_id: self.state.entity_id,
12995                container_id: mode.container_id.clone(),
12996                x: mode.cursor_x,
12997                y: mode.cursor_y,
12998                seq: self.seq,
12999            })
13000            .await?;
13001        self.state.intents_sent += 1;
13002        self.state.relocate_mode = None;
13003        self.state
13004            .push_log(format!("Moving {}…", mode.label));
13005        Ok(())
13006    }
13007
13008    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13009        let Some(mode) = self.state.claim_mode.as_mut() else {
13010            return;
13011        };
13012        mode.width_m = w.max(1);
13013        mode.height_m = h.max(1);
13014    }
13015
13016    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13017        let Some(mode) = self.state.claim_mode.as_mut() else {
13018            return;
13019        };
13020        let w = (mode.width_m as i32 + dw).max(1) as u32;
13021        let h = (mode.height_m as i32 + dh).max(1) as u32;
13022        mode.width_m = w;
13023        mode.height_m = h;
13024    }
13025
13026    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
13027    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13028        let Some(mode) = self.state.claim_mode.as_mut() else {
13029            return;
13030        };
13031        let max_x = self.state.world_width_m.max(1.0);
13032        let max_y = self.state.world_height_m.max(1.0);
13033        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13034        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13035        mode.anchor_x = nx.floor();
13036        mode.anchor_y = ny.floor();
13037    }
13038
13039    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13040        if !self.state.is_alive() {
13041            anyhow::bail!("you are dead");
13042        }
13043        let Some(mode) = self.state.claim_mode.clone() else {
13044            anyhow::bail!("not in claim mode");
13045        };
13046        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13047            self.state.claim_quote()
13048        else {
13049            anyhow::bail!("cannot quote claim");
13050        };
13051        if !valid {
13052            anyhow::bail!(reason);
13053        }
13054        if !can_afford {
13055            anyhow::bail!(
13056                "not enough copper (need {})",
13057                crate::currency::format_copper(purchase)
13058            );
13059        }
13060        let (x0, y0, x1, y1) = self
13061            .state
13062            .claim_footprint_rect()
13063            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13064        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13065        self.seq += 1;
13066        self.session
13067            .submit_intent(Intent::BuyPlot {
13068                entity_id: self.state.entity_id,
13069                zone_id: mode.zone_id,
13070                x0,
13071                y0,
13072                x1,
13073                y1,
13074                seq: self.seq,
13075            })
13076            .await?;
13077        self.state.intents_sent += 1;
13078        self.state.claim_mode = None;
13079        self.state
13080            .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
13081        Ok(())
13082    }
13083
13084    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13085        if !self.state.is_alive() {
13086            anyhow::bail!("you are dead");
13087        }
13088        let zone_id = self
13089            .state
13090            .claim_mode
13091            .as_ref()
13092            .map(|m| m.zone_id.clone())
13093            .or_else(|| {
13094                self.state
13095                    .free_property_zone_under_player()
13096                    .map(|z| z.id.clone())
13097            })
13098            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13099        self.seq += 1;
13100        self.session
13101            .submit_intent(Intent::BuyPlotAllFree {
13102                entity_id: self.state.entity_id,
13103                zone_id,
13104                seq: self.seq,
13105            })
13106            .await?;
13107        self.state.intents_sent += 1;
13108        self.state.claim_mode = None;
13109        self.state.push_log("Claiming largest free plot…");
13110        Ok(())
13111    }
13112
13113    pub async fn confirm_sell_plot_to_crown(
13114        &mut self,
13115        plot_id: uuid::Uuid,
13116    ) -> anyhow::Result<()> {
13117        if !self.state.is_alive() {
13118            anyhow::bail!("you are dead");
13119        }
13120        self.seq += 1;
13121        self.session
13122            .submit_intent(Intent::SellPlotToCrown {
13123                entity_id: self.state.entity_id,
13124                plot_id,
13125                seq: self.seq,
13126            })
13127            .await?;
13128        self.state.intents_sent += 1;
13129        self.state.sell_plot_confirm = None;
13130        self.state.sell_plot_armed_at = None;
13131        self.state.push_log("Selling plot to the crown…");
13132        Ok(())
13133    }
13134
13135    pub async fn set_plot_farm_public(
13136        &mut self,
13137        plot_id: uuid::Uuid,
13138        public: bool,
13139        public_tax_discount_bps: u32,
13140    ) -> anyhow::Result<()> {
13141        self.seq += 1;
13142        self.session
13143            .submit_intent(Intent::SetPlotFarmPublic {
13144                entity_id: self.state.entity_id,
13145                plot_id,
13146                public,
13147                public_tax_discount_bps,
13148                seq: self.seq,
13149            })
13150            .await?;
13151        self.state.intents_sent += 1;
13152        Ok(())
13153    }
13154
13155    pub async fn plot_farm_allow_upsert(
13156        &mut self,
13157        plot_id: uuid::Uuid,
13158        character_id: Option<uuid::Uuid>,
13159        character_name: String,
13160        tax_discount_bps: u32,
13161    ) -> anyhow::Result<()> {
13162        self.seq += 1;
13163        self.session
13164            .submit_intent(Intent::PlotFarmAllowUpsert {
13165                entity_id: self.state.entity_id,
13166                plot_id,
13167                character_id,
13168                character_name,
13169                tax_discount_bps,
13170                seq: self.seq,
13171            })
13172            .await?;
13173        self.state.intents_sent += 1;
13174        Ok(())
13175    }
13176
13177    pub async fn plot_farm_allow_remove(
13178        &mut self,
13179        plot_id: uuid::Uuid,
13180        character_id: uuid::Uuid,
13181    ) -> anyhow::Result<()> {
13182        self.seq += 1;
13183        self.session
13184            .submit_intent(Intent::PlotFarmAllowRemove {
13185                entity_id: self.state.entity_id,
13186                plot_id,
13187                character_id,
13188                seq: self.seq,
13189            })
13190            .await?;
13191        self.state.intents_sent += 1;
13192        Ok(())
13193    }
13194
13195    pub fn open_farm_access_panel(&mut self) {
13196        let Some(plot) = self.state.my_plot_under_player() else {
13197            self.state
13198                .push_log("Stand on your deed plot to manage farm access");
13199            return;
13200        };
13201        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13202        self.state.farm_access_index = 0;
13203        self.state.show_farm_access = true;
13204    }
13205
13206    pub fn close_farm_access_panel(&mut self) {
13207        self.state.show_farm_access = false;
13208        self.state.farm_access_name_draft.clear();
13209        self.state.farm_access_index = 0;
13210    }
13211
13212    pub fn farm_access_move(&mut self, delta: i32) {
13213        let n = self.farm_access_row_count().max(1);
13214        let idx = self.state.farm_access_index as i32 + delta;
13215        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13216    }
13217
13218    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13219        let Some(plot) = self.state.my_plot_under_player() else {
13220            return vec![FarmAccessRow::PublicToggle];
13221        };
13222        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13223        for g in &plot.farm_allow {
13224            rows.push(FarmAccessRow::AllowRemove {
13225                character_id: g.character_id,
13226                label: if g.character_label.trim().is_empty() {
13227                    g.character_id.to_string()[..8].to_string()
13228                } else {
13229                    g.character_label.clone()
13230                },
13231                tax_discount_bps: g.tax_discount_bps,
13232            });
13233        }
13234        for e in &self.state.entities {
13235            if e.id == self.state.entity_id || e.label.trim().is_empty() {
13236                continue;
13237            }
13238            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13239                continue;
13240            }
13241            if self
13242                .state
13243                .npcs
13244                .iter()
13245                .any(|n| n.id == e.label || n.label == e.label)
13246            {
13247                continue;
13248            }
13249            if plot
13250                .farm_allow
13251                .iter()
13252                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13253            {
13254                continue;
13255            }
13256            rows.push(FarmAccessRow::NearbyAdd {
13257                name: e.label.clone(),
13258            });
13259        }
13260        rows
13261    }
13262
13263    pub fn farm_access_row_count(&self) -> usize {
13264        self.farm_access_rows().len().max(1)
13265    }
13266
13267    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13268        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13269            self.close_farm_access_panel();
13270            return Ok(());
13271        };
13272        let rows = self.farm_access_rows();
13273        let Some(row) = rows.get(self.state.farm_access_index) else {
13274            return Ok(());
13275        };
13276        match row {
13277            FarmAccessRow::PublicToggle => {
13278                self.set_plot_farm_public(
13279                    plot.plot_id,
13280                    !plot.farm_public,
13281                    plot.public_tax_discount_bps,
13282                )
13283                .await
13284            }
13285            FarmAccessRow::PublicDiscount => Ok(()),
13286            FarmAccessRow::AllowRemove { character_id, .. } => {
13287                self.plot_farm_allow_remove(plot.plot_id, *character_id)
13288                    .await
13289            }
13290            FarmAccessRow::NearbyAdd { name } => {
13291                let disc = self
13292                    .state
13293                    .farm_access_discount_bps
13294                    .max(plot.public_tax_discount_bps);
13295                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13296                    .await
13297            }
13298        }
13299    }
13300
13301    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13302        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13303            return Ok(());
13304        };
13305        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13306        self.state.farm_access_discount_bps = next;
13307        self.state.farm_access_index = 1;
13308        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13309            .await
13310    }
13311
13312    /// Till the cell under you on a farmable plot (`c`).
13313    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13314        if self.state.farmable_plot_under_player().is_none() {
13315            anyhow::bail!("stand on a farmable plot to cultivate");
13316        }
13317        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13318            let (px, py) = self.state.player_position();
13319            if self
13320                .state
13321                .terrain_at(px, py)
13322                .is_some_and(|k| k == TerrainKindView::Tilled)
13323            {
13324                anyhow::bail!("already tilled — stand on bare soil and press c");
13325            }
13326            anyhow::bail!("cannot till this cell — move onto soil on your plot");
13327        };
13328        self.cultivate_at(tx, ty).await
13329    }
13330
13331    /// Plant seeds on empty tilled soil under you (`p`).
13332    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13333        if self.state.farmable_plot_under_player().is_none() {
13334            anyhow::bail!("stand on a farmable plot to plant");
13335        }
13336        if !self.state.underfoot_free_tilled_plant_slot() {
13337            anyhow::bail!("stand on empty tilled soil and press p");
13338        }
13339        let seeds = self.state.farm_seed_entries();
13340        if seeds.is_empty() {
13341            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13342        }
13343        if seeds.len() == 1 {
13344            return self.plant_seeds(seeds[0].0.clone(), 1).await;
13345        }
13346        self.open_plant_menu();
13347        Ok(())
13348    }
13349
13350    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
13351    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13352        let Some(plot) = self.state.my_plot_under_player() else {
13353            anyhow::bail!("stand on your plot to build");
13354        };
13355        if plot.building_id.is_some() {
13356            anyhow::bail!("this plot already has a building");
13357        }
13358        let building_now = self
13359            .state
13360            .timed_channel
13361            .as_ref()
13362            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13363        if !building_now && self.state.building_materials.is_empty() {
13364            anyhow::bail!("no building materials loaded — wait a moment and try again");
13365        }
13366        self.state.show_plot_build_menu = true;
13367        self.state.show_craft_menu = false;
13368        self.state.show_shop_menu = false;
13369        self.state.shop_catalog = None;
13370        self.state.show_stats = false;
13371        self.state.show_inventory_menu = false;
13372        self.state.plot_build_focus_wall = true;
13373        let walls = self.state.plot_build_wall_options().len();
13374        let roofs = self.state.plot_build_roof_options().len();
13375        if walls > 0 {
13376            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13377        } else {
13378            self.state.plot_build_wall_index = 0;
13379        }
13380        if roofs > 0 {
13381            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13382        } else {
13383            self.state.plot_build_roof_index = 0;
13384        }
13385        Ok(())
13386    }
13387
13388    pub fn close_plot_build_menu(&mut self) {
13389        self.state.show_plot_build_menu = false;
13390    }
13391
13392    pub fn plot_build_menu_move(&mut self, delta: i32) {
13393        let walls = self.state.plot_build_wall_options();
13394        let roofs = self.state.plot_build_roof_options();
13395        if self.state.plot_build_focus_wall {
13396            if walls.is_empty() {
13397                return;
13398            }
13399            let n = walls.len() as i32;
13400            let cur = self.state.plot_build_wall_index as i32;
13401            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13402        } else {
13403            if roofs.is_empty() {
13404                return;
13405            }
13406            let n = roofs.len() as i32;
13407            let cur = self.state.plot_build_roof_index as i32;
13408            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13409        }
13410    }
13411
13412    pub fn plot_build_menu_toggle_focus(&mut self) {
13413        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13414    }
13415
13416    /// Confirm selection from the B menu.
13417    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13418        let wall = self
13419            .state
13420            .plot_build_selected_wall()
13421            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13422            .id
13423            .clone();
13424        let roof = self
13425            .state
13426            .plot_build_selected_roof()
13427            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13428            .id
13429            .clone();
13430        // Keep the B menu open so it switches to the in-progress panel.
13431        self.start_plot_build(&wall, &roof).await
13432    }
13433
13434    /// Cancel an in-progress plot build (materials returned).
13435    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13436        self.seq += 1;
13437        self.session
13438            .submit_intent(Intent::CancelPlotBuild {
13439                entity_id: self.state.entity_id,
13440                seq: self.seq,
13441            })
13442            .await?;
13443        self.state.intents_sent += 1;
13444        Ok(())
13445    }
13446
13447    /// Start timed shell craft on the plot's solid tilled rectangle.
13448    pub async fn start_plot_build(
13449        &mut self,
13450        wall_material_id: &str,
13451        roof_material_id: &str,
13452    ) -> anyhow::Result<()> {
13453        let Some(plot) = self.state.my_plot_under_player() else {
13454            anyhow::bail!("stand on your plot to build");
13455        };
13456        if plot.building_id.is_some() {
13457            anyhow::bail!("this plot already has a building");
13458        }
13459        let plot_id = plot.plot_id;
13460        self.seq += 1;
13461        self.session
13462            .submit_intent(Intent::StartPlotBuild {
13463                entity_id: self.state.entity_id,
13464                plot_id,
13465                wall_material_id: wall_material_id.to_string(),
13466                roof_material_id: roof_material_id.to_string(),
13467                seq: self.seq,
13468            })
13469            .await?;
13470        self.state.intents_sent += 1;
13471        Ok(())
13472    }
13473
13474    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
13475    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13476        let (px, py) = self.state.player_position();
13477        let mut best: Option<(f32, String, bool)> = None;
13478        for d in &self.state.doors {
13479            if d.lock_id.is_none() {
13480                continue;
13481            }
13482            let dist = (d.x - px).hypot(d.y - py);
13483            if dist > 3.5 {
13484                continue;
13485            }
13486            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13487                best = Some((dist, d.id.clone(), d.locked));
13488            }
13489        }
13490        let Some((_, door_id, locked_now)) = best else {
13491            anyhow::bail!("no lockable door nearby");
13492        };
13493        let locked = !locked_now;
13494        self.seq += 1;
13495        self.session
13496            .submit_intent(Intent::SetDoorLocked {
13497                entity_id: self.state.entity_id,
13498                door_id,
13499                locked,
13500                seq: self.seq,
13501            })
13502            .await?;
13503        self.state.intents_sent += 1;
13504        Ok(())
13505    }
13506
13507    /// Enter through a nearby open player-building door (`Enter`).
13508    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
13509        if !self.state.is_alive() {
13510            anyhow::bail!("you are dead");
13511        }
13512        if self.state.effective_inside_building().is_some() {
13513            anyhow::bail!("already inside");
13514        }
13515        let (px, py) = self.state.player_position();
13516        let mut best: Option<(f32, String)> = None;
13517        for d in &self.state.doors {
13518            if !d.open || d.locked {
13519                continue;
13520            }
13521            let player_house = self
13522                .state
13523                .buildings
13524                .iter()
13525                .find(|b| b.id == d.building_id)
13526                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13527            if !player_house {
13528                continue;
13529            }
13530            let dist = (d.x - px).hypot(d.y - py);
13531            if dist > 3.5 {
13532                continue;
13533            }
13534            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13535                best = Some((dist, d.id.clone()));
13536            }
13537        }
13538        let Some((_, door_id)) = best else {
13539            anyhow::bail!("no open house door nearby — open with f first");
13540        };
13541        self.seq += 1;
13542        self.session
13543            .submit_intent(Intent::EnterBuildingDoor {
13544                entity_id: self.state.entity_id,
13545                door_id,
13546                seq: self.seq,
13547            })
13548            .await?;
13549        self.state.intents_sent += 1;
13550        Ok(())
13551    }
13552
13553    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
13554    /// Works even when the door is closed or locked.
13555    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
13556        if !self.state.is_alive() {
13557            anyhow::bail!("you are dead");
13558        }
13559        let Some(bid) = self.state.effective_inside_building() else {
13560            anyhow::bail!("not inside a building");
13561        };
13562        let (px, py) = self.state.player_position();
13563        let mut best: Option<(f32, String)> = None;
13564        for d in &self.state.doors {
13565            if d.building_id != bid || d.portal.is_none() {
13566                continue;
13567            }
13568            let player_house = self
13569                .state
13570                .buildings
13571                .iter()
13572                .find(|b| b.id == d.building_id)
13573                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13574            if !player_house {
13575                continue;
13576            }
13577            let dist = (d.x - px).hypot(d.y - py);
13578            if dist > 1.5 {
13579                continue;
13580            }
13581            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13582                best = Some((dist, d.id.clone()));
13583            }
13584        }
13585        let Some((_, door_id)) = best else {
13586            anyhow::bail!("stand by the door to exit");
13587        };
13588        self.seq += 1;
13589        self.session
13590            .submit_intent(Intent::ExitBuildingDoor {
13591                entity_id: self.state.entity_id,
13592                door_id,
13593                seq: self.seq,
13594            })
13595            .await?;
13596        self.state.intents_sent += 1;
13597        Ok(())
13598    }
13599
13600    /// Confirm interior layout for your player building (copper charged on server).
13601    pub async fn confirm_interior_edit(
13602        &mut self,
13603        building_id: String,
13604        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
13605        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
13606    ) -> anyhow::Result<()> {
13607        self.seq += 1;
13608        self.session
13609            .submit_intent(Intent::ConfirmInteriorEdit {
13610                entity_id: self.state.entity_id,
13611                building_id,
13612                rooms,
13613                room_doors,
13614                seq: self.seq,
13615            })
13616            .await?;
13617        self.state.intents_sent += 1;
13618        Ok(())
13619    }
13620
13621    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
13622        if !self.state.is_alive() {
13623            anyhow::bail!("you are dead");
13624        }
13625        self.seq += 1;
13626        self.session
13627            .submit_intent(Intent::Cultivate {
13628                entity_id: self.state.entity_id,
13629                x,
13630                y,
13631                seq: self.seq,
13632            })
13633            .await?;
13634        self.state.intents_sent += 1;
13635        Ok(())
13636    }
13637
13638    pub async fn plant_seeds(
13639        &mut self,
13640        seed_template_id: String,
13641        quantity: u32,
13642    ) -> anyhow::Result<()> {
13643        if !self.state.is_alive() {
13644            anyhow::bail!("you are dead");
13645        }
13646        self.seq += 1;
13647        self.session
13648            .submit_intent(Intent::PlantSeeds {
13649                entity_id: self.state.entity_id,
13650                seed_template_id: seed_template_id.clone(),
13651                quantity,
13652                seq: self.seq,
13653            })
13654            .await?;
13655        self.state.intents_sent += 1;
13656        self.state
13657            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
13658        Ok(())
13659    }
13660
13661    pub fn open_plant_menu(&mut self) {
13662        if self.state.farm_seed_entries().is_empty() {
13663            self.state.push_log("No seeds in inventory to plant");
13664            return;
13665        }
13666        self.state.show_plant_menu = true;
13667        self.state.plant_menu_index = 0;
13668        self.state.plant_quantity = 1;
13669        self.state.clamp_plant_menu();
13670    }
13671
13672    pub fn close_plant_menu(&mut self) {
13673        self.state.show_plant_menu = false;
13674    }
13675
13676    pub fn plant_menu_move(&mut self, delta: i32) {
13677        let n = self.state.farm_seed_entries().len();
13678        if n == 0 {
13679            return;
13680        }
13681        let idx = self.state.plant_menu_index as i32 + delta;
13682        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
13683        self.state.clamp_plant_menu();
13684    }
13685
13686    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
13687        let next = self.state.plant_quantity as i32 + delta;
13688        self.state.plant_quantity = next.max(1) as u32;
13689        self.state.clamp_plant_menu();
13690    }
13691
13692    pub fn plant_menu_set_quantity_max(&mut self) {
13693        if let Some((_, max, _)) = self.state.plant_menu_selection() {
13694            self.state.plant_quantity = max;
13695        }
13696        self.state.clamp_plant_menu();
13697    }
13698
13699    pub fn plant_menu_set_quantity_min(&mut self) {
13700        self.state.plant_quantity = 1;
13701        self.state.clamp_plant_menu();
13702    }
13703
13704    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
13705        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
13706            self.close_plant_menu();
13707            anyhow::bail!("no seeds to plant");
13708        };
13709        self.close_plant_menu();
13710        self.plant_seeds(seed, qty).await?;
13711        self.state.push_log(format!("Planted {qty}× {label}"));
13712        Ok(())
13713    }
13714
13715    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
13716    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
13717    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
13718        if !self.state.is_alive() {
13719            anyhow::bail!("you are dead");
13720        }
13721        let binding = self
13722            .state
13723            .hotbar_ability(slot)
13724            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
13725            .to_string();
13726        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
13727            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
13728            if qty == 0 {
13729                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
13730            }
13731            return self.use_item(template_id).await;
13732        }
13733        let ability_id = binding;
13734        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
13735            return self
13736                .cast_ability(&ability_id, Some(self.state.entity_id))
13737                .await;
13738        }
13739        let is_heal = ability_id == "heal_touch"
13740            || self
13741                .state
13742                .ability_meta
13743                .get(&ability_id)
13744                .map(|meta| meta.is_heal)
13745                .unwrap_or(false);
13746        let target = if is_heal {
13747            Some(
13748                self.state
13749                    .target_for_slot(2)
13750                    .unwrap_or(self.state.entity_id),
13751            )
13752        } else {
13753            self.state
13754                .target_for_slot(1)
13755                .or_else(|| self.state.target_for_slot(2))
13756        };
13757        let Some(target_id) = target else {
13758            anyhow::bail!("no target — Tab to select, then press the hotbar key");
13759        };
13760        self.cast_ability(&ability_id, Some(target_id)).await
13761    }
13762
13763    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
13764    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
13765    pub async fn set_hotbar_slot(
13766        &mut self,
13767        slot: u8,
13768        ability_id: Option<&str>,
13769    ) -> anyhow::Result<()> {
13770        if !self.state.is_alive() {
13771            anyhow::bail!("you are dead");
13772        }
13773        if !(1..=9).contains(&slot) {
13774            anyhow::bail!("hotbar slot must be 1–9");
13775        }
13776        let ability_id = ability_id
13777            .map(str::trim)
13778            .filter(|id| !id.is_empty())
13779            .map(str::to_string);
13780        self.seq += 1;
13781        self.session
13782            .submit_intent(Intent::SetHotbarSlot {
13783                entity_id: self.state.entity_id,
13784                slot,
13785                ability_id: ability_id.clone(),
13786                seq: self.seq,
13787            })
13788            .await?;
13789        self.state.intents_sent += 1;
13790        let idx = (slot - 1) as usize;
13791        if self.state.hotbar.len() < 9 {
13792            self.state.hotbar.resize(9, None);
13793        }
13794        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
13795            *slot_mut = ability_id.clone();
13796        }
13797        match ability_id {
13798            Some(id) => {
13799                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
13800                    format!("use {tid}")
13801                } else {
13802                    id
13803                };
13804                self.state.push_log(format!("Hotbar {slot} → {label}"))
13805            }
13806            None => self.state.push_log(format!("Hotbar {slot} cleared")),
13807        }
13808        Ok(())
13809    }
13810
13811    pub fn npc_verb_options(&self) -> Vec<&'static str> {
13812        self.state.npc_verb_options()
13813    }
13814
13815    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
13816        let Some(npc_id) = self.state.npc_verb_target.clone() else {
13817            return Ok(());
13818        };
13819        let options = self.npc_verb_options();
13820        let choice = options
13821            .get(self.state.npc_verb_index)
13822            .copied()
13823            .unwrap_or("Talk");
13824        self.seq += 1;
13825        match choice {
13826            "Trade" | "Bank" | "Storage" | "Market" => {
13827                self.session
13828                    .submit_intent(Intent::Interact {
13829                        entity_id: self.state.entity_id,
13830                        target_id: npc_id,
13831                        seq: self.seq,
13832                    })
13833                    .await?;
13834            }
13835            _ => {
13836                self.session
13837                    .submit_intent(Intent::NpcTalkOpen {
13838                        entity_id: self.state.entity_id,
13839                        npc_id,
13840                        seq: self.seq,
13841                    })
13842                    .await?;
13843            }
13844        }
13845        self.state.intents_sent += 1;
13846        Ok(())
13847    }
13848
13849    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
13850        let Some(chat) = self.state.npc_chat.clone() else {
13851            return Ok(());
13852        };
13853        let message = chat.input.trim().to_string();
13854        if message.is_empty() || chat.pending {
13855            return Ok(());
13856        }
13857        if let Some(c) = self.state.npc_chat.as_mut() {
13858            c.lines.push(format!("You: {message}"));
13859            c.input.clear();
13860            c.pending = true;
13861        }
13862        self.seq += 1;
13863        self.session
13864            .submit_intent(Intent::NpcTalkSay {
13865                entity_id: self.state.entity_id,
13866                npc_id: chat.npc_id,
13867                message,
13868                seq: self.seq,
13869            })
13870            .await?;
13871        self.state.intents_sent += 1;
13872        Ok(())
13873    }
13874
13875    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
13876        let return_to_verbs = self.state.npc_verb_target.is_some();
13877        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
13878            self.state.show_npc_chat = false;
13879            if return_to_verbs {
13880                self.state.show_npc_verb_menu = true;
13881            }
13882            return Ok(());
13883        };
13884        self.seq += 1;
13885        self.session
13886            .submit_intent(Intent::NpcTalkClose {
13887                entity_id: self.state.entity_id,
13888                npc_id,
13889                seq: self.seq,
13890            })
13891            .await?;
13892        self.state.intents_sent += 1;
13893        self.state.show_npc_chat = false;
13894        self.state.npc_chat = None;
13895        if return_to_verbs {
13896            self.state.show_npc_verb_menu = true;
13897        }
13898        Ok(())
13899    }
13900
13901    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
13902    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
13903        if self.state.show_quest_offer
13904            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
13905        {
13906            self.quest_offer_decline();
13907            return Ok(());
13908        }
13909        if self.state.show_npc_chat {
13910            return self.npc_talk_close().await;
13911        }
13912        if self.state.show_shop_menu {
13913            return self.back_from_shop_menu().await;
13914        }
13915        if self.state.bank_panel.is_some() {
13916            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
13917                self.bank_transfer_back();
13918                return Ok(());
13919            }
13920            return self.close_bank_panel().await;
13921        }
13922        if self.state.storage_panel.is_some() {
13923            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
13924                self.storage_ui_back();
13925                return Ok(());
13926            }
13927            return self.close_storage_panel().await;
13928        }
13929        if self.state.market_panel.is_some() {
13930            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
13931                self.market_ui_back();
13932                return Ok(());
13933            }
13934            if self.state.market_buy_confirm.is_some() {
13935                self.state.market_buy_confirm = None;
13936                return Ok(());
13937            }
13938            return self.close_market_panel().await;
13939        }
13940        if self.state.show_npc_verb_menu {
13941            self.state.show_npc_verb_menu = false;
13942            self.state.npc_verb_target = None;
13943        }
13944        Ok(())
13945    }
13946
13947    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
13948        self.seq += 1;
13949        self.session
13950            .submit_intent(Intent::TestDamage {
13951                entity_id: self.state.entity_id,
13952                amount,
13953                seq: self.seq,
13954            })
13955            .await?;
13956        self.state.intents_sent += 1;
13957        Ok(())
13958    }
13959
13960    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
13961        self.cycle_combat_target_slot(1, reverse).await
13962    }
13963
13964    pub async fn cycle_combat_target_slot(
13965        &mut self,
13966        slot_index: u8,
13967        reverse: bool,
13968    ) -> anyhow::Result<()> {
13969        if !self.state.is_alive() {
13970            anyhow::bail!("you are dead");
13971        }
13972        let candidates = self.state.candidates_for_slot(slot_index);
13973        if candidates.is_empty() {
13974            anyhow::bail!("no targets nearby");
13975        }
13976        let current = self.state.target_for_slot(slot_index);
13977        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
13978        let next_idx = match idx {
13979            None => 0,
13980            Some(i) if reverse => {
13981                if i == 0 {
13982                    candidates.len() - 1
13983                } else {
13984                    i - 1
13985                }
13986            }
13987            Some(i) => (i + 1) % candidates.len(),
13988        };
13989        if idx == Some(next_idx) && candidates.len() == 1 {
13990            self.clear_combat_target_slot(slot_index).await?;
13991            return Ok(());
13992        }
13993        let (target_id, label) = candidates[next_idx].clone();
13994        self.set_combat_target_slot(slot_index, target_id, &label)
13995            .await
13996    }
13997
13998    pub async fn set_combat_target_slot(
13999        &mut self,
14000        slot_index: u8,
14001        target_id: EntityId,
14002        label: &str,
14003    ) -> anyhow::Result<()> {
14004        if !self.state.is_alive() {
14005            anyhow::bail!("you are dead");
14006        }
14007        self.seq += 1;
14008        self.session
14009            .submit_intent(Intent::SetTargetSlot {
14010                entity_id: self.state.entity_id,
14011                slot_index,
14012                target_id,
14013                seq: self.seq,
14014            })
14015            .await?;
14016        self.state.intents_sent += 1;
14017        if slot_index == 1 {
14018            self.state.combat_target = Some(target_id);
14019            self.state.combat_target_label = Some(label.to_string());
14020        }
14021        self.state
14022            .push_log(format!("Slot {slot_index} target: {label}"));
14023        Ok(())
14024    }
14025
14026    pub async fn set_combat_target(
14027        &mut self,
14028        target_id: EntityId,
14029        label: &str,
14030    ) -> anyhow::Result<()> {
14031        self.set_combat_target_slot(1, target_id, label).await
14032    }
14033
14034    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14035        if slot_index == 1 && self.state.combat_target.is_none() {
14036            return Ok(());
14037        }
14038        self.seq += 1;
14039        self.session
14040            .submit_intent(Intent::ClearTargetSlot {
14041                entity_id: self.state.entity_id,
14042                slot_index,
14043                seq: self.seq,
14044            })
14045            .await?;
14046        if slot_index == 1 {
14047            self.state.combat_target = None;
14048            self.state.combat_target_label = None;
14049        }
14050        self.state.intents_sent += 1;
14051        self.state
14052            .push_log(format!("Slot {slot_index} target cleared"));
14053        Ok(())
14054    }
14055
14056    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14057        self.clear_combat_target_slot(1).await
14058    }
14059
14060    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14061        if !self.state.is_alive() {
14062            anyhow::bail!("you are dead");
14063        }
14064        self.seq += 1;
14065        self.session
14066            .submit_intent(Intent::AdvanceRotation {
14067                entity_id: self.state.entity_id,
14068                slot_index,
14069                seq: self.seq,
14070            })
14071            .await?;
14072        self.state.intents_sent += 1;
14073        Ok(())
14074    }
14075
14076    pub async fn assign_slot_preset(
14077        &mut self,
14078        slot_index: u8,
14079        preset_id: &str,
14080    ) -> anyhow::Result<()> {
14081        if !self.state.is_alive() {
14082            anyhow::bail!("you are dead");
14083        }
14084        self.seq += 1;
14085        self.session
14086            .submit_intent(Intent::AssignSlotPreset {
14087                entity_id: self.state.entity_id,
14088                slot_index,
14089                preset_id: preset_id.to_string(),
14090                seq: self.seq,
14091            })
14092            .await?;
14093        self.state.intents_sent += 1;
14094        if let Some(slot) = self
14095            .state
14096            .combat_slots
14097            .iter_mut()
14098            .find(|s| s.slot_index == slot_index)
14099        {
14100            slot.preset_id = Some(preset_id.to_string());
14101            if let Some(preset) = self
14102                .state
14103                .rotation_presets
14104                .iter()
14105                .find(|p| p.id == preset_id)
14106            {
14107                slot.preset_label = Some(preset.label.clone());
14108                slot.rotation = preset.abilities.clone();
14109                slot.rotation_index = 0;
14110            }
14111        }
14112        self.state
14113            .push_log(format!("T{slot_index} loadout → {preset_id}"));
14114        Ok(())
14115    }
14116
14117    pub async fn cast_ability(
14118        &mut self,
14119        ability_id: &str,
14120        target_id: Option<EntityId>,
14121    ) -> anyhow::Result<()> {
14122        if !self.state.is_alive() {
14123            anyhow::bail!("you are dead");
14124        }
14125        let allows_ground = self.state.ability_allows_ground(ability_id);
14126        let requires_ground = self.state.ability_requires_ground(ability_id);
14127        if requires_ground && self.state.ground_target.is_none() {
14128            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14129        }
14130        let (resolved_target_id, target_point) = if allows_ground {
14131            if let Some((x, y, z)) = self.state.ground_target {
14132                (
14133                    target_id.unwrap_or(self.state.entity_id),
14134                    Some(flatland_protocol::AimPoint { x, y, z }),
14135                )
14136            } else {
14137                (
14138                    target_id
14139                        .or_else(|| self.state.target_for_slot(2))
14140                        .or_else(|| self.state.target_for_slot(1))
14141                        .unwrap_or(self.state.entity_id),
14142                    None,
14143                )
14144            }
14145        } else {
14146            (
14147                target_id
14148                    .or_else(|| self.state.target_for_slot(2))
14149                    .or_else(|| self.state.target_for_slot(1))
14150                    .unwrap_or(self.state.entity_id),
14151                None,
14152            )
14153        };
14154        self.seq += 1;
14155        self.session
14156            .submit_intent(Intent::Cast {
14157                entity_id: self.state.entity_id,
14158                ability_id: ability_id.to_string(),
14159                target_id: resolved_target_id,
14160                target_point,
14161                seq: self.seq,
14162            })
14163            .await?;
14164        self.state.intents_sent += 1;
14165        match target_point {
14166            Some(point) => self.state.push_log(format!(
14167                "Cast {ability_id} → ({:.1}, {:.1})",
14168                point.x, point.y
14169            )),
14170            None => self
14171                .state
14172                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14173        }
14174        Ok(())
14175    }
14176
14177    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14178        self.seq += 1;
14179        self.session
14180            .submit_intent(Intent::UpsertRotationPreset {
14181                entity_id: self.state.entity_id,
14182                preset: preset.clone(),
14183                seq: self.seq,
14184            })
14185            .await?;
14186        self.state.intents_sent += 1;
14187        if let Some(existing) = self
14188            .state
14189            .rotation_presets
14190            .iter_mut()
14191            .find(|p| p.id == preset.id)
14192        {
14193            *existing = preset.clone();
14194        } else {
14195            self.state.rotation_presets.push(preset.clone());
14196        }
14197        for slot in &mut self.state.combat_slots {
14198            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14199                slot.preset_label = Some(preset.label.clone());
14200                slot.rotation = preset.abilities.clone();
14201            }
14202        }
14203        self.state
14204            .push_log(format!("Saved rotation: {}", preset.label));
14205        Ok(())
14206    }
14207
14208    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14209        self.seq += 1;
14210        self.session
14211            .submit_intent(Intent::DeleteRotationPreset {
14212                entity_id: self.state.entity_id,
14213                preset_id: preset_id.to_string(),
14214                seq: self.seq,
14215            })
14216            .await?;
14217        self.state.intents_sent += 1;
14218        self.state.rotation_presets.retain(|p| p.id != preset_id);
14219        for slot in &mut self.state.combat_slots {
14220            if slot.preset_id.as_deref() == Some(preset_id) {
14221                slot.preset_id = None;
14222                slot.preset_label = None;
14223                slot.rotation.clear();
14224                slot.rotation_index = 0;
14225            }
14226        }
14227        self.state
14228            .push_log(format!("Deleted rotation: {preset_id}"));
14229        Ok(())
14230    }
14231
14232    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14233        if !self.state.is_alive() {
14234            anyhow::bail!("you are dead");
14235        }
14236        let enabled = !self
14237            .state
14238            .combat_slots
14239            .iter()
14240            .find(|s| s.slot_index == slot_index)
14241            .map(|s| s.auto_enabled)
14242            .unwrap_or(false);
14243        self.seq += 1;
14244        self.session
14245            .submit_intent(Intent::SetAutoAttack {
14246                entity_id: self.state.entity_id,
14247                slot_index,
14248                enabled,
14249                seq: self.seq,
14250            })
14251            .await?;
14252        if slot_index == 1 {
14253            self.state.auto_attack = enabled;
14254        }
14255        self.state.intents_sent += 1;
14256        self.state.push_log(format!(
14257            "T{slot_index} auto {}",
14258            if enabled { "ON" } else { "OFF" }
14259        ));
14260        Ok(())
14261    }
14262
14263    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14264        if !self.state.connected {
14265            anyhow::bail!("not connected");
14266        }
14267        if !self.state.is_alive() {
14268            anyhow::bail!("you are dead");
14269        }
14270        let (px, py) = self.state.player_position();
14271        if self
14272            .state
14273            .ground_drops
14274            .iter()
14275            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14276        {
14277            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14278        }
14279        self.seq += 1;
14280        self.session
14281            .submit_intent(Intent::Pickup {
14282                entity_id: self.state.entity_id,
14283                drop_id: None,
14284                seq: self.seq,
14285            })
14286            .await?;
14287        self.state.intents_sent += 1;
14288        Ok(())
14289    }
14290
14291    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
14292        self.toggle_auto_attack_slot(1).await
14293    }
14294
14295    pub async fn dodge(&mut self) -> anyhow::Result<()> {
14296        if !self.state.is_alive() {
14297            anyhow::bail!("you are dead");
14298        }
14299        let (forward, strafe) = self.last_move_axes();
14300        self.seq += 1;
14301        self.session
14302            .submit_intent(Intent::Dodge {
14303                entity_id: self.state.entity_id,
14304                forward,
14305                strafe,
14306                seq: self.seq,
14307            })
14308            .await?;
14309        self.state.intents_sent += 1;
14310        self.state.push_log("Dodge!");
14311        Ok(())
14312    }
14313
14314    pub async fn lunge(&mut self) -> anyhow::Result<()> {
14315        if !self.state.is_alive() {
14316            anyhow::bail!("you are dead");
14317        }
14318        let (forward, strafe) = self.last_move_axes();
14319        self.seq += 1;
14320        self.session
14321            .submit_intent(Intent::Lunge {
14322                entity_id: self.state.entity_id,
14323                forward,
14324                strafe,
14325                seq: self.seq,
14326            })
14327            .await?;
14328        self.state.intents_sent += 1;
14329        self.state.push_log("Lunge!");
14330        Ok(())
14331    }
14332
14333    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14334        if !self.state.is_alive() {
14335            anyhow::bail!("you are dead");
14336        }
14337        self.seq += 1;
14338        self.session
14339            .submit_intent(Intent::DirectionalJump {
14340                entity_id: self.state.entity_id,
14341                forward,
14342                strafe,
14343                seq: self.seq,
14344            })
14345            .await?;
14346        self.state.intents_sent += 1;
14347        self.state.push_log("Jump!");
14348        Ok(())
14349    }
14350
14351    /// Remembered WASD axes for lunge when not currently moving.
14352    pub fn last_move_axes(&self) -> (f32, f32) {
14353        (self.last_move_forward, self.last_move_strafe)
14354    }
14355
14356    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14357        if !self.state.is_alive() {
14358            anyhow::bail!("you are dead");
14359        }
14360        self.seq += 1;
14361        self.session
14362            .submit_intent(Intent::Block {
14363                entity_id: self.state.entity_id,
14364                enabled,
14365                seq: self.seq,
14366            })
14367            .await?;
14368        self.state.intents_sent += 1;
14369        if enabled {
14370            self.state.push_log("Blocking");
14371        }
14372        Ok(())
14373    }
14374
14375    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14376        if !self.state.is_alive() {
14377            anyhow::bail!("you are dead");
14378        }
14379        self.seq += 1;
14380        self.session
14381            .submit_intent(Intent::EquipMainhand {
14382                entity_id: self.state.entity_id,
14383                template_id,
14384                instance_id: None,
14385                seq: self.seq,
14386            })
14387            .await?;
14388        self.state.intents_sent += 1;
14389        Ok(())
14390    }
14391
14392    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
14393    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14394        let idx = self.state.equip_menu_index;
14395        let slots = equip_paperdoll_rows(&self.state);
14396        let Some(row) = slots.get(idx) else {
14397            return Ok(());
14398        };
14399        match row {
14400            EquipPaperdollRow::Body { slot, filled } => {
14401                if *filled {
14402                    self.equip_worn(*slot, None).await
14403                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14404                    self.equip_worn(*slot, Some(inst)).await
14405                } else {
14406                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
14407                    Ok(())
14408                }
14409            }
14410            EquipPaperdollRow::Mainhand { filled } => {
14411                if *filled {
14412                    self.unequip_mainhand().await
14413                } else if let Some(tid) = first_inventory_weapon(&self.state) {
14414                    self.equip_mainhand(Some(tid)).await
14415                } else {
14416                    self.state.push_log("No weapon in inventory".to_string());
14417                    Ok(())
14418                }
14419            }
14420            EquipPaperdollRow::Offhand { filled, locked } => {
14421                if *locked {
14422                    self.state
14423                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
14424                    Ok(())
14425                } else if *filled {
14426                    self.unequip_offhand().await
14427                } else if let Some(tid) = first_inventory_offhand(&self.state) {
14428                    self.equip_offhand(Some(tid)).await
14429                } else {
14430                    self.state
14431                        .push_log("No offhand item in inventory".to_string());
14432                    Ok(())
14433                }
14434            }
14435        }
14436    }
14437
14438    pub async fn say(
14439        &mut self,
14440        channel: flatland_protocol::ChatChannel,
14441        text: &str,
14442    ) -> anyhow::Result<()> {
14443        self.say_to(channel, text, None).await
14444    }
14445
14446    pub async fn say_to(
14447        &mut self,
14448        channel: flatland_protocol::ChatChannel,
14449        text: &str,
14450        to_entity: Option<EntityId>,
14451    ) -> anyhow::Result<()> {
14452        self.seq += 1;
14453        self.session
14454            .submit_intent(Intent::Say {
14455                entity_id: self.state.entity_id,
14456                channel,
14457                text: text.to_string(),
14458                to_entity,
14459                seq: self.seq,
14460            })
14461            .await?;
14462        self.state.intents_sent += 1;
14463        Ok(())
14464    }
14465
14466    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14467        let Some(peer) = self.state.player_verbs.target_entity else {
14468            return Ok(());
14469        };
14470        let label = self.state.player_verbs.target_label.clone();
14471        let choice = crate::social::PlayerVerbState::options()
14472            .get(self.state.player_verbs.index)
14473            .copied()
14474            .unwrap_or("Whisper");
14475        self.state.player_verbs.close();
14476        match choice {
14477            "Trade" => {
14478                // Only request — peer must accept (Y in chat). Do not auto-respond;
14479                // TradeRespond requires a pending inbound request from the peer.
14480                self.seq += 1;
14481                self.session
14482                    .submit_intent(Intent::TradeRequest {
14483                        entity_id: self.state.entity_id,
14484                        peer_entity_id: peer,
14485                        seq: self.seq,
14486                    })
14487                    .await?;
14488                self.state.intents_sent += 1;
14489                self.state
14490                    .social_chat
14491                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
14492            }
14493            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
14494            _ => self.state.social_chat.focus_nearby(),
14495        }
14496        Ok(())
14497    }
14498
14499    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
14500        let Some(pending) = self.state.social_chat.pending_trade.take() else {
14501            return Ok(());
14502        };
14503        self.seq += 1;
14504        self.session
14505            .submit_intent(Intent::TradeRespond {
14506                entity_id: self.state.entity_id,
14507                peer_entity_id: pending.from_entity,
14508                accept,
14509                seq: self.seq,
14510            })
14511            .await?;
14512        self.state.intents_sent += 1;
14513        if accept {
14514            self.state
14515                .social_chat
14516                .push_system(format!("Accepted trade with {}", pending.from_name));
14517        } else {
14518            self.state
14519                .social_chat
14520                .push_system(format!("Declined trade with {}", pending.from_name));
14521        }
14522        Ok(())
14523    }
14524
14525    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
14526        let text = self.state.social_chat.buffer.trim().to_string();
14527        if text.is_empty() {
14528            return Ok(());
14529        }
14530        self.state.social_chat.buffer.clear();
14531        if crate::social::is_chat_slash_line(&text) {
14532            match crate::social::parse_chat_slash(&text) {
14533                Some(cmd) => return self.apply_chat_slash(cmd).await,
14534                None => {
14535                    self.state.social_chat.push_system(format!(
14536                        "Unknown command — {}",
14537                        crate::social::chat_slash_help_text()
14538                    ));
14539                    return Ok(());
14540                }
14541            }
14542        }
14543        let thread = self.state.social_chat.thread;
14544        let channel = thread.channel();
14545        let to = thread.to_entity();
14546        if let Some(peer) = to {
14547            let label = self.state.social_chat.peer_label.clone();
14548            self.state
14549                .social_chat
14550                .remember_whisper_peer(peer, &label, channel);
14551        }
14552        self.say_to(channel, &text, to).await
14553    }
14554
14555    async fn apply_chat_slash(
14556        &mut self,
14557        cmd: crate::social::ChatSlashCommand,
14558    ) -> anyhow::Result<()> {
14559        use crate::social::{chat_slash_help_text, ChatSlashCommand};
14560        match cmd {
14561            ChatSlashCommand::Help => {
14562                self.state
14563                    .social_chat
14564                    .push_system(chat_slash_help_text().to_string());
14565                Ok(())
14566            }
14567            ChatSlashCommand::Nearby { message } => {
14568                self.state.social_chat.focus_nearby();
14569                self.state
14570                    .social_chat
14571                    .push_system("Nearby speech — everyone close can hear");
14572                if let Some(msg) = message {
14573                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
14574                        .await
14575                } else {
14576                    Ok(())
14577                }
14578            }
14579            ChatSlashCommand::Reply { message } => {
14580                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14581                    self.state.social_chat.push_system(
14582                        "No one to reply to — wait for a whisper, or /whisper Name",
14583                    );
14584                    return Ok(());
14585                };
14586                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
14587                self.state
14588                    .social_chat
14589                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
14590                self.state.social_chat.push_system(format!(
14591                    "Replying to {} — type and Enter · /nearby",
14592                    peer.label
14593                ));
14594                if let Some(msg) = message {
14595                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
14596                } else {
14597                    Ok(())
14598                }
14599            }
14600            ChatSlashCommand::Whisper { name, message } => {
14601                let (peer_id, label, stone) = if let Some(name) = name {
14602                    match self.resolve_whisper_target(&name) {
14603                        Ok(t) => t,
14604                        Err(err) => {
14605                            self.state.social_chat.push_system(err);
14606                            return Ok(());
14607                        }
14608                    }
14609                } else {
14610                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14611                        self.state.social_chat.push_system(
14612                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
14613                        );
14614                        return Ok(());
14615                    };
14616                    (
14617                        peer.entity_id,
14618                        peer.label,
14619                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
14620                    )
14621                };
14622                self.state
14623                    .social_chat
14624                    .set_whisper_thread(peer_id, &label, stone);
14625                let channel = if stone {
14626                    flatland_protocol::ChatChannel::WhisperStone
14627                } else {
14628                    flatland_protocol::ChatChannel::Whisper
14629                };
14630                if let Some(msg) = message {
14631                    self.state.social_chat.push_system(format!(
14632                        "Whisper → {label}"
14633                    ));
14634                    self.say_to(channel, &msg, Some(peer_id)).await
14635                } else {
14636                    self.state.social_chat.push_system(format!(
14637                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
14638                    ));
14639                    Ok(())
14640                }
14641            }
14642        }
14643    }
14644
14645    /// Resolve `/whisper Name` against AOI players (label match).
14646    fn resolve_whisper_target(
14647        &self,
14648        name: &str,
14649    ) -> Result<(EntityId, String, bool), String> {
14650        let needle = name.trim().to_ascii_lowercase();
14651        if needle.is_empty() {
14652            return Err("Usage: /whisper Name [message]".into());
14653        }
14654        let mut candidates: Vec<(EntityId, String)> = self
14655            .state
14656            .entities
14657            .iter()
14658            .filter(|e| e.id != self.state.entity_id)
14659            .filter(|e| !e.label.trim().is_empty())
14660            .filter(|e| e.vitals.is_some())
14661            .filter(|e| {
14662                !self
14663                    .state
14664                    .npcs
14665                    .iter()
14666                    .any(|n| n.id == e.id.to_string())
14667            })
14668            .filter(|e| {
14669                !self
14670                    .state
14671                    .hired_workers
14672                    .iter()
14673                    .any(|w| w.entity_id == e.id)
14674            })
14675            .map(|e| (e.id, e.label.clone()))
14676            .collect();
14677
14678        // Also allow matching the last whisper peer by name even if they left AOI briefly.
14679        if let Some(last) = &self.state.social_chat.last_whisper_peer {
14680            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
14681                candidates.push((last.entity_id, last.label.clone()));
14682            }
14683        }
14684
14685        let exact: Vec<_> = candidates
14686            .iter()
14687            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
14688            .cloned()
14689            .collect();
14690        let pool = if exact.len() == 1 {
14691            exact
14692        } else if exact.len() > 1 {
14693            return Err(format!(
14694                "Several players named '{name}' nearby — move closer and try again"
14695            ));
14696        } else {
14697            let starts: Vec<_> = candidates
14698                .iter()
14699                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
14700                .cloned()
14701                .collect();
14702            if starts.len() == 1 {
14703                starts
14704            } else if starts.len() > 1 {
14705                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
14706                return Err(format!(
14707                    "Ambiguous name '{name}' — matches: {}",
14708                    names.join(", ")
14709                ));
14710            } else {
14711                let contains: Vec<_> = candidates
14712                    .iter()
14713                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
14714                    .cloned()
14715                    .collect();
14716                if contains.len() == 1 {
14717                    contains
14718                } else if contains.is_empty() {
14719                    return Err(format!(
14720                        "No player matching '{name}' in range — get closer or check the spelling"
14721                    ));
14722                } else {
14723                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
14724                    return Err(format!(
14725                        "Ambiguous name '{name}' — matches: {}",
14726                        names.join(", ")
14727                    ));
14728                }
14729            }
14730        };
14731
14732        let (id, label) = pool.into_iter().next().unwrap();
14733        let stone = self
14734            .state
14735            .social_chat
14736            .last_whisper_peer
14737            .as_ref()
14738            .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
14739        Ok((id, label, stone))
14740    }
14741
14742    pub async fn trade_present_selected(
14743        &mut self,
14744        item_instance_id: uuid::Uuid,
14745    ) -> anyhow::Result<()> {
14746        self.trade_present_quantity(item_instance_id, None).await
14747    }
14748
14749    pub async fn trade_present_quantity(
14750        &mut self,
14751        item_instance_id: uuid::Uuid,
14752        quantity: Option<u32>,
14753    ) -> anyhow::Result<()> {
14754        self.seq += 1;
14755        self.session
14756            .submit_intent(Intent::TradePresent {
14757                entity_id: self.state.entity_id,
14758                item_instance_id,
14759                quantity,
14760                seq: self.seq,
14761            })
14762            .await?;
14763        self.state.intents_sent += 1;
14764        self.state.trade_ui.qty_entry = None;
14765        self.state.trade_ui.picking_inventory = false;
14766        Ok(())
14767    }
14768
14769    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
14770    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
14771        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
14772            let qty = self.state.trade_ui.present_quantity();
14773            return self
14774                .trade_present_quantity(entry.item_instance_id, qty)
14775                .await;
14776        }
14777        if !self.state.trade_ui.picking_inventory {
14778            return Ok(());
14779        }
14780        let stacks = self.state.trade_presentable_stacks();
14781        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
14782            return Ok(());
14783        };
14784        let Some(id) = stack.item_instance_id else {
14785            return Ok(());
14786        };
14787        let label = stack
14788            .display_name
14789            .clone()
14790            .unwrap_or_else(|| stack.template_id.clone());
14791        if stack.quantity <= 1 {
14792            self.trade_present_quantity(id, Some(1)).await
14793        } else {
14794            self.state
14795                .trade_ui
14796                .begin_qty_entry(id, label, stack.quantity);
14797            Ok(())
14798        }
14799    }
14800
14801    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
14802        self.seq += 1;
14803        self.session
14804            .submit_intent(Intent::TradeSetReady {
14805                entity_id: self.state.entity_id,
14806                ready,
14807                seq: self.seq,
14808            })
14809            .await?;
14810        self.state.intents_sent += 1;
14811        Ok(())
14812    }
14813
14814    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
14815        self.seq += 1;
14816        self.session
14817            .submit_intent(Intent::TradeCancel {
14818                entity_id: self.state.entity_id,
14819                seq: self.seq,
14820            })
14821            .await?;
14822        self.state.intents_sent += 1;
14823        self.state.trade_ui.close();
14824        Ok(())
14825    }
14826
14827    pub async fn destroy_whisper_stone(
14828        &mut self,
14829        item_instance_id: uuid::Uuid,
14830    ) -> anyhow::Result<()> {
14831        self.seq += 1;
14832        self.session
14833            .submit_intent(Intent::DestroyWhisperStone {
14834                entity_id: self.state.entity_id,
14835                item_instance_id,
14836                seq: self.seq,
14837            })
14838            .await?;
14839        self.state.intents_sent += 1;
14840        Ok(())
14841    }
14842
14843    pub async fn stop(&mut self) -> anyhow::Result<()> {
14844        self.seq += 1;
14845        self.session
14846            .submit_intent(Intent::Stop {
14847                entity_id: self.state.entity_id,
14848                seq: self.seq,
14849            })
14850            .await?;
14851        self.state.intents_sent += 1;
14852        Ok(())
14853    }
14854
14855    pub fn disconnect(&self) {
14856        self.session.disconnect();
14857    }
14858}
14859
14860fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14861    let dx = ax - bx;
14862    let dy = ay - by;
14863    (dx * dx + dy * dy).sqrt()
14864}
14865
14866#[cfg(test)]
14867mod tests {
14868    use std::collections::BTreeMap;
14869
14870    use super::*;
14871    use flatland_protocol::{
14872        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
14873    };
14874
14875    fn sample_state() -> GameState {
14876        let mut state = GameState {
14877            session_id: 1,
14878            entity_id: 1,
14879            character_id: None,
14880            tick: 0,
14881            chunk_rev: 0,
14882            content_rev: 0,
14883            publish_rev: 0,
14884            entities: vec![EntityState {
14885                id: 1,
14886                label: "You".into(),
14887                transform: Transform {
14888                    position: WorldCoord::surface(128.0, 128.0),
14889                    yaw: 0.0,
14890                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14891                },
14892                vitals: None,
14893                attributes: None,
14894                skills: None,
14895                inside_building: None,
14896                tile_id: None,
14897                paperdoll_ref: None,
14898                draw_scale: 1.0,
14899                presentation_state: None,
14900                sprite_mode: None,
14901                progression_xp: None,
14902                combat_cues: vec![],
14903                statuses: vec![],
14904            }],
14905            player: None,
14906            resource_nodes: vec![ResourceNodeView {
14907                id: "oak-1".into(),
14908                label: "Oak".into(),
14909                x: 130.0,
14910                y: 128.0,
14911                z: 0.0,
14912                item_template: "oak_log".into(),
14913                state: ResourceNodeState::Available,
14914                blocking: true,
14915                blocking_radius_m: 0.8,
14916                harvest_off: false,
14917                tile_id: None,
14918                                yaw: 0.0,
14919                pitch: 0.0,
14920                roll: 0.0,
14921                draw_scale: 1.0,
14922                sprite_mode: None,
14923                growth_progress: None,
14924                presentation_state: None,
14925                channel_start_tick: None,
14926                channel_end_tick: None,
14927                harvest_drop_templates: vec![],
14928            }],
14929            ground_drops: vec![],
14930            placed_containers: vec![],
14931            buildings: vec![BuildingView {
14932                id: "broker-hut".into(),
14933                label: "Broker".into(),
14934                x: 148.0,
14935                y: 118.0,
14936                width_m: 8.0,
14937                depth_m: 6.0,
14938                interior_blueprint: Some("broker_hut".into()),
14939                tags: vec![],
14940                market_boundary_zone_ids: vec![],
14941                market_max_volume: None,
14942                wall_set: None,
14943                roof_set: None,
14944            }],
14945            doors: vec![flatland_protocol::DoorView {
14946                id: "door-1".into(),
14947                building_id: "broker-hut".into(),
14948                x: 148.0,
14949                y: 118.0,
14950                open: false,
14951                portal: Some("front".into()),
14952            locked: false,
14953            accessible: true,
14954            lock_id: None,
14955        }],
14956            interior_map: None,
14957            npcs: vec![],
14958            blueprints: vec![],
14959            building_materials: vec![],
14960            world_x0: 0.0,
14961            world_y0: 0.0,
14962            world_width_m: 256.0,
14963            world_height_m: 256.0,
14964            terrain_zones: Vec::new(),
14965            z_platforms: Vec::new(),
14966            z_transitions: Vec::new(),
14967            z_bands_outdoor_backup: None,
14968            world_clock: flatland_protocol::WorldClock::default(),
14969            inventory: std::collections::HashMap::new(),
14970            inventory_hints: std::collections::HashMap::new(),
14971            logs: VecDeque::new(),
14972            intents_sent: 0,
14973            ticks_received: 0,
14974            connected: true,
14975            disconnect_reason: None,
14976            show_stats: false,
14977            hud_log_hidden: false,
14978            show_equip_menu: false,
14979            equip_menu_index: 0,
14980            show_craft_menu: false,
14981            show_plot_build_menu: false,
14982            plot_build_focus_wall: true,
14983            plot_build_wall_index: 0,
14984            plot_build_roof_index: 0,
14985            craft_menu_index: 0,
14986            craft_batch_quantity: 1,
14987            show_shop_menu: false,
14988            shop_catalog: None,
14989            bank_panel: None,
14990            bank_menu_index: 0,
14991            bank_ui_mode: BankUiMode::Menu,
14992            storage_panel: None,
14993                market_panel: None,
14994            market_menu_index: 0,
14995            market_filter: String::new(),
14996            market_filter_focused: false,
14997            market_category_filter: None,
14998            market_buy_confirm: None,
14999            market_ui_mode: MarketUiMode::Browse,
15000            storage_menu_index: 0,
15001            storage_ui_mode: StorageUiMode::Menu,
15002            shop_tab: ShopTab::default(),
15003            shop_menu_index: 0,
15004            shop_quantity: 1,
15005            shop_trade_log: VecDeque::new(),
15006            show_npc_verb_menu: false,
15007            npc_verb_target: None,
15008            npc_verb_index: 0,
15009            player_verbs: crate::social::PlayerVerbState::default(),
15010            social_chat: crate::social::SocialChatState::default(),
15011            trade_ui: crate::social::TradeUiState::default(),
15012            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15013            show_npc_chat: false,
15014            npc_chat: None,
15015            show_inventory_menu: false,
15016            inventory_menu_index: 0,
15017            inventory_tab: InventoryTab::OnPerson,
15018            inventory_filter: String::new(),
15019            inventory_filter_focused: false,
15020            show_move_picker: false,
15021            show_rename_prompt: false,
15022            rename_plot_id: None,
15023            highlighted_plot_id: None,
15024            show_worker_rename: false,
15025            rename_buffer: String::new(),
15026            move_picker_index: 0,
15027            move_picker: None,
15028            show_grant_picker: false,
15029            grant_picker_index: 0,
15030            grant_picker: None,
15031            show_destroy_picker: false,
15032            destroy_confirm_pending: false,
15033            destroy_picker: None,
15034            combat_target: None,
15035            combat_target_label: None,
15036            ground_target: None,
15037            combat_fx: Vec::new(),
15038            property_zones: Vec::new(),
15039            tax_zones: Vec::new(),
15040            growth_zones: Vec::new(),
15041            biome_zones: Vec::new(),
15042            terrain_kind_nav: Vec::new(),
15043            property_plots: Vec::new(),
15044            property_plot_settings: None,
15045            claim_mode: None,
15046            relocate_mode: None,
15047            sell_plot_confirm: None,
15048            sell_plot_armed_at: None,
15049            show_plant_menu: false,
15050            plant_menu_index: 0,
15051            show_farm_access: false,
15052            farm_access_name_draft: String::new(),
15053            farm_access_discount_bps: 0,
15054            farm_access_index: 0,
15055            plant_quantity: 1,
15056            in_combat: false,
15057            auto_attack: true,
15058            combat_has_los: false,
15059            attack_cd_ticks: 0,
15060            gcd_ticks: 0,
15061            weapon_ability_id: "unarmed".into(),
15062            mainhand_template_id: None,
15063            mainhand_label: None,
15064            mainhand_instance_id: None,
15065            offhand_template_id: None,
15066            offhand_label: None,
15067            offhand_instance_id: None,
15068            mainhand_hand_slots: 1,
15069            defense: None,
15070            worn: BTreeMap::new(),
15071            carry_mass: 0.0,
15072            carry_mass_max: 0.0,
15073            encumbrance: flatland_protocol::EncumbranceState::Light,
15074            inventory_stacks: Vec::new(),
15075            keychain_stacks: Vec::new(),
15076            whisper_pouch_stacks: Vec::new(),
15077            combat_target_detail: None,
15078                statuses: Vec::new(),
15079            cast_progress: None,
15080            timed_channel: None,
15081            plot_build_offer: None,
15082            ability_cooldowns: Vec::new(),
15083            blocking_active: false,
15084            max_target_slots: 1,
15085            combat_slots: Vec::new(),
15086            rotation_presets: Vec::new(),
15087            known_abilities: Vec::new(),
15088            ability_meta: std::collections::HashMap::new(),
15089            ability_mastery: std::collections::HashMap::new(),
15090            hotbar: vec![None; 9],
15091            max_abilities_per_rotation: 0,
15092            show_loadout_menu: false,
15093            show_keychain_menu: false,
15094            keychain_menu_index: 0,
15095            show_rotation_editor: false,
15096            loadout_menu_index: 0,
15097            loadout_hotbar_slot: 1,
15098            loadout_ability_index: 0,
15099            loadout_focus_presets: false,
15100            rotation_editor: RotationEditorState::default(),
15101            harvest_in_progress: false,
15102            harvest_started_at: None,
15103            pending_craft_ack: None,
15104            pending_worker_job_ack: None,
15105            attending_worker_instance_id: None,
15106            quest_log: Vec::new(),
15107            interactables: Vec::new(),
15108            ledger: None,
15109            career: None,
15110            character_sheet_tab: CharacterSheetTab::Character,
15111            ledger_period: LedgerPeriod::Day,
15112            show_quest_offer: false,
15113            pending_quest_offer: None,
15114            show_quest_menu: false,
15115            quest_menu_index: 0,
15116            quest_withdraw_confirm: false,
15117            hired_workers: Vec::new(),
15118            show_workers_menu: false,
15119            workers_menu_index: 0,
15120            workers_menu_compact: false,
15121            worker_step_display: BTreeMap::new(),
15122            worker_error_display: BTreeMap::new(),
15123            show_worker_give_picker: false,
15124            worker_give_picker_index: 0,
15125            worker_give_picker: None,
15126            show_worker_give_target_picker: false,
15127            worker_give_target_picker_index: 0,
15128            worker_give_target_picker: None,
15129            show_worker_take_picker: false,
15130            worker_take_picker_index: 0,
15131            worker_take_picker: None,
15132            show_worker_teach_picker: false,
15133            worker_teach_picker_index: 0,
15134            worker_teach_picker: None,
15135            worker_route_editor: None,
15136            progression_curve: None,
15137        };
15138        state.player = state.entities.first().cloned();
15139        state
15140    }
15141
15142    #[test]
15143    fn whisper_cancels_when_peer_walks_out_of_range() {
15144        let mut state = sample_state();
15145        state.player = state.entities.first().cloned();
15146        let mut peer = state.entities[0].clone();
15147        peer.id = 2;
15148        peer.label = "Ada".into();
15149        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
15150        state.entities.push(peer.clone());
15151        state.social_chat.focus_whisper(2, "Ada");
15152        state.refresh_whisper_range();
15153        assert!(matches!(
15154            state.social_chat.thread,
15155            crate::social::ChatThreadKind::Whisper { peer: 2 }
15156        ));
15157
15158        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
15159        state.entities[1] = peer;
15160        state.refresh_whisper_range();
15161        assert_eq!(
15162            state.social_chat.thread,
15163            crate::social::ChatThreadKind::Nearby
15164        );
15165        assert!(!state.social_chat.input_focused);
15166    }
15167
15168    #[test]
15169    fn probe_use_world_hired_worker_manage() {
15170        let mut state = sample_state();
15171        state.hired_workers.push(flatland_protocol::HiredWorkerView {
15172            instance_id: "worker-1".into(),
15173            entity_id: 42,
15174            def_id: "worker_laborer".into(),
15175            label: "Sam".into(),
15176            x: 129.0,
15177            y: 128.0,
15178            z: 0.0,
15179            mode: flatland_protocol::WorkerModeView::JobLoop,
15180            state: flatland_protocol::WorkerStateView::Working,
15181            step_label: "cultivate".into(),
15182            vitals: flatland_protocol::WorkerVitalsSummary {
15183                health_pct: 100.0,
15184                stamina_pct: 100.0,
15185            },
15186            carry_pct: 0.0,
15187            last_error: None,
15188            wage_copper_per_interval: 1,
15189            effective_wage_copper: 1,
15190            wage_meters_walked: 0.0,
15191            lodging_container_id: None,
15192            route: None,
15193            route_stop_index: None,
15194            known_blueprint_ids: Vec::new(),
15195            level: 1,
15196            worker_xp: 0.0,
15197            inventory: Vec::new(),
15198        });
15199        let probe = state.probe_use_world();
15200        let primary = probe.primary.expect("primary");
15201        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15202        assert_eq!(primary.id, "worker-1");
15203        assert!(primary.hint_line().contains("Manage"));
15204        assert!(primary.hint_line().contains("Sam"));
15205        assert_eq!(
15206            state.nearest_interact_target().as_deref(),
15207            Some("worker-1")
15208        );
15209    }
15210
15211    #[test]
15212    fn market_clerk_verb_options_include_market() {
15213        let mut state = sample_state();
15214        state.npcs.push(flatland_protocol::NpcView {
15215            id: "mira_market".into(),
15216            label: "Mira".into(),
15217            role: "market_clerk".into(),
15218            x: 129.0,
15219            y: 128.0,
15220            building_id: Some("town_market".into()),
15221            entity_id: None,
15222            life_state: None,
15223            hp_pct: None,
15224            can_trade: false,
15225            tile_id: None,
15226            behavior_state: None,
15227            presentation_state: None,
15228            sprite_mode: None,
15229            paperdoll_ref: None,
15230        draw_scale: 1.0,
15231        });
15232        state.npc_verb_target = Some("mira_market".into());
15233        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15234    }
15235
15236    #[test]
15237    fn market_list_excludes_currency_stacks() {
15238        let mut state = sample_state();
15239        state.inventory_stacks = vec![
15240            flatland_protocol::ItemStack {
15241                template_id: "copper_coin".into(),
15242                quantity: 50,
15243                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15244                display_name: Some("Copper Coin".into()),
15245                ..Default::default()
15246            },
15247            flatland_protocol::ItemStack {
15248                template_id: "oak_log".into(),
15249                quantity: 2,
15250                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15251                display_name: Some("Oak Log".into()),
15252                ..Default::default()
15253            },
15254            flatland_protocol::ItemStack {
15255                template_id: "whisper_stone".into(),
15256                quantity: 1,
15257                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15258                display_name: Some("Whisper Stone".into()),
15259                category: Some("quest".into()),
15260                listable: Some(false),
15261                ..Default::default()
15262            },
15263        ];
15264        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15265        assert_eq!(opts.len(), 1);
15266        assert!(opts[0].label.contains("Oak"));
15267    }
15268
15269    #[test]
15270    fn market_browse_filters_by_category_and_search() {
15271        let mut state = sample_state();
15272        state.market_panel = Some(flatland_protocol::MarketPanel {
15273            npc_id: "mira_market".into(),
15274            npc_label: "Mira".into(),
15275            building_id: "town_market".into(),
15276            building_label: "Town Market".into(),
15277            used_volume: 0.0,
15278            max_volume: 100.0,
15279            listings: vec![
15280                flatland_protocol::MarketListingView {
15281                    listing_id: uuid::Uuid::from_u128(1),
15282                    seller_character_id: uuid::Uuid::from_u128(2),
15283                    seller_label: "Ada".into(),
15284                    hall_building_id: "town_market".into(),
15285                    hall_label: "Town Market".into(),
15286                    template_id: "oak_log".into(),
15287                    display_name: "Oak Log".into(),
15288                    category: "resource".into(),
15289                    quantity: 3,
15290                    unit_price_copper: 10,
15291                    line_total_copper: 30,
15292                    npc_price: false,
15293                    mine: false,
15294                },
15295                flatland_protocol::MarketListingView {
15296                    listing_id: uuid::Uuid::from_u128(3),
15297                    seller_character_id: uuid::Uuid::from_u128(2),
15298                    seller_label: "Ada".into(),
15299                    hall_building_id: "town_market".into(),
15300                    hall_label: "Town Market".into(),
15301                    template_id: "short_sword".into(),
15302                    display_name: "Short Sword".into(),
15303                    category: "weapon".into(),
15304                    quantity: 1,
15305                    unit_price_copper: 100,
15306                    line_total_copper: 100,
15307                    npc_price: false,
15308                    mine: false,
15309                },
15310            ],
15311            tax_bps: 0,
15312            tax_flat_copper: 0,
15313            list_vaults: vec![],
15314        });
15315        assert_eq!(state.market_filtered_listing_indices().len(), 2);
15316        state.market_category_filter = Some("Weapons");
15317        let weapons = state.market_filtered_listing_indices();
15318        assert_eq!(weapons.len(), 1);
15319        assert_eq!(
15320            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15321            "Short Sword"
15322        );
15323        state.market_category_filter = None;
15324        state.market_filter = "oak".into();
15325        let oak = state.market_filtered_listing_indices();
15326        assert_eq!(oak.len(), 1);
15327        assert_eq!(
15328            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15329            "Oak Log"
15330        );
15331    }
15332
15333    #[test]
15334    fn market_list_source_includes_person_and_vaults() {
15335        let mut state = sample_state();
15336        let item_id = uuid::Uuid::from_u128(1);
15337        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15338            template_id: "oak_log".into(),
15339            quantity: 2,
15340            item_instance_id: Some(item_id),
15341            display_name: Some("Oak Log".into()),
15342            ..Default::default()
15343        }];
15344        state.market_panel = Some(flatland_protocol::MarketPanel {
15345            npc_id: "mira_market".into(),
15346            npc_label: "Mira".into(),
15347            building_id: "town_market".into(),
15348            building_label: "Town Market".into(),
15349            used_volume: 0.0,
15350            max_volume: 100.0,
15351            listings: vec![],
15352            tax_bps: 0,
15353            tax_flat_copper: 0,
15354            list_vaults: vec![flatland_protocol::MarketListVault {
15355                building_id: "town_storage".into(),
15356                building_label: "Town Storage".into(),
15357                contents: vec![flatland_protocol::ItemStack {
15358                    template_id: "lumber".into(),
15359                    quantity: 1,
15360                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
15361                    display_name: Some("Lumber".into()),
15362                    ..Default::default()
15363                }],
15364            }],
15365        });
15366        let sources = state.market_list_source_options();
15367        assert_eq!(sources.len(), 2);
15368        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15369        assert!(matches!(
15370            sources[1].0,
15371            MarketListSourceKind::TownStorage { .. }
15372        ));
15373        assert!(sources[1].1.contains("Town Storage"));
15374    }
15375
15376    #[test]
15377    fn probe_use_world_npc_beats_nearby_loot() {
15378        let mut state = sample_state();
15379        state.npcs.push(flatland_protocol::NpcView {
15380            id: "ada".into(),
15381            label: "Ada".into(),
15382            role: "broker".into(),
15383            x: 129.0,
15384            y: 128.0,
15385            building_id: None,
15386            entity_id: None,
15387            life_state: None,
15388            hp_pct: None,
15389            can_trade: true,
15390            tile_id: None,
15391            behavior_state: None,
15392            presentation_state: None,
15393            sprite_mode: None,
15394            paperdoll_ref: None,
15395        draw_scale: 1.0,
15396        });
15397        state.ground_drops.push(flatland_protocol::GroundDropView {
15398            id: "d1".into(),
15399            template_id: "lumber".into(),
15400            quantity: 1,
15401            x: 128.5,
15402            y: 128.0,
15403            z: 0.0,
15404            tile_id: None,
15405            display_name: None,
15406            yaw: 0.0,
15407            pitch: 0.0,
15408            roll: 0.0,
15409            draw_scale: 1.0,
15410        });
15411        let probe = state.probe_use_world();
15412        let primary = probe.primary.expect("primary");
15413        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15414        assert_eq!(primary.id, "ada");
15415    }
15416
15417    #[test]
15418    fn probe_use_world_harvest_when_in_range() {
15419        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
15420        let probe = state.probe_use_world();
15421        assert!(
15422            probe.primary.is_none(),
15423            "oak is 2m away, out of harvest range"
15424        );
15425        assert!(probe
15426            .candidates
15427            .iter()
15428            .any(|c| c.kind == crate::UseWorldKind::Harvest));
15429
15430        let mut state = sample_state();
15431        state.resource_nodes[0].x = 129.0;
15432        let probe = state.probe_use_world();
15433        let primary = probe.primary.expect("primary");
15434        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15435    }
15436
15437    #[test]
15438    fn probe_use_world_door_uses_building_label() {
15439        let mut state = sample_state();
15440        state.doors[0].x = 129.0;
15441        state.doors[0].y = 128.0;
15442        let probe = state.probe_use_world();
15443        let primary = probe.primary.expect("primary");
15444        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
15445        assert_eq!(primary.label, "Broker");
15446        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
15447    }
15448
15449    #[test]
15450    fn empty_entity_tick_preserves_welcome_snapshot() {
15451        let mut state = sample_state();
15452        state.inventory.insert("carrot".into(), 3);
15453        let delta = TickDelta {
15454            tick: 1,
15455            entities: vec![],
15456            resource_nodes: vec![],
15457            ground_drops: vec![],
15458            placed_containers: vec![],
15459            buildings: vec![],
15460            doors: vec![],
15461            interior_map: None,
15462            npcs: vec![],
15463            inventory: vec![],
15464            blueprints: vec![],
15465            building_materials: vec![],
15466            world_clock: flatland_protocol::WorldClock::default(),
15467            combat: None,
15468            quest_log: vec![],
15469            hired_workers: Vec::new(),
15470            interactables: vec![],
15471            ledger: None,
15472            career: None,
15473            combat_fx: Vec::new(),
15474            property_plots: Vec::new(),
15475        terrain_overlays: Vec::new(),
15476        };
15477
15478        state.apply_tick_fields(&delta, 1);
15479
15480        assert_eq!(state.entities.len(), 1);
15481        assert!(state.player.is_some());
15482        assert_eq!(state.inventory.get("carrot"), Some(&3));
15483        assert_eq!(state.resource_nodes.len(), 1);
15484    }
15485
15486    #[test]
15487    fn tick_preserves_world_layers_when_delta_omits_them() {
15488        let mut state = sample_state();
15489        let delta = TickDelta {
15490            tick: 1,
15491            entities: state.entities.clone(),
15492            resource_nodes: vec![],
15493            ground_drops: vec![],
15494            placed_containers: vec![],
15495            buildings: vec![],
15496            doors: vec![],
15497            interior_map: None,
15498            npcs: vec![],
15499            inventory: vec![],
15500            blueprints: vec![],
15501            building_materials: vec![],
15502            world_clock: flatland_protocol::WorldClock::default(),
15503            combat: None,
15504            quest_log: vec![],
15505            hired_workers: Vec::new(),
15506            interactables: vec![],
15507            ledger: None,
15508            career: None,
15509            combat_fx: Vec::new(),
15510            property_plots: Vec::new(),
15511        terrain_overlays: Vec::new(),
15512        };
15513
15514        state.apply_tick_fields(&delta, 1);
15515
15516        assert_eq!(state.resource_nodes.len(), 1);
15517        assert_eq!(state.buildings.len(), 1);
15518        assert_eq!(state.doors.len(), 1);
15519    }
15520
15521    #[test]
15522    fn tick_updates_resource_nodes_when_server_sends_them() {
15523        let mut state = sample_state();
15524        let delta = TickDelta {
15525            tick: 1,
15526            entities: state.entities.clone(),
15527            resource_nodes: vec![ResourceNodeView {
15528                id: "oak-1".into(),
15529                label: "Oak".into(),
15530                x: 130.0,
15531                y: 128.0,
15532                z: 0.0,
15533                item_template: "oak_log".into(),
15534                state: ResourceNodeState::Cooldown,
15535                blocking: true,
15536                blocking_radius_m: 0.8,
15537                harvest_off: false,
15538                tile_id: None,
15539                                yaw: 0.0,
15540                pitch: 0.0,
15541                roll: 0.0,
15542                draw_scale: 1.0,
15543                sprite_mode: None,
15544                growth_progress: None,
15545                presentation_state: None,
15546                channel_start_tick: None,
15547                channel_end_tick: None,
15548                harvest_drop_templates: vec![],
15549            }],
15550            buildings: vec![],
15551            doors: vec![],
15552            interior_map: None,
15553            npcs: vec![],
15554            inventory: vec![],
15555            blueprints: vec![],
15556            building_materials: vec![],
15557            world_clock: flatland_protocol::WorldClock::default(),
15558            ground_drops: vec![],
15559            placed_containers: vec![],
15560            combat: None,
15561            quest_log: vec![],
15562            hired_workers: Vec::new(),
15563            interactables: vec![],
15564            ledger: None,
15565            career: None,
15566            combat_fx: Vec::new(),
15567            property_plots: Vec::new(),
15568        terrain_overlays: Vec::new(),
15569        };
15570
15571        state.apply_tick_fields(&delta, 1);
15572
15573        assert!(matches!(
15574            state.resource_nodes[0].state,
15575            ResourceNodeState::Cooldown
15576        ));
15577    }
15578
15579    #[test]
15580    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
15581        let mut state = GameState {
15582            session_id: 1,
15583            entity_id: 1,
15584            character_id: None,
15585            tick: 0,
15586            chunk_rev: 0,
15587            content_rev: 0,
15588            publish_rev: 0,
15589            entities: vec![EntityState {
15590                id: 1,
15591                label: "You".into(),
15592                transform: Transform {
15593                    position: WorldCoord::surface(4.5, 2.0),
15594                    yaw: 0.0,
15595                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15596                },
15597                vitals: None,
15598                attributes: None,
15599                skills: None,
15600                inside_building: Some("broker_hut".into()),
15601                tile_id: None,
15602                paperdoll_ref: None,
15603                draw_scale: 1.0,
15604                presentation_state: None,
15605                sprite_mode: None,
15606                progression_xp: None,
15607                combat_cues: vec![],
15608                statuses: vec![],
15609            }],
15610            player: None,
15611            resource_nodes: vec![],
15612            ground_drops: vec![],
15613            placed_containers: vec![],
15614            buildings: vec![BuildingView {
15615                id: "broker_hut".into(),
15616                label: "Broker".into(),
15617                x: 158.0,
15618                y: 124.0,
15619                width_m: 8.0,
15620                depth_m: 6.0,
15621                interior_blueprint: Some("broker_hut".into()),
15622                tags: vec![],
15623                market_boundary_zone_ids: vec![],
15624                market_max_volume: None,
15625                wall_set: None,
15626                roof_set: None,
15627            }],
15628            doors: vec![flatland_protocol::DoorView {
15629                id: "broker_hut_exit".into(),
15630                building_id: "broker_hut".into(),
15631                x: 4.3,
15632                y: 0.9,
15633                open: true,
15634                portal: Some("front".into()),
15635            locked: false,
15636            accessible: true,
15637            lock_id: None,
15638        }],
15639            interior_map: None,
15640            npcs: vec![flatland_protocol::NpcView {
15641                id: "ada_broker".into(),
15642                label: "Ada".into(),
15643                x: 4.5,
15644                y: 2.0,
15645                building_id: Some("broker_hut".into()),
15646                role: "broker".into(),
15647                entity_id: None,
15648                life_state: None,
15649                hp_pct: None,
15650                can_trade: true,
15651                tile_id: None,
15652                behavior_state: None,
15653                presentation_state: None,
15654                sprite_mode: None,
15655                paperdoll_ref: None,
15656            draw_scale: 1.0,
15657            }],
15658            blueprints: vec![],
15659            building_materials: vec![],
15660            world_x0: 0.0,
15661            world_y0: 0.0,
15662            world_width_m: 256.0,
15663            world_height_m: 256.0,
15664            terrain_zones: Vec::new(),
15665            z_platforms: Vec::new(),
15666            z_transitions: Vec::new(),
15667            z_bands_outdoor_backup: None,
15668            world_clock: flatland_protocol::WorldClock::default(),
15669            inventory: std::collections::HashMap::new(),
15670            inventory_hints: std::collections::HashMap::new(),
15671            logs: VecDeque::new(),
15672            intents_sent: 0,
15673            ticks_received: 0,
15674            connected: true,
15675            disconnect_reason: None,
15676            show_stats: false,
15677            hud_log_hidden: false,
15678            show_equip_menu: false,
15679            equip_menu_index: 0,
15680            show_craft_menu: false,
15681            show_plot_build_menu: false,
15682            plot_build_focus_wall: true,
15683            plot_build_wall_index: 0,
15684            plot_build_roof_index: 0,
15685            craft_menu_index: 0,
15686            craft_batch_quantity: 1,
15687            show_shop_menu: false,
15688            shop_catalog: None,
15689            bank_panel: None,
15690            bank_menu_index: 0,
15691            bank_ui_mode: BankUiMode::Menu,
15692            storage_panel: None,
15693                market_panel: None,
15694            market_menu_index: 0,
15695            market_filter: String::new(),
15696            market_filter_focused: false,
15697            market_category_filter: None,
15698            market_buy_confirm: None,
15699            market_ui_mode: MarketUiMode::Browse,
15700            storage_menu_index: 0,
15701            storage_ui_mode: StorageUiMode::Menu,
15702            shop_tab: ShopTab::default(),
15703            shop_menu_index: 0,
15704            shop_quantity: 1,
15705            shop_trade_log: VecDeque::new(),
15706            show_npc_verb_menu: false,
15707            npc_verb_target: None,
15708            npc_verb_index: 0,
15709            player_verbs: crate::social::PlayerVerbState::default(),
15710            social_chat: crate::social::SocialChatState::default(),
15711            trade_ui: crate::social::TradeUiState::default(),
15712            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15713            show_npc_chat: false,
15714            npc_chat: None,
15715            show_inventory_menu: false,
15716            inventory_menu_index: 0,
15717            inventory_tab: InventoryTab::OnPerson,
15718            inventory_filter: String::new(),
15719            inventory_filter_focused: false,
15720            show_move_picker: false,
15721            show_rename_prompt: false,
15722            rename_plot_id: None,
15723            highlighted_plot_id: None,
15724            show_worker_rename: false,
15725            rename_buffer: String::new(),
15726            move_picker_index: 0,
15727            move_picker: None,
15728            show_grant_picker: false,
15729            grant_picker_index: 0,
15730            grant_picker: None,
15731            show_destroy_picker: false,
15732            destroy_confirm_pending: false,
15733            destroy_picker: None,
15734            combat_target: None,
15735            combat_target_label: None,
15736            ground_target: None,
15737            combat_fx: Vec::new(),
15738            property_zones: Vec::new(),
15739            tax_zones: Vec::new(),
15740            growth_zones: Vec::new(),
15741            biome_zones: Vec::new(),
15742            terrain_kind_nav: Vec::new(),
15743            property_plots: Vec::new(),
15744            property_plot_settings: None,
15745            claim_mode: None,
15746            relocate_mode: None,
15747            sell_plot_confirm: None,
15748            sell_plot_armed_at: None,
15749            show_plant_menu: false,
15750            plant_menu_index: 0,
15751            show_farm_access: false,
15752            farm_access_name_draft: String::new(),
15753            farm_access_discount_bps: 0,
15754            farm_access_index: 0,
15755            plant_quantity: 1,
15756            in_combat: false,
15757            auto_attack: true,
15758            combat_has_los: false,
15759            attack_cd_ticks: 0,
15760            gcd_ticks: 0,
15761            weapon_ability_id: "unarmed".into(),
15762            mainhand_template_id: None,
15763            mainhand_label: None,
15764            mainhand_instance_id: None,
15765            offhand_template_id: None,
15766            offhand_label: None,
15767            offhand_instance_id: None,
15768            mainhand_hand_slots: 1,
15769            defense: None,
15770            worn: BTreeMap::new(),
15771            carry_mass: 0.0,
15772            carry_mass_max: 0.0,
15773            encumbrance: flatland_protocol::EncumbranceState::Light,
15774            inventory_stacks: Vec::new(),
15775            keychain_stacks: Vec::new(),
15776            whisper_pouch_stacks: Vec::new(),
15777            combat_target_detail: None,
15778                statuses: Vec::new(),
15779            cast_progress: None,
15780            timed_channel: None,
15781            plot_build_offer: None,
15782            ability_cooldowns: Vec::new(),
15783            blocking_active: false,
15784            max_target_slots: 1,
15785            combat_slots: Vec::new(),
15786            rotation_presets: Vec::new(),
15787            known_abilities: Vec::new(),
15788            ability_meta: std::collections::HashMap::new(),
15789            ability_mastery: std::collections::HashMap::new(),
15790            hotbar: vec![None; 9],
15791            max_abilities_per_rotation: 0,
15792            show_loadout_menu: false,
15793            show_keychain_menu: false,
15794            keychain_menu_index: 0,
15795            show_rotation_editor: false,
15796            loadout_menu_index: 0,
15797            loadout_hotbar_slot: 1,
15798            loadout_ability_index: 0,
15799            loadout_focus_presets: false,
15800            rotation_editor: RotationEditorState::default(),
15801            harvest_in_progress: false,
15802            harvest_started_at: None,
15803            pending_craft_ack: None,
15804            pending_worker_job_ack: None,
15805            attending_worker_instance_id: None,
15806            quest_log: Vec::new(),
15807            interactables: Vec::new(),
15808            ledger: None,
15809            career: None,
15810            character_sheet_tab: CharacterSheetTab::Character,
15811            ledger_period: LedgerPeriod::Day,
15812            show_quest_offer: false,
15813            pending_quest_offer: None,
15814            show_quest_menu: false,
15815            quest_menu_index: 0,
15816            quest_withdraw_confirm: false,
15817            hired_workers: Vec::new(),
15818            show_workers_menu: false,
15819            workers_menu_index: 0,
15820            workers_menu_compact: false,
15821            worker_step_display: BTreeMap::new(),
15822            worker_error_display: BTreeMap::new(),
15823            show_worker_give_picker: false,
15824            worker_give_picker_index: 0,
15825            worker_give_picker: None,
15826            show_worker_give_target_picker: false,
15827            worker_give_target_picker_index: 0,
15828            worker_give_target_picker: None,
15829            show_worker_take_picker: false,
15830            worker_take_picker_index: 0,
15831            worker_take_picker: None,
15832            show_worker_teach_picker: false,
15833            worker_teach_picker_index: 0,
15834            worker_teach_picker: None,
15835            worker_route_editor: None,
15836            progression_curve: None,
15837        };
15838        state.player = state.entities.first().cloned();
15839        assert_eq!(
15840            state.nearest_interact_target().as_deref(),
15841            Some("ada_broker")
15842        );
15843    }
15844
15845    #[test]
15846    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
15847        let mut state = sample_state();
15848        // Player is at (128, 128) per sample_state(). One chest just inside
15849        // CONTAINER_RANGE_M, one clearly beyond it.
15850        state.placed_containers = vec![
15851            flatland_protocol::PlacedContainerView {
15852                id: "near".into(),
15853                template_id: "wooden_chest_small".into(),
15854                display_name: "Wooden Chest".into(),
15855                x: 130.0,
15856                y: 128.0,
15857                z: 0.0,
15858                locked: true,
15859                accessible: true,
15860                owner_character_id: None,
15861                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
15862                lock_id: None,
15863                capacity_volume: None,
15864                item_instance_id: Some(uuid::Uuid::from_u128(1)),
15865                tile_id: None,
15866                worker_lodging_capacity: None,
15867                blocking: false,
15868                blocking_radius_m: 0.0,
15869            building_id: None,
15870            },
15871            flatland_protocol::PlacedContainerView {
15872                id: "far".into(),
15873                template_id: "wooden_chest_small".into(),
15874                display_name: "Distant Chest".into(),
15875                x: 128.0 + CONTAINER_RANGE_M + 5.0,
15876                y: 128.0,
15877                z: 0.0,
15878                locked: false,
15879                accessible: true,
15880                owner_character_id: None,
15881                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
15882                lock_id: None,
15883                capacity_volume: None,
15884                item_instance_id: Some(uuid::Uuid::from_u128(2)),
15885                tile_id: None,
15886                worker_lodging_capacity: None,
15887                blocking: false,
15888                blocking_radius_m: 0.0,
15889            building_id: None,
15890            },
15891        ];
15892
15893        let nearby = state.nearby_containers();
15894        assert_eq!(
15895            nearby.len(),
15896            1,
15897            "far chest must not appear once out of range"
15898        );
15899        assert_eq!(nearby[0].view.id, "near");
15900        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
15901        assert!(nearby[0].rows[0].is_chest_shell);
15902
15903        // The same chest, but locked and inaccessible (no key held), must hide
15904        // contents but still show the selectable chest shell row.
15905        state.placed_containers[0].accessible = false;
15906        let nearby = state.nearby_containers();
15907        assert_eq!(nearby.len(), 1);
15908        assert_eq!(nearby[0].rows.len(), 1);
15909        assert!(nearby[0].rows[0].is_chest_shell);
15910    }
15911
15912    #[test]
15913    fn chest_pickup_destinations_offer_person_and_worn_bag() {
15914        let mut state = sample_state();
15915        let back_id = uuid::Uuid::from_u128(42);
15916        state.worn.insert(
15917            BodySlot::Back,
15918            flatland_protocol::ItemStack {
15919                template_id: "travel_backpack".into(),
15920                quantity: 1,
15921                item_instance_id: Some(back_id),
15922                props: Default::default(),
15923                status_bindings: Vec::new(),
15924                contents: Vec::new(),
15925                display_name: Some("Travel Backpack".into()),
15926                category: Some("container".into()),
15927                base_mass: Some(2.5),
15928                base_volume: Some(12.0),
15929                capacity_volume: Some(80.0),
15930                stackable: Some(false),
15931                world_placeable: Some(false),
15932                worker_lodging_capacity: None,
15933                equip_slot: None,
15934                armor_physical: None,
15935                resists: vec![],
15936                hand_slots: None,
15937            listable: None,
15938        },
15939        );
15940        let opts = state.chest_pickup_destinations("chest-1");
15941        assert!(matches!(
15942            opts.first().map(|o| &o.kind),
15943            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
15944        ));
15945        assert!(opts.iter().any(|o| matches!(
15946            &o.kind,
15947            MoveOptionKind::PickupPlaced {
15948                nest_parent_instance_id: None,
15949                ..
15950            }
15951        )));
15952        assert!(opts.iter().any(|o| matches!(
15953            &o.kind,
15954            MoveOptionKind::PickupPlaced {
15955                nest_parent_instance_id: Some(id),
15956                ..
15957            } if *id == back_id
15958        )));
15959        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15960    }
15961
15962    #[test]
15963    fn placed_container_public_label_hides_owner_custom_name() {
15964        let owner = uuid::Uuid::from_u128(99);
15965        let mut state = sample_state();
15966        state.character_id = Some(uuid::Uuid::from_u128(1));
15967        state.inventory_hints.insert(
15968            "wooden_chest_medium".into(),
15969            InventoryHint {
15970                display_name: "Medium Wooden Chest".into(),
15971                category: "container".into(),
15972                base_mass: None,
15973                base_volume: None,
15974                capacity_volume: None,
15975                stackable: false,
15976                listable: true,
15977            },
15978        );
15979        let chest = flatland_protocol::PlacedContainerView {
15980            id: "c1".into(),
15981            template_id: "wooden_chest_medium".into(),
15982            display_name: "Barry's Loot #a3f2".into(),
15983            x: 128.0,
15984            y: 128.0,
15985            z: 0.0,
15986            locked: false,
15987            accessible: true,
15988            owner_character_id: Some(owner),
15989            contents: vec![],
15990            lock_id: None,
15991            capacity_volume: None,
15992            item_instance_id: None,
15993            tile_id: None,
15994            worker_lodging_capacity: None,
15995            blocking: false,
15996            blocking_radius_m: 0.0,
15997            building_id: None,
15998        };
15999        assert_eq!(
16000            state.placed_container_public_label(&chest),
16001            "Medium Wooden Chest"
16002        );
16003        state.character_id = Some(owner);
16004        assert_eq!(
16005            state.placed_container_public_label(&chest),
16006            "Barry's Loot #a3f2"
16007        );
16008    }
16009
16010    #[test]
16011    fn location_context_shows_crop_growth_percent_not_depleted() {
16012        let mut state = sample_state();
16013        state.player = state.entities.first().cloned();
16014        state.resource_nodes[0].label = "Carrot (growing)".into();
16015        state.resource_nodes[0].x = 128.2;
16016        state.resource_nodes[0].y = 128.0;
16017        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16018        state.resource_nodes[0].growth_progress = Some(0.47);
16019        let lines = state.location_context_lines();
16020        let line = lines
16021            .iter()
16022            .find(|l| l.text.contains("Carrot"))
16023            .map(|l| l.text.as_str())
16024            .unwrap_or("");
16025        assert!(
16026            line.contains("(growing, 47%)"),
16027            "expected growth percent, got: {line}"
16028        );
16029        assert!(
16030            !line.contains("depleted"),
16031            "growing crop should not show depleted: {line}"
16032        );
16033    }
16034
16035    #[test]
16036    fn resource_node_near_action_suffix_prefers_growth() {
16037        let node = ResourceNodeView {
16038            id: "crop".into(),
16039            label: "Wheat".into(),
16040            x: 0.0,
16041            y: 0.0,
16042            z: 0.0,
16043            item_template: "wheat".into(),
16044            state: ResourceNodeState::Cooldown,
16045            blocking: false,
16046            blocking_radius_m: 0.0,
16047            harvest_off: false,
16048            tile_id: None,
16049                        yaw: 0.0,
16050            pitch: 0.0,
16051            roll: 0.0,
16052            draw_scale: 1.0,
16053            sprite_mode: None,
16054            growth_progress: Some(0.12),
16055            presentation_state: None,
16056            channel_start_tick: None,
16057            channel_end_tick: None,
16058            harvest_drop_templates: vec![],
16059        };
16060        assert_eq!(
16061            resource_node_near_action_suffix(&node),
16062            " (growing, 12%)"
16063        );
16064    }
16065
16066    #[test]
16067    fn location_context_lists_nearby_resource_node() {
16068        let mut state = sample_state();
16069        state.player = state.entities.first().cloned();
16070        state.resource_nodes[0].x = 128.2;
16071        state.resource_nodes[0].y = 128.0;
16072        let lines = state.location_context_lines();
16073        assert!(
16074            lines
16075                .iter()
16076                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16077            "expected resource node in context: {:?}",
16078            lines
16079        );
16080    }
16081
16082    #[test]
16083    fn quest_board_usable_within_board_radius() {
16084        let mut state = sample_state();
16085        state.player = state.entities.first().cloned();
16086        state.interactables = vec![flatland_protocol::InteractableView {
16087            id: "board-1".into(),
16088            kind: "quest_board".into(),
16089            label: "Town Quest Board".into(),
16090            x: 130.5,
16091            y: 128.0,
16092            z: 0.0,
16093            board_id: Some("starter_town_board".into()),
16094        }];
16095        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
16096        assert_eq!(
16097            state.nearest_interact_target().as_deref(),
16098            Some("board-1"),
16099            "quest board should be selectable at ~2.5m"
16100        );
16101        let lines = state.location_context_lines();
16102        assert!(
16103            lines
16104                .iter()
16105                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16106            "HUD should advertise f when board is in range: {:?}",
16107            lines
16108        );
16109    }
16110
16111    #[test]
16112    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
16113        let mut state = sample_state();
16114        state.worn.insert(
16115            BodySlot::Back,
16116            flatland_protocol::ItemStack {
16117                template_id: "travel_backpack".into(),
16118                quantity: 1,
16119                item_instance_id: Some(uuid::Uuid::from_u128(3)),
16120                props: Default::default(),
16121                status_bindings: Vec::new(),
16122                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16123                display_name: None,
16124                category: None,
16125                base_mass: None,
16126                base_volume: None,
16127                capacity_volume: None,
16128                stackable: None,
16129                world_placeable: None,
16130                worker_lodging_capacity: None,
16131                equip_slot: None,
16132                armor_physical: None,
16133                resists: vec![],
16134                hand_slots: None,
16135            listable: None,
16136        },
16137        );
16138        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16139        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16140            id: "chest-1".into(),
16141            template_id: "wooden_chest_small".into(),
16142            display_name: "Wooden Chest".into(),
16143            x: 129.0,
16144            y: 128.0,
16145            z: 0.0,
16146            locked: false,
16147            accessible: true,
16148            owner_character_id: None,
16149            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16150            lock_id: None,
16151            capacity_volume: None,
16152            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16153            tile_id: None,
16154            worker_lodging_capacity: None,
16155            blocking: false,
16156            blocking_radius_m: 0.0,
16157            building_id: None,
16158        }];
16159
16160        state.inventory_tab = InventoryTab::OnPerson;
16161        let rows = state.inventory_selectable_rows();
16162        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16163        assert_eq!(
16164            sections,
16165            vec![
16166                InventorySection::Worn,   // backpack shell
16167                InventorySection::Worn,   // iron_ore nested in backpack
16168                InventorySection::Person, // lumber
16169            ]
16170        );
16171        assert_eq!(rows[0].stack.template_id, "travel_backpack");
16172        assert!(rows[0].is_equip_shell);
16173        assert_eq!(rows[1].stack.template_id, "iron_ore");
16174        assert_eq!(rows[1].depth, 1);
16175        assert_eq!(rows[2].stack.template_id, "lumber");
16176
16177        let lines = state.inventory_browser_lines();
16178        assert!(lines.iter().any(|l| matches!(
16179            l,
16180            InventoryBrowserLine::Section(s) if s.contains("Worn")
16181        )));
16182        assert!(lines.iter().any(|l| matches!(
16183            l,
16184            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16185                || text.contains("backpack")
16186        )));
16187        assert!(!lines.iter().any(|l| matches!(
16188            l,
16189            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16190        )));
16191
16192        state.inventory_tab = InventoryTab::Nearby;
16193        let nearby_rows = state.inventory_selectable_rows();
16194        assert_eq!(nearby_rows.len(), 2);
16195        assert!(nearby_rows[0].is_chest_shell);
16196        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16197        let nearby_lines = state.inventory_browser_lines();
16198        assert!(nearby_lines.iter().any(|l| matches!(
16199            l,
16200            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16201        )));
16202    }
16203
16204    #[test]
16205    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16206        let mut state = sample_state();
16207        let back_id = uuid::Uuid::from_u128(5);
16208        state.worn.insert(
16209            BodySlot::Back,
16210            flatland_protocol::ItemStack {
16211                template_id: "travel_backpack".into(),
16212                quantity: 1,
16213                item_instance_id: Some(back_id),
16214                props: Default::default(),
16215                status_bindings: Vec::new(),
16216                contents: Vec::new(),
16217                display_name: None,
16218                category: Some("container".into()),
16219                base_mass: None,
16220                base_volume: None,
16221                capacity_volume: Some(80.0),
16222                stackable: None,
16223                world_placeable: None,
16224                worker_lodging_capacity: None,
16225                equip_slot: None,
16226                armor_physical: None,
16227                resists: vec![],
16228                hand_slots: None,
16229            listable: None,
16230        },
16231        );
16232        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16233            id: "chest-1".into(),
16234            template_id: "wooden_chest_small".into(),
16235            display_name: "Wooden Chest".into(),
16236            x: 129.0,
16237            y: 128.0,
16238            z: 0.0,
16239            locked: false,
16240            accessible: true,
16241            owner_character_id: None,
16242            contents: Vec::new(),
16243            lock_id: None,
16244            capacity_volume: None,
16245            item_instance_id: Some(uuid::Uuid::from_u128(6)),
16246            tile_id: None,
16247            worker_lodging_capacity: None,
16248            blocking: false,
16249            blocking_radius_m: 0.0,
16250            building_id: None,
16251        }];
16252
16253        // Item currently sitting loose on the person (Root): backpack + nearby
16254        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
16255        let opts = state.move_destinations_for(
16256            &flatland_protocol::InventoryLocation::Root,
16257            None,
16258            None,
16259            "lumber",
16260        );
16261        assert!(!opts.iter().any(|o| matches!(
16262            &o.kind,
16263            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16264        )));
16265        assert!(opts.iter().any(|o| matches!(
16266            &o.kind,
16267            MoveOptionKind::Move { location, parent_instance_id, .. }
16268                if *location == flatland_protocol::InventoryLocation::Worn {
16269                    slot: BodySlot::Back,
16270                } && *parent_instance_id == Some(back_id)
16271        )));
16272        assert!(opts.iter().any(|o| matches!(
16273            &o.kind,
16274            MoveOptionKind::Move { location, .. }
16275                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16276        )));
16277        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16278        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16279
16280        // Item currently inside the worn backpack: the backpack itself must be
16281        // excluded from its own destination list (can't move an item into the
16282        // container it's already in).
16283        let from_backpack = flatland_protocol::InventoryLocation::Worn {
16284            slot: BodySlot::Back,
16285        };
16286        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16287        assert!(!opts.iter().any(|o| matches!(
16288            &o.kind,
16289            MoveOptionKind::Move { location, parent_instance_id, .. }
16290                if *location == from_backpack && *parent_instance_id == Some(back_id)
16291        )));
16292        assert!(opts.iter().any(|o| matches!(
16293            &o.kind,
16294            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16295        )));
16296    }
16297
16298    #[test]
16299    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16300        let mut state = sample_state();
16301        // Insert out of display order — BTreeMap iteration must still yield the
16302        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
16303        state.worn.insert(
16304            BodySlot::Waist,
16305            flatland_protocol::ItemStack {
16306                template_id: "simple_belt".into(),
16307                quantity: 1,
16308                item_instance_id: Some(uuid::Uuid::from_u128(10)),
16309                props: Default::default(),
16310                status_bindings: Vec::new(),
16311                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16312                display_name: None,
16313                category: Some("container".into()),
16314                base_mass: None,
16315                base_volume: None,
16316                capacity_volume: None,
16317                stackable: None,
16318                world_placeable: None,
16319                worker_lodging_capacity: None,
16320                equip_slot: None,
16321                armor_physical: None,
16322                resists: vec![],
16323                hand_slots: None,
16324            listable: None,
16325        },
16326        );
16327        state.worn.insert(
16328            BodySlot::Head,
16329            flatland_protocol::ItemStack {
16330                template_id: "cloth_cap".into(),
16331                quantity: 1,
16332                item_instance_id: Some(uuid::Uuid::from_u128(11)),
16333                props: Default::default(),
16334                status_bindings: Vec::new(),
16335                contents: Vec::new(),
16336                display_name: None,
16337                category: Some("armor".into()),
16338                base_mass: None,
16339                base_volume: None,
16340                capacity_volume: None,
16341                stackable: None,
16342                world_placeable: None,
16343                worker_lodging_capacity: None,
16344                equip_slot: None,
16345                armor_physical: None,
16346                resists: vec![],
16347                hand_slots: None,
16348            listable: None,
16349        },
16350        );
16351        state.worn.insert(
16352            BodySlot::Back,
16353            flatland_protocol::ItemStack {
16354                template_id: "travel_backpack".into(),
16355                quantity: 1,
16356                item_instance_id: Some(uuid::Uuid::from_u128(12)),
16357                props: Default::default(),
16358                status_bindings: Vec::new(),
16359                contents: Vec::new(),
16360                display_name: None,
16361                category: Some("container".into()),
16362                base_mass: None,
16363                base_volume: None,
16364                capacity_volume: None,
16365                stackable: None,
16366                world_placeable: None,
16367                worker_lodging_capacity: None,
16368                equip_slot: None,
16369                armor_physical: None,
16370                resists: vec![],
16371                hand_slots: None,
16372            listable: None,
16373        },
16374        );
16375
16376        let rows = state.worn_rows();
16377        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
16378        assert_eq!(rows.len(), 4);
16379        assert_eq!(rows[0].stack.template_id, "cloth_cap");
16380        assert!(rows[0].is_equip_shell);
16381        assert_eq!(rows[1].stack.template_id, "travel_backpack");
16382        assert!(rows[1].is_equip_shell);
16383        assert_eq!(rows[2].stack.template_id, "simple_belt");
16384        assert!(rows[2].is_equip_shell);
16385        assert_eq!(rows[3].stack.template_id, "leather_pouch");
16386        assert_eq!(rows[3].depth, 1);
16387        assert!(!rows[3].is_equip_shell);
16388    }
16389
16390    #[test]
16391    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16392        let mut state = sample_state();
16393        state.worn.insert(
16394            BodySlot::Waist,
16395            flatland_protocol::ItemStack {
16396                template_id: "simple_belt".into(),
16397                quantity: 1,
16398                item_instance_id: Some(uuid::Uuid::from_u128(20)),
16399                props: Default::default(),
16400                status_bindings: Vec::new(),
16401                contents: Vec::new(),
16402                display_name: Some("Simple Belt".into()),
16403                category: Some("container".into()),
16404                base_mass: None,
16405                base_volume: None,
16406                capacity_volume: None,
16407                stackable: None,
16408                world_placeable: None,
16409                worker_lodging_capacity: None,
16410                equip_slot: None,
16411                armor_physical: None,
16412                resists: vec![],
16413                hand_slots: None,
16414            listable: None,
16415        },
16416        );
16417        state.worn.insert(
16418            BodySlot::Head,
16419            flatland_protocol::ItemStack {
16420                template_id: "cloth_cap".into(),
16421                quantity: 1,
16422                item_instance_id: Some(uuid::Uuid::from_u128(21)),
16423                props: Default::default(),
16424                status_bindings: Vec::new(),
16425                contents: Vec::new(),
16426                display_name: Some("Cloth Cap".into()),
16427                category: Some("armor".into()),
16428                base_mass: None,
16429                base_volume: None,
16430                capacity_volume: None,
16431                stackable: None,
16432                world_placeable: None,
16433                worker_lodging_capacity: None,
16434                equip_slot: None,
16435                armor_physical: None,
16436                resists: vec![],
16437                hand_slots: None,
16438            listable: None,
16439        },
16440        );
16441
16442        let opts = state.move_destinations_for(
16443            &flatland_protocol::InventoryLocation::Root,
16444            None,
16445            None,
16446            "leather_pouch",
16447        );
16448        assert!(
16449            opts.iter().any(|o| matches!(
16450                &o.kind,
16451                MoveOptionKind::Move { location, .. }
16452                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16453            )),
16454            "belt loop must be offered when moving a pouch"
16455        );
16456        assert!(
16457            !opts.iter().any(|o| matches!(
16458                &o.kind,
16459                MoveOptionKind::Move { location, .. }
16460                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
16461            )),
16462            "armor slots can't hold other items and must not appear as move destinations"
16463        );
16464        let belt_opt = opts
16465            .iter()
16466            .find(|o| matches!(
16467                &o.kind,
16468                MoveOptionKind::Move { location, .. }
16469                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16470            ))
16471            .unwrap();
16472        assert!(belt_opt.label.contains("belt loop"));
16473
16474        let opts = state.move_destinations_for(
16475            &flatland_protocol::InventoryLocation::Root,
16476            None,
16477            None,
16478            "lumber",
16479        );
16480        assert!(
16481            !opts.iter().any(|o| o.label.contains("belt loop")),
16482            "loose materials must not target the belt shell — only nested pouches"
16483        );
16484    }
16485
16486    #[test]
16487    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
16488        let mut state = sample_state();
16489        let belt_id = uuid::Uuid::from_u128(30);
16490        let pouch_id = uuid::Uuid::from_u128(31);
16491        state.worn.insert(
16492            BodySlot::Waist,
16493            flatland_protocol::ItemStack {
16494                template_id: "simple_belt".into(),
16495                quantity: 1,
16496                item_instance_id: Some(belt_id),
16497                props: Default::default(),
16498                status_bindings: Vec::new(),
16499                world_placeable: None,
16500                worker_lodging_capacity: None,
16501                equip_slot: None,
16502                armor_physical: None,
16503                resists: vec![],
16504                hand_slots: None,
16505                contents: vec![flatland_protocol::ItemStack {
16506                    template_id: "dimensional_pouch".into(),
16507                    quantity: 1,
16508                    item_instance_id: Some(pouch_id),
16509                    props: Default::default(),
16510                    status_bindings: Vec::new(),
16511                    contents: Vec::new(),
16512                    display_name: Some("Dimensional Pouch".into()),
16513                    category: Some("container".into()),
16514                    base_mass: None,
16515                    base_volume: None,
16516                    capacity_volume: Some(200.0),
16517                    stackable: None,
16518                    world_placeable: None,
16519                    worker_lodging_capacity: None,
16520                    equip_slot: None,
16521                    armor_physical: None,
16522                    resists: vec![],
16523                    hand_slots: None,
16524            listable: None,
16525        }],
16526                display_name: Some("Simple Belt".into()),
16527                category: Some("container".into()),
16528                base_mass: None,
16529                base_volume: None,
16530                capacity_volume: None,
16531                stackable: None,
16532            listable: None,
16533        },
16534        );
16535
16536        let opts = state.move_destinations_for(
16537            &flatland_protocol::InventoryLocation::Root,
16538            None,
16539            None,
16540            "iron_ore",
16541        );
16542        assert!(
16543            opts.iter().any(|o| matches!(
16544                &o.kind,
16545                MoveOptionKind::Move {
16546                    location,
16547                    parent_instance_id,
16548                    ..
16549                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16550                    && *parent_instance_id == Some(pouch_id)
16551            )),
16552            "dimensional pouch clipped on belt must accept loose items"
16553        );
16554        assert!(
16555            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
16556            "destination label should name the pouch"
16557        );
16558    }
16559
16560    #[test]
16561    fn container_volume_label_on_placed_chest_shell() {
16562        let mut state = sample_state();
16563        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16564            id: "chest-1".into(),
16565            template_id: "wooden_chest_small".into(),
16566            display_name: "Camp Chest".into(),
16567            x: 129.0,
16568            y: 128.0,
16569            z: 0.0,
16570            locked: false,
16571            accessible: true,
16572            owner_character_id: None,
16573            contents: vec![flatland_protocol::ItemStack {
16574                template_id: "iron_ore".into(),
16575                quantity: 2,
16576                item_instance_id: None,
16577                props: Default::default(),
16578                status_bindings: Vec::new(),
16579                contents: Vec::new(),
16580                display_name: None,
16581                category: None,
16582                base_mass: None,
16583                base_volume: Some(2.0),
16584                capacity_volume: None,
16585                stackable: None,
16586                world_placeable: None,
16587                worker_lodging_capacity: None,
16588                equip_slot: None,
16589                armor_physical: None,
16590                resists: vec![],
16591                hand_slots: None,
16592            listable: None,
16593        }],
16594            lock_id: None,
16595            capacity_volume: Some(60.0),
16596            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16597            tile_id: None,
16598            worker_lodging_capacity: None,
16599            blocking: false,
16600            blocking_radius_m: 0.0,
16601            building_id: None,
16602        }];
16603        let nearby = state.nearby_containers();
16604        let label = state.container_volume_label(&nearby[0].rows[0]);
16605        assert!(
16606            label.contains("vol 4/60"),
16607            "expected used/cap in label, got {label}"
16608        );
16609        assert!(
16610            label.contains("56 free"),
16611            "expected free space, got {label}"
16612        );
16613    }
16614
16615    #[test]
16616    fn key_pair_chest_label_from_placed_lock_id() {
16617        let mut state = sample_state();
16618        let owner = uuid::Uuid::from_u128(77);
16619        state.character_id = Some(owner);
16620        let lock = uuid::Uuid::from_u128(99).to_string();
16621        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16622            id: "chest-1".into(),
16623            template_id: "wooden_chest_small".into(),
16624            display_name: "Barry's Loot #a3f2".into(),
16625            x: 129.0,
16626            y: 128.0,
16627            z: 0.0,
16628            locked: true,
16629            accessible: true,
16630            owner_character_id: Some(owner),
16631            contents: Vec::new(),
16632            lock_id: Some(lock.clone()),
16633            capacity_volume: None,
16634            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16635            tile_id: None,
16636            worker_lodging_capacity: None,
16637            blocking: false,
16638            blocking_radius_m: 0.0,
16639            building_id: None,
16640        }];
16641        let key_id = uuid::Uuid::from_u128(5);
16642        let key = flatland_protocol::ItemStack {
16643            template_id: KEY_TEMPLATE.into(),
16644            quantity: 1,
16645            item_instance_id: Some(key_id),
16646            props: BTreeMap::from([
16647                (PROP_OPENS_LOCK_ID.into(), lock),
16648                (
16649                    PROP_OPENS_CONTAINER_NAME.into(),
16650                    "Barry's Loot #a3f2".into(),
16651                ),
16652            ]),
16653            status_bindings: Vec::new(),
16654            contents: Vec::new(),
16655            display_name: Some("Container Key".into()),
16656            category: Some("key".into()),
16657            base_mass: None,
16658            base_volume: None,
16659            capacity_volume: None,
16660            stackable: None,
16661            world_placeable: None,
16662            worker_lodging_capacity: None,
16663            equip_slot: None,
16664            armor_physical: None,
16665            resists: vec![],
16666            hand_slots: None,
16667            listable: None,
16668        };
16669        state.inventory_stacks = vec![key.clone()];
16670        assert_eq!(
16671            state.key_pair_chest_label(&key).as_deref(),
16672            Some("Barry's Loot #a3f2")
16673        );
16674        assert!(state.key_drop_blocked(&key));
16675    }
16676
16677    #[test]
16678    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
16679        let mut state = sample_state();
16680        let lock = uuid::Uuid::from_u128(101).to_string();
16681        let key = flatland_protocol::ItemStack {
16682            template_id: KEY_TEMPLATE.into(),
16683            quantity: 1,
16684            item_instance_id: Some(uuid::Uuid::from_u128(7)),
16685            props: BTreeMap::from([
16686                (PROP_OPENS_LOCK_ID.into(), lock),
16687                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
16688            ]),
16689            status_bindings: Vec::new(),
16690            contents: Vec::new(),
16691            display_name: None,
16692            category: Some("key".into()),
16693            base_mass: None,
16694            base_volume: None,
16695            capacity_volume: None,
16696            stackable: None,
16697            world_placeable: None,
16698            worker_lodging_capacity: None,
16699            equip_slot: None,
16700            armor_physical: None,
16701            resists: vec![],
16702            hand_slots: None,
16703            listable: None,
16704        };
16705        state.placed_containers.clear();
16706        assert_eq!(
16707            state.key_pair_chest_label(&key).as_deref(),
16708            Some("Camp Stash")
16709        );
16710    }
16711
16712    #[test]
16713    fn key_drop_allowed_when_paired_chest_unlocked() {
16714        let mut state = sample_state();
16715        let lock = uuid::Uuid::from_u128(100).to_string();
16716        let key_id = uuid::Uuid::from_u128(6);
16717        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16718            id: "chest-1".into(),
16719            template_id: "wooden_chest_small".into(),
16720            display_name: "Camp Chest".into(),
16721            x: 129.0,
16722            y: 128.0,
16723            z: 0.0,
16724            locked: false,
16725            accessible: true,
16726            owner_character_id: None,
16727            contents: Vec::new(),
16728            lock_id: Some(lock.clone()),
16729            capacity_volume: None,
16730            item_instance_id: None,
16731            tile_id: None,
16732            worker_lodging_capacity: None,
16733            blocking: false,
16734            blocking_radius_m: 0.0,
16735            building_id: None,
16736        }];
16737        let key = flatland_protocol::ItemStack {
16738            template_id: KEY_TEMPLATE.into(),
16739            quantity: 1,
16740            item_instance_id: Some(key_id),
16741            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
16742            status_bindings: Vec::new(),
16743            contents: Vec::new(),
16744            display_name: None,
16745            category: Some("key".into()),
16746            base_mass: None,
16747            base_volume: None,
16748            capacity_volume: None,
16749            stackable: None,
16750            world_placeable: None,
16751            worker_lodging_capacity: None,
16752                equip_slot: None,
16753                armor_physical: None,
16754                resists: vec![],
16755                hand_slots: None,
16756            listable: None,
16757        };
16758        state.inventory_stacks = vec![key.clone()];
16759        assert!(!state.key_drop_blocked(&key));
16760        let opts = state.move_destinations_for(
16761            &flatland_protocol::InventoryLocation::Root,
16762            None,
16763            Some(key_id),
16764            KEY_TEMPLATE,
16765        );
16766        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
16767    }
16768
16769    #[test]
16770    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
16771        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
16772
16773        let mut state = sample_state();
16774        let curve = ProgressionCurve::default();
16775        let bootstrap = ProgressionXp::bootstrap_new(
16776            curve.baseline_display,
16777            curve.xp_base,
16778            curve.xp_growth,
16779        );
16780        let mut fresh = bootstrap.clone();
16781        fresh.strength += 0.08;
16782        if let Some(player) = state.player.as_mut() {
16783            player.progression_xp = Some(bootstrap);
16784        }
16785
16786        let combat = CombatHud {
16787            progression_xp: Some(fresh.clone()),
16788            progression_baseline: curve.baseline_display,
16789            progression_xp_base: curve.xp_base,
16790            progression_xp_growth: curve.xp_growth,
16791            attributes: state.player.as_ref().and_then(|p| p.attributes),
16792            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
16793            ..CombatHud::default()
16794        };
16795        state.apply_combat_hud(&combat);
16796
16797        let xp = state
16798            .player
16799            .as_ref()
16800            .and_then(|p| p.progression_xp.as_ref())
16801            .expect("xp");
16802        assert!((xp.strength - fresh.strength).abs() < 0.001);
16803        assert!(state.progression_curve.is_some());
16804    }
16805
16806    #[test]
16807    fn combat_hud_syncs_known_abilities_and_hotbar() {
16808        use flatland_protocol::CombatHud;
16809
16810        let mut state = sample_state();
16811        let combat = CombatHud {
16812            known_abilities: vec!["unarmed".into(), "fireball".into()],
16813            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
16814            max_abilities_per_rotation: 4,
16815            ability_id: "short_sword_slash".into(),
16816            ..CombatHud::default()
16817        };
16818        state.apply_combat_hud(&combat);
16819
16820        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
16821        assert_eq!(state.hotbar_ability(1), Some("fireball"));
16822        assert_eq!(state.hotbar_ability(2), None);
16823        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
16824        assert_eq!(state.max_abilities_per_rotation, 4);
16825        let choices = state.loadout_ability_choices();
16826        assert!(choices.iter().any(|a| a == "short_sword_slash"));
16827        assert!(choices.iter().any(|a| a == "fireball"));
16828    }
16829
16830    #[test]
16831    fn loadout_hotbar_choices_include_inventory_consumables() {
16832        let mut state = sample_state();
16833        state.known_abilities = vec!["unarmed".into()];
16834        state.weapon_ability_id = "unarmed".into();
16835        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16836            template_id: "bottle_of_water".into(),
16837            quantity: 3,
16838            item_instance_id: Some(uuid::Uuid::from_u128(9)),
16839            display_name: Some("Bottle of Water".into()),
16840            category: Some("consumable".into()),
16841            ..Default::default()
16842        }];
16843        state.inventory.insert("bottle_of_water".into(), 3);
16844        state.inventory_hints.insert(
16845            "bottle_of_water".into(),
16846            InventoryHint {
16847                display_name: "Bottle of Water".into(),
16848                category: "consumable".into(),
16849                ..Default::default()
16850            },
16851        );
16852
16853        let choices = state.loadout_hotbar_choices();
16854        assert!(choices.iter().any(|c| c.binding == "unarmed"));
16855        let water = choices
16856            .iter()
16857            .find(|c| c.binding == "item:bottle_of_water")
16858            .expect("water binding");
16859        assert_eq!(water.meta.as_deref(), Some("use"));
16860        assert!(water.label.contains("Water"));
16861        assert_eq!(
16862            state.hotbar_slot_label(1),
16863            None,
16864            "unbound until set"
16865        );
16866        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
16867        assert_eq!(
16868            state.hotbar_slot_label(5).as_deref(),
16869            Some("Bottle of Water×3")
16870        );
16871    }
16872
16873    #[test]
16874    fn storage_store_options_excludes_hand_equipped() {
16875        let mut state = sample_state();
16876        let sword_id = uuid::Uuid::from_u128(11);
16877        let ore_id = uuid::Uuid::from_u128(22);
16878        state.inventory_stacks = vec![
16879            flatland_protocol::ItemStack {
16880                template_id: "short_sword".into(),
16881                quantity: 1,
16882                item_instance_id: Some(sword_id),
16883                display_name: Some("Short Sword".into()),
16884                category: Some("weapon".into()),
16885                ..Default::default()
16886            },
16887            flatland_protocol::ItemStack {
16888                template_id: "iron_ore".into(),
16889                quantity: 5,
16890                item_instance_id: Some(ore_id),
16891                display_name: Some("Iron Ore".into()),
16892                category: Some("resource".into()),
16893                ..Default::default()
16894            },
16895        ];
16896        state.mainhand_template_id = Some("short_sword".into());
16897        state.mainhand_instance_id = Some(sword_id);
16898
16899        let opts = state.storage_store_options();
16900        assert_eq!(opts.len(), 1);
16901        assert_eq!(opts[0].item_instance_id, ore_id);
16902        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
16903    }
16904
16905    #[test]
16906    fn loose_consumable_move_picker_offers_use_and_storage() {
16907        let mut state = sample_state();
16908        let inst = uuid::Uuid::from_u128(77);
16909        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16910            template_id: "carrot".into(),
16911            quantity: 2,
16912            item_instance_id: Some(inst),
16913            props: Default::default(),
16914            status_bindings: Vec::new(),
16915            contents: Vec::new(),
16916            display_name: Some("Wild Carrot".into()),
16917            category: Some("consumable".into()),
16918            base_mass: None,
16919            base_volume: None,
16920            capacity_volume: None,
16921            stackable: Some(true),
16922            world_placeable: None,
16923            worker_lodging_capacity: None,
16924                equip_slot: None,
16925                armor_physical: None,
16926                resists: vec![],
16927                hand_slots: None,
16928            listable: None,
16929        }];
16930        state.inventory_hints.insert(
16931            "carrot".into(),
16932            InventoryHint {
16933                display_name: "Wild Carrot".into(),
16934                category: "consumable".into(),
16935                base_mass: Some(0.15),
16936                base_volume: Some(0.3),
16937                capacity_volume: None,
16938                stackable: true,
16939                listable: true,
16940            },
16941        );
16942        state.show_inventory_menu = true;
16943        state.inventory_menu_index = 0;
16944
16945        let row = state.inventory_selected_row().expect("carrot row");
16946        let mut options = state.move_destinations_for(
16947            &row.from,
16948            row.from_parent_instance_id,
16949            row.stack.item_instance_id,
16950            &row.stack.template_id,
16951        );
16952        if row.from == flatland_protocol::InventoryLocation::Root
16953            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
16954        {
16955            options.insert(
16956                0,
16957                MoveOption {
16958                    label: "Use (eat / drink)".into(),
16959                    kind: MoveOptionKind::Use,
16960                },
16961            );
16962        }
16963
16964        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
16965        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
16966        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
16967    }
16968
16969    #[test]
16970    fn inventory_category_group_order_is_stable() {
16971        assert_eq!(inventory_category_group("weapon").0, "Weapons");
16972        assert_eq!(inventory_category_group("armor").0, "Armor");
16973        assert_eq!(inventory_category_group("consumable").0, "Consumables");
16974        assert_eq!(inventory_category_group("resource").0, "Resources");
16975        assert_eq!(inventory_category_group("container").0, "Containers");
16976        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
16977        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
16978    }
16979
16980    #[test]
16981    fn page_list_index_clamps_without_wrap() {
16982        assert_eq!(page_list_index(0, -1, 25), 0);
16983        assert_eq!(page_list_index(0, 1, 25), 10);
16984        assert_eq!(page_list_index(12, 1, 25), 22);
16985        assert_eq!(page_list_index(22, 1, 25), 24);
16986        assert_eq!(page_list_index(5, 1, 0), 0);
16987        assert_eq!(page_list_index(3, -1, 8), 0);
16988    }
16989
16990    #[test]
16991    fn inventory_filter_hides_non_matching_person_items() {
16992        let mut state = sample_state();
16993        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16994        sword.display_name = Some("Iron Sword".into());
16995        sword.category = Some("weapon".into());
16996        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
16997        herb.display_name = Some("Wild Herb".into());
16998        herb.category = Some("consumable".into());
16999        state.inventory_stacks = vec![sword, herb];
17000        state.inventory_tab = InventoryTab::OnPerson;
17001        state.inventory_filter = "sword".into();
17002
17003        let rows = state.inventory_selectable_rows();
17004        assert_eq!(rows.len(), 1);
17005        assert_eq!(rows[0].stack.template_id, "iron_sword");
17006
17007        let lines = state.inventory_browser_lines();
17008        assert!(lines.iter().any(|l| matches!(
17009            l,
17010            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17011        )));
17012        assert!(!lines.iter().any(|l| matches!(
17013            l,
17014            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17015        )));
17016    }
17017
17018    #[test]
17019    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17020        let mut state = sample_state();
17021        let id_a = uuid::Uuid::from_u128(0xa1);
17022        let id_b = uuid::Uuid::from_u128(0xb2);
17023        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17024        sword_a.display_name = Some("Iron Sword".into());
17025        sword_a.category = Some("weapon".into());
17026        sword_a.item_instance_id = Some(id_a);
17027        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17028        sword_b.display_name = Some("Iron Sword".into());
17029        sword_b.category = Some("weapon".into());
17030        sword_b.item_instance_id = Some(id_b);
17031        state.inventory_stacks = vec![sword_a, sword_b];
17032        state.inventory_tab = InventoryTab::OnPerson;
17033
17034        let lines = state.inventory_browser_lines();
17035        let items: Vec<_> = lines
17036            .iter()
17037            .filter_map(|l| match l {
17038                InventoryBrowserLine::Item {
17039                    title,
17040                    instance_tooltip,
17041                    ..
17042                } => Some((title.clone(), instance_tooltip.clone())),
17043                _ => None,
17044            })
17045            .collect();
17046        assert_eq!(items.len(), 2);
17047        for (title, tip) in &items {
17048            assert!(
17049                !title.contains('#'),
17050                "title should not show instance suffix: {title}"
17051            );
17052            assert!(
17053                tip.is_some(),
17054                "two identical rows should expose instance on hover"
17055            );
17056        }
17057
17058        state.inventory_stacks.pop();
17059        let lines = state.inventory_browser_lines();
17060        let one = lines.iter().find_map(|l| match l {
17061            InventoryBrowserLine::Item {
17062                title,
17063                instance_tooltip,
17064                ..
17065            } => Some((title.clone(), instance_tooltip.clone())),
17066            _ => None,
17067        });
17068        let (title, tip) = one.expect("one sword row");
17069        assert!(!title.contains('#'));
17070        assert!(tip.is_none(), "single row should not need instance tooltip");
17071    }
17072
17073    #[test]
17074    fn inventory_person_rows_group_by_category() {
17075        let mut state = sample_state();
17076        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17077        sword.category = Some("weapon".into());
17078        sword.display_name = Some("Iron Sword".into());
17079        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17080        ore.category = Some("resource".into());
17081        ore.display_name = Some("Iron Ore".into());
17082        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17083        potion.category = Some("consumable".into());
17084        potion.display_name = Some("Health Potion".into());
17085        state.inventory_stacks = vec![ore, potion, sword];
17086        state.inventory_tab = InventoryTab::OnPerson;
17087
17088        let lines = state.inventory_browser_lines();
17089        let labels: Vec<&str> = lines
17090            .iter()
17091            .filter_map(|l| match l {
17092                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17093                _ => None,
17094            })
17095            .collect();
17096        assert!(
17097            labels.iter().any(|s| s.contains("Weapons")),
17098            "expected Weapons group: {labels:?}"
17099        );
17100        assert!(labels.iter().any(|s| s.contains("Consumables")));
17101        assert!(labels.iter().any(|s| s.contains("Resources")));
17102
17103        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17104        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
17105        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17106        assert!(weapon_pos < consumable_pos);
17107        assert!(consumable_pos < resource_pos);
17108    }
17109
17110    #[test]
17111    fn inventory_tab_cycle_resets_selection() {
17112        let mut state = sample_state();
17113        state.inventory_tab = InventoryTab::OnPerson;
17114        state.inventory_menu_index = 3;
17115        state.inventory_tab = state.inventory_tab.cycle(true);
17116        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17117        // Client method resets index; enum cycle alone does not — verify cycle labels.
17118        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17119        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17120        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17121        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17122    }
17123
17124    #[test]
17125    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17126        assert_eq!(parse_bank_copper_amount(""), Some(0));
17127        assert_eq!(parse_bank_copper_amount("  "), Some(0));
17128        assert_eq!(parse_bank_copper_amount("0"), Some(0));
17129        assert_eq!(parse_bank_copper_amount("250"), Some(250));
17130        assert_eq!(parse_bank_copper_amount("nope"), None);
17131    }
17132
17133    #[test]
17134    fn parse_storage_quantity_blank_and_zero_mean_all() {
17135        assert_eq!(parse_storage_quantity(""), Some(None));
17136        assert_eq!(parse_storage_quantity("  "), Some(None));
17137        assert_eq!(parse_storage_quantity("0"), Some(None));
17138        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17139        assert_eq!(parse_storage_quantity("nope"), None);
17140    }
17141
17142    #[test]
17143    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17144        assert!(worker_error_is_hud_noise("path stuck — repathing"));
17145        assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
17146        assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
17147        // Real problem: player may need to place lodging / fix assignment.
17148        assert!(!worker_error_is_hud_noise(
17149            "path stuck — no lodging to reset to"
17150        ));
17151    }
17152
17153    #[test]
17154    fn leaving_building_restores_outdoor_z_bands() {
17155        use flatland_protocol::{InteriorMapView, ZPlatformView};
17156
17157        let mut state = sample_state();
17158        state.z_platforms.clear();
17159        state.z_transitions.clear();
17160        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
17161        state.interior_map = Some(InteriorMapView {
17162            building_id: "broker_hut".into(),
17163            blueprint_id: "broker_hut".into(),
17164            background_color: "#000".into(),
17165            default_floor_color: None,
17166            floor_height_m: 3.0,
17167            z_platforms: vec![ZPlatformView {
17168                id: "floor_0".into(),
17169                z: 0.0,
17170                x0: 0.0,
17171                y0: 0.0,
17172                x1: 8.0,
17173                y1: 8.0,
17174            }],
17175            z_transitions: vec![],
17176            rooms: vec![],
17177            room_doors: vec![],
17178        });
17179        state.sync_interior_map_context();
17180        assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
17181        assert!(state.z_bands_outdoor_backup.is_some());
17182
17183        state.player.as_mut().unwrap().inside_building = None;
17184        state.sync_interior_map_context();
17185        assert!(
17186            state.z_platforms.is_empty(),
17187            "leaving must restore outdoor bands (empty), not leave interior platforms"
17188        );
17189        assert!(state.z_bands_outdoor_backup.is_none());
17190        assert!(state.interior_map.is_none());
17191    }
17192
17193    #[test]
17194    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
17195        let node = ResourceNodeView {
17196            id: "crop-carrot-1_copy10".into(),
17197            label: "crop-carrot-1_copy10".into(),
17198            x: 0.0,
17199            y: 0.0,
17200            z: 0.0,
17201            item_template: "carrot".into(),
17202            state: ResourceNodeState::Available,
17203            blocking: false,
17204            blocking_radius_m: 0.5,
17205            harvest_off: false,
17206            tile_id: None,
17207            yaw: 0.0,
17208            pitch: 0.0,
17209            roll: 0.0,
17210            draw_scale: 1.0,
17211            sprite_mode: None,
17212            growth_progress: None,
17213            presentation_state: None,
17214            channel_start_tick: None,
17215            channel_end_tick: None,
17216            harvest_drop_templates: vec![],
17217        };
17218        let label = super::resource_node_route_label(&node);
17219        assert!(label.starts_with("Carrot ("), "got {label}");
17220        assert!(label.ends_with(')'), "got {label}");
17221
17222        let mut named = node;
17223        named.label = "Sweet Pad".into();
17224        named.id = "crop-carrot-a3f2b1c0".into();
17225        assert_eq!(
17226            super::resource_node_route_label(&named),
17227            "Sweet Pad (b1c0)"
17228        );
17229    }
17230
17231    #[test]
17232    fn plot_public_label_uses_owner_zone_and_label() {
17233        let plot = flatland_protocol::PropertyPlotView {
17234            plot_id: uuid::Uuid::nil(),
17235            property_zone_id: "zone_a".into(),
17236            zone_label: Some("Starter Town East 1".into()),
17237            deed_instance_id: uuid::Uuid::nil(),
17238            x0: 0.0,
17239            y0: 0.0,
17240            x1: 4.0,
17241            y1: 4.0,
17242            upkeep_copper_per_day: 1,
17243            arrears_days: 0,
17244            is_mine: true,
17245            may_farm: true,
17246            purchase_basis_copper: 0,
17247            farm_public: false,
17248            public_tax_discount_bps: 0,
17249            farm_allow: vec![],
17250            owner_character_id: None,
17251            owner_label: Some("Madsin".into()),
17252            building_id: None,
17253            plot_code: "xyz1234a".into(),
17254            label: "Food Pad".into(),
17255        };
17256        assert_eq!(
17257            super::plot_public_label(&plot),
17258            "Madsin — Starter Town East 1 — Food Pad"
17259        );
17260    }
17261}