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        let sticky = state
990            .worker_error_display
991            .get(&w.instance_id)
992            .and_then(|s| s.shown(now))
993            .filter(|e| !worker_error_is_hud_noise(e));
994        let live = w.last_error.as_deref().filter(|e| {
995            !worker_error_is_transient(e) && !worker_error_is_hud_noise(e)
996        });
997        if let Some(err) = sticky.or(live) {
998            if let Some(hint) = w
999                .issue_hint
1000                .as_deref()
1001                .filter(|h| !h.is_empty())
1002                .or_else(|| worker_issue_fix_hint(err))
1003            {
1004                return Some(format!("Worker {}: {err} — {hint}", w.label));
1005            }
1006            return Some(format!("Worker {}: {err}", w.label));
1007        }
1008        // Plan issue with hint but cleared last_error (sticky already shown above).
1009        if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1010            return Some(format!("Worker {}: {hint}", w.label));
1011        }
1012    }
1013    None
1014}
1015
1016/// Fallback fix copy when the server did not send `issue_hint`.
1017pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1018    let e = err.to_ascii_lowercase();
1019    if e.contains("missing")
1020        || e.contains("container not found")
1021        || e.contains("lodging container not found")
1022    {
1023        return Some("edit route (e): replace the missing chest/bed");
1024    }
1025    if e.contains("stranded at interior") || e.contains("interior map coords") {
1026        return Some("recovered — continuing route");
1027    }
1028    if e.contains("stuck inside")
1029        || e.contains("sent outside")
1030        || e.contains("sent to door")
1031        || e.contains("left building")
1032    {
1033        return Some("auto-exit for outdoor work — restart after update if it still loops");
1034    }
1035    if e.contains("collapsed") || e.contains("need food") {
1036        return Some("stock lodging bed with food and drink");
1037    }
1038    if e.contains("overburdened") {
1039        return Some("add a deposit/sell stop, or empty their pack");
1040    }
1041    if e.contains("need a hoe") || e.contains("need a dibber") {
1042        return Some("give them the tool or withdraw it on the route");
1043    }
1044    None
1045}
1046
1047/// True when a worker `last_error` is transient noise (soft-skip chatter).
1048pub fn worker_error_is_transient(err: &str) -> bool {
1049    let e = err.to_ascii_lowercase();
1050    e.contains("continuing route")
1051        || e.contains("storage full")
1052        || e.starts_with("nothing to withdraw")
1053}
1054
1055/// True for routine worker recoveries that should not paint the status bar.
1056/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1057pub fn worker_error_is_hud_noise(err: &str) -> bool {
1058    let e = err.to_ascii_lowercase();
1059    e.contains("returned to lodging after path")
1060        || e.contains("path failure")
1061        || e.contains("no path to")
1062        || e.contains("pathfinding")
1063        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1064        || e.contains("repathing")
1065        || e.contains("nudged clear")
1066        // Auto space rescue — informative in the log, not a red alert.
1067        || e.contains("auto-recovery")
1068        || e.contains("stranded at interior map coords")
1069}
1070
1071/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1072#[derive(Debug, Clone)]
1073pub struct PendingWorkerJobAck {
1074    pub seq: u32,
1075    pub worker_instance_id: String,
1076    pub worker_label: String,
1077    pub idle: bool,
1078    pub stop_count: usize,
1079    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1080    pub prev_mode: flatland_protocol::WorkerModeView,
1081    pub prev_step_label: String,
1082    pub prev_last_error: Option<String>,
1083}
1084
1085fn push_inventory_rows(
1086    rows: &mut Vec<InventoryRow>,
1087    depth: usize,
1088    stack: &flatland_protocol::ItemStack,
1089    from: &flatland_protocol::InventoryLocation,
1090    from_parent_instance_id: Option<uuid::Uuid>,
1091    section: InventorySection,
1092) {
1093    push_inventory_rows_filtered(
1094        rows,
1095        depth,
1096        stack,
1097        from,
1098        from_parent_instance_id,
1099        section,
1100        "",
1101    );
1102}
1103
1104fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1105    if filter.is_empty() {
1106        return true;
1107    }
1108    let f = filter.to_ascii_lowercase();
1109    let name = stack
1110        .display_name
1111        .as_deref()
1112        .unwrap_or("")
1113        .to_ascii_lowercase();
1114    let tid = stack.template_id.to_ascii_lowercase();
1115    name.contains(&f)
1116        || tid.contains(&f)
1117        || stack
1118            .contents
1119            .iter()
1120            .any(|c| stack_matches_filter(c, filter))
1121}
1122
1123fn push_inventory_rows_filtered(
1124    rows: &mut Vec<InventoryRow>,
1125    depth: usize,
1126    stack: &flatland_protocol::ItemStack,
1127    from: &flatland_protocol::InventoryLocation,
1128    from_parent_instance_id: Option<uuid::Uuid>,
1129    section: InventorySection,
1130    filter: &str,
1131) {
1132    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1133        return;
1134    }
1135    let self_hit = filter.is_empty() || {
1136        let f = filter.to_ascii_lowercase();
1137        let name = stack
1138            .display_name
1139            .as_deref()
1140            .unwrap_or("")
1141            .to_ascii_lowercase();
1142        let tid = stack.template_id.to_ascii_lowercase();
1143        name.contains(&f) || tid.contains(&f)
1144    };
1145    rows.push(InventoryRow {
1146        depth,
1147        stack: stack.clone(),
1148        from: from.clone(),
1149        from_parent_instance_id,
1150        is_equip_shell: false,
1151        is_chest_shell: false,
1152        section,
1153    });
1154    for child in &stack.contents {
1155        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1156            push_inventory_rows_filtered(
1157                rows,
1158                depth + 1,
1159                child,
1160                from,
1161                stack.item_instance_id,
1162                section,
1163                if self_hit { "" } else { filter },
1164            );
1165        }
1166    }
1167}
1168
1169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1170pub enum ShopTab {
1171    #[default]
1172    Buy,
1173    Sell,
1174}
1175
1176#[derive(Debug, Clone)]
1177pub struct NpcChatState {
1178    pub npc_id: String,
1179    pub npc_label: String,
1180    pub lines: Vec<String>,
1181    pub input: String,
1182    pub pending: bool,
1183    pub talk_depth: flatland_protocol::NpcTalkDepth,
1184    pub trade_allowed: bool,
1185    pub banner: Option<String>,
1186}
1187
1188impl Default for NpcChatState {
1189    fn default() -> Self {
1190        Self {
1191            npc_id: String::new(),
1192            npc_label: String::new(),
1193            lines: Vec::new(),
1194            input: String::new(),
1195            pending: false,
1196            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1197            trade_allowed: true,
1198            banner: None,
1199        }
1200    }
1201}
1202
1203#[derive(Debug, Clone)]
1204pub struct GameState {
1205    pub session_id: SessionId,
1206    pub entity_id: EntityId,
1207    /// Logged-in character — used to show owner-only container labels.
1208    pub character_id: Option<uuid::Uuid>,
1209    pub tick: Tick,
1210    pub chunk_rev: u64,
1211    pub content_rev: u64,
1212    pub publish_rev: u64,
1213    pub entities: Vec<EntityState>,
1214    pub player: Option<EntityState>,
1215    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1216    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1217    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1218    pub buildings: Vec<BuildingView>,
1219    pub doors: Vec<DoorView>,
1220    pub interior_map: Option<InteriorMapView>,
1221    pub npcs: Vec<NpcView>,
1222    pub blueprints: Vec<BlueprintView>,
1223    /// Wall/roof packs for the B plot-build menu.
1224    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1225    /// Outdoor play AABB origin (meters).
1226    pub world_x0: f32,
1227    pub world_y0: f32,
1228    pub world_width_m: f32,
1229    pub world_height_m: f32,
1230    pub terrain_zones: Vec<TerrainZoneView>,
1231    pub z_platforms: Vec<ZPlatformView>,
1232    pub z_transitions: Vec<ZTransitionView>,
1233    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1234    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1235    #[doc(hidden)]
1236    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1237    pub world_clock: flatland_protocol::WorldClock,
1238    pub inventory: std::collections::HashMap<String, u32>,
1239    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1240    pub logs: VecDeque<String>,
1241    pub intents_sent: u64,
1242    pub ticks_received: u64,
1243    pub connected: bool,
1244    pub disconnect_reason: Option<String>,
1245    pub show_stats: bool,
1246    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1247    pub hud_log_hidden: bool,
1248    pub show_equip_menu: bool,
1249    pub equip_menu_index: usize,
1250    pub show_craft_menu: bool,
1251    pub craft_menu_index: usize,
1252    /// How many timed crafts to queue when confirming the craft menu.
1253    pub craft_batch_quantity: u32,
1254    /// B — wall/roof material picker for plot buildings.
1255    pub show_plot_build_menu: bool,
1256    /// True = navigate walls; false = navigate roofs (Tab switches).
1257    pub plot_build_focus_wall: bool,
1258    pub plot_build_wall_index: usize,
1259    pub plot_build_roof_index: usize,
1260    pub show_shop_menu: bool,
1261    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1262    pub bank_panel: Option<flatland_protocol::BankPanel>,
1263    pub bank_menu_index: usize,
1264    pub bank_ui_mode: BankUiMode,
1265    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1266    pub market_panel: Option<flatland_protocol::MarketPanel>,
1267    /// Selected row among filtered market browse listings.
1268    pub market_menu_index: usize,
1269    /// Browse / list-pick text filter (`/` to focus).
1270    pub market_filter: String,
1271    pub market_filter_focused: bool,
1272    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1273    pub market_category_filter: Option<&'static str>,
1274    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1275    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1276    pub market_ui_mode: MarketUiMode,
1277    pub storage_menu_index: usize,
1278    pub storage_ui_mode: StorageUiMode,
1279    pub shop_tab: ShopTab,
1280    pub shop_menu_index: usize,
1281    pub shop_quantity: u32,
1282    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1283    pub shop_trade_log: VecDeque<String>,
1284    pub show_npc_verb_menu: bool,
1285    pub npc_verb_target: Option<String>,
1286    pub npc_verb_index: usize,
1287    /// Nearby player Speak / Whisper / Trade dock.
1288    pub player_verbs: crate::social::PlayerVerbState,
1289    pub social_chat: crate::social::SocialChatState,
1290    pub trade_ui: crate::social::TradeUiState,
1291    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1292    pub show_npc_chat: bool,
1293    pub npc_chat: Option<NpcChatState>,
1294    pub show_inventory_menu: bool,
1295    pub inventory_menu_index: usize,
1296    pub inventory_tab: InventoryTab,
1297    pub inventory_filter: String,
1298    pub inventory_filter_focused: bool,
1299    pub show_move_picker: bool,
1300    pub move_picker_index: usize,
1301    pub move_picker: Option<MovePicker>,
1302    pub show_grant_picker: bool,
1303    pub grant_picker_index: usize,
1304    pub grant_picker: Option<GrantTargetPicker>,
1305    pub show_destroy_picker: bool,
1306    pub destroy_confirm_pending: bool,
1307    pub destroy_picker: Option<DestroyPicker>,
1308    /// Rename prompt for a selected container (`n` in inventory).
1309    pub show_rename_prompt: bool,
1310    /// When set, the rename prompt targets a property plot (deed or on-plot).
1311    pub rename_plot_id: Option<uuid::Uuid>,
1312    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1313    pub highlighted_plot_id: Option<uuid::Uuid>,
1314    /// Rename prompt for a hired worker (`n` in workers menu).
1315    pub show_worker_rename: bool,
1316    pub rename_buffer: String,
1317    /// Slot-1 combat target (mirrors server after SetTarget).
1318    pub combat_target: Option<EntityId>,
1319    pub combat_target_label: Option<String>,
1320    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1321    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1322    pub ground_target: Option<(f32, f32, f32)>,
1323    /// Active combat footprints from the server (`plans/39`).
1324    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1325    /// Authored crown property zones (claimable land) — plan 40.
1326    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1327    /// Tax overlays for claim cost premium preview.
1328    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1329    /// Growth / fertility overlays (farming, respawn).
1330    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1331    /// Climate / biome overlays.
1332    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1333    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1334    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1335    /// Claimed property plots near the observer.
1336    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1337    /// Server knobs for client-side claim quotes.
1338    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1339    /// Local claim-footprint editor (not server state).
1340    pub claim_mode: Option<ClaimModeState>,
1341    /// Local relocate ghost for a placed chest/lodging (not server state).
1342    pub relocate_mode: Option<RelocateModeState>,
1343    /// Second `f` within a short window confirms selling this plot back to the crown.
1344    pub sell_plot_confirm: Option<uuid::Uuid>,
1345    /// When `sell_plot_confirm` was armed (for double-tap window).
1346    pub sell_plot_armed_at: Option<Instant>,
1347    /// Choose seed type when planting on owned tilled soil (`f`).
1348    pub show_plant_menu: bool,
1349    pub plant_menu_index: usize,
1350    /// Deed-holder farm access panel (public toggle + allow-list).
1351    pub show_farm_access: bool,
1352    /// Draft name for adding a tenant from the farm-access panel.
1353    pub farm_access_name_draft: String,
1354    /// Public / grant discount draft (bps) while editing farm access.
1355    pub farm_access_discount_bps: u32,
1356    /// Selected row in the farm-access panel (0 = public toggle).
1357    pub farm_access_index: usize,
1358    pub plant_quantity: u32,
1359    pub in_combat: bool,
1360    pub auto_attack: bool,
1361    pub combat_has_los: bool,
1362    pub attack_cd_ticks: u64,
1363    pub gcd_ticks: u64,
1364    pub weapon_ability_id: String,
1365    pub mainhand_template_id: Option<String>,
1366    pub mainhand_label: Option<String>,
1367    pub mainhand_instance_id: Option<uuid::Uuid>,
1368    pub offhand_template_id: Option<String>,
1369    pub offhand_label: Option<String>,
1370    pub offhand_instance_id: Option<uuid::Uuid>,
1371    pub mainhand_hand_slots: u8,
1372    pub defense: Option<flatland_protocol::DefenseHud>,
1373    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1374    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1375    pub carry_mass: f32,
1376    pub carry_mass_max: f32,
1377    pub encumbrance: flatland_protocol::EncumbranceState,
1378    /// Full nested inventory stacks from the server (root only; worn are separate).
1379    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1380    /// Keys stowed on the virtual keychain (zero carry mass).
1381    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1382    /// Whisper stones in the pouch (zero carry mass).
1383    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1384    /// Active status effects on the local player (buff/debuff strip).
1385    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1386    pub combat_target_detail: Option<CombatTargetHud>,
1387    pub cast_progress: Option<CastProgressHud>,
1388    /// Till / plant / other non-combat timed channels on the local player.
1389    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1390    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1391    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1392    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1393    pub blocking_active: bool,
1394    pub max_target_slots: u8,
1395    pub combat_slots: Vec<CombatSlotHud>,
1396    pub rotation_presets: Vec<RotationPreset>,
1397    /// Learned abilities from combat HUD (usable casts).
1398    pub known_abilities: Vec<String>,
1399    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1400    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1401    /// Per-ability mastery from combat HUD (tier / XP).
1402    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1403    /// Server-persisted hotbar bindings (index 0 = key 1).
1404    pub hotbar: Vec<Option<String>>,
1405    /// Max abilities allowed in one rotation (mind score).
1406    pub max_abilities_per_rotation: u8,
1407    pub show_loadout_menu: bool,
1408    pub show_keychain_menu: bool,
1409    pub keychain_menu_index: usize,
1410    pub show_rotation_editor: bool,
1411    /// Selected rotation preset in the loadout menu.
1412    pub loadout_menu_index: usize,
1413    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1414    pub loadout_hotbar_slot: u8,
1415    /// Selected known-ability row in the loadout menu.
1416    pub loadout_ability_index: usize,
1417    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1418    pub loadout_focus_presets: bool,
1419    pub rotation_editor: RotationEditorState,
1420    /// True after a harvest intent is accepted until result/reject/disconnect.
1421    pub harvest_in_progress: bool,
1422    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1423    pub harvest_started_at: Option<Instant>,
1424    /// Craft log deferred until the server acks the craft intent.
1425    pub pending_craft_ack: Option<(u32, String, u32)>,
1426    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1427    pub interactables: Vec<flatland_protocol::InteractableView>,
1428    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1429    pub career: Option<flatland_protocol::PlayerCareerView>,
1430    pub character_sheet_tab: CharacterSheetTab,
1431    pub ledger_period: LedgerPeriod,
1432    pub show_quest_offer: bool,
1433    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1434    pub show_quest_menu: bool,
1435    pub quest_menu_index: usize,
1436    pub quest_withdraw_confirm: bool,
1437    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1438    pub show_workers_menu: bool,
1439    pub workers_menu_index: usize,
1440    /// Workers panel: one-line rows instead of full cards (more on screen).
1441    pub workers_menu_compact: bool,
1442    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1443    /// Public so out-of-crate tests can construct [`GameState`].
1444    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1445    /// Held worker route errors (readable when server clears `last_error` each tick).
1446    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1447    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1448    pub show_worker_give_picker: bool,
1449    pub worker_give_picker_index: usize,
1450    pub worker_give_picker: Option<WorkerGivePicker>,
1451    /// Inventory `g` — pick which nearby worker receives the selected stack.
1452    pub show_worker_give_target_picker: bool,
1453    pub worker_give_target_picker_index: usize,
1454    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1455    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1456    pub show_worker_take_picker: bool,
1457    pub worker_take_picker_index: usize,
1458    pub worker_take_picker: Option<WorkerTakePicker>,
1459    /// Teach-blueprint sheet opened from the workers menu (`t`).
1460    pub show_worker_teach_picker: bool,
1461    pub worker_teach_picker_index: usize,
1462    pub worker_teach_picker: Option<WorkerTeachPicker>,
1463    /// Active harvest-route editor (`h` → `e` on a worker).
1464    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1465    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1466    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1467    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1468    pub attending_worker_instance_id: Option<String>,
1469    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1470    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1471}
1472
1473impl GameState {
1474    pub fn push_log(&mut self, line: impl Into<String>) {
1475        self.logs.push_back(line.into());
1476        while self.logs.len() > MAX_LOG_LINES {
1477            self.logs.pop_front();
1478        }
1479    }
1480
1481    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1482        self.shop_trade_log.push_back(line.into());
1483        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1484            self.shop_trade_log.pop_front();
1485        }
1486    }
1487
1488    pub fn clear_shop_trade_log(&mut self) {
1489        self.shop_trade_log.clear();
1490    }
1491
1492    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1493        if !self.show_shop_menu {
1494            return;
1495        }
1496        let msg = notice.message.trim();
1497        if msg.is_empty() {
1498            return;
1499        }
1500        if notice.coins_delta != 0
1501            || msg.starts_with("Bought ")
1502            || msg.starts_with("Sold ")
1503            || msg.contains("taught you how to craft")
1504            || msg.starts_with("need ")
1505        {
1506            self.push_shop_trade_log(msg);
1507        }
1508    }
1509
1510    pub fn is_alive(&self) -> bool {
1511        self.player
1512            .as_ref()
1513            .and_then(|p| p.vitals)
1514            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1515            .unwrap_or(true)
1516    }
1517
1518    /// Verb menu entries for the current `npc_verb_target`.
1519    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1520        let Some(ref id) = self.npc_verb_target else {
1521            return vec![];
1522        };
1523        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1524            return vec!["Talk"];
1525        };
1526        let role = npc.role.as_str();
1527        if Self::npc_role_is_bank(role) {
1528            return vec!["Bank", "Talk"];
1529        }
1530        if Self::npc_role_is_storage(role) {
1531            return vec!["Storage", "Talk"];
1532        }
1533        if Self::npc_role_is_market(role) {
1534            return vec!["Market", "Talk"];
1535        }
1536        if npc.can_trade || Self::npc_role_can_trade(role) {
1537            vec!["Talk", "Trade"]
1538        } else {
1539            vec!["Talk"]
1540        }
1541    }
1542
1543    fn npc_role_can_trade(role: &str) -> bool {
1544        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1545    }
1546
1547    fn npc_role_is_bank(role: &str) -> bool {
1548        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1549    }
1550
1551    fn npc_role_is_storage(role: &str) -> bool {
1552        role.eq_ignore_ascii_case("storage_manager")
1553    }
1554
1555    fn npc_role_is_market(role: &str) -> bool {
1556        role.eq_ignore_ascii_case("market_clerk")
1557    }
1558
1559    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1560        vec![
1561            "Deposit…",
1562            "Withdraw…",
1563            "Deposit all",
1564            "Withdraw all",
1565            "Transfer…",
1566        ]
1567    }
1568
1569    pub fn storage_menu_options(&self) -> Vec<String> {
1570        let mut opts = vec!["Store…".into(), "Take…".into()];
1571        if let Some(panel) = &self.storage_panel {
1572            for dest in &panel.ship_destinations {
1573                opts.push(format!(
1574                    "Ship → {} ({} cp / {} ticks)",
1575                    dest.label, dest.fee_copper, dest.travel_ticks
1576                ));
1577            }
1578        }
1579        opts
1580    }
1581
1582    /// Loose on-person stacks eligible for town-storage store.
1583    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
1584    /// already outside root inventory.
1585    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1586        let equipped = self.hand_equipped_instance_ids();
1587        self.person_rows()
1588            .into_iter()
1589            .filter(|r| r.depth == 0)
1590            .filter_map(|r| {
1591                let id = r.stack.item_instance_id?;
1592                if equipped.contains(&id) {
1593                    return None;
1594                }
1595                Some(StoragePickOption {
1596                    item_instance_id: id,
1597                    label: storage_stack_label(&r.stack),
1598                    quantity: r.stack.quantity,
1599                    category: r.stack.category.clone().unwrap_or_default(),
1600                })
1601            })
1602            .collect()
1603    }
1604
1605    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
1606    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1607        let mut ids = std::collections::HashSet::new();
1608        if let Some(id) = self.mainhand_instance_id {
1609            ids.insert(id);
1610        } else if let Some(tid) = &self.mainhand_template_id {
1611            if let Some(id) = self
1612                .inventory_stacks
1613                .iter()
1614                .find(|s| &s.template_id == tid)
1615                .and_then(|s| s.item_instance_id)
1616            {
1617                ids.insert(id);
1618            }
1619        }
1620        if let Some(id) = self.offhand_instance_id {
1621            ids.insert(id);
1622        } else if let Some(tid) = &self.offhand_template_id {
1623            if let Some(id) = self
1624                .inventory_stacks
1625                .iter()
1626                .find(|s| {
1627                    &s.template_id == tid
1628                        && s.item_instance_id
1629                            .is_some_and(|iid| !ids.contains(&iid))
1630                })
1631                .and_then(|s| s.item_instance_id)
1632            {
1633                ids.insert(id);
1634            }
1635        }
1636        ids
1637    }
1638
1639    /// Vault stacks eligible for take / ship.
1640    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1641        let Some(panel) = &self.storage_panel else {
1642            return Vec::new();
1643        };
1644        panel
1645            .contents
1646            .iter()
1647            .filter_map(|s| {
1648                let id = s.item_instance_id?;
1649                Some(StoragePickOption {
1650                    item_instance_id: id,
1651                    label: storage_stack_label(s),
1652                    quantity: s.quantity,
1653                    category: s.category.clone().unwrap_or_default(),
1654                })
1655            })
1656            .collect()
1657    }
1658
1659    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1660    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1661        let mut opts = Vec::new();
1662        if !self
1663            .market_list_item_options(&MarketListSourceKind::Person)
1664            .is_empty()
1665        {
1666            opts.push((MarketListSourceKind::Person, "On person".into()));
1667        }
1668        if let Some(panel) = &self.market_panel {
1669            for vault in &panel.list_vaults {
1670                let source = MarketListSourceKind::TownStorage {
1671                    building_id: vault.building_id.clone(),
1672                };
1673                if self.market_list_item_options(&source).is_empty() {
1674                    continue;
1675                }
1676                let label = if vault.building_label.is_empty() {
1677                    format!("Town storage ({})", vault.building_id)
1678                } else {
1679                    format!("Town storage — {}", vault.building_label)
1680                };
1681                opts.push((source, label));
1682            }
1683        }
1684        opts
1685    }
1686
1687    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1688    pub fn market_list_item_options(
1689        &self,
1690        source: &MarketListSourceKind,
1691    ) -> Vec<StoragePickOption> {
1692        let filter = self.market_filter.as_str();
1693        let cat_filter = self.market_category_filter;
1694        let mut opts: Vec<StoragePickOption> = match source {
1695            MarketListSourceKind::Person => {
1696                let equipped = self.hand_equipped_instance_ids();
1697                self.person_rows()
1698                    .into_iter()
1699                    .filter(|r| r.depth == 0)
1700                    .filter(|r| self.stack_is_market_listable(&r.stack))
1701                    .filter_map(|r| {
1702                        let id = r.stack.item_instance_id?;
1703                        if equipped.contains(&id) {
1704                            return None;
1705                        }
1706                        Some(StoragePickOption {
1707                            item_instance_id: id,
1708                            label: storage_stack_label(&r.stack),
1709                            quantity: r.stack.quantity,
1710                            category: r
1711                                .stack
1712                                .category
1713                                .clone()
1714                                .or_else(|| {
1715                                    self.inventory_item_category(&r.stack.template_id)
1716                                        .map(str::to_string)
1717                                })
1718                                .unwrap_or_default(),
1719                        })
1720                    })
1721                    .collect()
1722            }
1723            MarketListSourceKind::TownStorage { building_id } => {
1724                let Some(panel) = &self.market_panel else {
1725                    return Vec::new();
1726                };
1727                let Some(vault) = panel
1728                    .list_vaults
1729                    .iter()
1730                    .find(|v| &v.building_id == building_id)
1731                else {
1732                    return Vec::new();
1733                };
1734                vault
1735                    .contents
1736                    .iter()
1737                    .filter(|s| self.stack_is_market_listable(s))
1738                    .filter_map(|s| {
1739                        let id = s.item_instance_id?;
1740                        Some(StoragePickOption {
1741                            item_instance_id: id,
1742                            label: storage_stack_label(s),
1743                            quantity: s.quantity,
1744                            category: s
1745                                .category
1746                                .clone()
1747                                .or_else(|| {
1748                                    self.inventory_item_category(&s.template_id)
1749                                        .map(str::to_string)
1750                                })
1751                                .unwrap_or_default(),
1752                        })
1753                    })
1754                    .collect()
1755            }
1756        };
1757        opts.retain(|o| {
1758            if !list_label_matches(&o.label, filter) {
1759                return false;
1760            }
1761            if let Some(group) = cat_filter {
1762                inventory_category_group(&o.category).0 == group
1763            } else {
1764                true
1765            }
1766        });
1767        opts
1768    }
1769
1770    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1771        if crate::currency::is_currency(&stack.template_id) {
1772            return false;
1773        }
1774        if let Some(flag) = stack.listable {
1775            return flag;
1776        }
1777        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1778            return hint.listable;
1779        }
1780        let cat = stack
1781            .category
1782            .as_deref()
1783            .or_else(|| self.inventory_item_category(&stack.template_id))
1784            .unwrap_or("");
1785        category_default_listable(cat)
1786    }
1787
1788    /// Category group labels present in the current browse book or list-pick source.
1789    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1790        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1791        match &self.market_ui_mode {
1792            MarketUiMode::ListPick { source, .. } => {
1793                let raw: Vec<_> = match source {
1794                    MarketListSourceKind::Person => self
1795                        .person_rows()
1796                        .into_iter()
1797                        .filter(|r| r.depth == 0)
1798                        .filter(|r| self.stack_is_market_listable(&r.stack))
1799                        .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1800                        .map(|r| {
1801                            r.stack
1802                                .category
1803                                .clone()
1804                                .or_else(|| {
1805                                    self.inventory_item_category(&r.stack.template_id)
1806                                        .map(str::to_string)
1807                                })
1808                                .unwrap_or_default()
1809                        })
1810                        .collect(),
1811                    MarketListSourceKind::TownStorage { building_id } => self
1812                        .market_panel
1813                        .as_ref()
1814                        .and_then(|p| {
1815                            p.list_vaults
1816                                .iter()
1817                                .find(|v| &v.building_id == building_id)
1818                        })
1819                        .map(|vault| {
1820                            vault
1821                                .contents
1822                                .iter()
1823                                .filter(|s| self.stack_is_market_listable(s))
1824                                .filter(|s| {
1825                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
1826                                })
1827                                .map(|s| {
1828                                    s.category
1829                                        .clone()
1830                                        .or_else(|| {
1831                                            self.inventory_item_category(&s.template_id)
1832                                                .map(str::to_string)
1833                                        })
1834                                        .unwrap_or_default()
1835                                })
1836                                .collect::<Vec<_>>()
1837                        })
1838                        .unwrap_or_default(),
1839                };
1840                for category in raw {
1841                    let (label, ord) = inventory_category_group(&category);
1842                    seen.insert(ord, label);
1843                }
1844            }
1845            _ => {
1846                if let Some(panel) = &self.market_panel {
1847                    for listing in &panel.listings {
1848                        if !list_label_matches(&listing.display_name, &self.market_filter)
1849                            && !list_label_matches(&listing.seller_label, &self.market_filter)
1850                        {
1851                            continue;
1852                        }
1853                        let (label, ord) = inventory_category_group(&listing.category);
1854                        seen.insert(ord, label);
1855                    }
1856                }
1857            }
1858        }
1859        seen.into_values().collect()
1860    }
1861
1862    /// Indices into `market_panel.listings` after category + text filter.
1863    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1864        let Some(panel) = &self.market_panel else {
1865            return Vec::new();
1866        };
1867        let filter = self.market_filter.as_str();
1868        let cat_filter = self.market_category_filter;
1869        panel
1870            .listings
1871            .iter()
1872            .enumerate()
1873            .filter(|(_, listing)| {
1874                if !list_label_matches(&listing.display_name, filter)
1875                    && !list_label_matches(&listing.seller_label, filter)
1876                    && !list_label_matches(&listing.template_id, filter)
1877                {
1878                    return false;
1879                }
1880                if let Some(group) = cat_filter {
1881                    inventory_category_group(&listing.category).0 == group
1882                } else {
1883                    true
1884                }
1885            })
1886            .map(|(i, _)| i)
1887            .collect()
1888    }
1889
1890    pub fn clear_harvest_state(&mut self) {
1891        self.harvest_in_progress = false;
1892        self.harvest_started_at = None;
1893    }
1894
1895    fn harvest_state_stale(&self) -> bool {
1896        match self.harvest_started_at {
1897            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1898            None => self.harvest_in_progress,
1899        }
1900    }
1901
1902    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1903        self.player.as_ref().and_then(|p| p.vitals)
1904    }
1905
1906    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1907        let materials_ok = blueprint.inputs.iter().all(|input| {
1908            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1909        });
1910        let tools_ok = blueprint
1911            .required_tools
1912            .iter()
1913            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1914        let station_ok = match blueprint.station.as_deref() {
1915            None | Some("hand") => true,
1916            Some(tag) => self.player_at_station_tag(tag),
1917        };
1918        materials_ok && tools_ok && station_ok
1919    }
1920
1921    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1922        if !self.can_craft_blueprint(blueprint) {
1923            return 0;
1924        }
1925        let mut limit = u32::MAX;
1926        for input in &blueprint.inputs {
1927            if input.quantity == 0 {
1928                continue;
1929            }
1930            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1931            limit = limit.min(have / input.quantity);
1932        }
1933        for tool in &blueprint.required_tools {
1934            if tool.consumed {
1935                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1936                limit = limit.min(have);
1937            }
1938        }
1939        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1940        if CRAFT_STAMINA_COST > 0.0 {
1941            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1942        }
1943        limit
1944    }
1945
1946    pub fn clamp_craft_batch_quantity(&mut self) {
1947        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1948            self.craft_batch_quantity = 1;
1949            return;
1950        };
1951        let max = self.max_craft_batches(bp).max(1);
1952        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1953    }
1954
1955    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1956        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1957            return;
1958        };
1959        let max = self.max_craft_batches(&bp).max(1);
1960        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1961        self.craft_batch_quantity = next as u32;
1962    }
1963
1964    pub fn craft_batch_set_max(&mut self) {
1965        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1966            return;
1967        };
1968        let max = self.max_craft_batches(&bp);
1969        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1970    }
1971
1972    pub fn craft_batch_set_min(&mut self) {
1973        self.craft_batch_quantity = 1;
1974    }
1975
1976    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1977        let preserve_ui = self.show_shop_menu;
1978        let tab = self.shop_tab;
1979        let index = self.shop_menu_index;
1980        let qty = self.shop_quantity;
1981
1982        self.show_shop_menu = true;
1983        self.bank_panel = None;
1984        self.show_craft_menu = false;
1985        self.show_inventory_menu = false;
1986        self.show_stats = false;
1987        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1988            self.npc_verb_target = Some(catalog.npc_id.clone());
1989        }
1990        self.shop_catalog = Some(catalog);
1991
1992        if preserve_ui {
1993            self.shop_tab = tab;
1994            self.shop_menu_index = index;
1995            self.shop_quantity = qty;
1996        } else {
1997            self.shop_tab = ShopTab::Buy;
1998            self.shop_menu_index = 0;
1999            self.shop_quantity = 1;
2000            self.clear_shop_trade_log();
2001        }
2002        self.show_npc_verb_menu = false;
2003        self.clamp_shop_selection();
2004    }
2005
2006    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
2007        let same_teller = self
2008            .bank_panel
2009            .as_ref()
2010            .is_some_and(|p| p.npc_id == panel.npc_id);
2011        self.bank_panel = Some(panel);
2012        self.storage_panel = None;
2013        self.market_panel = None;
2014        self.shop_catalog = None;
2015        self.show_shop_menu = false;
2016        self.show_craft_menu = false;
2017        self.show_inventory_menu = false;
2018        self.show_stats = false;
2019        self.show_npc_verb_menu = false;
2020        self.show_npc_chat = false;
2021        self.npc_chat = None;
2022        if !same_teller {
2023            self.bank_menu_index = 0;
2024            self.bank_ui_mode = BankUiMode::Menu;
2025        }
2026        if let Some(panel) = &self.bank_panel {
2027            if self.npc_verb_target.is_none() {
2028                self.npc_verb_target = Some(panel.npc_id.clone());
2029            }
2030        }
2031    }
2032
2033    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
2034        let same_manager = self
2035            .storage_panel
2036            .as_ref()
2037            .is_some_and(|p| p.npc_id == panel.npc_id);
2038        self.storage_panel = Some(panel);
2039        self.bank_panel = None;
2040        self.market_panel = None;
2041        self.bank_ui_mode = BankUiMode::Menu;
2042        self.shop_catalog = None;
2043        self.show_shop_menu = false;
2044        self.show_craft_menu = false;
2045        self.show_inventory_menu = false;
2046        self.show_stats = false;
2047        self.show_npc_verb_menu = false;
2048        self.show_npc_chat = false;
2049        self.npc_chat = None;
2050        if !same_manager {
2051            self.storage_menu_index = 0;
2052            self.storage_ui_mode = StorageUiMode::Menu;
2053        } else {
2054            self.clamp_storage_pick_index();
2055        }
2056        if let Some(panel) = &self.storage_panel {
2057            if self.npc_verb_target.is_none() {
2058                self.npc_verb_target = Some(panel.npc_id.clone());
2059            }
2060        }
2061    }
2062
2063    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2064        self.market_panel = Some(panel);
2065        self.bank_panel = None;
2066        self.storage_panel = None;
2067        self.shop_catalog = None;
2068        self.show_shop_menu = false;
2069        self.show_craft_menu = false;
2070        self.show_inventory_menu = false;
2071        self.show_stats = false;
2072        self.show_npc_verb_menu = false;
2073        self.show_npc_chat = false;
2074        self.npc_chat = None;
2075        self.market_menu_index = 0;
2076        self.market_buy_confirm = None;
2077        self.market_ui_mode = MarketUiMode::Browse;
2078        self.market_filter.clear();
2079        self.market_filter_focused = false;
2080        self.market_category_filter = None;
2081        if let Some(panel) = &self.market_panel {
2082            if self.npc_verb_target.is_none() {
2083                self.npc_verb_target = Some(panel.npc_id.clone());
2084            }
2085        }
2086    }
2087
2088    pub fn clear_market_panel(&mut self) {
2089        self.market_panel = None;
2090        self.market_menu_index = 0;
2091        self.market_buy_confirm = None;
2092        self.market_ui_mode = MarketUiMode::Browse;
2093        self.market_filter.clear();
2094        self.market_filter_focused = false;
2095        self.market_category_filter = None;
2096    }
2097
2098    pub fn clear_bank_panel(&mut self) {
2099        self.bank_panel = None;
2100        self.bank_menu_index = 0;
2101        self.bank_ui_mode = BankUiMode::Menu;
2102    }
2103
2104    pub fn clear_storage_panel(&mut self) {
2105        self.storage_panel = None;
2106        self.storage_menu_index = 0;
2107        self.storage_ui_mode = StorageUiMode::Menu;
2108    }
2109
2110    fn clamp_storage_pick_index(&mut self) {
2111        match &self.storage_ui_mode {
2112            StorageUiMode::StorePick { index } => {
2113                let n = self.storage_store_options().len();
2114                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2115                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2116            }
2117            StorageUiMode::TakePick { index } => {
2118                let n = self.storage_vault_options().len();
2119                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2120                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2121            }
2122            StorageUiMode::ShipPick {
2123                dest_building_id,
2124                dest_label,
2125                index,
2126            } => {
2127                let n = self.storage_vault_options().len();
2128                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2129                self.storage_ui_mode = StorageUiMode::ShipPick {
2130                    dest_building_id: dest_building_id.clone(),
2131                    dest_label: dest_label.clone(),
2132                    index: next,
2133                };
2134            }
2135            StorageUiMode::Menu
2136            | StorageUiMode::StoreAmount { .. }
2137            | StorageUiMode::TakeAmount { .. }
2138            | StorageUiMode::ShipAmount { .. } => {}
2139        }
2140    }
2141
2142    pub fn shop_list_len(&self) -> usize {
2143        let Some(catalog) = &self.shop_catalog else {
2144            return 0;
2145        };
2146        match self.shop_tab {
2147            ShopTab::Buy => catalog.sells.len(),
2148            ShopTab::Sell => catalog.buys.len(),
2149        }
2150    }
2151
2152    pub fn shop_menu_move(&mut self, delta: i32) {
2153        let n = self.shop_list_len();
2154        if n == 0 {
2155            return;
2156        }
2157        let idx = self.shop_menu_index as i32;
2158        let next = (idx + delta).rem_euclid(n as i32);
2159        self.shop_menu_index = next as usize;
2160        self.clamp_shop_quantity();
2161    }
2162
2163    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2164        let max = self.shop_quantity_max();
2165        if max == 0 {
2166            self.shop_quantity = 0;
2167            return;
2168        }
2169        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2170        self.shop_quantity = next as u32;
2171    }
2172
2173    pub(crate) fn clamp_shop_selection(&mut self) {
2174        let n = self.shop_list_len();
2175        if n == 0 {
2176            self.shop_menu_index = 0;
2177        } else {
2178            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2179        }
2180        self.clamp_shop_quantity();
2181    }
2182
2183    fn shop_quantity_max(&self) -> u32 {
2184        let Some(catalog) = &self.shop_catalog else {
2185            return 1;
2186        };
2187        match self.shop_tab {
2188            ShopTab::Buy => {
2189                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2190                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2191                        return 1;
2192                    }
2193                }
2194                99
2195            }
2196            ShopTab::Sell => catalog
2197                .buys
2198                .get(self.shop_menu_index)
2199                .map(|l| l.quantity)
2200                .unwrap_or(0),
2201        }
2202    }
2203
2204    pub fn shop_quantity_set_max(&mut self) {
2205        self.shop_quantity = self.shop_quantity_max();
2206    }
2207
2208    pub fn shop_quantity_set_min(&mut self) {
2209        let max = self.shop_quantity_max();
2210        self.shop_quantity = if max == 0 { 0 } else { 1 };
2211    }
2212
2213    fn clamp_shop_quantity(&mut self) {
2214        let max = self.shop_quantity_max();
2215        if max == 0 {
2216            self.shop_quantity = 0;
2217        } else {
2218            self.shop_quantity = self.shop_quantity.max(1).min(max);
2219        }
2220    }
2221
2222    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2223        let Some(id) = self.effective_inside_building() else {
2224            return false;
2225        };
2226        self.buildings
2227            .iter()
2228            .find(|b| b.id == id)
2229            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2230    }
2231
2232    /// Short hint for UI when a recipe cannot be started.
2233    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2234        if self.can_craft_blueprint(blueprint) {
2235            return None;
2236        }
2237        let mut missing = Vec::new();
2238        for input in &blueprint.inputs {
2239            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2240            if have < input.quantity {
2241                let name = self.blueprint_ingredient_label(input);
2242                missing.push(format!("{}×{} (have {have})", input.quantity, name));
2243            }
2244        }
2245        for tool in &blueprint.required_tools {
2246            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2247            if have < 1 {
2248                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2249            }
2250        }
2251        if let Some(station) = blueprint.station.as_deref() {
2252            if station != "hand" && !self.player_at_station_tag(station) {
2253                missing.push(format!("station: {station} (enter building)"));
2254            }
2255        }
2256        if missing.is_empty() {
2257            None
2258        } else {
2259            Some(missing.join(", "))
2260        }
2261    }
2262
2263    pub fn player_entity(&self) -> Option<&EntityState> {
2264        self.player
2265            .as_ref()
2266            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2267    }
2268
2269    /// Apply persisted HUD / workers UI prefs from `client.json`.
2270    pub fn apply_client_ui_prefs(&mut self) {
2271        let cfg = crate::client_config::ClientConfig::load();
2272        if let Some(hidden) = cfg.hud_log_hidden {
2273            self.hud_log_hidden = hidden;
2274        }
2275        if let Some(compact) = cfg.workers_menu_compact {
2276            self.workers_menu_compact = compact;
2277        }
2278    }
2279
2280    pub fn player_position(&self) -> (f32, f32) {
2281        let (x, y, _) = self.player_position_with_z();
2282        (x, y)
2283    }
2284
2285    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2286        if let Some(p) = self.player_entity() {
2287            (
2288                p.transform.position.x,
2289                p.transform.position.y,
2290                p.transform.position.z,
2291            )
2292        } else {
2293            (0.0, 0.0, 0.0)
2294        }
2295    }
2296
2297    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2298        let mut rows: Vec<(String, u32, String)> = self
2299            .inventory
2300            .iter()
2301            .filter(|(_, q)| **q > 0)
2302            .map(|(id, qty)| {
2303                let label = self
2304                    .inventory_hints
2305                    .get(id)
2306                    .map(|h| h.display_name.clone())
2307                    .unwrap_or_else(|| id.clone());
2308                (id.clone(), *qty, label)
2309            })
2310            .collect();
2311        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2312        rows
2313    }
2314
2315    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2316        self.inventory_hints
2317            .get(template_id)
2318            .map(|h| h.category.as_str())
2319            .filter(|c| !c.is_empty())
2320    }
2321
2322    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2323        stack
2324            .props
2325            .get("grants_item_status_effect")
2326            .map(|s| !s.is_empty())
2327            .unwrap_or(false)
2328    }
2329
2330    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2331        stack
2332            .props
2333            .get("grants_item_status_effect")
2334            .map(String::as_str)
2335            .filter(|s| !s.is_empty())
2336    }
2337
2338    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2339        stack
2340            .props
2341            .get("grants_item_status_mode")
2342            .map(String::as_str)
2343            .unwrap_or("on_hit")
2344    }
2345
2346    /// Candidate gear for a grant consumable (inventory + worn).
2347    pub fn grant_target_options(
2348        &self,
2349        grant: &flatland_protocol::ItemStack,
2350    ) -> Vec<GrantTargetOption> {
2351        let mode = Self::grant_mode(grant);
2352        let grant_tags: Vec<&str> = grant
2353            .props
2354            .get("grants_item_status_tags")
2355            .map(|s| {
2356                s.split(',')
2357                    .map(str::trim)
2358                    .filter(|t| !t.is_empty())
2359                    .collect()
2360            })
2361            .unwrap_or_default();
2362        let grant_id = grant.item_instance_id;
2363        let mut out = Vec::new();
2364        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2365            let Some(iid) = stack.item_instance_id else {
2366                return;
2367            };
2368            if Some(iid) == grant_id {
2369                return;
2370            }
2371            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2372                return;
2373            }
2374            if !grant_target_matches_mode(stack, mode) {
2375                return;
2376            }
2377            if !grant_tags_match(stack, &grant_tags) {
2378                return;
2379            }
2380            let name = stack
2381                .display_name
2382                .clone()
2383                .unwrap_or_else(|| stack.template_id.clone());
2384            let bindings = if stack.status_bindings.is_empty() {
2385                String::new()
2386            } else {
2387                format!(
2388                    " · {}",
2389                    stack
2390                        .status_bindings
2391                        .iter()
2392                        .map(|b| b.effect_id.as_str())
2393                        .collect::<Vec<_>>()
2394                        .join(", ")
2395                )
2396            };
2397            out.push(GrantTargetOption {
2398                label: format!("{where_label}: {name}{bindings}"),
2399                target_instance_id: iid,
2400            });
2401        };
2402        fn walk(
2403            stacks: &[flatland_protocol::ItemStack],
2404            where_label: &str,
2405            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2406        ) {
2407            for s in stacks {
2408                push(s, where_label);
2409                if !s.contents.is_empty() {
2410                    let nested = format!(
2411                        "{where_label}/{}",
2412                        s.display_name
2413                            .as_deref()
2414                            .unwrap_or(s.template_id.as_str())
2415                    );
2416                    walk(&s.contents, &nested, push);
2417                }
2418            }
2419        }
2420        walk(&self.inventory_stacks, "Bag", &mut push);
2421        for (slot, stack) in &self.worn {
2422            push(stack, body_slot_label(*slot));
2423            let nest = format!(
2424                "{}/{}",
2425                body_slot_label(*slot),
2426                stack
2427                    .display_name
2428                    .as_deref()
2429                    .unwrap_or(stack.template_id.as_str())
2430            );
2431            walk(&stack.contents, &nest, &mut push);
2432        }
2433        out
2434    }
2435
2436    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2437        self.inventory_hints
2438            .get(template_id)
2439            .and_then(|h| h.base_mass)
2440            .unwrap_or(0.5)
2441    }
2442
2443    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2444        self.inventory_hints
2445            .get(template_id)
2446            .and_then(|h| h.base_volume)
2447            .unwrap_or(1.0)
2448    }
2449
2450    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2451        let unit = stack
2452            .base_mass
2453            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2454        unit * stack.quantity as f32
2455    }
2456
2457    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2458        let unit = stack.base_volume.unwrap_or(1.0);
2459        unit * stack.quantity as f32
2460            + stack
2461                .contents
2462                .iter()
2463                .map(Self::stack_tree_volume)
2464                .sum::<f32>()
2465    }
2466
2467    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2468        contents.iter().map(Self::stack_tree_volume).sum()
2469    }
2470
2471    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2472        self.inventory_hints
2473            .get(template_id)
2474            .and_then(|h| h.capacity_volume)
2475            .filter(|c| *c > 0.0)
2476    }
2477
2478    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2479        stack
2480            .capacity_volume
2481            .filter(|c| *c > 0.0)
2482            .or_else(|| self.template_capacity_volume(&stack.template_id))
2483    }
2484
2485    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2486    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2487        let Some((used, cap)) = self.container_volume_stats(row) else {
2488            return String::new();
2489        };
2490        let free = (cap - used).max(0.0);
2491        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2492    }
2493
2494    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2495        if row.is_chest_shell {
2496            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2497                return None;
2498            };
2499            let chest = self
2500                .placed_containers
2501                .iter()
2502                .find(|c| c.id == *container_id)?;
2503            let cap = self
2504                .stack_capacity_volume(&row.stack)
2505                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2506            let used = if chest.accessible {
2507                Self::contents_used_volume(&chest.contents)
2508            } else {
2509                0.0
2510            };
2511            return Some((used, cap));
2512        }
2513
2514        let cap = self.stack_capacity_volume(&row.stack)?;
2515        let used = Self::contents_used_volume(&row.stack.contents);
2516        Some((used, cap))
2517    }
2518
2519    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2520        if row.is_chest_shell {
2521            return true;
2522        }
2523        if row.is_equip_shell {
2524            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2525        }
2526        self.inventory_item_category(&row.stack.template_id) == Some("container")
2527            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2528    }
2529
2530    fn container_stack_for(
2531        &self,
2532        location: &flatland_protocol::InventoryLocation,
2533        parent_instance_id: Option<uuid::Uuid>,
2534    ) -> Option<flatland_protocol::ItemStack> {
2535        match location {
2536            flatland_protocol::InventoryLocation::Root => {
2537                let pid = parent_instance_id?;
2538                self.find_stack_by_instance(&self.inventory_stacks, pid)
2539            }
2540            flatland_protocol::InventoryLocation::Worn { slot } => {
2541                let worn = self.worn.get(slot)?;
2542                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2543                    Some(worn.clone())
2544                } else {
2545                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2546                }
2547            }
2548            flatland_protocol::InventoryLocation::Placed { container_id } => {
2549                let chest = self
2550                    .placed_containers
2551                    .iter()
2552                    .find(|c| c.id == *container_id)?;
2553                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2554                    Some(flatland_protocol::ItemStack {
2555                        template_id: chest.template_id.clone(),
2556                        quantity: 1,
2557                        item_instance_id: chest.item_instance_id,
2558                        props: Default::default(),
2559                        status_bindings: Vec::new(),
2560                        contents: chest.contents.clone(),
2561                        display_name: Some(chest.display_name.clone()),
2562                        category: Some("container".into()),
2563                        capacity_volume: self
2564                            .inventory_hints
2565                            .get(&chest.template_id)
2566                            .and_then(|h| h.capacity_volume),
2567                        worker_lodging_capacity: chest.worker_lodging_capacity,
2568                        ..Default::default()
2569                    })
2570                } else {
2571                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2572                }
2573            }
2574            flatland_protocol::InventoryLocation::Keychain => None,
2575            flatland_protocol::InventoryLocation::WhisperPouch => None,
2576        }
2577    }
2578
2579    fn find_stack_by_instance(
2580        &self,
2581        stacks: &[flatland_protocol::ItemStack],
2582        instance_id: uuid::Uuid,
2583    ) -> Option<flatland_protocol::ItemStack> {
2584        for stack in stacks {
2585            if stack.item_instance_id == Some(instance_id) {
2586                return Some(stack.clone());
2587            }
2588            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2589                return Some(found);
2590            }
2591        }
2592        None
2593    }
2594
2595    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2596    pub fn max_movable_to(
2597        &self,
2598        template_id: &str,
2599        stack_qty: u32,
2600        from: &flatland_protocol::InventoryLocation,
2601        to: &flatland_protocol::InventoryLocation,
2602        parent_instance_id: Option<uuid::Uuid>,
2603    ) -> u32 {
2604        let unit_vol = self.item_base_volume(template_id);
2605        let unit_mass = self.item_base_mass(template_id);
2606        let mut limit = stack_qty;
2607
2608        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2609            let cap = parent
2610                .capacity_volume
2611                .or_else(|| {
2612                    self.inventory_hints
2613                        .get(&parent.template_id)
2614                        .and_then(|h| h.capacity_volume)
2615                })
2616                .unwrap_or(0.0);
2617            if cap > 0.0 && unit_vol > 0.0 {
2618                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2619                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2620            }
2621        }
2622
2623        let to_person = matches!(
2624            to,
2625            flatland_protocol::InventoryLocation::Root
2626                | flatland_protocol::InventoryLocation::Worn { .. }
2627        );
2628        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2629        if to_person && from_placed && unit_mass > 0.0 {
2630            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2631            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2632                limit = 0;
2633            } else {
2634                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2635            }
2636        }
2637
2638        limit.max(0).min(stack_qty)
2639    }
2640
2641    pub fn move_picker_max_at_selection(&self) -> u32 {
2642        let Some(picker) = &self.move_picker else {
2643            return 1;
2644        };
2645        let Some(opt) = picker.options.get(self.move_picker_index) else {
2646            return picker.stack_quantity;
2647        };
2648        match &opt.kind {
2649            MoveOptionKind::Cancel
2650            | MoveOptionKind::Drop
2651            | MoveOptionKind::Use
2652            | MoveOptionKind::GrantApply
2653            | MoveOptionKind::SellPlotToCrown { .. }
2654            | MoveOptionKind::PickupPlaced { .. }
2655            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2656            MoveOptionKind::Move {
2657                location,
2658                parent_instance_id,
2659            } => self.max_movable_to(
2660                &picker.template_id,
2661                picker.stack_quantity,
2662                &picker.from,
2663                location,
2664                *parent_instance_id,
2665            ),
2666        }
2667    }
2668
2669    pub fn clamp_move_picker_quantity(&mut self) {
2670        let max = self.move_picker_max_at_selection();
2671        if let Some(picker) = &mut self.move_picker {
2672            if max == 0 {
2673                picker.quantity = 1;
2674            } else {
2675                picker.quantity = picker.quantity.clamp(1, max);
2676            }
2677        }
2678    }
2679
2680    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2681        let max = self.move_picker_max_at_selection().max(1);
2682        if let Some(picker) = &mut self.move_picker {
2683            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2684            picker.quantity = next as u32;
2685        }
2686    }
2687
2688    pub fn move_picker_set_quantity_max(&mut self) {
2689        let max = self.move_picker_max_at_selection();
2690        if let Some(picker) = &mut self.move_picker {
2691            picker.quantity = if max == 0 {
2692                1
2693            } else {
2694                max.min(picker.stack_quantity)
2695            };
2696        }
2697    }
2698
2699    pub fn move_picker_set_quantity_min(&mut self) {
2700        if let Some(picker) = &mut self.move_picker {
2701            picker.quantity = 1;
2702        }
2703    }
2704
2705    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2706        if let Some(picker) = &mut self.destroy_picker {
2707            let max = picker.stack_quantity.max(1);
2708            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2709            picker.quantity = next as u32;
2710        }
2711    }
2712
2713    pub fn destroy_picker_set_quantity_max(&mut self) {
2714        if let Some(picker) = &mut self.destroy_picker {
2715            picker.quantity = picker.stack_quantity.max(1);
2716        }
2717    }
2718
2719    pub fn destroy_picker_set_quantity_min(&mut self) {
2720        if let Some(picker) = &mut self.destroy_picker {
2721            picker.quantity = 1;
2722        }
2723    }
2724
2725    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2726        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2727        (have, have >= need)
2728    }
2729
2730    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
2731    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2732        let have = self
2733            .plot_build_offer
2734            .as_ref()
2735            .and_then(|o| {
2736                o.available
2737                    .iter()
2738                    .find(|s| s.template_id == template_id)
2739                    .map(|s| s.quantity)
2740            })
2741            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
2742        (have, have >= need)
2743    }
2744
2745    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2746        self.building_materials
2747            .iter()
2748            .filter(|m| m.can_wall)
2749            .collect()
2750    }
2751
2752    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2753        self.building_materials
2754            .iter()
2755            .filter(|m| m.can_roof)
2756            .collect()
2757    }
2758
2759    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2760        self.plot_build_wall_options()
2761            .get(self.plot_build_wall_index)
2762            .copied()
2763    }
2764
2765    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2766        self.plot_build_roof_options()
2767            .get(self.plot_build_roof_index)
2768            .copied()
2769    }
2770
2771    /// BOM lines for the current wall/roof selection and tilled pad area.
2772    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
2773        let Some(wall) = self.plot_build_selected_wall() else {
2774            return Vec::new();
2775        };
2776        let Some(roof) = self.plot_build_selected_roof() else {
2777            return Vec::new();
2778        };
2779        let area = self
2780            .plot_build_offer
2781            .as_ref()
2782            .filter(|o| o.pad_ok)
2783            .map(|o| o.pad_width_m * o.pad_depth_m)
2784            .unwrap_or(0.0);
2785        if area <= 0.0 {
2786            return Vec::new();
2787        }
2788        let mut map: std::collections::HashMap<String, (String, u32)> =
2789            std::collections::HashMap::new();
2790        for line in &wall.wall_bom {
2791            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2792            if qty == 0 {
2793                continue;
2794            }
2795            let name = if line.display_name.is_empty() {
2796                line.template_id.clone()
2797            } else {
2798                line.display_name.clone()
2799            };
2800            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2801            entry.1 = entry.1.saturating_add(qty);
2802        }
2803        for line in &roof.roof_bom {
2804            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2805            if qty == 0 {
2806                continue;
2807            }
2808            let name = if line.display_name.is_empty() {
2809                line.template_id.clone()
2810            } else {
2811                line.display_name.clone()
2812            };
2813            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2814            entry.1 = entry.1.saturating_add(qty);
2815        }
2816        let mut out: Vec<_> = map
2817            .into_iter()
2818            .map(|(id, (name, qty))| (id, name, qty))
2819            .collect();
2820        out.sort_by(|a, b| a.0.cmp(&b.0));
2821        out
2822    }
2823
2824    pub fn plot_build_duration_secs(&self) -> Option<f32> {
2825        let wall = self.plot_build_selected_wall()?;
2826        let roof = self.plot_build_selected_roof()?;
2827        let offer = self.plot_build_offer.as_ref()?;
2828        if !offer.pad_ok {
2829            return None;
2830        }
2831        let area = offer.pad_width_m * offer.pad_depth_m;
2832        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
2833        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
2834        Some(ticks.max(2.0) / 30.0)
2835    }
2836
2837    pub fn plot_build_can_afford(&self) -> bool {
2838        if self
2839            .plot_build_offer
2840            .as_ref()
2841            .is_none_or(|o| !o.pad_ok)
2842        {
2843            return false;
2844        }
2845        self.plot_build_bom_lines()
2846            .iter()
2847            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
2848    }
2849
2850    pub fn currency_display(&self) -> String {
2851        crate::currency::currency_line(&self.inventory)
2852    }
2853
2854    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2855    pub fn in_shallow_water(&self) -> bool {
2856        let (px, py) = self.player_position();
2857        self.terrain_at(px, py)
2858            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2859    }
2860
2861    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2862        self.terrain_zone_at(x, y).map(|z| z.kind)
2863    }
2864
2865    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2866    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2867        use std::cell::RefCell;
2868
2869        const CHUNK: i32 = 8;
2870        thread_local! {
2871            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2872                RefCell::new(None);
2873        }
2874
2875        let zones = &self.terrain_zones;
2876        if zones.is_empty() {
2877            return None;
2878        }
2879        if zones.len() <= 48 {
2880            return zones
2881                .iter()
2882                .enumerate()
2883                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2884                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2885                .map(|(_, z)| z);
2886        }
2887
2888        let ptr = zones.as_ptr();
2889        let len = zones.len();
2890        INDEX.with(|cell| {
2891            let mut slot = cell.borrow_mut();
2892            let stale = match slot.as_ref() {
2893                Some((p, l, _)) => *p != ptr || *l != len,
2894                None => true,
2895            };
2896            if stale {
2897                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2898                    std::collections::HashMap::new();
2899                for (zi, z) in zones.iter().enumerate() {
2900                    let x0 = z.x0.min(z.x1).floor() as i32;
2901                    let y0 = z.y0.min(z.y1).floor() as i32;
2902                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2903                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2904                    let cx0 = x0.div_euclid(CHUNK);
2905                    let cy0 = y0.div_euclid(CHUNK);
2906                    let cx1 = x1.div_euclid(CHUNK);
2907                    let cy1 = y1.div_euclid(CHUNK);
2908                    for cy in cy0..=cy1 {
2909                        for cx in cx0..=cx1 {
2910                            chunks.entry((cx, cy)).or_default().push(zi);
2911                        }
2912                    }
2913                }
2914                *slot = Some((ptr, len, chunks));
2915            }
2916            let chunks = &slot.as_ref().expect("index").2;
2917            let cx = (x.floor() as i32).div_euclid(CHUNK);
2918            let cy = (y.floor() as i32).div_euclid(CHUNK);
2919            let mut best: Option<(usize, &TerrainZoneView)> = None;
2920            if let Some(list) = chunks.get(&(cx, cy)) {
2921                for &zi in list {
2922                    let Some(z) = zones.get(zi) else { continue };
2923                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2924                        continue;
2925                    }
2926                    best = match best {
2927                        None => Some((zi, z)),
2928                        Some((bi, bz)) => {
2929                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2930                                Some((zi, z))
2931                            } else {
2932                                Some((bi, bz))
2933                            }
2934                        }
2935                    };
2936                }
2937            }
2938            best.map(|(_, z)| z)
2939        })
2940    }
2941
2942    /// Ground elevation from terrain zones (m).
2943    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2944        self.terrain_zone_at(x, y)
2945            .map(|z| z.elevation)
2946            .unwrap_or(0.0)
2947    }
2948
2949    /// Walkable z levels at a map column (terrain + platforms).
2950    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2951        const TOL: f32 = 0.35;
2952        let mut levels = vec![self.elevation_at(x, y)];
2953        for p in &self.z_platforms {
2954            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2955                levels.push(p.z);
2956            }
2957        }
2958        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2959        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2960        levels
2961    }
2962
2963    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2964        const TOL: f32 = 0.35;
2965        self.walkable_levels_at(x, y)
2966            .iter()
2967            .any(|&l| (l - z).abs() <= TOL)
2968    }
2969
2970    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2971        let mut top = self.elevation_at(x, y);
2972        for p in &self.z_platforms {
2973            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2974                top = top.max(p.z);
2975            }
2976        }
2977        top
2978    }
2979
2980    /// Authoritative interior context from the server (`inside_building` flag).
2981    pub fn effective_inside_building(&self) -> Option<String> {
2982        self.player_entity().and_then(|p| p.inside_building.clone())
2983    }
2984
2985    /// True when a placed chest shares the player's outdoor/interior space.
2986    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
2987    /// labels); they must not be drawn or treated as nearby world props.
2988    pub fn placed_container_in_current_space(
2989        &self,
2990        c: &flatland_protocol::PlacedContainerView,
2991    ) -> bool {
2992        match (
2993            self.effective_inside_building().as_deref(),
2994            c.building_id.as_deref(),
2995        ) {
2996            (None, None) => true,
2997            (Some(a), Some(b)) => a == b,
2998            _ => false,
2999        }
3000    }
3001
3002    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3003        self.inventory_stacks = stacks.to_vec();
3004        self.inventory.clear();
3005        self.inventory_hints.clear();
3006        fn walk(
3007            stacks: &[flatland_protocol::ItemStack],
3008            inventory: &mut std::collections::HashMap<String, u32>,
3009            hints: &mut std::collections::HashMap<String, InventoryHint>,
3010        ) {
3011            for stack in stacks {
3012                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
3013                if stack.display_name.is_some()
3014                    || stack.category.is_some()
3015                    || stack.base_mass.is_some()
3016                    || stack.base_volume.is_some()
3017                {
3018                    hints.insert(
3019                        stack.template_id.clone(),
3020                        InventoryHint {
3021                            display_name: stack
3022                                .display_name
3023                                .clone()
3024                                .unwrap_or_else(|| stack.template_id.clone()),
3025                            category: stack.category.clone().unwrap_or_default(),
3026                            base_mass: stack.base_mass,
3027                            base_volume: stack.base_volume,
3028                            capacity_volume: stack.capacity_volume,
3029                            stackable: stack.stackable.unwrap_or(true),
3030                            listable: stack.listable.unwrap_or_else(|| {
3031                                category_default_listable(
3032                                    stack.category.as_deref().unwrap_or(""),
3033                                )
3034                            }),
3035                        },
3036                    );
3037                }
3038                walk(&stack.contents, inventory, hints);
3039            }
3040        }
3041        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
3042        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
3043        for item in self.worn.values() {
3044            walk(
3045                std::slice::from_ref(item),
3046                &mut self.inventory,
3047                &mut self.inventory_hints,
3048            );
3049        }
3050    }
3051
3052    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
3053    /// inventory UI updates before the next tick snapshot arrives.
3054    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3055        let subtract_items =
3056            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3057        for stack in &notice.inventory_delta {
3058            if stack.quantity == 0 {
3059                continue;
3060            }
3061            if subtract_items {
3062                crate::currency::drain_template_stacks(
3063                    &mut self.inventory_stacks,
3064                    &stack.template_id,
3065                    stack.quantity,
3066                );
3067                continue;
3068            }
3069            let stackable = self
3070                .inventory_hints
3071                .get(&stack.template_id)
3072                .map(|h| h.stackable)
3073                .or(stack.stackable)
3074                .unwrap_or(true);
3075            if stackable {
3076                if let Some(existing) = self
3077                    .inventory_stacks
3078                    .iter_mut()
3079                    .find(|s| s.template_id == stack.template_id)
3080                {
3081                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
3082                    if stack.display_name.is_some() {
3083                        existing.display_name = stack.display_name.clone();
3084                    }
3085                    if stack.category.is_some() {
3086                        existing.category = stack.category.clone();
3087                    }
3088                    continue;
3089                }
3090            }
3091            self.inventory_stacks.push(stack.clone());
3092        }
3093        if notice.coins_delta != 0 {
3094            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3095        }
3096        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3097            let stacks = self.inventory_stacks.clone();
3098            self.sync_inventory_from_stacks(&stacks);
3099        }
3100        self.record_shop_trade_notice(notice);
3101    }
3102
3103    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
3104    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
3105    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
3106    /// iteration alone gives a stable row order.
3107    pub fn worn_rows(&self) -> Vec<InventoryRow> {
3108        let mut rows = Vec::new();
3109        for (slot, item) in &self.worn {
3110            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3111            rows.push(InventoryRow {
3112                depth: 0,
3113                stack: item.clone(),
3114                from: from.clone(),
3115                from_parent_instance_id: None,
3116                is_equip_shell: true,
3117                is_chest_shell: false,
3118                section: InventorySection::Worn,
3119            });
3120            for child in &item.contents {
3121                push_inventory_rows(
3122                    &mut rows,
3123                    1,
3124                    child,
3125                    &from,
3126                    item.item_instance_id,
3127                    InventorySection::Worn,
3128                );
3129            }
3130        }
3131        rows
3132    }
3133
3134    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
3135    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3136        let equipped = self.hand_equipped_instance_ids();
3137        self.inventory_stacks
3138            .iter()
3139            .filter(|s| {
3140                s.item_instance_id
3141                    .is_some_and(|id| !equipped.contains(&id))
3142            })
3143            .collect()
3144    }
3145
3146    /// Root on-person stacks that can be handed to a hired worker.
3147    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3148        let equipped = self.hand_equipped_instance_ids();
3149        self.inventory_stacks
3150            .iter()
3151            .filter_map(|stack| {
3152                let item_instance_id = stack.item_instance_id?;
3153                if equipped.contains(&item_instance_id) {
3154                    return None;
3155                }
3156                let label = stack
3157                    .display_name
3158                    .clone()
3159                    .unwrap_or_else(|| stack.template_id.clone());
3160                let label = if stack.quantity > 1 {
3161                    format!("{label} ×{}", stack.quantity)
3162                } else {
3163                    label
3164                };
3165                Some(WorkerGiveOption {
3166                    item_instance_id,
3167                    label,
3168                    quantity: stack.quantity,
3169                    template_id: stack.template_id.clone(),
3170                })
3171            })
3172            .collect()
3173    }
3174
3175    /// Employer-known blueprints the selected worker does not yet know.
3176    pub fn teachable_blueprint_options(
3177        &self,
3178        worker: &flatland_protocol::HiredWorkerView,
3179    ) -> Vec<WorkerTeachOption> {
3180        let copper = crate::currency::copper_from_counts(&self.inventory);
3181        let mut options: Vec<WorkerTeachOption> = self
3182            .blueprints
3183            .iter()
3184            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3185            .map(|bp| {
3186                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3187                let cost = bp.worker_train_copper;
3188                WorkerTeachOption {
3189                    blueprint_id: bp.id.clone(),
3190                    label: if bp.label.is_empty() {
3191                        bp.id.clone()
3192                    } else {
3193                        bp.label.clone()
3194                    },
3195                    cost_copper: cost,
3196                    min_level,
3197                    worker_level: worker.level,
3198                    can_afford: copper >= cost,
3199                    level_ok: worker.level >= min_level,
3200                }
3201            })
3202            .collect();
3203        options.sort_by(|a, b| a.label.cmp(&b.label));
3204        options
3205    }
3206
3207    /// Loose on-person inventory (not worn, not inside a placed chest).
3208    /// Root stacks are ordered by category group; nested contents stay under their parent.
3209    pub fn person_rows(&self) -> Vec<InventoryRow> {
3210        self.person_rows_filtered("")
3211    }
3212
3213    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3214        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3215        roots.sort_by(|a, b| {
3216            let ca = a
3217                .category
3218                .as_deref()
3219                .or_else(|| self.inventory_item_category(&a.template_id))
3220                .unwrap_or("");
3221            let cb = b
3222                .category
3223                .as_deref()
3224                .or_else(|| self.inventory_item_category(&b.template_id))
3225                .unwrap_or("");
3226            let ga = inventory_category_group(ca).1;
3227            let gb = inventory_category_group(cb).1;
3228            ga.cmp(&gb).then_with(|| {
3229                let na = a
3230                    .display_name
3231                    .as_deref()
3232                    .unwrap_or(a.template_id.as_str());
3233                let nb = b
3234                    .display_name
3235                    .as_deref()
3236                    .unwrap_or(b.template_id.as_str());
3237                na.cmp(nb)
3238            })
3239        });
3240        let mut rows = Vec::new();
3241        for stack in roots {
3242            push_inventory_rows_filtered(
3243                &mut rows,
3244                0,
3245                stack,
3246                &flatland_protocol::InventoryLocation::Root,
3247                None,
3248                InventorySection::Person,
3249                filter,
3250            );
3251        }
3252        rows
3253    }
3254
3255    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3256        if filter.is_empty() {
3257            return self.worn_rows();
3258        }
3259        let mut rows = Vec::new();
3260        for (slot, item) in &self.worn {
3261            if !stack_matches_filter(item, filter) {
3262                continue;
3263            }
3264            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3265            let self_hit = {
3266                let f = filter.to_ascii_lowercase();
3267                let name = item
3268                    .display_name
3269                    .as_deref()
3270                    .unwrap_or("")
3271                    .to_ascii_lowercase();
3272                let tid = item.template_id.to_ascii_lowercase();
3273                name.contains(&f) || tid.contains(&f)
3274            };
3275            rows.push(InventoryRow {
3276                depth: 0,
3277                stack: item.clone(),
3278                from: from.clone(),
3279                from_parent_instance_id: None,
3280                is_equip_shell: true,
3281                is_chest_shell: false,
3282                section: InventorySection::Worn,
3283            });
3284            for child in &item.contents {
3285                if self_hit || stack_matches_filter(child, filter) {
3286                    push_inventory_rows_filtered(
3287                        &mut rows,
3288                        1,
3289                        child,
3290                        &from,
3291                        item.item_instance_id,
3292                        InventorySection::Worn,
3293                        if self_hit { "" } else { filter },
3294                    );
3295                }
3296            }
3297        }
3298        rows
3299    }
3300
3301    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3302    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3303        let mut rows = self.worn_rows();
3304        rows.extend(self.person_rows());
3305        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3306    }
3307
3308    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3309    /// populated when `accessible` — this is what makes a chest's contents
3310    /// disappear the moment you walk away or it's locked without your key.
3311    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3312        let (px, py) = self.player_position();
3313        let mut list: Vec<NearbyContainer> = self
3314            .placed_containers
3315            .iter()
3316            .filter(|c| self.placed_container_in_current_space(c))
3317            .filter_map(|c| {
3318                let distance_m = (c.x - px).hypot(c.y - py);
3319                if distance_m > CONTAINER_RANGE_M {
3320                    return None;
3321                }
3322                let mut rows = Vec::new();
3323                let from = flatland_protocol::InventoryLocation::Placed {
3324                    container_id: c.id.clone(),
3325                };
3326                rows.push(InventoryRow {
3327                    depth: 0,
3328                    stack: flatland_protocol::ItemStack {
3329                        template_id: c.template_id.clone(),
3330                        quantity: 1,
3331                        item_instance_id: c.item_instance_id,
3332                        props: Default::default(),
3333                        status_bindings: Vec::new(),
3334                        contents: Vec::new(),
3335                        display_name: Some(c.display_name.clone()),
3336                        category: Some("container".into()),
3337                        capacity_volume: c.capacity_volume,
3338                        worker_lodging_capacity: c.worker_lodging_capacity,
3339                        ..Default::default()
3340                    },
3341                    from: from.clone(),
3342                    from_parent_instance_id: None,
3343                    is_equip_shell: false,
3344                    is_chest_shell: true,
3345                    section: InventorySection::Nearby,
3346                });
3347                if c.accessible {
3348                    for child in &c.contents {
3349                        push_inventory_rows(
3350                            &mut rows,
3351                            1,
3352                            child,
3353                            &from,
3354                            c.item_instance_id,
3355                            InventorySection::Nearby,
3356                        );
3357                    }
3358                }
3359                Some(NearbyContainer {
3360                    view: c.clone(),
3361                    distance_m,
3362                    rows,
3363                })
3364            })
3365            .collect();
3366        list.sort_by(|a, b| {
3367            a.distance_m
3368                .partial_cmp(&b.distance_m)
3369                .unwrap_or(std::cmp::Ordering::Equal)
3370        });
3371        list
3372    }
3373
3374    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3375    pub fn nearest_placed_container(
3376        &self,
3377        max_dist: f32,
3378    ) -> Option<flatland_protocol::PlacedContainerView> {
3379        let (px, py) = self.player_position();
3380        self.placed_containers
3381            .iter()
3382            .filter(|c| self.placed_container_in_current_space(c))
3383            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3384            .min_by(|a, b| {
3385                let da = (a.x - px).hypot(a.y - py);
3386                let db = (b.x - px).hypot(b.y - py);
3387                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3388            })
3389            .cloned()
3390    }
3391
3392    /// Selectable rows for the **active inventory tab** (and current filter).
3393    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3394    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3395        let filter = self.inventory_filter.as_str();
3396        match self.inventory_tab {
3397            InventoryTab::OnPerson => {
3398                let mut rows = self.worn_rows_filtered(filter);
3399                rows.extend(self.person_rows_filtered(filter));
3400                rows
3401            }
3402            InventoryTab::Nearby => {
3403                let mut rows = Vec::new();
3404                for nc in self.nearby_containers() {
3405                    if filter.is_empty() {
3406                        rows.extend(nc.rows);
3407                        continue;
3408                    }
3409                    let shell = nc.rows.first().cloned();
3410                    let contents: Vec<_> = nc
3411                        .rows
3412                        .iter()
3413                        .skip(1)
3414                        .filter(|r| stack_matches_filter(&r.stack, filter))
3415                        .cloned()
3416                        .collect();
3417                    let shell_hit = shell
3418                        .as_ref()
3419                        .map(|s| stack_matches_filter(&s.stack, filter))
3420                        .unwrap_or(false);
3421                    if shell_hit || !contents.is_empty() {
3422                        if let Some(s) = shell {
3423                            rows.push(s);
3424                        }
3425                        if shell_hit {
3426                            rows.extend(nc.rows.into_iter().skip(1));
3427                        } else {
3428                            rows.extend(contents);
3429                        }
3430                    }
3431                }
3432                rows
3433            }
3434        }
3435    }
3436
3437    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3438        self.inventory_selectable_rows()
3439            .into_iter()
3440            .nth(self.inventory_menu_index)
3441    }
3442
3443    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3444        let cat = self
3445            .inventory_item_category(&row.stack.template_id)
3446            .unwrap_or("");
3447        if cat == "key" {
3448            self.key_inventory_label(&row.stack)
3449        } else {
3450            row.stack
3451                .display_name
3452                .clone()
3453                .unwrap_or_else(|| row.stack.template_id.clone())
3454        }
3455    }
3456
3457    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3458    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3459        let bindings = format_status_bindings_suffix(
3460            &row.stack.status_bindings,
3461            self.tick,
3462            DEFAULT_TICK_HZ,
3463        );
3464        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3465            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3466            let mode = Self::grant_mode(&row.stack);
3467            format!(" [grant {effect} · {mode} — e apply]")
3468        } else {
3469            String::new()
3470        };
3471        let qty = if row.stack.quantity > 1 {
3472            format!(" ×{}", row.stack.quantity)
3473        } else {
3474            String::new()
3475        };
3476        let worn_slot = if row.is_equip_shell {
3477            match row.from {
3478                flatland_protocol::InventoryLocation::Worn { slot } => {
3479                    format!(" ({})", body_slot_label(slot))
3480                }
3481                _ => String::new(),
3482            }
3483        } else {
3484            String::new()
3485        };
3486        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3487    }
3488
3489    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3490        (
3491            row.stack.template_id.clone(),
3492            self.inventory_row_base_label(row),
3493            self.inventory_row_visible_mod_signature(row),
3494        )
3495    }
3496
3497    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3498    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3499        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3500        for row in self.inventory_selectable_rows() {
3501            if row.stack.item_instance_id.is_none() {
3502                continue;
3503            }
3504            let key = self.inventory_row_instance_identity_key(&row);
3505            *counts.entry(key).or_default() += 1;
3506        }
3507        counts
3508            .into_iter()
3509            .filter(|(_, n)| *n > 1)
3510            .map(|(k, _)| k)
3511            .collect()
3512    }
3513
3514    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3515        let hex: String = id
3516            .as_simple()
3517            .to_string()
3518            .chars()
3519            .filter(|c| c.is_ascii_hexdigit())
3520            .collect();
3521        let short = if hex.len() >= 4 {
3522            &hex[hex.len() - 4..]
3523        } else {
3524            hex.as_str()
3525        };
3526        format!("Instance {id} (#{short})")
3527    }
3528
3529    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3530    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3531        let cat = self
3532            .inventory_item_category(&row.stack.template_id)
3533            .unwrap_or("");
3534        let label = self.inventory_row_base_label(row);
3535        let hint: String = if row.is_equip_shell {
3536            " [worn — Enter to unequip]".into()
3537        } else if row.is_chest_shell {
3538            let (locked, lodging_note) = match &row.from {
3539                flatland_protocol::InventoryLocation::Placed { container_id } => {
3540                    let locked = self
3541                        .placed_containers
3542                        .iter()
3543                        .find(|c| c.id == *container_id)
3544                        .map(|c| c.locked)
3545                        .unwrap_or(false);
3546                    let lodging_note = self
3547                        .lodging_occupancy_label(container_id)
3548                        .map(|who| format!(" [lodging: {who}]"))
3549                        .unwrap_or_default();
3550                    (locked, lodging_note)
3551                }
3552                _ => (false, String::new()),
3553            };
3554            if locked {
3555                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3556            } else {
3557                format!(" [Enter pick up · l lock]{lodging_note}")
3558            }
3559        } else if cat == "key" {
3560            self.key_inventory_hint(&row.stack)
3561        } else {
3562            match cat {
3563                "weapon" => " [weapon]".into(),
3564                "container" => " [bag/chest/belt]".into(),
3565                "lodging" => " [worker lodging]".into(),
3566                "armor" => " [armor]".into(),
3567                _ => String::new(),
3568            }
3569        };
3570        let qty = if row.stack.quantity > 1 {
3571            format!(" ×{}", row.stack.quantity)
3572        } else {
3573            String::new()
3574        };
3575        let bindings = format_status_bindings_suffix(
3576            &row.stack.status_bindings,
3577            self.tick,
3578            DEFAULT_TICK_HZ,
3579        );
3580        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3581            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3582            let mode = Self::grant_mode(&row.stack);
3583            format!(" [grant {effect} · {mode} — e apply]")
3584        } else {
3585            String::new()
3586        };
3587        let mass = self.stack_mass(&row.stack);
3588        let mass_kg = (mass >= 0.05).then_some(mass);
3589        let mass_str = mass_kg
3590            .map(|m| format!("  {m:.1} kg"))
3591            .unwrap_or_default();
3592        let volume = self.container_volume_stats(row);
3593        let vol_str = self.container_volume_label(row);
3594
3595        let mut title = label.clone();
3596        title.push_str(&qty);
3597        if row.is_equip_shell {
3598            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3599                title.push_str(&format!(" ({})", body_slot_label(slot)));
3600            }
3601        }
3602
3603        InventoryRowView {
3604            depth: row.depth,
3605            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3606            title: format!("{title}{grant_hint}{bindings}"),
3607            mass_kg,
3608            volume,
3609            instance_tooltip: None,
3610        }
3611    }
3612
3613    fn push_browser_item(
3614        &self,
3615        lines: &mut Vec<InventoryBrowserLine>,
3616        row: &InventoryRow,
3617        global_idx: &mut usize,
3618        target: usize,
3619        highlight: bool,
3620        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3621    ) {
3622        let mut view = self.format_inventory_row(row);
3623        if let Some(id) = row.stack.item_instance_id {
3624            let key = self.inventory_row_instance_identity_key(row);
3625            if ambiguous_instance_keys.contains(&key) {
3626                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3627            }
3628        }
3629        lines.push(InventoryBrowserLine::Item {
3630            selectable_index: *global_idx,
3631            selected: highlight && *global_idx == target,
3632            depth: view.depth,
3633            text: view.text,
3634            title: view.title,
3635            mass_kg: view.mass_kg,
3636            volume: view.volume,
3637            instance_tooltip: view.instance_tooltip,
3638        });
3639        *global_idx += 1;
3640    }
3641
3642    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3643    /// `selectable_index` matching `inventory_menu_index`.
3644    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3645        let mut lines = Vec::new();
3646        let target = self.inventory_menu_index;
3647        let highlight = !self.show_move_picker && !self.show_grant_picker;
3648        let filter = self.inventory_filter.as_str();
3649        let mut global_idx = 0usize;
3650        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3651
3652        match self.inventory_tab {
3653            InventoryTab::OnPerson => {
3654                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3655                let worn = self.worn_rows_filtered(filter);
3656                if worn.is_empty() {
3657                    lines.push(InventoryBrowserLine::Hint(
3658                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3659                    ));
3660                } else {
3661                    for row in &worn {
3662                        if row.is_equip_shell {
3663                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3664                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3665                                    "  {}:",
3666                                    body_slot_label(slot)
3667                                )));
3668                            }
3669                        }
3670                        self.push_browser_item(
3671                            &mut lines,
3672                            row,
3673                            &mut global_idx,
3674                            target,
3675                            highlight,
3676                            &ambiguous_instance_keys,
3677                        );
3678                    }
3679                }
3680
3681                lines.push(InventoryBrowserLine::Blank);
3682                lines.push(InventoryBrowserLine::Section(
3683                    "— On you (loose, not worn) —".into(),
3684                ));
3685                let person = self.person_rows_filtered(filter);
3686                if person.is_empty() {
3687                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3688                } else {
3689                    let mut last_group: Option<&'static str> = None;
3690                    for row in &person {
3691                        if row.depth == 0 {
3692                            let cat = row
3693                                .stack
3694                                .category
3695                                .as_deref()
3696                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3697                                .unwrap_or("");
3698                            let (group, _) = inventory_category_group(cat);
3699                            if last_group != Some(group) {
3700                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3701                                    "  {group}"
3702                                )));
3703                                last_group = Some(group);
3704                            }
3705                        }
3706                        self.push_browser_item(
3707                            &mut lines,
3708                            row,
3709                            &mut global_idx,
3710                            target,
3711                            highlight,
3712                            &ambiguous_instance_keys,
3713                        );
3714                    }
3715                }
3716            }
3717            InventoryTab::Nearby => {
3718                let nearby = self.nearby_containers();
3719                if nearby.is_empty() {
3720                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3721                    lines.push(InventoryBrowserLine::Hint(
3722                        "  (none within reach — walk up to a chest)".into(),
3723                    ));
3724                    lines.push(InventoryBrowserLine::Hint(
3725                        "  Select an on-person item, then m / Enter → move into chest.".into(),
3726                    ));
3727                } else {
3728                    let mut any_visible = false;
3729                    for nc in &nearby {
3730                        let shell = nc.rows.first();
3731                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
3732                            nc.rows.iter().skip(1).collect()
3733                        } else {
3734                            let shell_hit = shell
3735                                .map(|s| {
3736                                    let f = filter.to_ascii_lowercase();
3737                                    let name = s
3738                                        .stack
3739                                        .display_name
3740                                        .as_deref()
3741                                        .unwrap_or("")
3742                                        .to_ascii_lowercase();
3743                                    let tid = s.stack.template_id.to_ascii_lowercase();
3744                                    name.contains(&f) || tid.contains(&f)
3745                                })
3746                                .unwrap_or(false);
3747                            if shell_hit {
3748                                nc.rows.iter().skip(1).collect()
3749                            } else {
3750                                nc.rows
3751                                    .iter()
3752                                    .skip(1)
3753                                    .filter(|r| stack_matches_filter(&r.stack, filter))
3754                                    .collect()
3755                            }
3756                        };
3757                        let shell_visible = filter.is_empty()
3758                            || shell
3759                                .map(|s| stack_matches_filter(&s.stack, filter))
3760                                .unwrap_or(false)
3761                            || !contents.is_empty();
3762                        if !shell_visible && shell.is_some() {
3763                            continue;
3764                        }
3765                        any_visible = true;
3766                        lines.push(InventoryBrowserLine::Blank);
3767                        let lock_note = if nc.view.locked && nc.view.accessible {
3768                            "  unlocked with your key"
3769                        } else if nc.view.locked {
3770                            "  locked"
3771                        } else {
3772                            ""
3773                        };
3774                        lines.push(InventoryBrowserLine::Section(format!(
3775                            "— {} ({:.0}m away){lock_note} —",
3776                            nc.view.display_name, nc.distance_m
3777                        )));
3778                        if !nc.view.accessible {
3779                            lines.push(InventoryBrowserLine::Hint(
3780                                "  locked — need the matching key (l to try)".into(),
3781                            ));
3782                        } else if nc.rows.is_empty() {
3783                            lines.push(InventoryBrowserLine::Hint(
3784                                "  (empty — switch to On person, select an item, m to move in)"
3785                                    .into(),
3786                            ));
3787                        } else if let Some(shell_row) = shell {
3788                            self.push_browser_item(
3789                                &mut lines,
3790                                shell_row,
3791                                &mut global_idx,
3792                                target,
3793                                highlight,
3794                                &ambiguous_instance_keys,
3795                            );
3796                            for row in contents {
3797                                self.push_browser_item(
3798                                    &mut lines,
3799                                    row,
3800                                    &mut global_idx,
3801                                    target,
3802                                    highlight,
3803                                    &ambiguous_instance_keys,
3804                                );
3805                            }
3806                        }
3807                    }
3808                    if !any_visible {
3809                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3810                        lines.push(InventoryBrowserLine::Hint(
3811                            "  (no matching items — clear filter with Esc)".into(),
3812                        ));
3813                    }
3814                }
3815            }
3816        }
3817        lines
3818    }
3819
3820    /// Destinations for picking up a placed chest/crate into inventory.
3821    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3822        let mut opts = Vec::new();
3823        opts.push(MoveOption {
3824            label: "Relocate…".into(),
3825            kind: MoveOptionKind::RelocatePlaced {
3826                container_id: container_id.to_string(),
3827            },
3828        });
3829        opts.push(MoveOption {
3830            label: "On your person (loose)".into(),
3831            kind: MoveOptionKind::PickupPlaced {
3832                container_id: container_id.to_string(),
3833                nest_location: flatland_protocol::InventoryLocation::Root,
3834                nest_parent_instance_id: None,
3835            },
3836        });
3837        for (slot, item) in &self.worn {
3838            if item.category.as_deref() != Some("container") {
3839                continue;
3840            }
3841            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3842                continue;
3843            }
3844            let Some(parent_id) = item.item_instance_id else {
3845                continue;
3846            };
3847            let shell_name = item
3848                .display_name
3849                .clone()
3850                .unwrap_or_else(|| item.template_id.clone());
3851            opts.push(MoveOption {
3852                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3853                kind: MoveOptionKind::PickupPlaced {
3854                    container_id: container_id.to_string(),
3855                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3856                    nest_parent_instance_id: Some(parent_id),
3857                },
3858            });
3859            // Nested pouches inside the worn bag.
3860            Self::append_chest_pickup_nested(
3861                &mut opts,
3862                container_id,
3863                flatland_protocol::InventoryLocation::Worn { slot: *slot },
3864                item,
3865                &format!("in {shell_name}"),
3866            );
3867        }
3868        opts.push(MoveOption {
3869            label: "Cancel".into(),
3870            kind: MoveOptionKind::Cancel,
3871        });
3872        opts
3873    }
3874
3875    fn append_chest_pickup_nested(
3876        opts: &mut Vec<MoveOption>,
3877        container_id: &str,
3878        location: flatland_protocol::InventoryLocation,
3879        parent: &flatland_protocol::ItemStack,
3880        context: &str,
3881    ) {
3882        for child in &parent.contents {
3883            if child.category.as_deref() != Some("container") {
3884                continue;
3885            }
3886            if !Self::is_volume_container_stack(child) {
3887                continue;
3888            }
3889            // Skip placeable nested chests — relocating into another chest is not useful.
3890            if child.world_placeable == Some(true) {
3891                continue;
3892            }
3893            let Some(child_id) = child.item_instance_id else {
3894                continue;
3895            };
3896            let name = child
3897                .display_name
3898                .clone()
3899                .unwrap_or_else(|| child.template_id.clone());
3900            opts.push(MoveOption {
3901                label: format!("{name} ({context})"),
3902                kind: MoveOptionKind::PickupPlaced {
3903                    container_id: container_id.to_string(),
3904                    nest_location: location.clone(),
3905                    nest_parent_instance_id: Some(child_id),
3906                },
3907            });
3908            Self::append_chest_pickup_nested(
3909                opts,
3910                container_id,
3911                location.clone(),
3912                child,
3913                &format!("in {name}"),
3914            );
3915        }
3916    }
3917
3918    /// Build the "move to…" destination list for an item currently at `from`.
3919    pub fn move_destinations_for(
3920        &self,
3921        from: &flatland_protocol::InventoryLocation,
3922        from_parent_instance_id: Option<uuid::Uuid>,
3923        moving_instance_id: Option<uuid::Uuid>,
3924        moving_template_id: &str,
3925    ) -> Vec<MoveOption> {
3926        let mut opts = Vec::new();
3927        if *from != flatland_protocol::InventoryLocation::Root {
3928            opts.push(MoveOption {
3929                label: "On your person (loose)".into(),
3930                kind: MoveOptionKind::Move {
3931                    location: flatland_protocol::InventoryLocation::Root,
3932                    parent_instance_id: None,
3933                },
3934            });
3935        }
3936        for (slot, item) in &self.worn {
3937            if item.category.as_deref() != Some("container") {
3938                continue;
3939            }
3940            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3941            let shell_name = item
3942                .display_name
3943                .clone()
3944                .unwrap_or_else(|| item.template_id.clone());
3945
3946            // Backpack and other worn volume containers — store directly inside the shell.
3947            if *slot != BodySlot::Waist
3948                && item.item_instance_id != moving_instance_id
3949                && Self::is_volume_container_stack(item)
3950            {
3951                Self::push_move_destination(
3952                    &mut opts,
3953                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3954                    location.clone(),
3955                    item.item_instance_id,
3956                    from,
3957                    from_parent_instance_id,
3958                );
3959            }
3960
3961            // Belt loops only accept pouch attachments — not loose materials.
3962            if *slot == BodySlot::Waist
3963                && Self::attaches_to_belt_loop(moving_template_id)
3964                && item.item_instance_id != moving_instance_id
3965            {
3966                Self::push_move_destination(
3967                    &mut opts,
3968                    format!("{shell_name} (belt loop)"),
3969                    location.clone(),
3970                    item.item_instance_id,
3971                    from,
3972                    from_parent_instance_id,
3973                );
3974            }
3975
3976            let context = if *slot == BodySlot::Waist {
3977                format!("on {shell_name}")
3978            } else {
3979                format!("in {shell_name}")
3980            };
3981            Self::append_nested_container_destinations(
3982                &mut opts,
3983                location,
3984                item,
3985                &context,
3986                from,
3987                from_parent_instance_id,
3988                moving_instance_id,
3989            );
3990        }
3991        for nc in self.nearby_containers() {
3992            if !nc.view.accessible {
3993                continue;
3994            }
3995            let location = flatland_protocol::InventoryLocation::Placed {
3996                container_id: nc.view.id.clone(),
3997            };
3998            Self::push_move_destination(
3999                &mut opts,
4000                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
4001                location,
4002                nc.view.item_instance_id,
4003                from,
4004                from_parent_instance_id,
4005            );
4006        }
4007        let allow_drop = moving_instance_id
4008            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
4009            .unwrap_or(true)
4010            && moving_instance_id
4011                .and_then(|id| self.stack_for_instance(id))
4012                .map(|stack| {
4013                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
4014                })
4015                .unwrap_or(
4016                    moving_template_id != KEY_TEMPLATE
4017                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
4018                );
4019        if allow_drop {
4020            opts.push(MoveOption {
4021                label: "Drop on the ground".into(),
4022                kind: MoveOptionKind::Drop,
4023            });
4024        }
4025        opts.push(MoveOption {
4026            label: "Cancel".into(),
4027            kind: MoveOptionKind::Cancel,
4028        });
4029        opts
4030    }
4031
4032    fn is_same_container_dest(
4033        dest_location: &flatland_protocol::InventoryLocation,
4034        dest_parent: Option<uuid::Uuid>,
4035        from: &flatland_protocol::InventoryLocation,
4036        from_parent: Option<uuid::Uuid>,
4037    ) -> bool {
4038        dest_location == from && dest_parent == from_parent
4039    }
4040
4041    fn push_move_destination(
4042        opts: &mut Vec<MoveOption>,
4043        label: String,
4044        location: flatland_protocol::InventoryLocation,
4045        parent_instance_id: Option<uuid::Uuid>,
4046        from: &flatland_protocol::InventoryLocation,
4047        from_parent_instance_id: Option<uuid::Uuid>,
4048    ) {
4049        if Self::is_same_container_dest(
4050            &location,
4051            parent_instance_id,
4052            from,
4053            from_parent_instance_id,
4054        ) {
4055            return;
4056        }
4057        opts.push(MoveOption {
4058            label,
4059            kind: MoveOptionKind::Move {
4060                location,
4061                parent_instance_id,
4062            },
4063        });
4064    }
4065
4066    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4067        stack.capacity_volume.is_some_and(|c| c > 0.0)
4068    }
4069
4070    fn attaches_to_belt_loop(template_id: &str) -> bool {
4071        matches!(template_id, "leather_pouch" | "dimensional_pouch")
4072    }
4073
4074    fn append_nested_container_destinations(
4075        opts: &mut Vec<MoveOption>,
4076        location: flatland_protocol::InventoryLocation,
4077        container: &flatland_protocol::ItemStack,
4078        context: &str,
4079        from: &flatland_protocol::InventoryLocation,
4080        from_parent_instance_id: Option<uuid::Uuid>,
4081        moving_instance_id: Option<uuid::Uuid>,
4082    ) {
4083        for child in &container.contents {
4084            if Self::is_volume_container_stack(child)
4085                && child.item_instance_id != moving_instance_id
4086            {
4087                let name = child
4088                    .display_name
4089                    .clone()
4090                    .unwrap_or_else(|| child.template_id.clone());
4091                Self::push_move_destination(
4092                    opts,
4093                    format!("{name} ({context})"),
4094                    location.clone(),
4095                    child.item_instance_id,
4096                    from,
4097                    from_parent_instance_id,
4098                );
4099            }
4100            let nested_context = format!(
4101                "in {}",
4102                child.display_name.as_deref().unwrap_or(&child.template_id)
4103            );
4104            Self::append_nested_container_destinations(
4105                opts,
4106                location.clone(),
4107                child,
4108                &nested_context,
4109                from,
4110                from_parent_instance_id,
4111                moving_instance_id,
4112            );
4113        }
4114    }
4115
4116    fn clamp_inventory_indices(&mut self) {
4117        let n = self.inventory_selectable_rows().len();
4118        self.inventory_menu_index = if n == 0 {
4119            0
4120        } else {
4121            self.inventory_menu_index.min(n - 1)
4122        };
4123        if let Some(picker) = &self.move_picker {
4124            let pn = picker.options.len();
4125            self.move_picker_index = if pn == 0 {
4126                0
4127            } else {
4128                self.move_picker_index.min(pn - 1)
4129            };
4130        }
4131    }
4132
4133    /// Drop interior map layers whenever the player is outdoors.
4134    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
4135    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
4136    /// force `build_grid` into a full-map z pass on every click until client restart.
4137    fn sync_interior_map_context(&mut self) {
4138        if self.effective_inside_building().is_none() {
4139            self.interior_map = None;
4140            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4141                self.z_platforms = platforms;
4142                self.z_transitions = transitions;
4143            }
4144            return;
4145        }
4146        self.sync_interior_z_bands();
4147    }
4148
4149    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
4150    fn sync_interior_z_bands(&mut self) {
4151        if self.effective_inside_building().is_some() {
4152            if let Some(map) = &self.interior_map {
4153                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4154                    if self.z_bands_outdoor_backup.is_none() {
4155                        self.z_bands_outdoor_backup = Some((
4156                            std::mem::take(&mut self.z_platforms),
4157                            std::mem::take(&mut self.z_transitions),
4158                        ));
4159                    }
4160                    self.z_platforms = map.z_platforms.clone();
4161                    self.z_transitions = map.z_transitions.clone();
4162                }
4163            }
4164        }
4165    }
4166
4167    fn apply_snapshot_fields(
4168        &mut self,
4169        snapshot: &flatland_protocol::Snapshot,
4170        entity_id: EntityId,
4171    ) {
4172        self.tick = snapshot.tick;
4173        self.chunk_rev = snapshot.chunk_rev;
4174        self.content_rev = snapshot.content_rev;
4175        self.publish_rev = snapshot.publish_rev;
4176        self.resource_nodes = snapshot.resource_nodes.clone();
4177        self.ground_drops = snapshot.ground_drops.clone();
4178        self.placed_containers = snapshot.placed_containers.clone();
4179        self.world_x0 = snapshot.world_x0;
4180        self.world_y0 = snapshot.world_y0;
4181        self.world_width_m = snapshot.world_width_m;
4182        self.world_height_m = snapshot.world_height_m;
4183        self.world_clock = snapshot.world_clock;
4184        self.terrain_zones = snapshot.terrain_zones.clone();
4185        self.z_platforms = snapshot.z_platforms.clone();
4186        self.z_transitions = snapshot.z_transitions.clone();
4187        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
4188        self.z_bands_outdoor_backup = None;
4189        self.buildings = snapshot.buildings.clone();
4190        self.doors = snapshot.doors.clone();
4191        self.interior_map = snapshot.interior_map.clone();
4192        self.npcs = snapshot.npcs.clone();
4193        self.blueprints = snapshot.blueprints.clone();
4194        self.building_materials = snapshot.building_materials.clone();
4195        self.sync_inventory_from_stacks(&snapshot.inventory);
4196        self.player = snapshot
4197            .entities
4198            .iter()
4199            .find(|e| e.id == entity_id)
4200            .cloned();
4201        self.entities = snapshot.entities.clone();
4202        self.quest_log = snapshot.quest_log.clone();
4203        self.apply_hired_workers(snapshot.hired_workers.clone());
4204        self.interactables = snapshot.interactables.clone();
4205        self.ledger = snapshot.ledger.clone();
4206        self.career = snapshot.career.clone();
4207        self.combat_fx = snapshot.combat_fx.clone();
4208        self.property_zones = snapshot.property_zones.clone();
4209        self.tax_zones = snapshot.tax_zones.clone();
4210        self.growth_zones = snapshot.growth_zones.clone();
4211        self.biome_zones = snapshot.biome_zones.clone();
4212        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4213        self.property_plots = snapshot.property_plots.clone();
4214        self.property_plot_settings = snapshot.property_plot_settings.clone();
4215        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
4216        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
4217        if self.effective_inside_building().is_some() {
4218            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4219        }
4220        self.sync_interior_map_context();
4221        self.refresh_whisper_range();
4222    }
4223
4224    /// Re-derive inventory selection state after a snapshot/tick — chests that
4225    /// went out of range or got locked simply vanish from the row list, and the
4226    /// move picker (if any) closes once its item is no longer reachable.
4227    fn refresh_inventory_ui(&mut self) {
4228        if let Some(picker) = &self.move_picker {
4229            let instance_id = picker.item_instance_id;
4230            let still_exists = self
4231                .inventory_selectable_rows()
4232                .iter()
4233                .any(|r| r.stack.item_instance_id == Some(instance_id));
4234            if !still_exists {
4235                self.move_picker = None;
4236                self.show_move_picker = false;
4237            }
4238        }
4239        if let Some(picker) = &self.destroy_picker {
4240            let instance_id = picker.item_instance_id;
4241            let still_exists = self
4242                .inventory_selectable_rows()
4243                .iter()
4244                .any(|r| r.stack.item_instance_id == Some(instance_id));
4245            if !still_exists {
4246                self.destroy_picker = None;
4247                self.show_destroy_picker = false;
4248                self.destroy_confirm_pending = false;
4249            }
4250        }
4251        self.clamp_inventory_indices();
4252    }
4253
4254    /// Replace the hired-worker list from a snapshot/delta.
4255    ///
4256    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
4257    /// selected worker (and its step line) does not jump when the server rebuilds
4258    /// the list.
4259    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4260        let selected_id = self
4261            .hired_workers
4262            .get(self.workers_menu_index)
4263            .map(|w| w.instance_id.clone());
4264        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4265        let now = Instant::now();
4266        for w in &workers {
4267            let prev_err = self
4268                .hired_workers
4269                .iter()
4270                .find(|p| p.instance_id == w.instance_id)
4271                .and_then(|p| p.last_error.as_deref());
4272            let new_err = w.last_error.as_deref();
4273            if new_err != prev_err {
4274                if let Some(err) = new_err {
4275                    if !worker_error_is_transient(err) {
4276                        self.push_log(format!("Worker {}: {err}", w.label));
4277                    }
4278                }
4279            }
4280        }
4281        let mut next_display = BTreeMap::new();
4282        let mut next_errors = BTreeMap::new();
4283        for w in &workers {
4284            let mut sticky = self
4285                .worker_step_display
4286                .remove(&w.instance_id)
4287                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4288            sticky.observe(&w.step_label, now);
4289            next_display.insert(w.instance_id.clone(), sticky);
4290
4291            let mut err_sticky = self
4292                .worker_error_display
4293                .remove(&w.instance_id)
4294                .unwrap_or_default();
4295            err_sticky.observe(w.last_error.as_deref(), now);
4296            if err_sticky.shown(now).is_some() {
4297                next_errors.insert(w.instance_id.clone(), err_sticky);
4298            }
4299        }
4300        self.worker_step_display = next_display;
4301        self.worker_error_display = next_errors;
4302        self.hired_workers = workers;
4303        self.sync_worker_take_picker_from_hired();
4304        if let Some(id) = selected_id {
4305            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4306                self.workers_menu_index = idx;
4307                return;
4308            }
4309        }
4310        if self.workers_menu_index >= self.hired_workers.len() {
4311            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4312        }
4313    }
4314
4315    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4316    fn sync_worker_take_picker_from_hired(&mut self) {
4317        if !self.show_worker_take_picker {
4318            return;
4319        }
4320        let Some(picker) = self.worker_take_picker.clone() else {
4321            return;
4322        };
4323        let Some(worker) = self
4324            .hired_workers
4325            .iter()
4326            .find(|w| w.instance_id == picker.worker_instance_id)
4327            .cloned()
4328        else {
4329            self.show_worker_take_picker = false;
4330            self.worker_take_picker = None;
4331            self.worker_take_picker_index = 0;
4332            return;
4333        };
4334        let options: Vec<WorkerGiveOption> = worker
4335            .inventory
4336            .iter()
4337            .filter_map(|stack| {
4338                let item_instance_id = stack.item_instance_id?;
4339                let label = stack
4340                    .display_name
4341                    .clone()
4342                    .unwrap_or_else(|| stack.template_id.clone());
4343                let label = if stack.quantity > 1 {
4344                    format!("{label} ×{}", stack.quantity)
4345                } else {
4346                    label
4347                };
4348                Some(WorkerGiveOption {
4349                    item_instance_id,
4350                    label,
4351                    quantity: stack.quantity,
4352                    template_id: stack.template_id.clone(),
4353                })
4354            })
4355            .collect();
4356        if options.is_empty() {
4357            self.show_worker_take_picker = false;
4358            self.worker_take_picker = None;
4359            self.worker_take_picker_index = 0;
4360            return;
4361        }
4362        let prev_id = picker
4363            .options
4364            .get(self.worker_take_picker_index)
4365            .map(|o| o.item_instance_id);
4366        let idx = prev_id
4367            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4368            .unwrap_or(0)
4369            .min(options.len().saturating_sub(1));
4370        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4371        let quantity = picker.quantity.clamp(1, max_qty);
4372        self.worker_take_picker_index = idx;
4373        self.worker_take_picker = Some(WorkerTakePicker {
4374            worker_instance_id: picker.worker_instance_id,
4375            worker_label: picker.worker_label,
4376            options,
4377            quantity,
4378        });
4379    }
4380
4381    /// Held coarse step label for the workers menu (`step:` line).
4382    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4383        self.worker_step_display
4384            .get(worker_instance_id)
4385            .map(|s| s.shown.as_str())
4386            .or_else(|| {
4387                self.hired_workers
4388                    .iter()
4389                    .find(|w| w.instance_id == worker_instance_id)
4390                    .map(|w| w.step_label.as_str())
4391            })
4392            .unwrap_or("")
4393    }
4394
4395    /// Held error line for workers UI (detail + compact).
4396    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4397        let now = Instant::now();
4398        self.worker_error_display
4399            .get(worker_instance_id)
4400            .and_then(|s| s.shown(now))
4401            .or_else(|| {
4402                self.hired_workers
4403                    .iter()
4404                    .find(|w| w.instance_id == worker_instance_id)
4405                    .and_then(|w| w.last_error.as_deref())
4406                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4407            })
4408            .filter(|e| !worker_error_is_hud_noise(e))
4409    }
4410
4411    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4412        self.in_combat = combat.in_combat;
4413        self.auto_attack = combat.auto_attack;
4414        self.combat_has_los = combat.has_los;
4415        self.attack_cd_ticks = combat.attack_cd_ticks;
4416        self.gcd_ticks = combat.gcd_ticks;
4417        self.weapon_ability_id = combat.ability_id.clone();
4418        self.mainhand_template_id = combat.mainhand_template_id.clone();
4419        self.mainhand_label = combat.mainhand_label.clone();
4420        self.mainhand_instance_id = combat.mainhand_instance_id;
4421        self.offhand_template_id = combat.offhand_template_id.clone();
4422        self.offhand_label = combat.offhand_label.clone();
4423        self.offhand_instance_id = combat.offhand_instance_id;
4424        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4425            1
4426        } else {
4427            combat.mainhand_hand_slots
4428        };
4429        self.defense = combat.defense.clone();
4430        self.worn = combat.worn.iter().cloned().collect();
4431        self.carry_mass = combat.carry_mass;
4432        self.carry_mass_max = combat.carry_mass_max;
4433        self.encumbrance = combat.encumbrance;
4434        self.cast_progress = combat.cast.clone();
4435        self.timed_channel = combat.timed_channel.clone();
4436        self.plot_build_offer = combat.plot_build.clone();
4437        self.ability_cooldowns = combat.ability_cooldowns.clone();
4438        self.blocking_active = combat.blocking_active;
4439        self.max_target_slots = combat.max_target_slots.max(1);
4440        self.combat_slots = combat.slots.clone();
4441        self.rotation_presets = combat.rotation_presets.clone();
4442        self.known_abilities = combat.known_abilities.clone();
4443        self.ability_meta = combat
4444            .ability_meta
4445            .iter()
4446            .cloned()
4447            .map(|meta| (meta.id.clone(), meta))
4448            .collect();
4449        self.ability_mastery = combat
4450            .ability_mastery
4451            .iter()
4452            .cloned()
4453            .map(|row| (row.ability_id.clone(), row))
4454            .collect();
4455        self.hotbar = combat.hotbar.clone();
4456        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4457        self.keychain_stacks = combat.keychain.clone();
4458        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4459        self.combat_target_detail = combat.target.clone();
4460        self.statuses = combat.statuses.clone();
4461        self.combat_target = combat.target_entity_id;
4462        if combat.progression_xp_base > 0.0 {
4463            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4464                baseline_display: combat.progression_baseline,
4465                xp_base: combat.progression_xp_base,
4466                xp_growth: combat.progression_xp_growth,
4467            });
4468        }
4469        if let Some(xp) = &combat.progression_xp {
4470            if let Some(player) = &mut self.player {
4471                player.progression_xp = Some(xp.clone());
4472                if let Some(attrs) = combat.attributes {
4473                    player.attributes = Some(attrs);
4474                }
4475                if let Some(skills) = &combat.skills {
4476                    player.skills = Some(skills.clone());
4477                }
4478            }
4479        }
4480        if let Some(label) = &combat.target_label {
4481            self.combat_target_label = Some(label.clone());
4482        } else if let Some(id) = combat.target_entity_id {
4483            self.combat_target_label = self
4484                .entities
4485                .iter()
4486                .find(|e| e.id == id)
4487                .map(|e| e.label.clone())
4488                .or_else(|| self.combat_target_label.clone());
4489        }
4490        self.refresh_inventory_ui();
4491    }
4492
4493    /// Entity id assigned to a combat slot (from server HUD).
4494    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4495        self.combat_slots
4496            .iter()
4497            .find(|s| s.slot_index == slot)
4498            .and_then(|s| s.target_entity_id)
4499            .or_else(|| if slot == 1 { self.combat_target } else { None })
4500    }
4501
4502    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4503    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4504        self.ability_meta
4505            .get(ability_id)
4506            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4507            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4508            // the server validates aim_mode.
4509            .unwrap_or(self.ground_target.is_some())
4510    }
4511
4512    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4513    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4514        self.ability_meta
4515            .get(ability_id)
4516            .map(|meta| meta.aim_mode == "ground")
4517            .unwrap_or(false)
4518    }
4519
4520    /// True when the ability may be placed in an auto-rotation preset.
4521    /// Missing meta (pre-sync) defaults to eligible; server still validates.
4522    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4523        self.ability_meta
4524            .get(ability_id)
4525            .map(|meta| meta.auto_rotation_eligible)
4526            .unwrap_or(true)
4527    }
4528
4529    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4530    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4531        self.ground_target = Some((x, y, 0.0));
4532    }
4533
4534    /// Clear the local free-aim ground target.
4535    pub fn clear_ground_target(&mut self) {
4536        self.ground_target = None;
4537    }
4538
4539    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4540    /// May be an ability id or `item:<template_id>` consumable binding.
4541    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4542        if !(1..=9).contains(&slot_1_to_9) {
4543            return None;
4544        }
4545        self.hotbar
4546            .get((slot_1_to_9 - 1) as usize)
4547            .and_then(|a| a.as_deref())
4548            .filter(|id| !id.is_empty())
4549    }
4550
4551    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4552    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4553        let binding = self.hotbar_ability(slot_1_to_9)?;
4554        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4555            let name = self
4556                .inventory_hints
4557                .get(template_id)
4558                .map(|h| h.display_name.as_str())
4559                .unwrap_or(template_id);
4560            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4561            Some(format!("{name}×{qty}"))
4562        } else {
4563            Some(binding.to_string())
4564        }
4565    }
4566
4567    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4568    pub fn loadout_ability_choices(&self) -> Vec<String> {
4569        let mut out = self.known_abilities.clone();
4570        let weapon = self.weapon_ability_id.trim();
4571        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4572            out.push(weapon.to_string());
4573        }
4574        out
4575    }
4576
4577    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4578    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4579        let mut out = Vec::new();
4580        for ability in self.loadout_ability_choices() {
4581            let meta = if ability == self.weapon_ability_id {
4582                Some("weapon".into())
4583            } else {
4584                None
4585            };
4586            out.push(LoadoutHotbarChoice {
4587                binding: ability.clone(),
4588                label: ability,
4589                meta,
4590            });
4591        }
4592        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4593        for stack in &self.inventory_stacks {
4594            if Self::stack_is_item_grant(stack) {
4595                continue;
4596            }
4597            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4598                continue;
4599            }
4600            let qty = stack.quantity.max(1);
4601            if let Some((_, _, existing)) = consumables
4602                .iter_mut()
4603                .find(|(id, _, _)| id == &stack.template_id)
4604            {
4605                *existing = existing.saturating_add(qty);
4606            } else {
4607                let label = stack
4608                    .display_name
4609                    .clone()
4610                    .or_else(|| {
4611                        self.inventory_hints
4612                            .get(&stack.template_id)
4613                            .map(|h| h.display_name.clone())
4614                    })
4615                    .unwrap_or_else(|| stack.template_id.clone());
4616                consumables.push((stack.template_id.clone(), label, qty));
4617            }
4618        }
4619        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4620        for (template_id, label, qty) in consumables {
4621            out.push(LoadoutHotbarChoice {
4622                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4623                label: format!("{label} ×{qty}"),
4624                meta: Some("use".into()),
4625            });
4626        }
4627        out
4628    }
4629
4630    /// Hostile wildlife / monsters for T1 (`Tab`).
4631    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4632        self.combat_candidates()
4633    }
4634
4635    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4636    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4637        let (px, py) = self.player_position();
4638        let dist = |id: EntityId| {
4639            self.entities
4640                .iter()
4641                .find(|e| e.id == id)
4642                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4643                .unwrap_or(f32::MAX)
4644        };
4645
4646        let mut allies = Vec::new();
4647        // Self first — heal_touch on T2.
4648        if let Some(me) = self.player.as_ref() {
4649            let alive = me
4650                .vitals
4651                .as_ref()
4652                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4653                .unwrap_or(true);
4654            if alive {
4655                allies.push((self.entity_id, "Yourself".into()));
4656            }
4657        }
4658        for entity in &self.entities {
4659            if entity.id == self.entity_id {
4660                continue;
4661            }
4662            if entity.vitals.is_some() {
4663                let alive = entity
4664                    .vitals
4665                    .as_ref()
4666                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4667                    .unwrap_or(true);
4668                if alive {
4669                    allies.push((entity.id, entity.label.clone()));
4670                }
4671            }
4672        }
4673        allies.sort_by(|(a, _), (b, _)| {
4674            if *a == self.entity_id {
4675                return std::cmp::Ordering::Less;
4676            }
4677            if *b == self.entity_id {
4678                return std::cmp::Ordering::Greater;
4679            }
4680            dist(*a)
4681                .partial_cmp(&dist(*b))
4682                .unwrap_or(std::cmp::Ordering::Equal)
4683        });
4684
4685        let mut monsters = self.combat_candidates();
4686        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4687        allies.into_iter().chain(monsters).collect()
4688    }
4689
4690    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4691        match slot_index {
4692            2 => self.t2_candidates(),
4693            _ => self.t1_candidates(),
4694        }
4695    }
4696
4697    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
4698    pub fn pick_combat_target_at(
4699        &self,
4700        wx: f32,
4701        wy: f32,
4702        slot_index: u8,
4703        radius_m: f32,
4704    ) -> Option<(EntityId, String)> {
4705        let mut best: Option<(f32, EntityId, String)> = None;
4706        for (id, label) in self.candidates_for_slot(slot_index) {
4707            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4708                // NPCs may only appear in NpcView — fall back to npc list coords.
4709                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4710                    let d = distance(wx, wy, npc.x, npc.y);
4711                    if d <= radius_m {
4712                        best = match best {
4713                            Some((bd, _, _)) if bd <= d => best,
4714                            _ => Some((d, id, label)),
4715                        };
4716                    }
4717                }
4718                continue;
4719            };
4720            let d = distance(
4721                wx,
4722                wy,
4723                entity.transform.position.x,
4724                entity.transform.position.y,
4725            );
4726            if d <= radius_m {
4727                best = match best {
4728                    Some((bd, _, _)) if bd <= d => best,
4729                    _ => Some((d, id, label)),
4730                };
4731            }
4732        }
4733        best.map(|(_, id, label)| (id, label))
4734    }
4735
4736    /// Full client reset after Welcome (initial connect or reconnect).
4737    pub(crate) fn restore_from_welcome(
4738        &mut self,
4739        session_id: SessionId,
4740        entity_id: EntityId,
4741        snapshot: &flatland_protocol::Snapshot,
4742    ) {
4743        self.clear_harvest_state();
4744        self.disconnect_reason = None;
4745        self.show_stats = false;
4746        self.show_craft_menu = false;
4747        self.show_shop_menu = false;
4748        self.shop_catalog = None;
4749        self.show_inventory_menu = false;
4750        self.session_id = session_id;
4751        self.entity_id = entity_id;
4752        self.connected = true;
4753        self.apply_snapshot_fields(snapshot, entity_id);
4754        if let Some(combat) = &snapshot.combat {
4755            self.apply_combat_hud(combat);
4756            let stacks = self.inventory_stacks.clone();
4757            self.sync_inventory_from_stacks(&stacks);
4758        }
4759    }
4760
4761    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4762        self.tick = delta.tick;
4763        self.world_clock = delta.world_clock;
4764
4765        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
4766        if delta.entities.is_empty() {
4767            self.ground_drops = delta.ground_drops.clone();
4768            self.combat_fx = delta.combat_fx.clone();
4769            self.property_plots = delta.property_plots.clone();
4770            self.apply_terrain_overlays(&delta.terrain_overlays);
4771            if let Some(combat) = &delta.combat {
4772                self.apply_combat_hud(combat);
4773                let stacks = self.inventory_stacks.clone();
4774                self.sync_inventory_from_stacks(&stacks);
4775            }
4776            // Peer vanished from AOI — drop directed whisper.
4777            self.refresh_whisper_range();
4778            return;
4779        }
4780        if !delta.buildings.is_empty() {
4781            self.buildings = delta.buildings.clone();
4782        }
4783        if !delta.blueprints.is_empty() {
4784            self.blueprints = delta.blueprints.clone();
4785        }
4786        if !delta.building_materials.is_empty() {
4787            self.building_materials = delta.building_materials.clone();
4788        }
4789        self.sync_inventory_from_stacks(&delta.inventory);
4790
4791        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4792            self.player = Some(updated.clone());
4793        }
4794        self.entities = delta.entities.clone();
4795        if self.player.is_none() {
4796            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4797        }
4798
4799        self.sync_interior_map_context();
4800
4801        // Static world layers: ticks often omit these (indoors, or unchanged).
4802        // Never wipe the welcome snapshot with an empty vec — except when indoors,
4803        // where empty means "no nodes in this building" (clear outdoor leftovers).
4804        if !delta.resource_nodes.is_empty() {
4805            self.resource_nodes = delta.resource_nodes.clone();
4806        } else if delta.interior_map.is_some()
4807            || self.effective_inside_building().is_some()
4808        {
4809            self.resource_nodes = delta.resource_nodes.clone();
4810        }
4811        self.ground_drops = delta.ground_drops.clone();
4812        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
4813        self.placed_containers = delta.placed_containers.clone();
4814        if !delta.doors.is_empty() {
4815            self.doors = delta.doors.clone();
4816        }
4817        if self.effective_inside_building().is_some() {
4818            if let Some(map) = &delta.interior_map {
4819                self.interior_map = Some(map.clone());
4820            }
4821        } else {
4822            self.interior_map = None;
4823        }
4824        self.sync_interior_z_bands();
4825        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
4826        self.npcs = delta.npcs.clone();
4827        if !delta.quest_log.is_empty() {
4828            self.quest_log = delta.quest_log.clone();
4829        }
4830        self.apply_hired_workers(delta.hired_workers.clone());
4831        if !delta.interactables.is_empty() {
4832            self.interactables = delta.interactables.clone();
4833        }
4834        if delta.ledger.is_some() {
4835            self.ledger = delta.ledger.clone();
4836        }
4837        if delta.career.is_some() {
4838            self.career = delta.career.clone();
4839        }
4840        self.combat_fx = delta.combat_fx.clone();
4841        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
4842        if !delta.property_plots.is_empty() {
4843            self.property_plots = delta.property_plots.clone();
4844        }
4845        self.apply_terrain_overlays(&delta.terrain_overlays);
4846        if let Some(combat) = &delta.combat {
4847            self.apply_combat_hud(combat);
4848            let stacks = self.inventory_stacks.clone();
4849            self.sync_inventory_from_stacks(&stacks);
4850        } else {
4851            self.refresh_inventory_ui();
4852        }
4853        self.refresh_whisper_range();
4854    }
4855
4856    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
4857    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
4858    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4859        self.terrain_zones
4860            .retain(|z| !z.id.starts_with("rt:"));
4861        self.terrain_zones.extend(overlays.iter().cloned());
4862    }
4863
4864    /// End directed whisper when the peer is farther than interact range (or missing).
4865    /// Each client runs this locally so both players drop whisper mode when either walks away.
4866    fn refresh_whisper_range(&mut self) {
4867        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4868            return;
4869        };
4870        let (px, py) = self.player_position();
4871        let in_range = self.entities.iter().any(|e| {
4872            e.id == peer
4873                && distance(
4874                    px,
4875                    py,
4876                    e.transform.position.x,
4877                    e.transform.position.y,
4878                ) <= INTERACTION_RADIUS_M
4879        });
4880        if !in_range {
4881            self.social_chat.cancel_whisper_out_of_range();
4882        }
4883    }
4884
4885    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
4886    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4887        let (px, py) = self.player_position();
4888        let mut out = Vec::new();
4889        for npc in &self.npcs {
4890            let Some(eid) = npc.entity_id else {
4891                continue;
4892            };
4893            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4894            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4895            if alive && has_hp {
4896                out.push((eid, npc.label.clone()));
4897            }
4898        }
4899        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4900            let dist = |id: EntityId| {
4901                self.entities
4902                    .iter()
4903                    .find(|e| e.id == id)
4904                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4905                    .unwrap_or(f32::MAX)
4906            };
4907            dist(*a_id)
4908                .partial_cmp(&dist(*b_id))
4909                .unwrap_or(std::cmp::Ordering::Equal)
4910                .then_with(|| a_label.cmp(b_label))
4911                .then_with(|| a_id.cmp(b_id))
4912        });
4913        out
4914    }
4915
4916    pub fn refresh_combat_target_label(&mut self) {
4917        let Some(id) = self.combat_target else {
4918            return;
4919        };
4920        if let Some((_, label)) = self
4921            .combat_candidates()
4922            .into_iter()
4923            .find(|(eid, _)| *eid == id)
4924        {
4925            self.combat_target_label = Some(label);
4926        } else if let Some(label) = self
4927            .entities
4928            .iter()
4929            .find(|e| e.id == id)
4930            .map(|e| e.label.clone())
4931        {
4932            self.combat_target_label = Some(label);
4933        }
4934    }
4935
4936    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4937        self.quest_log
4938            .iter()
4939            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4940            .collect()
4941    }
4942
4943    /// True when the observer has at least one free lodging slot (max workers = beds).
4944    pub fn has_worker_lodging(&self) -> bool {
4945        self.free_worker_lodging_slots() > 0
4946    }
4947
4948    /// Owned lodging capacity minus currently hired workers.
4949    pub fn free_worker_lodging_slots(&self) -> i64 {
4950        let slots: u32 = self
4951            .placed_containers
4952            .iter()
4953            .filter(|c| match (self.character_id, c.owner_character_id) {
4954                (Some(me), Some(owner)) => me == owner,
4955                (Some(_), None) => false,
4956                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4957            })
4958            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4959            .sum();
4960        let used = self.hired_workers.len() as u32;
4961        slots as i64 - used as i64
4962    }
4963
4964    /// Display names of hired workers assigned to this lodging container.
4965    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4966        let mut names: Vec<String> = self
4967            .hired_workers
4968            .iter()
4969            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4970            .map(|w| w.label.clone())
4971            .collect();
4972        names.sort();
4973        names
4974    }
4975
4976    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
4977    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4978        let is_lodging = self
4979            .placed_containers
4980            .iter()
4981            .find(|c| c.id == container_id)
4982            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4983        if !is_lodging {
4984            return None;
4985        }
4986        let names = self.lodging_occupant_labels(container_id);
4987        Some(if names.is_empty() {
4988            "vacant".into()
4989        } else {
4990            names.join(", ")
4991        })
4992    }
4993
4994    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4995        self.quest_log
4996            .iter()
4997            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4998            .or_else(|| {
4999                self.quest_log
5000                    .iter()
5001                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
5002            })
5003    }
5004
5005    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
5006    pub fn nearby_lockable_door(&self) -> bool {
5007        let (px, py) = self.player_position();
5008        self.doors
5009            .iter()
5010            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= 3.5)
5011    }
5012
5013    /// True when an open player-house door is in range for Enter-to-go-inside.
5014    pub fn nearby_open_player_door(&self) -> bool {
5015        if self.effective_inside_building().is_some() {
5016            return false;
5017        }
5018        let (px, py) = self.player_position();
5019        self.doors.iter().any(|d| {
5020            if !d.open || d.locked {
5021                return false;
5022            }
5023            let player_house = self
5024                .buildings
5025                .iter()
5026                .find(|b| b.id == d.building_id)
5027                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5028            player_house && (d.x - px).hypot(d.y - py) <= 3.5
5029        })
5030    }
5031
5032    /// True when inside a player house near the exterior portal (Enter exits freely).
5033    pub fn nearby_player_exit_door(&self) -> bool {
5034        let Some(bid) = self.effective_inside_building() else {
5035            return false;
5036        };
5037        let (px, py) = self.player_position();
5038        self.doors.iter().any(|d| {
5039            if d.building_id != bid || d.portal.is_none() {
5040                return false;
5041            }
5042            let player_house = self
5043                .buildings
5044                .iter()
5045                .find(|b| b.id == d.building_id)
5046                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5047            player_house && (d.x - px).hypot(d.y - py) <= 1.5
5048        })
5049    }
5050
5051    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
5052    pub fn nearest_interact_target(&self) -> Option<String> {
5053        let (px, py) = self.player_position();
5054        let inside = self.effective_inside_building();
5055
5056        #[derive(Clone, Copy, PartialEq, Eq)]
5057        enum Kind {
5058            Player,
5059            Npc,
5060            HiredWorker,
5061            QuestBoard,
5062            ExitDoor,
5063            EnterDoor,
5064            Well,
5065            Water,
5066        }
5067
5068        fn kind_priority(kind: Kind) -> u8 {
5069            match kind {
5070                Kind::Player => 0,
5071                Kind::Npc => 0,
5072                Kind::HiredWorker => 0,
5073                Kind::QuestBoard => 1,
5074                Kind::ExitDoor => 2,
5075                Kind::EnterDoor => 3,
5076                Kind::Well => 4,
5077                Kind::Water => 5,
5078            }
5079        }
5080
5081        let mut best: Option<(f32, Kind, String)> = None;
5082
5083        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5084            if dist > max {
5085                return;
5086            }
5087            let replace = match best {
5088                None => true,
5089                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5090                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5091                    kind_priority(kind) < kind_priority(bk)
5092                }
5093                _ => false,
5094            };
5095            if replace {
5096                best = Some((dist, kind, id));
5097            }
5098        };
5099
5100        for npc in &self.npcs {
5101            consider(
5102                distance(px, py, npc.x, npc.y),
5103                INTERACTION_RADIUS_M,
5104                Kind::Npc,
5105                npc.id.clone(),
5106            );
5107        }
5108
5109        for worker in &self.hired_workers {
5110            consider(
5111                distance(px, py, worker.x, worker.y),
5112                INTERACTION_RADIUS_M,
5113                Kind::HiredWorker,
5114                worker.instance_id.clone(),
5115            );
5116        }
5117
5118        for entity in &self.entities {
5119            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
5120            {
5121                continue;
5122            }
5123            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
5124            if self
5125                .hired_workers
5126                .iter()
5127                .any(|w| w.entity_id == entity.id)
5128            {
5129                continue;
5130            }
5131            consider(
5132                distance(
5133                    px,
5134                    py,
5135                    entity.transform.position.x,
5136                    entity.transform.position.y,
5137                ),
5138                INTERACTION_RADIUS_M,
5139                Kind::Player,
5140                entity.id.to_string(),
5141            );
5142        }
5143
5144        for door in &self.doors {
5145            if let Some(ref bid) = inside {
5146                if door.building_id != *bid {
5147                    continue;
5148                }
5149                let is_exit = door.portal.is_some();
5150                let max = if is_exit {
5151                    INTERACTION_RADIUS_M
5152                } else {
5153                    DOOR_INTERACTION_RADIUS_M
5154                };
5155                let kind = if is_exit {
5156                    Kind::ExitDoor
5157                } else {
5158                    Kind::EnterDoor
5159                };
5160                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5161                continue;
5162            }
5163            consider(
5164                distance(px, py, door.x, door.y),
5165                DOOR_INTERACTION_RADIUS_M,
5166                Kind::EnterDoor,
5167                door.id.clone(),
5168            );
5169        }
5170
5171        if inside.is_none() {
5172            for inter in &self.interactables {
5173                if inter.kind == "quest_board" {
5174                    consider(
5175                        distance(px, py, inter.x, inter.y),
5176                        QUEST_BOARD_INTERACTION_RADIUS_M,
5177                        Kind::QuestBoard,
5178                        inter.id.clone(),
5179                    );
5180                }
5181            }
5182            for building in &self.buildings {
5183                if !building.tags.iter().any(|t| t == "well") {
5184                    continue;
5185                }
5186                consider(
5187                    distance(px, py, building.x, building.y),
5188                    INTERACTION_RADIUS_M,
5189                    Kind::Well,
5190                    building.id.clone(),
5191                );
5192            }
5193            if self.in_shallow_water() {
5194                consider(
5195                    0.0,
5196                    INTERACTION_RADIUS_M,
5197                    Kind::Water,
5198                    "water_source".into(),
5199                );
5200            }
5201        }
5202
5203        best.map(|(_, _, id)| id)
5204    }
5205
5206    /// Nearest quest board and distance (any distance), for out-of-range feedback.
5207    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5208        if self.effective_inside_building().is_some() {
5209            return None;
5210        }
5211        let (px, py) = self.player_position();
5212        self.interactables
5213            .iter()
5214            .filter(|i| i.kind == "quest_board")
5215            .map(|i| {
5216                let label = if i.label.is_empty() {
5217                    "Quest board".to_string()
5218                } else {
5219                    i.label.clone()
5220                };
5221                (label, distance(px, py, i.x, i.y))
5222            })
5223            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5224    }
5225
5226    /// Human-readable template name from synced catalog hints.
5227    pub fn template_display_name(&self, template_id: &str) -> String {
5228        self.inventory_hints
5229            .get(template_id)
5230            .map(|h| h.display_name.clone())
5231            .filter(|n| !n.is_empty())
5232            .unwrap_or_else(|| humanize_template_id(template_id))
5233    }
5234
5235    /// Prefer blueprint-view display name, then inventory hints / humanized id.
5236    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5237        if !display_name.is_empty() {
5238            display_name.to_string()
5239        } else {
5240            self.template_display_name(template_id)
5241        }
5242    }
5243
5244    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5245        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5246    }
5247
5248    pub fn blueprint_ingredient_label(
5249        &self,
5250        input: &flatland_protocol::BlueprintIngredientView,
5251    ) -> String {
5252        self.blueprint_item_label(&input.template_id, &input.display_name)
5253    }
5254
5255    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5256        self.blueprint_item_label(&tool.item, &tool.display_name)
5257    }
5258
5259    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
5260    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5261        use crate::worker_route_editor::{
5262            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5263        };
5264        let lodging = self
5265            .worker_route_editor
5266            .as_ref()
5267            .and_then(|ed| ed.lodging_container_id.as_deref());
5268        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5269            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5270            None => node_candidates_stable(&self.resource_nodes),
5271        }
5272    }
5273
5274    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5275        if dist_m.is_nan() {
5276            return "—".into();
5277        }
5278        let from_bed = self
5279            .worker_route_editor
5280            .as_ref()
5281            .and_then(|ed| ed.lodging_container_id.as_deref())
5282            .and_then(|id| {
5283                self.placed_containers
5284                    .iter()
5285                    .find(|c| c.id == id)
5286                    .map(|c| c.display_name.clone())
5287            });
5288        match from_bed {
5289            Some(bed) => format!("{dist_m:.0}m from {bed}"),
5290            None => format!("{dist_m:.0}m"),
5291        }
5292    }
5293
5294    /// Chest label for the location panel — generic type unless this player owns it.
5295    pub fn placed_container_public_label(
5296        &self,
5297        c: &flatland_protocol::PlacedContainerView,
5298    ) -> String {
5299        let is_owner = match (self.character_id, c.owner_character_id) {
5300            (Some(me), Some(owner)) => me == owner,
5301            _ => false,
5302        };
5303        if is_owner {
5304            c.display_name.clone()
5305        } else {
5306            self.template_display_name(&c.template_id)
5307        }
5308    }
5309
5310    /// Keys on person and stowed on the keychain for the keychain overlay.
5311    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5312        let mut out = Vec::new();
5313        for stack in &self.inventory_stacks {
5314            if stack.template_id == KEY_TEMPLATE {
5315                out.push(KeychainEntry {
5316                    stack: stack.clone(),
5317                    stowed: false,
5318                });
5319            }
5320        }
5321        for stack in &self.keychain_stacks {
5322            if stack.template_id == KEY_TEMPLATE {
5323                out.push(KeychainEntry {
5324                    stack: stack.clone(),
5325                    stowed: true,
5326                });
5327            }
5328        }
5329        out
5330    }
5331
5332    /// Display name of the chest a `container_key` opens, when known on the client.
5333    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5334        if stack.template_id != KEY_TEMPLATE {
5335            return None;
5336        }
5337        if let Some(name) = stack
5338            .props
5339            .get(PROP_OPENS_CONTAINER_NAME)
5340            .filter(|n| !n.is_empty())
5341        {
5342            return Some(name.clone());
5343        }
5344        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5345        self.container_name_for_lock_id(opens)
5346    }
5347
5348    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
5349    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5350        if stack.template_id == KEY_TEMPLATE {
5351            self.template_display_name(KEY_TEMPLATE)
5352        } else {
5353            stack
5354                .display_name
5355                .clone()
5356                .unwrap_or_else(|| stack.template_id.clone())
5357        }
5358    }
5359
5360    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
5361    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5362        if stack.template_id != KEY_TEMPLATE {
5363            return String::new();
5364        }
5365        match self.key_pair_chest_label(stack) {
5366            Some(chest) if self.key_drop_blocked(stack) => {
5367                format!(" [key for {chest} — can't drop while locked]")
5368            }
5369            Some(chest) => format!(" [key for {chest}]"),
5370            None => " [key — unpaired]".into(),
5371        }
5372    }
5373
5374    /// Resolve a lock id to a container label (placed chest or one still on your person).
5375    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5376        for c in &self.placed_containers {
5377            if c.lock_id.as_deref() == Some(lock) {
5378                return Some(c.display_name.clone());
5379            }
5380        }
5381        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5382            self.worn
5383                .values()
5384                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5385        })
5386    }
5387
5388    /// Keys cannot be dropped while their paired chest is locked.
5389    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5390        if stack.template_id != KEY_TEMPLATE {
5391            return false;
5392        }
5393        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5394            return false;
5395        };
5396        for c in &self.placed_containers {
5397            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5398                return true;
5399            }
5400        }
5401        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5402            return true;
5403        }
5404        self.worn
5405            .values()
5406            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5407    }
5408
5409    /// Property deeds cannot be dropped or destroyed (store or trade only).
5410    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5411        stack.template_id == PROPERTY_DEED_TEMPLATE
5412    }
5413
5414    pub fn is_property_deed_template(template_id: &str) -> bool {
5415        template_id == PROPERTY_DEED_TEMPLATE
5416    }
5417
5418    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5419        stack
5420            .props
5421            .get("plot_id")
5422            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5423    }
5424
5425    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5426    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5427        let (px, py) = self.player_position();
5428        let (cx, cy) = self.farm_plot_cell_under_player()?;
5429        let tx = cx as f32 + 0.5;
5430        let ty = cy as f32 + 0.5;
5431        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5432            return None;
5433        }
5434        let kind = self
5435            .terrain_at(tx, ty)
5436            .or_else(|| self.terrain_at(px, py));
5437        if kind == Some(TerrainKindView::Tilled) {
5438            return None;
5439        }
5440        if matches!(
5441            kind,
5442            Some(TerrainKindView::ShallowWater)
5443                | Some(TerrainKindView::DeepWater)
5444                | Some(TerrainKindView::Rock)
5445        ) {
5446            return None;
5447        }
5448        Some((tx, ty))
5449    }
5450
5451    fn container_name_in_stacks(
5452        stacks: &[flatland_protocol::ItemStack],
5453        lock: &str,
5454    ) -> Option<String> {
5455        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5456            for s in stacks {
5457                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5458                    return Some(GameState::stack_container_label(s));
5459                }
5460                if let Some(name) = walk(&s.contents, lock) {
5461                    return Some(name);
5462                }
5463            }
5464            None
5465        }
5466        walk(stacks, lock)
5467    }
5468
5469    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5470        stack
5471            .props
5472            .get(PROP_CUSTOM_NAME)
5473            .cloned()
5474            .or_else(|| stack.display_name.clone())
5475            .unwrap_or_else(|| stack.template_id.clone())
5476    }
5477
5478    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5479        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5480            for s in stacks {
5481                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5482                    return true;
5483                }
5484                if walk(&s.contents, lock) {
5485                    return true;
5486                }
5487            }
5488            false
5489        }
5490        walk(stacks, lock)
5491    }
5492
5493    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5494        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5495            return Some(stack.clone());
5496        }
5497        for worn in self.worn.values() {
5498            if worn.item_instance_id == Some(instance_id) {
5499                return Some(worn.clone());
5500            }
5501            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5502                return Some(stack.clone());
5503            }
5504        }
5505        None
5506    }
5507
5508    /// Highest-`z_order` property zone covering `(x, y)`.
5509    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5510        self.property_zones
5511            .iter()
5512            .enumerate()
5513            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5514            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5515            .map(|(_, z)| z)
5516    }
5517
5518    /// Highest-`z_order` tax zone covering `(x, y)`.
5519    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5520        self.tax_zones
5521            .iter()
5522            .enumerate()
5523            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5524            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5525            .map(|(_, z)| z)
5526    }
5527
5528    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5529    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5530        let mut max_bps = 0u32;
5531        let mut y = y0 + 0.5;
5532        while y < y1 {
5533            let mut x = x0 + 0.5;
5534            while x < x1 {
5535                if let Some(tz) = self.tax_zone_at(x, y) {
5536                    max_bps = max_bps.max(tz.rate_bps);
5537                }
5538                x += 1.0;
5539            }
5540            y += 1.0;
5541        }
5542        max_bps
5543    }
5544
5545    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5546    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5547        let mode = self.claim_mode.as_ref()?;
5548        let w = mode.width_m.max(1) as f32;
5549        let h = mode.height_m.max(1) as f32;
5550        Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5551    }
5552
5553    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5554    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5555        let mode = self.relocate_mode.as_ref()?;
5556        let x0 = mode.cursor_x.floor();
5557        let y0 = mode.cursor_y.floor();
5558        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5559    }
5560
5561    /// Client quote matching server `quote_plot`:
5562    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5563    pub fn claim_quote(
5564        &self,
5565    ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5566        let mode = self.claim_mode.as_ref()?;
5567        let zone = self
5568            .property_zones
5569            .iter()
5570            .find(|z| z.id == mode.zone_id)?;
5571        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5572        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5573        let zone_area = zone_view_area_m2(zone).max(1.0);
5574        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5575        let weight = self
5576            .property_plot_settings
5577            .as_ref()
5578            .map(|s| s.tax_premium_weight)
5579            .unwrap_or(0.5)
5580            .max(0.0);
5581        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5582        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5583        let purchase = ((zone.crown_price_copper as f64)
5584            * (area_frac as f64)
5585            * (premium as f64))
5586            .ceil()
5587            .max(0.0) as u64;
5588        let upkeep = if zone.upkeep_copper_per_day == 0 {
5589            0
5590        } else {
5591            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5592                .ceil()
5593                .max(1.0) as u64
5594        };
5595        let copper = crate::currency::copper_from_counts(&self.inventory);
5596        let can_afford = copper >= purchase;
5597        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5598        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5599    }
5600
5601    fn validate_claim_footprint(
5602        &self,
5603        zone: &flatland_protocol::PropertyZoneView,
5604        x0: f32,
5605        y0: f32,
5606        x1: f32,
5607        y1: f32,
5608        area: f32,
5609    ) -> (bool, String) {
5610        let min_area = self
5611            .property_plot_settings
5612            .as_ref()
5613            .map(|s| s.min_plot_area_m2)
5614            .unwrap_or(4.0);
5615        if area + f32::EPSILON < min_area {
5616            return (false, "plot too small".into());
5617        }
5618        if zone.max_area_m2.is_some_and(|m| area > m) {
5619            return (false, "plot exceeds max area".into());
5620        }
5621        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5622            return (false, "plot must lie inside the property zone".into());
5623        }
5624        if self.property_plots.iter().any(|p| {
5625            rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5626        }) {
5627            return (false, "plot overlaps an existing claim".into());
5628        }
5629        (true, String::new())
5630    }
5631
5632    /// Standing in a property zone on a free cell (not already claimed).
5633    pub fn free_property_zone_under_player(
5634        &self,
5635    ) -> Option<&flatland_protocol::PropertyZoneView> {
5636        let (px, py) = self.player_position();
5637        let zone = self.property_zone_at(px, py)?;
5638        if self
5639            .property_plots
5640            .iter()
5641            .any(|p| point_in_plot(px, py, p))
5642        {
5643            return None;
5644        }
5645        Some(zone)
5646    }
5647
5648    /// Own plot covering the player (`is_mine`).
5649    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5650        let (px, py) = self.player_position();
5651        self.property_plots
5652            .iter()
5653            .find(|p| p.is_mine && point_in_plot(px, py, p))
5654    }
5655
5656    /// Plot under the player that they may farm (deed, grant, or public).
5657    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5658        let (px, py) = self.player_position();
5659        self.property_plots
5660            .iter()
5661            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5662    }
5663
5664    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5665    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5666        if self.farmable_plot_under_player().is_none() {
5667            return None;
5668        }
5669        let (px, py) = self.player_position();
5670        Some((px.floor() as i32, py.floor() as i32))
5671    }
5672
5673    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5674        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5675        self.resource_nodes.iter().any(|n| {
5676            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5677            ncx == cx && ncy == cy
5678                || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5679        })
5680    }
5681
5682    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5683        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5684        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5685            || self
5686                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5687                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5688        if !tilled {
5689            return false;
5690        }
5691        !self.resource_node_occupies_farm_cell(cx, cy)
5692    }
5693
5694    /// Empty tilled soil on the cell the player is standing on (no crop node).
5695    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5696        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5697            return false;
5698        };
5699        self.free_tilled_plant_slot_at(cx, cy)
5700    }
5701
5702    /// True when a free tilled cell (no resource node) is within interact range.
5703    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5704        let (px, py) = self.player_position();
5705        for dy in -2..=2 {
5706            for dx in -2..=2 {
5707                let cx = px.floor() as i32 + dx;
5708                let cy = py.floor() as i32 + dy;
5709                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5710                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5711                    continue;
5712                }
5713                if self.free_tilled_plant_slot_at(cx, cy) {
5714                    return true;
5715                }
5716            }
5717        }
5718        false
5719    }
5720
5721    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5722        stack.quantity > 0
5723            && (stack.props.contains_key("seed_for")
5724                || stack.template_id.ends_with("_seed")
5725                || stack.template_id == "potato_seed"
5726                || stack.template_id == "carrot_seed")
5727    }
5728
5729    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
5730    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5731        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5732        fn walk(
5733            stacks: &[flatland_protocol::ItemStack],
5734            counts: &mut std::collections::HashMap<String, u32>,
5735        ) {
5736            for s in stacks {
5737                if GameState::stack_is_farm_seed(s) {
5738                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5739                }
5740                walk(&s.contents, counts);
5741            }
5742        }
5743        walk(&self.inventory_stacks, &mut counts);
5744        for worn in self.worn.values() {
5745            walk(std::slice::from_ref(worn), &mut counts);
5746        }
5747        let mut out: Vec<_> = counts
5748            .into_iter()
5749            .map(|(template_id, quantity)| {
5750                let label = self
5751                    .inventory_hints
5752                    .get(&template_id)
5753                    .map(|h| h.display_name.clone())
5754                    .filter(|n| !n.trim().is_empty())
5755                    .unwrap_or_else(|| humanize_template_id(&template_id));
5756                (template_id, quantity, label)
5757            })
5758            .collect();
5759        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5760        out
5761    }
5762
5763    /// First farm seed template in inventory (root + nested bags).
5764    pub fn first_farm_seed_template(&self) -> Option<String> {
5765        self.farm_seed_entries()
5766            .into_iter()
5767            .next()
5768            .map(|(id, _, _)| id)
5769    }
5770
5771    pub fn clamp_plant_menu(&mut self) {
5772        let n = self.farm_seed_entries().len();
5773        if n == 0 {
5774            self.plant_menu_index = 0;
5775            self.plant_quantity = 1;
5776            return;
5777        }
5778        self.plant_menu_index = self.plant_menu_index.min(n - 1);
5779        let max_qty = self
5780            .farm_seed_entries()
5781            .get(self.plant_menu_index)
5782            .map(|(_, q, _)| *q)
5783            .unwrap_or(1)
5784            .max(1);
5785        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5786    }
5787
5788    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5789        let entries = self.farm_seed_entries();
5790        let (id, max, label) = entries.get(self.plant_menu_index)?;
5791        let qty = self.plant_quantity.min(*max).max(1);
5792        Some((id.clone(), qty, label.clone()))
5793    }
5794
5795    /// Terrain, interactables, and map objects near the player for the HUD location panel.
5796    pub fn location_context_lines(&self) -> Vec<ContextLine> {
5797        let (px, py) = self.player_position();
5798        let inside = self.effective_inside_building();
5799        let mut lines = Vec::new();
5800
5801        if let Some(kind) = self.terrain_at(px, py) {
5802            lines.push(ContextLine {
5803                on_top: true,
5804                text: format!("Terrain: {}", terrain_kind_label(kind)),
5805            });
5806        }
5807
5808        if let Some(id) = inside.as_ref() {
5809            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5810                lines.push(ContextLine {
5811                    on_top: true,
5812                    text: format!("Inside: {}", b.label),
5813                });
5814            }
5815        }
5816
5817        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5818
5819        for node in &self.resource_nodes {
5820            if node.id.starts_with("preview:") {
5821                continue;
5822            }
5823            let dist = distance(px, py, node.x, node.y);
5824            if dist > NEARBY_SCAN_M {
5825                continue;
5826            }
5827            let on_top = dist <= ON_TOP_RADIUS_M;
5828            let prefix = if on_top { "On" } else { "Near" };
5829            let name = resource_node_near_display_label(&node.label);
5830            let action = resource_node_near_action_suffix(node);
5831            nearby.push((
5832                dist,
5833                ContextLine {
5834                    on_top,
5835                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5836                },
5837            ));
5838        }
5839
5840        for drop in &self.ground_drops {
5841            let dist = distance(px, py, drop.x, drop.y);
5842            if dist > INTERACTION_RADIUS_M {
5843                continue;
5844            }
5845            let on_top = dist <= ON_TOP_RADIUS_M;
5846            let name = self.template_display_name(&drop.template_id);
5847            let prefix = if on_top { "On" } else { "Near" };
5848            let qty = if drop.quantity > 1 {
5849                format!(" ×{}", drop.quantity)
5850            } else {
5851                String::new()
5852            };
5853            nearby.push((
5854                dist,
5855                ContextLine {
5856                    on_top,
5857                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5858                },
5859            ));
5860        }
5861
5862        for c in &self.placed_containers {
5863            if !self.placed_container_in_current_space(c) {
5864                continue;
5865            }
5866            let dist = distance(px, py, c.x, c.y);
5867            if dist > CONTAINER_RANGE_M {
5868                continue;
5869            }
5870            let on_top = dist <= ON_TOP_RADIUS_M;
5871            let name = self.placed_container_public_label(c);
5872            let lock = if c.locked { " [locked]" } else { "" };
5873            let prefix = if on_top { "On" } else { "Near" };
5874            nearby.push((
5875                dist,
5876                ContextLine {
5877                    on_top,
5878                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5879                },
5880            ));
5881        }
5882
5883        for npc in &self.npcs {
5884            let dist = distance(px, py, npc.x, npc.y);
5885            if dist > NEARBY_SCAN_M {
5886                continue;
5887            }
5888            let on_top = dist <= ON_TOP_RADIUS_M;
5889            let prefix = if on_top { "On" } else { "Near" };
5890            nearby.push((
5891                dist,
5892                ContextLine {
5893                    on_top,
5894                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5895                },
5896            ));
5897        }
5898
5899        for door in &self.doors {
5900            let dist = distance(px, py, door.x, door.y);
5901            if dist > DOOR_INTERACTION_RADIUS_M {
5902                continue;
5903            }
5904            let building = self
5905                .buildings
5906                .iter()
5907                .find(|b| b.id == door.building_id)
5908                .map(|b| b.label.as_str())
5909                .unwrap_or(door.building_id.as_str());
5910            let player_house = self
5911                .buildings
5912                .iter()
5913                .find(|b| b.id == door.building_id)
5914                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5915            let action = if inside.is_some() && door.portal.is_some() {
5916                if player_house {
5917                    if door.locked {
5918                        "locked — l unlock · Enter exit".to_string()
5919                    } else if door.open {
5920                        "close · Enter exit · l lock".to_string()
5921                    } else {
5922                        "open · Enter exit · l lock".to_string()
5923                    }
5924                } else {
5925                    "exit".to_string()
5926                }
5927            } else if player_house {
5928                if door.locked {
5929                    "locked — l unlock".to_string()
5930                } else if door.open {
5931                    "close · Enter go inside · l lock".to_string()
5932                } else {
5933                    "open · l lock".to_string()
5934                }
5935            } else {
5936                "enter".to_string()
5937            };
5938            nearby.push((
5939                dist,
5940                ContextLine {
5941                    on_top: dist <= ON_TOP_RADIUS_M,
5942                    text: format!("{building} door ({dist:.1}m) — f {action}"),
5943                },
5944            ));
5945        }
5946
5947        if inside.is_none() {
5948            for inter in &self.interactables {
5949                if inter.kind != "quest_board" {
5950                    continue;
5951                }
5952                let dist = distance(px, py, inter.x, inter.y);
5953                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5954                    continue;
5955                }
5956                let on_top = dist <= ON_TOP_RADIUS_M;
5957                let prefix = if on_top { "On" } else { "Near" };
5958                let label = if inter.label.is_empty() {
5959                    "Quest board".to_string()
5960                } else {
5961                    inter.label.clone()
5962                };
5963                nearby.push((
5964                    dist,
5965                    ContextLine {
5966                        on_top,
5967                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5968                    },
5969                ));
5970            }
5971        }
5972
5973        if self.in_shallow_water() {
5974            let already = self
5975                .terrain_at(px, py)
5976                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5977            if !already {
5978                nearby.push((
5979                    0.0,
5980                    ContextLine {
5981                        on_top: true,
5982                        text: "Shallow water — f fill bottle".into(),
5983                    },
5984                ));
5985            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5986                line.text.push_str(" — f fill bottle");
5987            }
5988        }
5989
5990        if self.claim_mode.is_some() {
5991            nearby.push((
5992                0.0,
5993                ContextLine {
5994                    on_top: true,
5995                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5996                        .into(),
5997                },
5998            ));
5999        } else if let Some(plot) = self.my_plot_under_player() {
6000            let name = plot_public_label(plot);
6001            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
6002                format!("{name} — f again to sell to crown")
6003            } else {
6004                format!(
6005                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
6006                )
6007            };
6008            nearby.push((
6009                0.0,
6010                ContextLine {
6011                    on_top: true,
6012                    text: prompt,
6013                },
6014            ));
6015        } else if let Some(plot) = self.farmable_plot_under_player() {
6016            let name = plot_public_label(plot);
6017            let disc = if plot.farm_public {
6018                plot.public_tax_discount_bps / 100
6019            } else {
6020                plot.farm_allow
6021                    .iter()
6022                    .find(|g| Some(g.character_id) == self.character_id)
6023                    .map(|g| g.tax_discount_bps / 100)
6024                    .unwrap_or(0)
6025            };
6026            nearby.push((
6027                0.0,
6028                ContextLine {
6029                    on_top: true,
6030                    text: format!(
6031                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
6032                    ),
6033                },
6034            ));
6035        } else if let Some(zone) = self.free_property_zone_under_player() {
6036            let label = zone
6037                .label
6038                .as_deref()
6039                .filter(|s| !s.trim().is_empty())
6040                .unwrap_or(zone.id.as_str());
6041            nearby.push((
6042                0.0,
6043                ContextLine {
6044                    on_top: true,
6045                    text: format!("Claimable land: {label} — k buy plot"),
6046                },
6047            ));
6048        }
6049
6050        for entity in &self.entities {
6051            if entity.id == self.entity_id {
6052                continue;
6053            }
6054            let dist = distance(
6055                px,
6056                py,
6057                entity.transform.position.x,
6058                entity.transform.position.y,
6059            );
6060            if dist > NEARBY_SCAN_M {
6061                continue;
6062            }
6063            let label = if entity.label.is_empty() {
6064                format!("entity {}", entity.id)
6065            } else {
6066                entity.label.clone()
6067            };
6068            nearby.push((
6069                dist,
6070                ContextLine {
6071                    on_top: dist <= ON_TOP_RADIUS_M,
6072                    text: format!("Near: {label} ({dist:.1}m)"),
6073                },
6074            ));
6075        }
6076
6077        nearby.sort_by(|a, b| {
6078            a.0.partial_cmp(&b.0)
6079                .unwrap_or(std::cmp::Ordering::Equal)
6080                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6081        });
6082        lines.extend(nearby.into_iter().map(|(_, l)| l));
6083
6084        if lines.is_empty() {
6085            lines.push(ContextLine {
6086                on_top: false,
6087                text: "(nothing notable nearby)".into(),
6088            });
6089        }
6090
6091        lines
6092    }
6093}
6094
6095/// HUD line for the location / nearby panel.
6096#[derive(Debug, Clone)]
6097pub struct ContextLine {
6098    pub on_top: bool,
6099    pub text: String,
6100}
6101
6102const ON_TOP_RADIUS_M: f32 = 0.65;
6103const NEARBY_SCAN_M: f32 = 5.0;
6104
6105/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
6106pub fn resource_node_near_display_label(label: &str) -> String {
6107    label
6108        .strip_suffix(" (growing)")
6109        .unwrap_or(label)
6110        .to_string()
6111}
6112
6113fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6114    let t = label.trim();
6115    if t.is_empty() || t == id {
6116        return true;
6117    }
6118    let lower = t.to_ascii_lowercase();
6119    if lower.contains("_copy") {
6120        return true;
6121    }
6122    false
6123}
6124
6125fn humanize_item_template_label(template: &str) -> String {
6126    let base = template.rsplit('/').next().unwrap_or(template).trim();
6127    if base.is_empty() {
6128        return "Resource".into();
6129    }
6130    let stripped = base
6131        .strip_prefix("crop-")
6132        .or_else(|| base.strip_prefix("crop_"))
6133        .unwrap_or(base);
6134    stripped
6135        .split(|c: char| c == '-' || c == '_')
6136        .filter(|p| !p.is_empty())
6137        .map(|p| {
6138            let mut chars = p.chars();
6139            match chars.next() {
6140                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6141                None => String::new(),
6142            }
6143        })
6144        .collect::<Vec<_>>()
6145        .join(" ")
6146}
6147
6148/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
6149pub fn resource_node_id_suffix(id: &str) -> String {
6150    let chars: Vec<char> = id
6151        .chars()
6152        .rev()
6153        .filter(|c| c.is_ascii_alphanumeric())
6154        .take(4)
6155        .collect();
6156    chars.into_iter().rev().collect()
6157}
6158
6159/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
6160pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6161    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6162}
6163
6164pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6165    let cleaned = resource_node_near_display_label(label);
6166    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6167        cleaned
6168    } else if !item_template.trim().is_empty() {
6169        humanize_item_template_label(item_template)
6170    } else {
6171        id.to_string()
6172    };
6173    let suffix = resource_node_id_suffix(id);
6174    if suffix.is_empty() {
6175        friendly
6176    } else {
6177        format!("{friendly} ({suffix})")
6178    }
6179}
6180
6181/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
6182pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6183    use flatland_protocol::ResourceNodeState;
6184    if node.harvest_off {
6185        return " (decorative)".to_string();
6186    }
6187    if let Some(p) = node.growth_progress {
6188        if p < 1.0 - f32::EPSILON {
6189            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6190            return format!(" (growing, {pct}%)");
6191        }
6192        return " — f harvest".to_string();
6193    }
6194    match node.state {
6195        ResourceNodeState::Available => " — f harvest".to_string(),
6196        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6197        ResourceNodeState::Cooldown => " (depleted)".to_string(),
6198    }
6199}
6200
6201fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6202    use flatland_protocol::TerrainKindView;
6203    match kind {
6204        TerrainKindView::Grass => "Grass",
6205        TerrainKindView::Dirt => "Dirt",
6206        TerrainKindView::Tilled => "Tilled",
6207        TerrainKindView::Desert => "Desert",
6208        TerrainKindView::Hill => "Hills",
6209        TerrainKindView::Bog => "Bog",
6210        TerrainKindView::Beach => "Beach",
6211        TerrainKindView::ShallowWater => "Shallow water",
6212        TerrainKindView::DeepWater => "Deep water",
6213        TerrainKindView::Trail => "Trail",
6214        TerrainKindView::Road => "Road",
6215        TerrainKindView::Rock => "Rock",
6216    }
6217}
6218
6219fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6220    crate::world_zones::zone_rects_contain(rects, x, y)
6221}
6222
6223fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6224    zone.rects
6225        .iter()
6226        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6227        .sum()
6228}
6229
6230fn claim_rect_fully_inside_zone(
6231    zone: &flatland_protocol::PropertyZoneView,
6232    x0: f32,
6233    y0: f32,
6234    x1: f32,
6235    y1: f32,
6236) -> bool {
6237    let mut y = y0 + 0.5;
6238    while y < y1 {
6239        let mut x = x0 + 0.5;
6240        while x < x1 {
6241            if !zone_rects_contain(&zone.rects, x, y) {
6242                return false;
6243            }
6244            x += 1.0;
6245        }
6246        y += 1.0;
6247    }
6248    true
6249}
6250
6251fn rects_overlap_half_open(
6252    ax0: f32,
6253    ay0: f32,
6254    ax1: f32,
6255    ay1: f32,
6256    bx0: f32,
6257    by0: f32,
6258    bx1: f32,
6259    by1: f32,
6260) -> bool {
6261    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6262}
6263
6264fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6265    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6266}
6267
6268fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6269    plot_public_label(p)
6270}
6271
6272/// Canonical plot name: `{owner} — {zone} — {label}`.
6273pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6274    let zone = p
6275        .zone_label
6276        .as_deref()
6277        .filter(|s| !s.trim().is_empty())
6278        .unwrap_or_else(|| {
6279            if p.property_zone_id.is_empty() {
6280                "Homestead"
6281            } else {
6282                p.property_zone_id.as_str()
6283            }
6284        });
6285    let label = if p.label.trim().is_empty() {
6286        if p.plot_code.trim().is_empty() {
6287            p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6288        } else {
6289            p.plot_code.clone()
6290        }
6291    } else {
6292        p.label.clone()
6293    };
6294    match p.owner_label.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
6295        Some(owner) => format!("{owner} — {zone} — {label}"),
6296        None => format!("{zone} — {label}"),
6297    }
6298}
6299
6300/// Match `flatland_sim::economy_zones::snap_claim_rect`.
6301fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6302    let a = x0.min(x1).floor();
6303    let b = y0.min(y1).floor();
6304    let mut c = x0.max(x1).ceil();
6305    let mut d = y0.max(y1).ceil();
6306    if (c - a) < 1.0 {
6307        c = a + 1.0;
6308    }
6309    if (d - b) < 1.0 {
6310        d = b + 1.0;
6311    }
6312    (a, b, c, d)
6313}
6314
6315fn humanize_template_id(template_id: &str) -> String {
6316    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
6317    if looks_like_template_uuid(template_id) {
6318        return "Unknown item".into();
6319    }
6320    template_id
6321        .split('_')
6322        .map(|word| {
6323            let mut chars = word.chars();
6324            match chars.next() {
6325                None => String::new(),
6326                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6327            }
6328        })
6329        .collect::<Vec<_>>()
6330        .join(" ")
6331}
6332
6333fn looks_like_template_uuid(template_id: &str) -> bool {
6334    let bytes = template_id.as_bytes();
6335    if bytes.len() != 36 {
6336        return false;
6337    }
6338    let is_hex = |b: u8| b.is_ascii_hexdigit();
6339    let groups = [8usize, 4, 4, 4, 12];
6340    let mut i = 0;
6341    for (gi, &len) in groups.iter().enumerate() {
6342        if gi > 0 {
6343            if bytes.get(i) != Some(&b'-') {
6344                return false;
6345            }
6346            i += 1;
6347        }
6348        for _ in 0..len {
6349            if !bytes.get(i).copied().is_some_and(is_hex) {
6350                return false;
6351            }
6352            i += 1;
6353        }
6354    }
6355    true
6356}
6357
6358/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
6359const HARVEST_RANGE_M: f32 = 1.5;
6360
6361pub struct GameClient<S: PlayConnection> {
6362    session: S,
6363    seq: Seq,
6364    pub state: GameState,
6365    last_move_forward: f32,
6366    last_move_strafe: f32,
6367}
6368
6369impl<S: PlayConnection> GameClient<S> {
6370    pub fn new(session: S) -> Self {
6371        let session_id = session.session_id();
6372        let entity_id = session.entity_id();
6373        let mut client = Self {
6374            session,
6375            seq: 0,
6376            last_move_forward: 0.0,
6377            last_move_strafe: 0.0,
6378            state: GameState {
6379                session_id,
6380                entity_id,
6381                character_id: None,
6382                tick: 0,
6383                chunk_rev: 0,
6384                content_rev: 0,
6385                publish_rev: 0,
6386                entities: Vec::new(),
6387                player: None,
6388                resource_nodes: Vec::new(),
6389                ground_drops: Vec::new(),
6390                placed_containers: Vec::new(),
6391                buildings: Vec::new(),
6392                doors: Vec::new(),
6393                interior_map: None,
6394                npcs: Vec::new(),
6395                blueprints: Vec::new(),
6396                building_materials: Vec::new(),
6397                world_x0: 0.0,
6398                world_y0: 0.0,
6399                world_width_m: 0.0,
6400                world_height_m: 0.0,
6401                terrain_zones: Vec::new(),
6402                z_platforms: Vec::new(),
6403                z_transitions: Vec::new(),
6404                z_bands_outdoor_backup: None,
6405                world_clock: flatland_protocol::WorldClock::default(),
6406                inventory: std::collections::HashMap::new(),
6407                inventory_hints: std::collections::HashMap::new(),
6408                logs: VecDeque::new(),
6409                intents_sent: 0,
6410                ticks_received: 0,
6411                connected: false,
6412                disconnect_reason: None,
6413                show_stats: false,
6414            hud_log_hidden: false,
6415                show_equip_menu: false,
6416                equip_menu_index: 0,
6417                show_craft_menu: false,
6418                show_plot_build_menu: false,
6419                plot_build_focus_wall: true,
6420                plot_build_wall_index: 0,
6421                plot_build_roof_index: 0,
6422                craft_menu_index: 0,
6423                craft_batch_quantity: 1,
6424                show_shop_menu: false,
6425                shop_catalog: None,
6426                bank_panel: None,
6427                bank_menu_index: 0,
6428                bank_ui_mode: BankUiMode::Menu,
6429                storage_panel: None,
6430                market_panel: None,
6431                market_menu_index: 0,
6432                market_filter: String::new(),
6433                market_filter_focused: false,
6434                market_category_filter: None,
6435                market_buy_confirm: None,
6436                market_ui_mode: MarketUiMode::Browse,
6437                storage_menu_index: 0,
6438                storage_ui_mode: StorageUiMode::Menu,
6439                shop_tab: ShopTab::default(),
6440                shop_menu_index: 0,
6441                shop_quantity: 1,
6442                shop_trade_log: VecDeque::new(),
6443                show_npc_verb_menu: false,
6444                npc_verb_target: None,
6445                npc_verb_index: 0,
6446                player_verbs: crate::social::PlayerVerbState::default(),
6447                social_chat: crate::social::SocialChatState::default(),
6448                trade_ui: crate::social::TradeUiState::default(),
6449                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6450                show_npc_chat: false,
6451                npc_chat: None,
6452                show_inventory_menu: false,
6453                inventory_menu_index: 0,
6454                inventory_tab: InventoryTab::OnPerson,
6455                inventory_filter: String::new(),
6456                inventory_filter_focused: false,
6457                show_move_picker: false,
6458                show_rename_prompt: false,
6459                rename_plot_id: None,
6460                highlighted_plot_id: None,
6461                show_worker_rename: false,
6462                rename_buffer: String::new(),
6463                move_picker_index: 0,
6464                move_picker: None,
6465                show_grant_picker: false,
6466                grant_picker_index: 0,
6467                grant_picker: None,
6468                show_destroy_picker: false,
6469                destroy_confirm_pending: false,
6470                destroy_picker: None,
6471                combat_target: None,
6472                combat_target_label: None,
6473                ground_target: None,
6474                combat_fx: Vec::new(),
6475                property_zones: Vec::new(),
6476                tax_zones: Vec::new(),
6477                growth_zones: Vec::new(),
6478                biome_zones: Vec::new(),
6479                terrain_kind_nav: Vec::new(),
6480                property_plots: Vec::new(),
6481                property_plot_settings: None,
6482                claim_mode: None,
6483                relocate_mode: None,
6484                sell_plot_confirm: None,
6485                sell_plot_armed_at: None,
6486                show_plant_menu: false,
6487                plant_menu_index: 0,
6488                show_farm_access: false,
6489                farm_access_name_draft: String::new(),
6490                farm_access_discount_bps: 0,
6491                farm_access_index: 0,
6492                plant_quantity: 1,
6493                in_combat: false,
6494                auto_attack: true,
6495                combat_has_los: false,
6496                attack_cd_ticks: 0,
6497                gcd_ticks: 0,
6498                weapon_ability_id: "unarmed".into(),
6499                mainhand_template_id: None,
6500                mainhand_label: None,
6501                mainhand_instance_id: None,
6502                offhand_template_id: None,
6503                offhand_label: None,
6504                offhand_instance_id: None,
6505                mainhand_hand_slots: 1,
6506                defense: None,
6507                worn: BTreeMap::new(),
6508                carry_mass: 0.0,
6509                carry_mass_max: 0.0,
6510                encumbrance: flatland_protocol::EncumbranceState::Light,
6511                inventory_stacks: Vec::new(),
6512                keychain_stacks: Vec::new(),
6513            whisper_pouch_stacks: Vec::new(),
6514                combat_target_detail: None,
6515                statuses: Vec::new(),
6516                cast_progress: None,
6517                timed_channel: None,
6518                plot_build_offer: None,
6519                ability_cooldowns: Vec::new(),
6520                blocking_active: false,
6521                max_target_slots: 1,
6522                combat_slots: Vec::new(),
6523                rotation_presets: Vec::new(),
6524                known_abilities: Vec::new(),
6525                ability_meta: std::collections::HashMap::new(),
6526                ability_mastery: std::collections::HashMap::new(),
6527                hotbar: vec![None; 9],
6528                max_abilities_per_rotation: 0,
6529                show_loadout_menu: false,
6530                show_keychain_menu: false,
6531                keychain_menu_index: 0,
6532                show_rotation_editor: false,
6533                loadout_menu_index: 0,
6534                loadout_hotbar_slot: 1,
6535                loadout_ability_index: 0,
6536                loadout_focus_presets: false,
6537                rotation_editor: RotationEditorState::default(),
6538                harvest_in_progress: false,
6539                harvest_started_at: None,
6540                pending_craft_ack: None,
6541                pending_worker_job_ack: None,
6542                attending_worker_instance_id: None,
6543                quest_log: Vec::new(),
6544                interactables: Vec::new(),
6545                ledger: None,
6546                career: None,
6547                character_sheet_tab: CharacterSheetTab::Character,
6548                ledger_period: LedgerPeriod::Day,
6549                show_quest_offer: false,
6550                pending_quest_offer: None,
6551                show_quest_menu: false,
6552                quest_menu_index: 0,
6553                quest_withdraw_confirm: false,
6554                hired_workers: Vec::new(),
6555                show_workers_menu: false,
6556                workers_menu_index: 0,
6557                workers_menu_compact: false,
6558                worker_step_display: BTreeMap::new(),
6559                worker_error_display: BTreeMap::new(),
6560                show_worker_give_picker: false,
6561                worker_give_picker_index: 0,
6562                worker_give_picker: None,
6563                show_worker_give_target_picker: false,
6564                worker_give_target_picker_index: 0,
6565                worker_give_target_picker: None,
6566                show_worker_take_picker: false,
6567                worker_take_picker_index: 0,
6568                worker_take_picker: None,
6569                show_worker_teach_picker: false,
6570                worker_teach_picker_index: 0,
6571                worker_teach_picker: None,
6572                worker_route_editor: None,
6573                progression_curve: None,
6574            },
6575        };
6576        client.state.apply_client_ui_prefs();
6577        client
6578    }
6579
6580    pub fn entity_id(&self) -> EntityId {
6581        self.state.entity_id
6582    }
6583
6584    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6585        if self.state.connected {
6586            return Ok(());
6587        }
6588
6589        loop {
6590            match self.session.next_event().await {
6591                Some(SessionEvent::Welcome {
6592                    session_id,
6593                    entity_id,
6594                    snapshot,
6595                }) => {
6596                    self.state
6597                        .restore_from_welcome(session_id, entity_id, &snapshot);
6598                    self.state.apply_client_ui_prefs();
6599                    self.state.push_log(format!(
6600                        "Connected — session {session_id}, entity {entity_id}"
6601                    ));
6602                    return Ok(());
6603                }
6604                Some(SessionEvent::Disconnected { .. }) => {
6605                    anyhow::bail!("disconnected before welcome");
6606                }
6607                Some(_) => continue,
6608                None => anyhow::bail!("session closed before welcome"),
6609            }
6610        }
6611    }
6612
6613    /// Drain all pending server events (non-blocking).
6614    pub fn drain_events(&mut self) {
6615        while let Some(event) = self.session.try_next_event() {
6616            if self.handle_event_sync(event).is_err() {
6617                break;
6618            }
6619        }
6620    }
6621
6622    /// Wait for the next server event.
6623    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6624        self.session.next_event().await
6625    }
6626
6627    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6628        self.handle_event_sync(event)
6629    }
6630
6631    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6632        match event {
6633            SessionEvent::Welcome {
6634                session_id,
6635                entity_id,
6636                snapshot,
6637            } => {
6638                let resumed = self.state.connected;
6639                self.state
6640                    .restore_from_welcome(session_id, entity_id, &snapshot);
6641                if resumed {
6642                    self.state.push_log(format!(
6643                        "Session restored — session {session_id}, entity {entity_id}"
6644                    ));
6645                }
6646            }
6647            SessionEvent::ContentUpdated { snapshot } => {
6648                self.state
6649                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6650                self.state.push_log(format!(
6651                    "World updated (content rev {})",
6652                    snapshot.content_rev
6653                ));
6654            }
6655            SessionEvent::Tick(delta) => {
6656                self.state.apply_tick_fields(&delta, self.state.entity_id);
6657                self.state.ticks_received += 1;
6658            }
6659            SessionEvent::IntentAck {
6660                entity_id,
6661                seq,
6662                tick,
6663            } => {
6664                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6665                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6666                    if *craft_seq == seq {
6667                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6668                        if batches > 1 {
6669                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6670                        } else {
6671                            self.state.push_log(format!("Crafting {label}…"));
6672                        }
6673                    }
6674                }
6675                if self
6676                    .state
6677                    .pending_worker_job_ack
6678                    .as_ref()
6679                    .is_some_and(|p| p.seq == seq)
6680                {
6681                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6682                    if pending.idle {
6683                        self.state.push_log(format!(
6684                            "Route cleared for {} — worker idle",
6685                            pending.worker_label
6686                        ));
6687                    } else {
6688                        self.state.push_log(format!(
6689                            "Route saved for {} — {} stop(s), job loop active",
6690                            pending.worker_label, pending.stop_count
6691                        ));
6692                    }
6693                    if self
6694                        .state
6695                        .worker_route_editor
6696                        .as_ref()
6697                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6698                    {
6699                        self.close_worker_route_editor();
6700                    }
6701                }
6702            }
6703            SessionEvent::Chat(msg) => {
6704                let label = match msg.channel {
6705                    flatland_protocol::ChatChannel::Nearby => "nearby",
6706                    flatland_protocol::ChatChannel::Direct => "speak",
6707                    flatland_protocol::ChatChannel::Whisper => "whisper",
6708                    flatland_protocol::ChatChannel::WhisperStone => "stone",
6709                };
6710                let clarity = match msg.clarity {
6711                    flatland_protocol::ChatClarity::Clear => "",
6712                    flatland_protocol::ChatClarity::Partial => "~",
6713                    flatland_protocol::ChatClarity::Heavy => "…",
6714                };
6715                self.state.push_log(format!(
6716                    "[{label}{clarity}] {}: {}",
6717                    msg.from_name, msg.text
6718                ));
6719                let now_ms = std::time::SystemTime::now()
6720                    .duration_since(std::time::UNIX_EPOCH)
6721                    .map(|d| d.as_millis() as u64)
6722                    .unwrap_or(0);
6723                self.state
6724                    .social_chat
6725                    .note_speech(&msg, self.state.entity_id, now_ms);
6726                self.state
6727                    .social_chat
6728                    .push(crate::social::ChatLogEntry::from_message(
6729                        msg,
6730                        self.state.entity_id,
6731                    ));
6732            }
6733            SessionEvent::TradeOpened(panel) => {
6734                self.state.social_chat.pending_trade = None;
6735                let peer = panel.peer_name.clone();
6736                self.state.trade_ui.open(panel);
6737                self.state
6738                    .social_chat
6739                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6740                self.state
6741                    .social_chat
6742                    .push_cue(crate::social::AudioCue::TradeOpened);
6743            }
6744            SessionEvent::TradeClosed { reason } => {
6745                self.state.push_log(reason.clone());
6746                self.state.social_chat.push_system(reason);
6747                self.state.trade_ui.close();
6748            }
6749            SessionEvent::HarvestResult(result) => {
6750                self.state.clear_harvest_state();
6751                crate::harvest_trace!(
6752                    entity_id = self.state.entity_id,
6753                    node_id = %result.node_id,
6754                    template = %result.item_template,
6755                    quantity = result.quantity,
6756                    client_tick = self.state.tick,
6757                    "client applied harvest result"
6758                );
6759                let msg = if result.quantity == 0 {
6760                    format!(
6761                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6762                        result.item_template
6763                    )
6764                } else {
6765                    format!(
6766                        "Harvested {} x{} (on the ground — press P to pick up)",
6767                        result.item_template, result.quantity
6768                    )
6769                };
6770                self.state.push_log(msg);
6771            }
6772            SessionEvent::CraftResult(result) => {
6773                for stack in &result.consumed {
6774                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6775                        *qty = qty.saturating_sub(stack.quantity);
6776                        if *qty == 0 {
6777                            self.state.inventory.remove(&stack.template_id);
6778                        }
6779                    }
6780                }
6781                for stack in &result.outputs {
6782                    *self
6783                        .state
6784                        .inventory
6785                        .entry(stack.template_id.clone())
6786                        .or_insert(0) += stack.quantity;
6787                }
6788                if let Some(output) = result.outputs.first() {
6789                    if result.batch_total > 1 {
6790                        self.state.push_log(format!(
6791                            "Crafted {} x{} ({}/{})",
6792                            output.template_id,
6793                            output.quantity,
6794                            result.batch_index,
6795                            result.batch_total
6796                        ));
6797                    } else {
6798                        self.state.push_log(format!(
6799                            "Crafted {} x{}",
6800                            output.template_id, output.quantity
6801                        ));
6802                    }
6803                } else {
6804                    self.state
6805                        .push_log(format!("Craft finished: {}", result.blueprint_id));
6806                }
6807            }
6808            SessionEvent::Death(notice) => {
6809                self.state.clear_harvest_state();
6810                self.state.push_log(notice.message.clone());
6811                self.state.push_log(format!(
6812                    "Respawned at ({:.1}, {:.1})",
6813                    notice.respawn_x, notice.respawn_y
6814                ));
6815            }
6816            SessionEvent::Interaction(notice) => {
6817                if notice.message.starts_with("Harvest failed:") {
6818                    self.state.clear_harvest_state();
6819                }
6820                if notice.message.starts_with("Can't do that:") {
6821                    self.state.pending_craft_ack = None;
6822                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
6823                        if let Some(w) = self
6824                            .state
6825                            .hired_workers
6826                            .iter_mut()
6827                            .find(|w| w.instance_id == pending.worker_instance_id)
6828                        {
6829                            w.route = pending.prev_route;
6830                            w.mode = pending.prev_mode;
6831                            w.step_label = pending.prev_step_label;
6832                            w.last_error = pending.prev_last_error;
6833                        }
6834                        let reason = notice
6835                            .message
6836                            .strip_prefix("Can't do that:")
6837                            .unwrap_or(&notice.message)
6838                            .trim();
6839                        self.state.push_log(format!(
6840                            "Route save failed for {}: {reason}",
6841                            pending.worker_label
6842                        ));
6843                    }
6844                    let reason = notice
6845                        .message
6846                        .strip_prefix("Can't do that:")
6847                        .unwrap_or(&notice.message)
6848                        .trim();
6849                    if reason.contains("already tilled") {
6850                        if let Some(plot) = self.state.my_plot_under_player() {
6851                            self.state.sell_plot_confirm = Some(plot.plot_id);
6852                            self.state.sell_plot_armed_at = Some(Instant::now());
6853                        }
6854                    }
6855                }
6856                if notice.message.starts_with("Cast failed:") {
6857                    self.state.cast_progress = None;
6858                }
6859                if notice.message.contains("slain the") {
6860                    self.state.combat_target = None;
6861                    self.state.combat_target_label = None;
6862                }
6863                // Inbound trade request → inline Y/N in the CHAT column (no popup).
6864                if notice.message.contains("wants to trade") {
6865                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6866                        let from_name = notice
6867                            .message
6868                            .split(" wants to trade")
6869                            .next()
6870                            .unwrap_or("Player")
6871                            .to_string();
6872                        self.state.social_chat.pending_trade =
6873                            Some(crate::social::PendingTradeRequest {
6874                                from_entity,
6875                                from_name: from_name.clone(),
6876                            });
6877                        self.state.social_chat.push_system(format!(
6878                            "{from_name} wants to trade — [Y] accept · [N] decline"
6879                        ));
6880                        self.state
6881                            .social_chat
6882                            .push_cue(crate::social::AudioCue::TradeOffer);
6883                    }
6884                }
6885                if notice.message.starts_with("trade request declined") {
6886                    self.state
6887                        .social_chat
6888                        .push_system(notice.message.clone());
6889                    self.state
6890                        .social_chat
6891                        .push_cue(crate::social::AudioCue::TradeDeclined);
6892                }
6893                self.state.apply_interaction_notice(&notice);
6894                self.state.push_log(notice.message.clone());
6895            }
6896            SessionEvent::ShopOpened(catalog) => {
6897                self.state.apply_shop_catalog(catalog);
6898            }
6899            SessionEvent::BankOpened(panel) => {
6900                self.state.apply_bank_panel(panel);
6901            }
6902            SessionEvent::StorageOpened(panel) => {
6903                self.state.apply_storage_panel(panel);
6904            }
6905            SessionEvent::MarketOpened(panel) => {
6906                self.state.apply_market_panel(panel);
6907            }
6908            SessionEvent::NpcTalkOpened(opened) => {
6909                self.state.show_npc_verb_menu = false;
6910                if self.state.npc_verb_target.is_none() {
6911                    self.state.npc_verb_target = Some(opened.npc_id.clone());
6912                }
6913                let label = opened.npc_label.clone();
6914                let banner = if !opened.trade_allowed {
6915                    Some("Trade is unavailable right now.".to_string())
6916                } else {
6917                    None
6918                };
6919                self.state.show_npc_chat = true;
6920                self.state.npc_chat = Some(NpcChatState {
6921                    npc_id: opened.npc_id,
6922                    npc_label: opened.npc_label,
6923                    lines: if opened.greeting.is_empty() {
6924                        vec![]
6925                    } else {
6926                        vec![format!("{label}: {}", opened.greeting)]
6927                    },
6928                    input: String::new(),
6929                    pending: opened.greeting.is_empty(),
6930                    talk_depth: opened.talk_depth,
6931                    trade_allowed: opened.trade_allowed,
6932                    banner,
6933                });
6934            }
6935            SessionEvent::NpcTalkPending(_) => {
6936                if let Some(chat) = self.state.npc_chat.as_mut() {
6937                    chat.pending = true;
6938                }
6939            }
6940            SessionEvent::NpcTalkReply(reply) => {
6941                if let Some(chat) = self.state.npc_chat.as_mut() {
6942                    if chat.npc_id == reply.npc_id {
6943                        chat.pending = false;
6944                        if reply.trade_disabled {
6945                            chat.trade_allowed = false;
6946                            chat.banner = Some("Trade is unavailable right now.".to_string());
6947                        }
6948                        if reply.wind_down {
6949                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6950                            if chat.banner.is_none() {
6951                                chat.banner =
6952                                    Some("They're wrapping up — keep it brief.".to_string());
6953                            }
6954                        }
6955                        chat.lines
6956                            .push(format!("{}: {}", chat.npc_label, reply.line));
6957                    }
6958                }
6959            }
6960            SessionEvent::NpcTalkClosed(closed) => {
6961                if self
6962                    .state
6963                    .npc_chat
6964                    .as_ref()
6965                    .is_some_and(|c| c.npc_id == closed.npc_id)
6966                {
6967                    self.state.show_npc_chat = false;
6968                    self.state.npc_chat = None;
6969                }
6970            }
6971            SessionEvent::NpcTalkError(err) => {
6972                self.state.push_log(format!("Talk failed: {}", err.reason));
6973                if let Some(chat) = self.state.npc_chat.as_mut() {
6974                    chat.pending = false;
6975                }
6976            }
6977            SessionEvent::UseResult(result) => {
6978                // Inventory count hint only — the Interaction notice already logs
6979                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
6980                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6981                    *qty = qty.saturating_sub(1);
6982                    if *qty == 0 {
6983                        self.state.inventory.remove(&result.template_id);
6984                    }
6985                }
6986            }
6987            SessionEvent::QuestOffer(offer) => {
6988                self.state.pending_quest_offer = Some(offer.clone());
6989                self.state.show_quest_offer = true;
6990                self.state
6991                    .push_log(format!("Quest offered: {}", offer.title));
6992            }
6993            SessionEvent::QuestAccepted(notice) => {
6994                self.state.show_quest_offer = false;
6995                self.state.pending_quest_offer = None;
6996                self.state.push_log(notice.message);
6997            }
6998            SessionEvent::QuestWithdrawn(notice) => {
6999                self.state.show_quest_menu = false;
7000                self.state.quest_withdraw_confirm = false;
7001                self.state.push_log(notice.message);
7002            }
7003            SessionEvent::QuestStepCompleted(notice) => {
7004                self.state.push_log(notice.message);
7005            }
7006            SessionEvent::QuestCompleted(notice) => {
7007                self.state.push_log(notice.message);
7008            }
7009            SessionEvent::Disconnected { reason } => {
7010                self.state.clear_harvest_state();
7011                self.state.connected = false;
7012                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
7013                if let Some(r) = &self.state.disconnect_reason {
7014                    self.state.push_log(format!("Disconnected: {r}"));
7015                } else {
7016                    self.state.push_log("Disconnected from server");
7017                }
7018            }
7019        }
7020        Ok(())
7021    }
7022
7023    pub fn is_connected(&self) -> bool {
7024        self.state.connected
7025    }
7026
7027    pub fn close_overlays(&mut self) {
7028        self.state.show_stats = false;
7029        self.state.show_craft_menu = false;
7030        self.state.show_plot_build_menu = false;
7031        self.state.show_shop_menu = false;
7032        self.state.shop_catalog = None;
7033        self.state.show_npc_verb_menu = false;
7034        self.state.npc_verb_target = None;
7035        self.state.show_npc_chat = false;
7036        self.state.npc_chat = None;
7037        self.state.show_inventory_menu = false;
7038        self.state.show_loadout_menu = false;
7039        self.state.show_rotation_editor = false;
7040        self.state.rotation_editor.reset();
7041        self.state.show_rename_prompt = false;
7042        self.state.show_worker_rename = false;
7043        self.state.rename_buffer.clear();
7044        self.state.show_move_picker = false;
7045        self.state.move_picker = None;
7046        self.state.show_destroy_picker = false;
7047        self.state.destroy_confirm_pending = false;
7048        self.state.destroy_picker = None;
7049        self.state.show_quest_offer = false;
7050        self.state.pending_quest_offer = None;
7051        self.state.show_quest_menu = false;
7052        self.state.quest_withdraw_confirm = false;
7053        self.state.show_workers_menu = false;
7054        self.close_worker_give_picker();
7055        self.close_worker_give_target_picker();
7056        self.close_worker_take_picker();
7057        self.close_worker_teach_picker();
7058        self.state.worker_route_editor = None;
7059        self.state.claim_mode = None;
7060        self.state.relocate_mode = None;
7061        self.state.sell_plot_confirm = None;
7062        self.state.sell_plot_armed_at = None;
7063        self.close_farm_access_panel();
7064        if self.state.show_plant_menu {
7065            self.close_plant_menu();
7066        }
7067    }
7068
7069    /// Esc / back — pop one UI layer instead of closing every overlay at once.
7070    pub fn back_on_esc(&mut self) -> bool {
7071        if self.state.social_chat.composer_open() {
7072            self.state.social_chat.close_composer();
7073            return true;
7074        }
7075        if self.state.player_verbs.open {
7076            self.state.player_verbs.close();
7077            return true;
7078        }
7079        if self.state.whisper_pouch_ui.open {
7080            self.state.whisper_pouch_ui.open = false;
7081            return true;
7082        }
7083        if self.state.trade_ui.panel.is_some() {
7084            // async cancel — caller should prefer trade_cancel; close UI optimistically
7085            self.state.trade_ui.close();
7086            return true;
7087        }
7088        if self.state.show_rename_prompt {
7089            self.cancel_rename_prompt();
7090            return true;
7091        }
7092        if self.state.show_worker_rename {
7093            self.cancel_worker_rename();
7094            return true;
7095        }
7096        if self.state.show_destroy_picker {
7097            if self.state.destroy_confirm_pending {
7098                self.cancel_destroy_confirm();
7099            } else {
7100                self.close_destroy_picker();
7101            }
7102            return true;
7103        }
7104        if self.state.claim_mode.is_some() {
7105            self.cancel_claim_mode();
7106            return true;
7107        }
7108        if self.state.relocate_mode.is_some() {
7109            self.cancel_relocate_mode();
7110            return true;
7111        }
7112        if self.state.show_plant_menu {
7113            self.close_plant_menu();
7114            return true;
7115        }
7116        if self.state.show_farm_access {
7117            self.close_farm_access_panel();
7118            return true;
7119        }
7120        if self.state.sell_plot_confirm.is_some() {
7121            self.state.sell_plot_confirm = None;
7122            self.state.sell_plot_armed_at = None;
7123            self.state.push_log("Sell cancelled");
7124            return true;
7125        }
7126        if self.state.show_move_picker {
7127            self.close_move_picker();
7128            return true;
7129        }
7130        if self.state.show_rotation_editor {
7131            match self.state.rotation_editor.mode {
7132                RotationEditorMode::List => {
7133                    self.state.show_rotation_editor = false;
7134                    self.state.rotation_editor.reset();
7135                }
7136                RotationEditorMode::EditLabel => {
7137                    self.state.rotation_editor.label_buffer.clear();
7138                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7139                }
7140                RotationEditorMode::PickAbility => {
7141                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7142                }
7143                RotationEditorMode::EditSequence => {
7144                    self.state.rotation_editor.draft = None;
7145                    self.state.rotation_editor.mode = RotationEditorMode::List;
7146                }
7147            }
7148            return true;
7149        }
7150        if self.state.show_inventory_menu {
7151            self.close_inventory_menu();
7152            return true;
7153        }
7154        if self.state.show_craft_menu {
7155            self.close_craft_menu();
7156            return true;
7157        }
7158        if self.state.show_plot_build_menu {
7159            self.close_plot_build_menu();
7160            return true;
7161        }
7162        if self.state.show_keychain_menu {
7163            self.close_keychain_menu();
7164            return true;
7165        }
7166        if self.state.show_quest_offer {
7167            self.quest_offer_decline();
7168            return true;
7169        }
7170        if self.state.show_shop_menu {
7171            // Caller must await shop close (npc_interaction_back / play-loop).
7172            return false;
7173        }
7174        if self.state.bank_panel.is_some() {
7175            return false;
7176        }
7177        if self.state.storage_panel.is_some() {
7178            return false;
7179        }
7180        if self.state.market_panel.is_some() {
7181            return false;
7182        }
7183        if self.state.show_npc_chat {
7184            // play.rs calls npc_interaction_back().await on Esc
7185            return false;
7186        }
7187        if self.state.show_npc_verb_menu {
7188            self.state.show_npc_verb_menu = false;
7189            self.state.npc_verb_target = None;
7190            return true;
7191        }
7192        if self.state.show_quest_menu {
7193            if self.state.quest_withdraw_confirm {
7194                self.state.quest_withdraw_confirm = false;
7195            } else {
7196                self.state.show_quest_menu = false;
7197            }
7198            return true;
7199        }
7200        if self.state.worker_route_editor.is_some() {
7201            // Pop one sheet level; only close the editor at the root stop list.
7202            if self.re_at_root_sheet() {
7203                let reopen = self.state.attending_worker_instance_id.clone();
7204                self.close_worker_route_editor();
7205                if let Some(id) = reopen {
7206                    if let Some(idx) = self
7207                        .state
7208                        .hired_workers
7209                        .iter()
7210                        .position(|w| w.instance_id == id)
7211                    {
7212                        self.state.workers_menu_index = idx;
7213                        self.state.show_workers_menu = true;
7214                    }
7215                }
7216            } else {
7217                self.re_sheet_back();
7218            }
7219            return true;
7220        }
7221        if self.state.show_worker_give_picker {
7222            self.close_worker_give_picker();
7223            return true;
7224        }
7225        if self.state.show_worker_give_target_picker {
7226            self.close_worker_give_target_picker();
7227            return true;
7228        }
7229        if self.state.show_worker_take_picker {
7230            self.close_worker_take_picker();
7231            return true;
7232        }
7233        if self.state.show_worker_teach_picker {
7234            self.close_worker_teach_picker();
7235            return true;
7236        }
7237        if self.state.show_workers_menu {
7238            self.close_workers_menu_ui();
7239            return true;
7240        }
7241        if self.state.show_loadout_menu {
7242            self.state.show_loadout_menu = false;
7243            return true;
7244        }
7245        if self.state.show_stats {
7246            self.state.show_stats = false;
7247            return true;
7248        }
7249        if self.state.show_equip_menu {
7250            self.state.show_equip_menu = false;
7251            return true;
7252        }
7253        false
7254    }
7255
7256    pub fn toggle_stats(&mut self) {
7257        self.state.show_stats = !self.state.show_stats;
7258        if self.state.show_stats {
7259            self.state.character_sheet_tab = CharacterSheetTab::Character;
7260            self.state.show_craft_menu = false;
7261            self.state.show_shop_menu = false;
7262            self.state.shop_catalog = None;
7263            self.state.show_inventory_menu = false;
7264            self.state.show_equip_menu = false;
7265        }
7266    }
7267
7268    pub fn toggle_equip_menu(&mut self) {
7269        self.state.show_equip_menu = !self.state.show_equip_menu;
7270        if self.state.show_equip_menu {
7271            self.state.show_stats = false;
7272            self.state.show_craft_menu = false;
7273            self.state.show_shop_menu = false;
7274            self.state.shop_catalog = None;
7275            self.state.show_inventory_menu = false;
7276            self.state.show_loadout_menu = false;
7277        }
7278    }
7279
7280    pub fn cycle_character_sheet_tab(&mut self) {
7281        if self.state.show_stats {
7282            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7283        }
7284    }
7285
7286    pub fn set_ledger_period_digit(&mut self, c: char) {
7287        if self.state.show_stats {
7288            if let Some(p) = LedgerPeriod::from_digit(c) {
7289                self.state.ledger_period = p;
7290                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7291            }
7292        }
7293    }
7294
7295    pub fn cycle_ledger_period(&mut self) {
7296        if self.state.show_stats
7297            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
7298        {
7299            self.state.ledger_period = self.state.ledger_period.cycle();
7300        }
7301    }
7302
7303    pub fn open_inventory_menu(&mut self) {
7304        self.state.show_inventory_menu = true;
7305        self.state.show_craft_menu = false;
7306        self.state.show_shop_menu = false;
7307        self.state.shop_catalog = None;
7308        self.state.show_stats = false;
7309        self.state.show_move_picker = false;
7310        self.state.move_picker = None;
7311        self.state.show_destroy_picker = false;
7312        self.state.destroy_confirm_pending = false;
7313        self.state.destroy_picker = None;
7314        self.state.show_rename_prompt = false;
7315        self.state.rename_plot_id = None;
7316        self.state.rename_buffer.clear();
7317        self.state.inventory_filter_focused = false;
7318        self.state.clamp_inventory_indices();
7319    }
7320
7321    pub fn close_inventory_menu(&mut self) {
7322        self.state.show_inventory_menu = false;
7323        self.state.show_move_picker = false;
7324        self.state.move_picker = None;
7325        self.close_grant_picker();
7326        self.state.show_destroy_picker = false;
7327        self.state.destroy_confirm_pending = false;
7328        self.state.destroy_picker = None;
7329        self.state.show_rename_prompt = false;
7330        self.state.rename_plot_id = None;
7331        self.state.rename_buffer.clear();
7332        self.state.inventory_filter_focused = false;
7333    }
7334
7335    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7336        let Some(row) = self.state.inventory_selected_row() else {
7337            anyhow::bail!("inventory empty");
7338        };
7339        if GameState::is_property_deed_template(&row.stack.template_id) {
7340            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7341                anyhow::bail!("deed has no plot id");
7342            };
7343            let label = self
7344                .state
7345                .property_plots
7346                .iter()
7347                .find(|p| p.plot_id == plot_id)
7348                .map(|p| {
7349                    if p.label.trim().is_empty() {
7350                        p.plot_code.clone()
7351                    } else {
7352                        p.label.clone()
7353                    }
7354                })
7355                .unwrap_or_else(|| {
7356                    row.stack
7357                        .display_name
7358                        .clone()
7359                        .unwrap_or_else(|| "plot".into())
7360                });
7361            self.state.rename_buffer = label;
7362            self.state.rename_plot_id = Some(plot_id);
7363            self.state.highlighted_plot_id = Some(plot_id);
7364            self.state.show_rename_prompt = true;
7365            self.state.show_worker_rename = false;
7366            self.state.show_move_picker = false;
7367            self.state.show_destroy_picker = false;
7368            self.state.destroy_confirm_pending = false;
7369            return Ok(());
7370        }
7371        if !self.state.row_is_renameable_container(&row) {
7372            anyhow::bail!("only storage containers or deeds can be renamed");
7373        }
7374        let current = row
7375            .stack
7376            .display_name
7377            .clone()
7378            .unwrap_or_else(|| row.stack.template_id.clone());
7379        self.state.rename_buffer = current;
7380        self.state.rename_plot_id = None;
7381        self.state.show_rename_prompt = true;
7382        self.state.show_worker_rename = false;
7383        self.state.show_move_picker = false;
7384        self.state.show_destroy_picker = false;
7385        self.state.destroy_confirm_pending = false;
7386        Ok(())
7387    }
7388
7389    /// Rename the owned plot under the player's feet (`n` in world).
7390    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7391        let Some(plot) = self.state.my_plot_under_player().cloned() else {
7392            anyhow::bail!("stand on your plot to rename it");
7393        };
7394        let label = if plot.label.trim().is_empty() {
7395            plot.plot_code.clone()
7396        } else {
7397            plot.label.clone()
7398        };
7399        self.state.rename_buffer = label;
7400        self.state.rename_plot_id = Some(plot.plot_id);
7401        self.state.highlighted_plot_id = Some(plot.plot_id);
7402        self.state.show_rename_prompt = true;
7403        self.state.show_worker_rename = false;
7404        Ok(())
7405    }
7406
7407    pub fn cancel_rename_prompt(&mut self) {
7408        self.state.show_rename_prompt = false;
7409        self.state.rename_plot_id = None;
7410        self.state.rename_buffer.clear();
7411    }
7412
7413    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7414        let name = self.state.rename_buffer.trim().to_string();
7415        if name.is_empty() {
7416            anyhow::bail!("name cannot be empty");
7417        }
7418        if let Some(plot_id) = self.state.rename_plot_id {
7419            if name.chars().count() > 48 {
7420                anyhow::bail!("label must be 1–48 characters");
7421            }
7422            self.seq += 1;
7423            self.session
7424                .submit_intent(Intent::RenamePropertyPlot {
7425                    entity_id: self.state.entity_id,
7426                    plot_id,
7427                    label: name,
7428                    seq: self.seq,
7429                })
7430                .await?;
7431            self.state.intents_sent += 1;
7432            self.state.show_rename_prompt = false;
7433            self.state.rename_plot_id = None;
7434            self.state.rename_buffer.clear();
7435            return Ok(());
7436        }
7437        if name.chars().count() > 32 {
7438            anyhow::bail!("name must be 1–32 characters");
7439        }
7440        let Some(row) = self.state.inventory_selected_row() else {
7441            anyhow::bail!("inventory empty");
7442        };
7443        let Some(instance_id) = row.stack.item_instance_id else {
7444            anyhow::bail!("item has no instance id");
7445        };
7446        self.seq += 1;
7447        self.session
7448            .submit_intent(Intent::RenameContainer {
7449                entity_id: self.state.entity_id,
7450                item_instance_id: instance_id,
7451                location: row.from.clone(),
7452                name,
7453                seq: self.seq,
7454            })
7455            .await?;
7456        self.state.intents_sent += 1;
7457        self.state.show_rename_prompt = false;
7458        self.state.rename_buffer.clear();
7459        Ok(())
7460    }
7461
7462    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7463        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7464            anyhow::bail!("no worker selected");
7465        };
7466        self.state.rename_buffer = worker.label.clone();
7467        self.state.show_worker_rename = true;
7468        self.state.show_rename_prompt = false;
7469        Ok(())
7470    }
7471
7472    pub fn cancel_worker_rename(&mut self) {
7473        self.state.show_worker_rename = false;
7474        self.state.rename_buffer.clear();
7475    }
7476
7477    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7478        let name = self.state.rename_buffer.trim().to_string();
7479        if name.is_empty() {
7480            anyhow::bail!("name cannot be empty");
7481        }
7482        if name.chars().count() > 32 {
7483            anyhow::bail!("name must be 1–32 characters");
7484        }
7485        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7486            anyhow::bail!("no worker selected");
7487        };
7488        let worker_instance_id = worker.instance_id.clone();
7489        self.seq += 1;
7490        self.session
7491            .submit_intent(Intent::RenameHiredWorker {
7492                entity_id: self.state.entity_id,
7493                worker_instance_id: worker_instance_id.clone(),
7494                name: name.clone(),
7495                seq: self.seq,
7496            })
7497            .await?;
7498        self.state.intents_sent += 1;
7499        if let Some(w) = self
7500            .state
7501            .hired_workers
7502            .iter_mut()
7503            .find(|w| w.instance_id == worker_instance_id)
7504        {
7505            w.label = name.clone();
7506        }
7507        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7508            if ed.worker_instance_id == worker_instance_id {
7509                ed.worker_label = name.clone();
7510            }
7511        }
7512        self.state.show_worker_rename = false;
7513        self.state.rename_buffer.clear();
7514        self.state.push_log(format!("Renamed worker to \"{name}\""));
7515        Ok(())
7516    }
7517
7518    pub fn toggle_inventory_menu(&mut self) {
7519        if self.state.show_inventory_menu {
7520            self.close_inventory_menu();
7521        } else {
7522            self.open_inventory_menu();
7523        }
7524    }
7525
7526    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
7527    pub fn inventory_menu_move(&mut self, delta: i32) {
7528        if self.state.show_grant_picker {
7529            let Some(picker) = self.state.grant_picker.as_ref() else {
7530                return;
7531            };
7532            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7533            let filter = picker.filter.clone();
7534            let n = labels.len();
7535            if n == 0 {
7536                return;
7537            }
7538            self.state.grant_picker_index = step_filtered_index(
7539                self.state.grant_picker_index,
7540                delta,
7541                n,
7542                |i| list_label_matches(&labels[i], &filter),
7543            );
7544            return;
7545        }
7546        if self.state.show_move_picker {
7547            let Some(picker) = self.state.move_picker.as_ref() else {
7548                return;
7549            };
7550            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7551            let filter = picker.filter.clone();
7552            let n = labels.len();
7553            if n == 0 {
7554                return;
7555            }
7556            self.state.move_picker_index = step_filtered_index(
7557                self.state.move_picker_index,
7558                delta,
7559                n,
7560                |i| list_label_matches(&labels[i], &filter),
7561            );
7562            self.state.clamp_move_picker_quantity();
7563            return;
7564        }
7565        let n = self.state.inventory_selectable_rows().len();
7566        if n == 0 {
7567            return;
7568        }
7569        let idx = self.state.inventory_menu_index as i32;
7570        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7571    }
7572
7573    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
7574    pub fn inventory_menu_page(&mut self, pages: i32) {
7575        if self.state.show_grant_picker {
7576            let Some(picker) = self.state.grant_picker.as_ref() else {
7577                return;
7578            };
7579            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7580            let filter = picker.filter.clone();
7581            let n = labels.len();
7582            self.state.grant_picker_index = page_filtered_index(
7583                self.state.grant_picker_index,
7584                pages,
7585                n,
7586                |i| list_label_matches(&labels[i], &filter),
7587            );
7588            return;
7589        }
7590        if self.state.show_move_picker {
7591            let Some(picker) = self.state.move_picker.as_ref() else {
7592                return;
7593            };
7594            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7595            let filter = picker.filter.clone();
7596            let n = labels.len();
7597            self.state.move_picker_index = page_filtered_index(
7598                self.state.move_picker_index,
7599                pages,
7600                n,
7601                |i| list_label_matches(&labels[i], &filter),
7602            );
7603            self.state.clamp_move_picker_quantity();
7604            return;
7605        }
7606        let n = self.state.inventory_selectable_rows().len();
7607        self.state.inventory_menu_index =
7608            page_list_index(self.state.inventory_menu_index, pages, n);
7609    }
7610
7611    pub fn cycle_inventory_tab(&mut self, forward: bool) {
7612        if self.state.show_move_picker
7613            || self.state.show_grant_picker
7614            || self.state.show_destroy_picker
7615            || self.state.show_rename_prompt
7616            || self.state.inventory_filter_focused
7617        {
7618            return;
7619        }
7620        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7621        self.state.inventory_menu_index = 0;
7622        self.state.clamp_inventory_indices();
7623    }
7624
7625    pub fn focus_inventory_filter(&mut self) {
7626        if self.state.show_grant_picker {
7627            if let Some(p) = self.state.grant_picker.as_mut() {
7628                p.filter_focused = true;
7629            }
7630            return;
7631        }
7632        if self.state.show_move_picker {
7633            if let Some(p) = self.state.move_picker.as_mut() {
7634                p.filter_focused = true;
7635            }
7636            return;
7637        }
7638        self.state.inventory_filter_focused = true;
7639    }
7640
7641    pub fn set_inventory_filter(&mut self, filter: String) {
7642        self.state.inventory_filter = filter;
7643        self.state.inventory_menu_index = 0;
7644        self.state.clamp_inventory_indices();
7645    }
7646
7647    pub fn append_inventory_filter_char(&mut self, ch: char) {
7648        if ch.is_control() {
7649            return;
7650        }
7651        if self.state.show_grant_picker {
7652            if let Some(p) = self.state.grant_picker.as_mut() {
7653                if p.filter_focused {
7654                    p.filter.push(ch);
7655                    self.state.grant_picker_index = 0;
7656                }
7657            }
7658            return;
7659        }
7660        if self.state.show_move_picker {
7661            if let Some(p) = self.state.move_picker.as_mut() {
7662                if p.filter_focused {
7663                    p.filter.push(ch);
7664                    self.state.move_picker_index = 0;
7665                    self.state.clamp_move_picker_quantity();
7666                }
7667            }
7668            return;
7669        }
7670        if !self.state.inventory_filter_focused {
7671            return;
7672        }
7673        self.state.inventory_filter.push(ch);
7674        self.state.inventory_menu_index = 0;
7675        self.state.clamp_inventory_indices();
7676    }
7677
7678    pub fn inventory_filter_backspace(&mut self) {
7679        if self.state.show_grant_picker {
7680            if let Some(p) = self.state.grant_picker.as_mut() {
7681                if p.filter_focused {
7682                    p.filter.pop();
7683                    self.state.grant_picker_index = 0;
7684                }
7685            }
7686            return;
7687        }
7688        if self.state.show_move_picker {
7689            if let Some(p) = self.state.move_picker.as_mut() {
7690                if p.filter_focused {
7691                    p.filter.pop();
7692                    self.state.move_picker_index = 0;
7693                    self.state.clamp_move_picker_quantity();
7694                }
7695            }
7696            return;
7697        }
7698        if !self.state.inventory_filter_focused {
7699            return;
7700        }
7701        self.state.inventory_filter.pop();
7702        self.state.inventory_menu_index = 0;
7703        self.state.clamp_inventory_indices();
7704    }
7705
7706    /// Esc while filter focused: clear filter then blur. Returns true if handled.
7707    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7708        if self.state.show_grant_picker {
7709            if let Some(p) = self.state.grant_picker.as_mut() {
7710                if p.filter_focused {
7711                    if !p.filter.is_empty() {
7712                        p.filter.clear();
7713                        self.state.grant_picker_index = 0;
7714                    } else {
7715                        p.filter_focused = false;
7716                    }
7717                    return true;
7718                }
7719                if !p.filter.is_empty() {
7720                    p.filter.clear();
7721                    self.state.grant_picker_index = 0;
7722                    return true;
7723                }
7724            }
7725            return false;
7726        }
7727        if self.state.show_move_picker {
7728            if let Some(p) = self.state.move_picker.as_mut() {
7729                if p.filter_focused {
7730                    if !p.filter.is_empty() {
7731                        p.filter.clear();
7732                        self.state.move_picker_index = 0;
7733                        self.state.clamp_move_picker_quantity();
7734                    } else {
7735                        p.filter_focused = false;
7736                    }
7737                    return true;
7738                }
7739                if !p.filter.is_empty() {
7740                    p.filter.clear();
7741                    self.state.move_picker_index = 0;
7742                    self.state.clamp_move_picker_quantity();
7743                    return true;
7744                }
7745            }
7746            return false;
7747        }
7748        if self.state.inventory_filter_focused {
7749            if !self.state.inventory_filter.is_empty() {
7750                self.state.inventory_filter.clear();
7751                self.state.inventory_menu_index = 0;
7752                self.state.clamp_inventory_indices();
7753            } else {
7754                self.state.inventory_filter_focused = false;
7755            }
7756            return true;
7757        }
7758        if !self.state.inventory_filter.is_empty() {
7759            self.state.inventory_filter.clear();
7760            self.state.inventory_menu_index = 0;
7761            self.state.clamp_inventory_indices();
7762            return true;
7763        }
7764        false
7765    }
7766
7767    pub fn craft_menu_page(&mut self, pages: i32) {
7768        let n = self.state.blueprints.len();
7769        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7770        self.state.clamp_craft_batch_quantity();
7771    }
7772
7773    pub fn shop_menu_page(&mut self, pages: i32) {
7774        let n = self.state.shop_list_len();
7775        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7776        self.state.clamp_shop_quantity();
7777    }
7778
7779    pub fn workers_menu_page(&mut self, pages: i32) {
7780        let n = self.state.hired_workers.len();
7781        self.state.workers_menu_index =
7782            page_list_index(self.state.workers_menu_index, pages, n);
7783    }
7784
7785    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
7786    /// chest — or fall back to the "move to…" destination picker (including
7787    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
7788    /// Placed chest shells open a pick-up destination picker (`l` still locks).
7789    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7790        if self.state.show_destroy_picker {
7791            if self.state.destroy_confirm_pending {
7792                return self.confirm_destroy_item().await;
7793            }
7794            return self.request_destroy_confirm();
7795        }
7796        if self.state.show_grant_picker {
7797            return self.confirm_grant_picker().await;
7798        }
7799        if self.state.show_move_picker {
7800            return self.confirm_move_picker().await;
7801        }
7802        let Some(row) = self.state.inventory_selected_row() else {
7803            anyhow::bail!("inventory empty");
7804        };
7805        if row.is_equip_shell {
7806            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7807                anyhow::bail!("not a worn item");
7808            };
7809            return self.equip_worn(slot, None).await;
7810        }
7811        if row.is_chest_shell {
7812            return self.open_chest_pickup_picker();
7813        }
7814        let template_id = row.stack.template_id.clone();
7815        let instance_id = row.stack.item_instance_id;
7816        let category = self.state.inventory_item_category(&template_id);
7817        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7818
7819        if category == Some("weapon") {
7820            return self.equip_mainhand(Some(template_id)).await;
7821        }
7822        if category == Some("lodging") && on_person {
7823            if let Some(inst) = instance_id {
7824                return self.place_container(inst).await;
7825            }
7826        }
7827        if (category == Some("container") || category == Some("armor")) && on_person {
7828            if let Some(inst) = instance_id {
7829                let world_placeable = row.stack.world_placeable == Some(true)
7830                    || template_id.contains("chest");
7831                if world_placeable {
7832                    return self.place_container(inst).await;
7833                }
7834                // Pouches no longer equip directly — they clip onto a worn belt's loops
7835                // instead, so fall through to the move picker (offers "belt loop" when a
7836                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
7837                if let Some(slot) = guess_body_slot(&template_id) {
7838                    return self.equip_worn(slot, Some(inst)).await;
7839                }
7840            }
7841        }
7842        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
7843        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
7844        // instead of guessing.
7845        self.open_move_picker()
7846    }
7847
7848    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
7849    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7850        let Some(row) = self.state.inventory_selected_row() else {
7851            anyhow::bail!("inventory empty");
7852        };
7853        if row.from != flatland_protocol::InventoryLocation::Root {
7854            anyhow::bail!("select a consumable on your person");
7855        }
7856        if GameState::stack_is_item_grant(&row.stack) {
7857            return self.open_grant_target_picker();
7858        }
7859        if GameState::is_property_deed_template(&row.stack.template_id) {
7860            return self.open_move_picker();
7861        }
7862        let category = self
7863            .state
7864            .inventory_item_category(&row.stack.template_id);
7865        if category != Some("consumable") {
7866            anyhow::bail!("selected item is not consumable");
7867        }
7868        self.use_item(&row.stack.template_id).await
7869    }
7870
7871    /// Open picker: apply selected grant item onto inventory / worn gear.
7872    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7873        let Some(row) = self.state.inventory_selected_row() else {
7874            anyhow::bail!("inventory empty");
7875        };
7876        if row.from != flatland_protocol::InventoryLocation::Root {
7877            anyhow::bail!("select a grant item on your person");
7878        }
7879        if !GameState::stack_is_item_grant(&row.stack) {
7880            anyhow::bail!("selected item does not grant onto gear");
7881        }
7882        let Some(grant_instance_id) = row.stack.item_instance_id else {
7883            anyhow::bail!("grant has no instance id");
7884        };
7885        let effect_id = GameState::grant_effect_id(&row.stack)
7886            .unwrap_or("?")
7887            .to_string();
7888        let mode = GameState::grant_mode(&row.stack).to_string();
7889        let options = self.state.grant_target_options(&row.stack);
7890        if options.is_empty() {
7891            anyhow::bail!("no valid gear to apply {effect_id} to");
7892        }
7893        let grant_label = row
7894            .stack
7895            .display_name
7896            .clone()
7897            .unwrap_or_else(|| row.stack.template_id.clone());
7898        self.state.show_grant_picker = true;
7899        self.state.grant_picker_index = 0;
7900        self.state.grant_picker = Some(GrantTargetPicker {
7901            grant_instance_id,
7902            grant_label,
7903            effect_id,
7904            mode,
7905            options,
7906            filter: String::new(),
7907            filter_focused: false,
7908        });
7909        Ok(())
7910    }
7911
7912    pub fn close_grant_picker(&mut self) {
7913        self.state.show_grant_picker = false;
7914        self.state.grant_picker = None;
7915        self.state.grant_picker_index = 0;
7916    }
7917
7918    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7919        let Some(picker) = self.state.grant_picker.clone() else {
7920            self.close_grant_picker();
7921            return Ok(());
7922        };
7923        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7924            self.close_grant_picker();
7925            return Ok(());
7926        };
7927        self.close_grant_picker();
7928        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7929            .await?;
7930        self.state.push_log(format!(
7931            "Applying {} onto {}…",
7932            picker.effect_id, opt.label
7933        ));
7934        Ok(())
7935    }
7936
7937    /// `m`: always open the "move to…" picker for the selected item, even for
7938    /// weapons/wearables that Enter would otherwise equip/wear directly.
7939    /// Placed chests open the pick-up destination picker instead.
7940    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7941        let Some(row) = self.state.inventory_selected_row() else {
7942            anyhow::bail!("inventory empty");
7943        };
7944        if row.is_equip_shell {
7945            anyhow::bail!("this is a worn bag — press Enter to unequip it");
7946        }
7947        if row.is_chest_shell {
7948            return self.open_chest_pickup_picker();
7949        }
7950        let Some(instance_id) = row.stack.item_instance_id else {
7951            anyhow::bail!("item has no instance id");
7952        };
7953        let mut options = self.state.move_destinations_for(
7954            &row.from,
7955            row.from_parent_instance_id,
7956            row.stack.item_instance_id,
7957            &row.stack.template_id,
7958        );
7959        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7960        let category = self.state.inventory_item_category(&row.stack.template_id);
7961        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7962            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7963                options.insert(
7964                    0,
7965                    MoveOption {
7966                        label: "Sell plot to crown…".into(),
7967                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
7968                    },
7969                );
7970            }
7971        }
7972        if on_person && category == Some("consumable") {
7973            if GameState::stack_is_item_grant(&row.stack) {
7974                options.insert(
7975                    0,
7976                    MoveOption {
7977                        label: "Apply onto gear…".into(),
7978                        kind: MoveOptionKind::GrantApply,
7979                    },
7980                );
7981            } else {
7982                options.insert(
7983                    0,
7984                    MoveOption {
7985                        label: "Use (eat / drink)".into(),
7986                        kind: MoveOptionKind::Use,
7987                    },
7988                );
7989            }
7990        }
7991        let item_label = row
7992            .stack
7993            .display_name
7994            .clone()
7995            .unwrap_or_else(|| row.stack.template_id.clone());
7996        // Default to 1 so withdrawing from storage is partial unless the player
7997        // presses `a` for max fit (full stack when the destination allows it).
7998        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7999        self.state.move_picker = Some(MovePicker {
8000            item_instance_id: instance_id,
8001            from: row.from,
8002            item_label,
8003            template_id: row.stack.template_id.clone(),
8004            stack_quantity: row.stack.quantity,
8005            quantity: initial_qty.max(1),
8006            options,
8007            filter: String::new(),
8008            filter_focused: false,
8009        });
8010        self.state.move_picker_index = 0;
8011        self.state.show_move_picker = true;
8012        self.state.show_destroy_picker = false;
8013        self.state.destroy_confirm_pending = false;
8014        self.state.destroy_picker = None;
8015        self.state.clamp_move_picker_quantity();
8016        Ok(())
8017    }
8018
8019    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
8020    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
8021        let Some(row) = self.state.inventory_selected_row() else {
8022            anyhow::bail!("inventory empty");
8023        };
8024        if !row.is_chest_shell {
8025            anyhow::bail!("not a placed chest");
8026        }
8027        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
8028            anyhow::bail!("not a placed chest");
8029        };
8030        let Some(instance_id) = row.stack.item_instance_id else {
8031            anyhow::bail!("chest has no instance id");
8032        };
8033        let chest = self
8034            .state
8035            .placed_containers
8036            .iter()
8037            .find(|c| c.id == *container_id)
8038            .cloned()
8039            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8040        let (px, py) = self.state.player_position();
8041        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8042            anyhow::bail!("too far from {}", chest.display_name);
8043        }
8044        if chest.locked && !chest.accessible {
8045            anyhow::bail!(
8046                "need the matching key for {} before picking it up",
8047                chest.display_name
8048            );
8049        }
8050        let options = self.state.chest_pickup_destinations(container_id);
8051        let item_label = row
8052            .stack
8053            .display_name
8054            .clone()
8055            .unwrap_or_else(|| row.stack.template_id.clone());
8056        self.state.move_picker = Some(MovePicker {
8057            item_instance_id: instance_id,
8058            from: row.from.clone(),
8059            item_label,
8060            template_id: row.stack.template_id.clone(),
8061            stack_quantity: 1,
8062            quantity: 1,
8063            options,
8064            filter: String::new(),
8065            filter_focused: false,
8066        });
8067        self.state.move_picker_index = 0;
8068        self.state.show_move_picker = true;
8069        self.state.show_destroy_picker = false;
8070        self.state.destroy_confirm_pending = false;
8071        self.state.destroy_picker = None;
8072        Ok(())
8073    }
8074
8075    pub fn close_move_picker(&mut self) {
8076        self.state.show_move_picker = false;
8077        self.state.move_picker = None;
8078    }
8079
8080    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8081        self.state.move_picker_adjust_quantity(delta);
8082    }
8083
8084    pub fn move_picker_set_quantity_max(&mut self) {
8085        self.state.move_picker_set_quantity_max();
8086    }
8087
8088    pub fn move_picker_set_quantity_min(&mut self) {
8089        self.state.move_picker_set_quantity_min();
8090    }
8091
8092    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8093        self.state.destroy_picker_adjust_quantity(delta);
8094    }
8095
8096    pub fn destroy_picker_set_quantity_max(&mut self) {
8097        self.state.destroy_picker_set_quantity_max();
8098    }
8099
8100    pub fn destroy_picker_set_quantity_min(&mut self) {
8101        self.state.destroy_picker_set_quantity_min();
8102    }
8103
8104    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8105        let Some(picker) = self.state.move_picker.clone() else {
8106            self.close_move_picker();
8107            return Ok(());
8108        };
8109        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8110            self.close_move_picker();
8111            return Ok(());
8112        };
8113        match option.kind {
8114            MoveOptionKind::Cancel => {
8115                self.close_move_picker();
8116            }
8117            MoveOptionKind::Use => {
8118                self.close_move_picker();
8119                self.use_item(&picker.template_id).await?;
8120            }
8121            MoveOptionKind::GrantApply => {
8122                self.close_move_picker();
8123                self.open_grant_target_picker()?;
8124            }
8125            MoveOptionKind::SellPlotToCrown { plot_id } => {
8126                self.close_move_picker();
8127                self.confirm_sell_plot_to_crown(plot_id).await?;
8128            }
8129            MoveOptionKind::RelocatePlaced { container_id } => {
8130                self.close_move_picker();
8131                self.state.show_inventory_menu = false;
8132                self.begin_relocate_container(&container_id)?;
8133            }
8134            MoveOptionKind::Drop => {
8135                self.close_move_picker();
8136                if self
8137                    .state
8138                    .hand_equipped_instance_ids()
8139                    .contains(&picker.item_instance_id)
8140                {
8141                    anyhow::bail!("unequip that item first");
8142                }
8143                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8144                    if self.state.deed_bound(&stack) {
8145                        anyhow::bail!(
8146                            "cannot drop a property deed — store it or trade it to another player"
8147                        );
8148                    }
8149                    if self.state.key_drop_blocked(&stack) {
8150                        anyhow::bail!("cannot drop the key while its chest is locked");
8151                    }
8152                }
8153                self.drop_item(picker.item_instance_id, picker.from).await?;
8154                self.state
8155                    .push_log(format!("Dropped {}", picker.item_label));
8156            }
8157            MoveOptionKind::PickupPlaced {
8158                container_id,
8159                nest_location,
8160                nest_parent_instance_id,
8161            } => {
8162                self.close_move_picker();
8163                self.pickup_container(container_id.clone()).await?;
8164                let nest_into_bag = nest_parent_instance_id.is_some()
8165                    || !matches!(
8166                        nest_location,
8167                        flatland_protocol::InventoryLocation::Root
8168                    );
8169                if nest_into_bag {
8170                    self.move_item(
8171                        picker.item_instance_id,
8172                        flatland_protocol::InventoryLocation::Root,
8173                        nest_location,
8174                        nest_parent_instance_id,
8175                        None,
8176                    )
8177                    .await?;
8178                    self.state
8179                        .push_log(format!("Picked up {} into bag", picker.item_label));
8180                } else {
8181                    self.state
8182                        .push_log(format!("Picked up {}", picker.item_label));
8183                }
8184            }
8185            MoveOptionKind::Move {
8186                location,
8187                parent_instance_id,
8188            } => {
8189                self.close_move_picker();
8190                let qty = if picker.quantity >= picker.stack_quantity {
8191                    None
8192                } else {
8193                    Some(picker.quantity)
8194                };
8195                self.move_item(
8196                    picker.item_instance_id,
8197                    picker.from,
8198                    location,
8199                    parent_instance_id,
8200                    qty,
8201                )
8202                .await?;
8203                let moved = qty.unwrap_or(picker.stack_quantity);
8204                if moved >= picker.stack_quantity {
8205                    self.state.push_log(format!("Moved {}", picker.item_label));
8206                } else {
8207                    self.state.push_log(format!(
8208                        "Moved {} ×{} of {}",
8209                        picker.item_label, moved, picker.stack_quantity
8210                    ));
8211                }
8212            }
8213        }
8214        Ok(())
8215    }
8216
8217    /// `d`: drop the selected item on the ground immediately (no picker).
8218    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8219        let Some(row) = self.state.inventory_selected_row() else {
8220            anyhow::bail!("inventory empty");
8221        };
8222        if row.is_equip_shell {
8223            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8224        }
8225        if row.is_chest_shell {
8226            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8227        }
8228        let Some(inst) = row.stack.item_instance_id else {
8229            anyhow::bail!("item has no instance id");
8230        };
8231        if self.state.hand_equipped_instance_ids().contains(&inst) {
8232            anyhow::bail!("unequip that item first");
8233        }
8234        if self.state.deed_bound(&row.stack) {
8235            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8236        }
8237        if self.state.key_drop_blocked(&row.stack) {
8238            anyhow::bail!("cannot drop the key while its chest is locked");
8239        }
8240        let label = row
8241            .stack
8242            .display_name
8243            .clone()
8244            .unwrap_or_else(|| row.stack.template_id.clone());
8245        self.drop_item(inst, row.from).await?;
8246        self.state.push_log(format!("Dropped {label}"));
8247        Ok(())
8248    }
8249
8250    pub async fn drop_item(
8251        &mut self,
8252        item_instance_id: uuid::Uuid,
8253        from: flatland_protocol::InventoryLocation,
8254    ) -> anyhow::Result<()> {
8255        self.seq += 1;
8256        self.session
8257            .submit_intent(Intent::DropItem {
8258                entity_id: self.state.entity_id,
8259                item_instance_id,
8260                from,
8261                seq: self.seq,
8262            })
8263            .await?;
8264        self.state.intents_sent += 1;
8265        Ok(())
8266    }
8267
8268    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
8269    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8270        let Some(row) = self.state.inventory_selected_row() else {
8271            anyhow::bail!("inventory empty");
8272        };
8273        if row.is_equip_shell {
8274            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8275        }
8276        if row.is_chest_shell {
8277            anyhow::bail!("can't destroy a placed chest from the inventory list");
8278        }
8279        let Some(instance_id) = row.stack.item_instance_id else {
8280            anyhow::bail!("item has no instance id");
8281        };
8282        if self.state.hand_equipped_instance_ids().contains(&instance_id) {
8283            anyhow::bail!("unequip that item first");
8284        }
8285        if self.state.deed_bound(&row.stack) {
8286            anyhow::bail!(
8287                "cannot destroy a property deed — store it or trade it to another player"
8288            );
8289        }
8290        if self.state.key_drop_blocked(&row.stack) {
8291            anyhow::bail!("cannot destroy the key while its chest is locked");
8292        }
8293        let item_label = row
8294            .stack
8295            .display_name
8296            .clone()
8297            .unwrap_or_else(|| row.stack.template_id.clone());
8298        self.state.destroy_picker = Some(DestroyPicker {
8299            item_instance_id: instance_id,
8300            from: row.from,
8301            item_label,
8302            stack_quantity: row.stack.quantity,
8303            quantity: row.stack.quantity,
8304        });
8305        self.state.destroy_confirm_pending = false;
8306        self.state.show_destroy_picker = true;
8307        self.state.show_move_picker = false;
8308        self.state.move_picker = None;
8309        Ok(())
8310    }
8311
8312    pub fn close_destroy_picker(&mut self) {
8313        self.state.show_destroy_picker = false;
8314        self.state.destroy_confirm_pending = false;
8315        self.state.destroy_picker = None;
8316    }
8317
8318    pub fn cancel_destroy_confirm(&mut self) {
8319        self.state.destroy_confirm_pending = false;
8320    }
8321
8322    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8323        if self.state.destroy_picker.is_none() {
8324            self.close_destroy_picker();
8325            return Ok(());
8326        }
8327        self.state.destroy_confirm_pending = true;
8328        Ok(())
8329    }
8330
8331    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8332        let Some(picker) = self.state.destroy_picker.clone() else {
8333            self.close_destroy_picker();
8334            return Ok(());
8335        };
8336        let qty = if picker.quantity >= picker.stack_quantity {
8337            None
8338        } else {
8339            Some(picker.quantity)
8340        };
8341        self.destroy_item(picker.item_instance_id, picker.from, qty)
8342            .await?;
8343        let destroyed = qty.unwrap_or(picker.stack_quantity);
8344        if destroyed >= picker.stack_quantity {
8345            self.state
8346                .push_log(format!("Destroyed {}", picker.item_label));
8347        } else {
8348            self.state.push_log(format!(
8349                "Destroyed {} ×{} of {}",
8350                picker.item_label, destroyed, picker.stack_quantity
8351            ));
8352        }
8353        self.close_destroy_picker();
8354        Ok(())
8355    }
8356
8357    pub async fn destroy_item(
8358        &mut self,
8359        item_instance_id: uuid::Uuid,
8360        from: flatland_protocol::InventoryLocation,
8361        quantity: Option<u32>,
8362    ) -> anyhow::Result<()> {
8363        self.seq += 1;
8364        self.session
8365            .submit_intent(Intent::DestroyItem {
8366                entity_id: self.state.entity_id,
8367                item_instance_id,
8368                from,
8369                quantity,
8370                seq: self.seq,
8371            })
8372            .await?;
8373        self.state.intents_sent += 1;
8374        Ok(())
8375    }
8376
8377    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
8378    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8379        if let Some(row) = self.state.inventory_selected_row() {
8380            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8381                return self.toggle_placed_chest_lock(container_id).await;
8382            }
8383        }
8384        self.toggle_nearby_chest_lock().await
8385    }
8386
8387    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8388        let chest = self
8389            .state
8390            .placed_containers
8391            .iter()
8392            .find(|c| c.id == container_id)
8393            .cloned()
8394            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8395        let (px, py) = self.state.player_position();
8396        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8397            anyhow::bail!("too far from {}", chest.display_name);
8398        }
8399        if !chest.accessible && chest.locked {
8400            anyhow::bail!(
8401                "need the matching key for {} (each crafted chest has its own key)",
8402                chest.display_name
8403            );
8404        }
8405        let lock = !chest.locked;
8406        self.set_container_locked(
8407            flatland_protocol::InventoryLocation::Placed {
8408                container_id: chest.id.clone(),
8409            },
8410            lock,
8411        )
8412        .await?;
8413        self.state.push_log(if lock {
8414            format!("Locked {}", chest.display_name)
8415        } else {
8416            format!("Unlocked {}", chest.display_name)
8417        });
8418        Ok(())
8419    }
8420
8421    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
8422    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8423        let chest = self
8424            .state
8425            .nearest_placed_container(CONTAINER_RANGE_M)
8426            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8427        self.toggle_placed_chest_lock(&chest.id).await
8428    }
8429
8430    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8431        self.equip_mainhand(None).await
8432    }
8433
8434    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8435        if !self.state.is_alive() {
8436            anyhow::bail!("you are dead");
8437        }
8438        self.seq += 1;
8439        self.session
8440            .submit_intent(Intent::EquipOffhand {
8441                entity_id: self.state.entity_id,
8442                template_id,
8443                instance_id: None,
8444                seq: self.seq,
8445            })
8446            .await?;
8447        self.state.intents_sent += 1;
8448        Ok(())
8449    }
8450
8451    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8452        self.equip_offhand(None).await
8453    }
8454
8455    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8456        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8457        for slot in slots {
8458            self.equip_worn(slot, None).await?;
8459        }
8460        Ok(())
8461    }
8462
8463    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8464        let (px, py) = self.state.player_position();
8465        let nearest = self
8466            .state
8467            .placed_containers
8468            .iter()
8469            .min_by(|a, b| {
8470                let da = (a.x - px).hypot(a.y - py);
8471                let db = (b.x - px).hypot(b.y - py);
8472                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8473            })
8474            .cloned();
8475        let Some(chest) = nearest else {
8476            anyhow::bail!("no chest nearby");
8477        };
8478        if (chest.x - px).hypot(chest.y - py) > 2.0 {
8479            anyhow::bail!("too far from chest");
8480        }
8481        self.pickup_container(chest.id).await
8482    }
8483
8484    pub async fn equip_worn(
8485        &mut self,
8486        slot: BodySlot,
8487        instance_id: Option<uuid::Uuid>,
8488    ) -> anyhow::Result<()> {
8489        self.seq += 1;
8490        self.session
8491            .submit_intent(Intent::EquipWorn {
8492                entity_id: self.state.entity_id,
8493                slot,
8494                instance_id,
8495                seq: self.seq,
8496            })
8497            .await?;
8498        self.state.intents_sent += 1;
8499        Ok(())
8500    }
8501
8502    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8503        self.seq += 1;
8504        self.session
8505            .submit_intent(Intent::PlaceContainer {
8506                entity_id: self.state.entity_id,
8507                item_instance_id,
8508                seq: self.seq,
8509            })
8510            .await?;
8511        self.state.intents_sent += 1;
8512        Ok(())
8513    }
8514
8515    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8516        self.seq += 1;
8517        self.session
8518            .submit_intent(Intent::PickupContainer {
8519                entity_id: self.state.entity_id,
8520                container_id,
8521                seq: self.seq,
8522            })
8523            .await?;
8524        self.state.intents_sent += 1;
8525        Ok(())
8526    }
8527
8528    pub async fn move_item(
8529        &mut self,
8530        item_instance_id: uuid::Uuid,
8531        from: flatland_protocol::InventoryLocation,
8532        to: flatland_protocol::InventoryLocation,
8533        to_parent_instance_id: Option<uuid::Uuid>,
8534        quantity: Option<u32>,
8535    ) -> anyhow::Result<()> {
8536        self.seq += 1;
8537        self.session
8538            .submit_intent(Intent::MoveItem {
8539                entity_id: self.state.entity_id,
8540                item_instance_id,
8541                from,
8542                to,
8543                to_parent_instance_id,
8544                quantity,
8545                seq: self.seq,
8546            })
8547            .await?;
8548        self.state.intents_sent += 1;
8549        Ok(())
8550    }
8551
8552    pub async fn set_container_locked(
8553        &mut self,
8554        location: flatland_protocol::InventoryLocation,
8555        locked: bool,
8556    ) -> anyhow::Result<()> {
8557        self.seq += 1;
8558        self.session
8559            .submit_intent(Intent::SetContainerLocked {
8560                entity_id: self.state.entity_id,
8561                location,
8562                locked,
8563                seq: self.seq,
8564            })
8565            .await?;
8566        self.state.intents_sent += 1;
8567        Ok(())
8568    }
8569
8570    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8571        if !self.state.is_alive() {
8572            anyhow::bail!("you are dead");
8573        }
8574        self.seq += 1;
8575        self.session
8576            .submit_intent(Intent::Use {
8577                entity_id: self.state.entity_id,
8578                template_id: template_id.to_string(),
8579                seq: self.seq,
8580            })
8581            .await?;
8582        self.state.intents_sent += 1;
8583        Ok(())
8584    }
8585
8586    /// Bind a grant consumable onto a specific item instance (unique gear status).
8587    pub async fn use_grant(
8588        &mut self,
8589        grant_instance_id: uuid::Uuid,
8590        target_instance_id: uuid::Uuid,
8591    ) -> anyhow::Result<()> {
8592        if !self.state.is_alive() {
8593            anyhow::bail!("you are dead");
8594        }
8595        self.seq += 1;
8596        self.session
8597            .submit_intent(Intent::UseGrant {
8598                entity_id: self.state.entity_id,
8599                grant_instance_id,
8600                target_instance_id,
8601                seq: self.seq,
8602            })
8603            .await?;
8604        self.state.intents_sent += 1;
8605        Ok(())
8606    }
8607
8608    pub fn open_craft_menu(&mut self) {
8609        self.state.show_craft_menu = true;
8610        self.state.show_shop_menu = false;
8611        self.state.shop_catalog = None;
8612        self.state.show_stats = false;
8613        self.state.show_inventory_menu = false;
8614        if self.state.blueprints.is_empty() {
8615            self.state.craft_menu_index = 0;
8616            self.state.craft_batch_quantity = 1;
8617            return;
8618        }
8619        self.state.craft_menu_index = self
8620            .state
8621            .craft_menu_index
8622            .min(self.state.blueprints.len() - 1);
8623        if let Some(idx) = self
8624            .state
8625            .blueprints
8626            .iter()
8627            .position(|bp| self.state.can_craft_blueprint(bp))
8628        {
8629            self.state.craft_menu_index = idx;
8630        }
8631        self.state.clamp_craft_batch_quantity();
8632    }
8633
8634    pub fn close_craft_menu(&mut self) {
8635        self.state.show_craft_menu = false;
8636    }
8637
8638    pub fn toggle_keychain_menu(&mut self) {
8639        if self.state.show_keychain_menu {
8640            self.close_keychain_menu();
8641        } else {
8642            self.state.show_keychain_menu = true;
8643            self.state.show_craft_menu = false;
8644            self.state.show_shop_menu = false;
8645            self.state.show_inventory_menu = false;
8646            let n = self.state.keychain_entries().len();
8647            if n == 0 {
8648                self.state.keychain_menu_index = 0;
8649            } else {
8650                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8651            }
8652        }
8653    }
8654
8655    pub fn close_keychain_menu(&mut self) {
8656        self.state.show_keychain_menu = false;
8657    }
8658
8659    pub fn keychain_menu_move(&mut self, delta: i32) {
8660        let n = self.state.keychain_entries().len();
8661        if n == 0 {
8662            self.state.keychain_menu_index = 0;
8663            return;
8664        }
8665        let idx = self.state.keychain_menu_index as i32 + delta;
8666        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8667    }
8668
8669    pub fn keychain_menu_page(&mut self, pages: i32) {
8670        let n = self.state.keychain_entries().len();
8671        self.state.keychain_menu_index =
8672            page_list_index(self.state.keychain_menu_index, pages, n);
8673    }
8674
8675    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8676        if !self.state.is_alive() {
8677            anyhow::bail!("you are dead");
8678        }
8679        let entries = self.state.keychain_entries();
8680        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8681            anyhow::bail!("nothing selected");
8682        };
8683        let Some(instance_id) = entry.stack.item_instance_id else {
8684            anyhow::bail!("key has no instance id");
8685        };
8686        if entry.stowed {
8687            self.move_item(
8688                instance_id,
8689                flatland_protocol::InventoryLocation::Keychain,
8690                flatland_protocol::InventoryLocation::Root,
8691                None,
8692                Some(1),
8693            )
8694            .await
8695        } else {
8696            self.move_item(
8697                instance_id,
8698                flatland_protocol::InventoryLocation::Root,
8699                flatland_protocol::InventoryLocation::Keychain,
8700                None,
8701                Some(1),
8702            )
8703            .await
8704        }
8705    }
8706
8707    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8708        let npc_id = self
8709            .state
8710            .shop_catalog
8711            .as_ref()
8712            .map(|c| c.npc_id.clone());
8713        self.state.show_shop_menu = false;
8714        self.state.shop_catalog = None;
8715        self.state.clear_shop_trade_log();
8716        if let Some(npc_id) = npc_id {
8717            self.seq += 1;
8718            self.session
8719                .submit_intent(Intent::ShopClose {
8720                    entity_id: self.state.entity_id,
8721                    npc_id,
8722                    seq: self.seq,
8723                })
8724                .await?;
8725            self.state.intents_sent += 1;
8726        }
8727        Ok(())
8728    }
8729
8730    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8731        let Some(panel) = self.state.bank_panel.clone() else {
8732            return Ok(());
8733        };
8734        self.seq += 1;
8735        self.session
8736            .submit_intent(Intent::BankDeposit {
8737                entity_id: self.state.entity_id,
8738                npc_id: panel.npc_id,
8739                amount_copper,
8740                seq: self.seq,
8741            })
8742            .await?;
8743        self.state.intents_sent += 1;
8744        Ok(())
8745    }
8746
8747    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8748        let Some(panel) = self.state.bank_panel.clone() else {
8749            return Ok(());
8750        };
8751        self.seq += 1;
8752        self.session
8753            .submit_intent(Intent::BankWithdraw {
8754                entity_id: self.state.entity_id,
8755                npc_id: panel.npc_id,
8756                amount_copper,
8757                seq: self.seq,
8758            })
8759            .await?;
8760        self.state.intents_sent += 1;
8761        Ok(())
8762    }
8763
8764    pub async fn bank_transfer(
8765        &mut self,
8766        to_character_id: Option<uuid::Uuid>,
8767        to_name: String,
8768        amount_copper: u64,
8769    ) -> anyhow::Result<()> {
8770        let Some(panel) = self.state.bank_panel.clone() else {
8771            return Ok(());
8772        };
8773        self.seq += 1;
8774        self.session
8775            .submit_intent(Intent::BankTransfer {
8776                entity_id: self.state.entity_id,
8777                npc_id: panel.npc_id,
8778                to_character_id,
8779                to_name,
8780                amount_copper,
8781                seq: self.seq,
8782            })
8783            .await?;
8784        self.state.intents_sent += 1;
8785        Ok(())
8786    }
8787
8788    pub fn bank_menu_move(&mut self, delta: i32) {
8789        let n = self.state.bank_menu_options().len();
8790        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8791            return;
8792        }
8793        let idx = self.state.bank_menu_index as i32;
8794        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8795    }
8796
8797    pub fn storage_menu_move(&mut self, delta: i32) {
8798        let n = self.state.storage_menu_options().len();
8799        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8800            return;
8801        }
8802        let idx = self.state.storage_menu_index as i32;
8803        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8804    }
8805
8806    pub fn storage_pick_move(&mut self, delta: i32) {
8807        let n = match &self.state.storage_ui_mode {
8808            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8809            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8810                self.state.storage_vault_options().len()
8811            }
8812            StorageUiMode::Menu
8813            | StorageUiMode::StoreAmount { .. }
8814            | StorageUiMode::TakeAmount { .. }
8815            | StorageUiMode::ShipAmount { .. } => 0,
8816        };
8817        if n == 0 {
8818            return;
8819        }
8820        match &mut self.state.storage_ui_mode {
8821            StorageUiMode::StorePick { index }
8822            | StorageUiMode::TakePick { index }
8823            | StorageUiMode::ShipPick { index, .. } => {
8824                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8825            }
8826            StorageUiMode::Menu
8827            | StorageUiMode::StoreAmount { .. }
8828            | StorageUiMode::TakeAmount { .. }
8829            | StorageUiMode::ShipAmount { .. } => {}
8830        }
8831    }
8832
8833    pub fn storage_ui_back(&mut self) {
8834        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8835            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8836                index: *pick_index,
8837            },
8838            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8839                index: *pick_index,
8840            },
8841            StorageUiMode::ShipAmount {
8842                dest_building_id,
8843                dest_label,
8844                pick_index,
8845                ..
8846            } => StorageUiMode::ShipPick {
8847                dest_building_id: dest_building_id.clone(),
8848                dest_label: dest_label.clone(),
8849                index: *pick_index,
8850            },
8851            StorageUiMode::StorePick { .. }
8852            | StorageUiMode::TakePick { .. }
8853            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8854            StorageUiMode::Menu => StorageUiMode::Menu,
8855        };
8856    }
8857
8858    pub fn storage_amount_append_char(&mut self, c: char) {
8859        match &mut self.state.storage_ui_mode {
8860            StorageUiMode::StoreAmount { input, .. }
8861            | StorageUiMode::TakeAmount { input, .. }
8862            | StorageUiMode::ShipAmount { input, .. } => {
8863                if c.is_ascii_digit() && input.len() < 8 {
8864                    input.push(c);
8865                }
8866            }
8867            _ => {}
8868        }
8869    }
8870
8871    pub fn storage_amount_backspace(&mut self) {
8872        match &mut self.state.storage_ui_mode {
8873            StorageUiMode::StoreAmount { input, .. }
8874            | StorageUiMode::TakeAmount { input, .. }
8875            | StorageUiMode::ShipAmount { input, .. } => {
8876                input.pop();
8877            }
8878            _ => {}
8879        }
8880    }
8881
8882    pub fn storage_ui_typing(&self) -> bool {
8883        matches!(
8884            self.state.storage_ui_mode,
8885            StorageUiMode::StoreAmount { .. }
8886                | StorageUiMode::TakeAmount { .. }
8887                | StorageUiMode::ShipAmount { .. }
8888        )
8889    }
8890
8891    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8892        match self.state.storage_ui_mode.clone() {
8893            StorageUiMode::Menu => {
8894                let index = self.state.storage_menu_index;
8895                match index {
8896                    0 => {
8897                        let opts = self.state.storage_store_options();
8898                        if opts.is_empty() {
8899                            self.state.push_log("Nothing loose to store.");
8900                            return Ok(());
8901                        }
8902                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8903                    }
8904                    1 => {
8905                        let opts = self.state.storage_vault_options();
8906                        if opts.is_empty() {
8907                            self.state.push_log("Vault is empty.");
8908                            return Ok(());
8909                        }
8910                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8911                    }
8912                    n => {
8913                        let dest = self
8914                            .state
8915                            .storage_panel
8916                            .as_ref()
8917                            .and_then(|p| p.ship_destinations.get(n - 2))
8918                            .cloned();
8919                        let Some(dest) = dest else {
8920                            return Ok(());
8921                        };
8922                        let opts = self.state.storage_vault_options();
8923                        if opts.is_empty() {
8924                            self.state
8925                                .push_log("Vault is empty — nothing to ship.");
8926                            return Ok(());
8927                        }
8928                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
8929                            dest_building_id: dest.building_id,
8930                            dest_label: dest.label,
8931                            index: 0,
8932                        };
8933                    }
8934                }
8935            }
8936            StorageUiMode::StorePick { index } => {
8937                let opts = self.state.storage_store_options();
8938                let Some(opt) = opts.get(index) else {
8939                    self.state.push_log("Nothing loose to store.");
8940                    self.state.storage_ui_mode = StorageUiMode::Menu;
8941                    return Ok(());
8942                };
8943                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8944                    pick_index: index,
8945                    item_instance_id: opt.item_instance_id,
8946                    label: opt.label.clone(),
8947                    max_qty: opt.quantity.max(1),
8948                    input: String::new(),
8949                };
8950            }
8951            StorageUiMode::TakePick { index } => {
8952                let opts = self.state.storage_vault_options();
8953                let Some(opt) = opts.get(index) else {
8954                    self.state.push_log("Vault is empty.");
8955                    self.state.storage_ui_mode = StorageUiMode::Menu;
8956                    return Ok(());
8957                };
8958                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8959                    pick_index: index,
8960                    item_instance_id: opt.item_instance_id,
8961                    label: opt.label.clone(),
8962                    max_qty: opt.quantity.max(1),
8963                    input: String::new(),
8964                };
8965            }
8966            StorageUiMode::ShipPick {
8967                dest_building_id,
8968                dest_label,
8969                index,
8970            } => {
8971                let opts = self.state.storage_vault_options();
8972                let Some(opt) = opts.get(index) else {
8973                    self.state
8974                        .push_log("Vault is empty — nothing to ship.");
8975                    self.state.storage_ui_mode = StorageUiMode::Menu;
8976                    return Ok(());
8977                };
8978                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8979                    dest_building_id,
8980                    dest_label,
8981                    pick_index: index,
8982                    item_instance_id: opt.item_instance_id,
8983                    label: opt.label.clone(),
8984                    max_qty: opt.quantity.max(1),
8985                    input: String::new(),
8986                };
8987            }
8988            StorageUiMode::StoreAmount {
8989                item_instance_id,
8990                max_qty,
8991                input,
8992                ..
8993            } => {
8994                let Some(qty) = parse_storage_quantity(&input) else {
8995                    self.state
8996                        .push_log("Enter a quantity (blank or 0 = all).");
8997                    return Ok(());
8998                };
8999                let qty = qty.map(|n| n.min(max_qty).max(1));
9000                self.storage_store(item_instance_id, qty).await?;
9001                self.state.storage_ui_mode = StorageUiMode::Menu;
9002            }
9003            StorageUiMode::TakeAmount {
9004                item_instance_id,
9005                max_qty,
9006                input,
9007                ..
9008            } => {
9009                let Some(qty) = parse_storage_quantity(&input) else {
9010                    self.state
9011                        .push_log("Enter a quantity (blank or 0 = all).");
9012                    return Ok(());
9013                };
9014                let qty = qty.map(|n| n.min(max_qty).max(1));
9015                self.storage_take(item_instance_id, qty).await?;
9016                self.state.storage_ui_mode = StorageUiMode::Menu;
9017            }
9018            StorageUiMode::ShipAmount {
9019                dest_building_id,
9020                item_instance_id,
9021                max_qty,
9022                input,
9023                ..
9024            } => {
9025                let Some(qty) = parse_storage_quantity(&input) else {
9026                    self.state
9027                        .push_log("Enter a quantity (blank or 0 = all).");
9028                    return Ok(());
9029                };
9030                let qty = qty.map(|n| n.min(max_qty).max(1));
9031                self.storage_ship(dest_building_id, item_instance_id, qty)
9032                    .await?;
9033                self.state.storage_ui_mode = StorageUiMode::Menu;
9034            }
9035        }
9036        Ok(())
9037    }
9038
9039    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
9040        match self.state.bank_ui_mode.clone() {
9041            BankUiMode::Menu => {
9042                let choice = self
9043                    .state
9044                    .bank_menu_options()
9045                    .get(self.state.bank_menu_index)
9046                    .copied()
9047                    .unwrap_or("Deposit…");
9048                match choice {
9049                    "Withdraw…" => {
9050                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9051                            input: String::new(),
9052                        };
9053                    }
9054                    "Deposit all" => self.bank_deposit(0).await?,
9055                    "Withdraw all" => self.bank_withdraw(0).await?,
9056                    "Transfer…" => {
9057                        self.state.bank_ui_mode = BankUiMode::TransferName {
9058                            input: String::new(),
9059                        };
9060                    }
9061                    _ => {
9062                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
9063                            input: String::new(),
9064                        };
9065                    }
9066                }
9067            }
9068            BankUiMode::DepositAmount { input } => {
9069                let Some(amount) = parse_bank_copper_amount(&input) else {
9070                    self.state
9071                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
9072                    return Ok(());
9073                };
9074                self.bank_deposit(amount).await?;
9075                self.state.bank_ui_mode = BankUiMode::Menu;
9076            }
9077            BankUiMode::WithdrawAmount { input } => {
9078                let Some(amount) = parse_bank_copper_amount(&input) else {
9079                    self.state
9080                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
9081                    return Ok(());
9082                };
9083                self.bank_withdraw(amount).await?;
9084                self.state.bank_ui_mode = BankUiMode::Menu;
9085            }
9086            BankUiMode::TransferName { input } => {
9087                let name = input.trim().to_string();
9088                if name.is_empty() {
9089                    self.state.push_log("Enter the recipient character name.");
9090                    return Ok(());
9091                }
9092                self.state.bank_ui_mode = BankUiMode::TransferAmount {
9093                    to_name: name,
9094                    input: String::new(),
9095                };
9096            }
9097            BankUiMode::TransferAmount { to_name, input } => {
9098                let amount: u64 = match input.trim().parse() {
9099                    Ok(v) if v > 0 => v,
9100                    _ => {
9101                        self.state
9102                            .push_log("Enter a positive copper amount to transfer.");
9103                        return Ok(());
9104                    }
9105                };
9106                self.bank_transfer(None, to_name, amount).await?;
9107                self.state.bank_ui_mode = BankUiMode::Menu;
9108            }
9109        }
9110        Ok(())
9111    }
9112
9113    pub fn bank_transfer_back(&mut self) {
9114        match &self.state.bank_ui_mode {
9115            BankUiMode::TransferAmount { to_name, .. } => {
9116                self.state.bank_ui_mode = BankUiMode::TransferName {
9117                    input: to_name.clone(),
9118                };
9119            }
9120            BankUiMode::TransferName { .. }
9121            | BankUiMode::DepositAmount { .. }
9122            | BankUiMode::WithdrawAmount { .. } => {
9123                self.state.bank_ui_mode = BankUiMode::Menu;
9124            }
9125            BankUiMode::Menu => {}
9126        }
9127    }
9128
9129    pub fn bank_transfer_append_char(&mut self, c: char) {
9130        match &mut self.state.bank_ui_mode {
9131            BankUiMode::TransferName { input } => {
9132                if input.len() < 32 && !c.is_control() {
9133                    input.push(c);
9134                }
9135            }
9136            BankUiMode::DepositAmount { input }
9137            | BankUiMode::WithdrawAmount { input }
9138            | BankUiMode::TransferAmount { input, .. } => {
9139                if c.is_ascii_digit() && input.len() < 12 {
9140                    input.push(c);
9141                }
9142            }
9143            BankUiMode::Menu => {}
9144        }
9145    }
9146
9147    pub fn bank_transfer_backspace(&mut self) {
9148        match &mut self.state.bank_ui_mode {
9149            BankUiMode::TransferName { input }
9150            | BankUiMode::DepositAmount { input }
9151            | BankUiMode::WithdrawAmount { input }
9152            | BankUiMode::TransferAmount { input, .. } => {
9153                input.pop();
9154            }
9155            BankUiMode::Menu => {}
9156        }
9157    }
9158
9159    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9160        let npc_id = self
9161            .state
9162            .bank_panel
9163            .as_ref()
9164            .map(|p| p.npc_id.clone());
9165        self.state.clear_bank_panel();
9166        if let Some(npc_id) = npc_id {
9167            self.seq += 1;
9168            self.session
9169                .submit_intent(Intent::BankClose {
9170                    entity_id: self.state.entity_id,
9171                    npc_id,
9172                    seq: self.seq,
9173                })
9174                .await?;
9175            self.state.intents_sent += 1;
9176        }
9177        Ok(())
9178    }
9179
9180    pub async fn storage_store(
9181        &mut self,
9182        item_instance_id: uuid::Uuid,
9183        quantity: Option<u32>,
9184    ) -> anyhow::Result<()> {
9185        let Some(panel) = self.state.storage_panel.clone() else {
9186            return Ok(());
9187        };
9188        self.seq += 1;
9189        self.session
9190            .submit_intent(Intent::StorageStore {
9191                entity_id: self.state.entity_id,
9192                npc_id: panel.npc_id,
9193                item_instance_id,
9194                quantity,
9195                seq: self.seq,
9196            })
9197            .await?;
9198        self.state.intents_sent += 1;
9199        Ok(())
9200    }
9201
9202    pub async fn storage_take(
9203        &mut self,
9204        item_instance_id: uuid::Uuid,
9205        quantity: Option<u32>,
9206    ) -> anyhow::Result<()> {
9207        let Some(panel) = self.state.storage_panel.clone() else {
9208            return Ok(());
9209        };
9210        self.seq += 1;
9211        self.session
9212            .submit_intent(Intent::StorageTake {
9213                entity_id: self.state.entity_id,
9214                npc_id: panel.npc_id,
9215                item_instance_id,
9216                quantity,
9217                seq: self.seq,
9218            })
9219            .await?;
9220        self.state.intents_sent += 1;
9221        Ok(())
9222    }
9223
9224    pub async fn storage_ship(
9225        &mut self,
9226        dest_building_id: String,
9227        item_instance_id: uuid::Uuid,
9228        quantity: Option<u32>,
9229    ) -> anyhow::Result<()> {
9230        let Some(panel) = self.state.storage_panel.clone() else {
9231            return Ok(());
9232        };
9233        self.seq += 1;
9234        self.session
9235            .submit_intent(Intent::StorageShip {
9236                entity_id: self.state.entity_id,
9237                npc_id: panel.npc_id,
9238                dest_building_id,
9239                item_instance_id,
9240                quantity,
9241                seq: self.seq,
9242            })
9243            .await?;
9244        self.state.intents_sent += 1;
9245        Ok(())
9246    }
9247
9248    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9249        let npc_id = self
9250            .state
9251            .storage_panel
9252            .as_ref()
9253            .map(|p| p.npc_id.clone());
9254        self.state.clear_storage_panel();
9255        if let Some(npc_id) = npc_id {
9256            self.seq += 1;
9257            self.session
9258                .submit_intent(Intent::StorageClose {
9259                    entity_id: self.state.entity_id,
9260                    npc_id,
9261                    seq: self.seq,
9262                })
9263                .await?;
9264            self.state.intents_sent += 1;
9265        }
9266        Ok(())
9267    }
9268
9269    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9270        let npc_id = self
9271            .state
9272            .market_panel
9273            .as_ref()
9274            .map(|p| p.npc_id.clone());
9275        self.state.clear_market_panel();
9276        if let Some(npc_id) = npc_id {
9277            self.seq += 1;
9278            self.session
9279                .submit_intent(Intent::MarketClose {
9280                    entity_id: self.state.entity_id,
9281                    npc_id,
9282                    seq: self.seq,
9283                })
9284                .await?;
9285            self.state.intents_sent += 1;
9286        }
9287        Ok(())
9288    }
9289
9290    pub fn market_move_selection(&mut self, delta: i32) {
9291        let indices = self.state.market_filtered_listing_indices();
9292        let n = indices.len();
9293        if n == 0 {
9294            self.state.market_menu_index = 0;
9295            return;
9296        }
9297        let cur = self.state.market_menu_index as i32;
9298        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9299    }
9300
9301    pub fn market_page_selection(&mut self, pages: i32) {
9302        let indices = self.state.market_filtered_listing_indices();
9303        let n = indices.len();
9304        if n == 0 {
9305            self.state.market_menu_index = 0;
9306            return;
9307        }
9308        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9309    }
9310
9311    pub fn market_list_page(&mut self, pages: i32) {
9312        match &self.state.market_ui_mode {
9313            MarketUiMode::ListSource { index } => {
9314                let n = self.state.market_list_source_options().len();
9315                if n == 0 {
9316                    return;
9317                }
9318                let next = page_list_index(*index, pages, n);
9319                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9320            }
9321            MarketUiMode::ListPricingMode { index, .. } => {
9322                let next = page_list_index(*index, pages, 2);
9323                if let MarketUiMode::ListPricingMode { index, .. } =
9324                    &mut self.state.market_ui_mode
9325                {
9326                    *index = next;
9327                }
9328            }
9329            MarketUiMode::ListPick { source, index } => {
9330                let opts = self.state.market_list_item_options(source);
9331                let n = opts.len();
9332                if n == 0 {
9333                    return;
9334                }
9335                let next = page_list_index(*index, pages, n);
9336                self.state.market_ui_mode = MarketUiMode::ListPick {
9337                    source: source.clone(),
9338                    index: next,
9339                };
9340            }
9341            _ => {}
9342        }
9343    }
9344
9345    pub fn market_cycle_category(&mut self, delta: i32) {
9346        let groups = self.state.market_available_category_groups();
9347        // All + groups
9348        let mut labels: Vec<Option<&'static str>> = vec![None];
9349        labels.extend(groups.into_iter().map(Some));
9350        let n = labels.len() as i32;
9351        let cur = labels
9352            .iter()
9353            .position(|g| *g == self.state.market_category_filter)
9354            .unwrap_or(0) as i32;
9355        let next = (cur + delta).rem_euclid(n) as usize;
9356        self.state.market_category_filter = labels[next];
9357        self.state.market_menu_index = 0;
9358        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9359            let source = source.clone();
9360            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9361        }
9362    }
9363
9364    pub fn focus_market_filter(&mut self) {
9365        self.state.market_filter_focused = true;
9366    }
9367
9368    pub fn append_market_filter_char(&mut self, ch: char) {
9369        if !self.state.market_filter_focused {
9370            return;
9371        }
9372        if ch.is_control() {
9373            return;
9374        }
9375        if self.state.market_filter.len() < 48 {
9376            self.state.market_filter.push(ch);
9377            self.state.market_menu_index = 0;
9378            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9379                let source = source.clone();
9380                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9381            }
9382        }
9383    }
9384
9385    pub fn market_filter_backspace(&mut self) {
9386        if !self.state.market_filter_focused {
9387            return;
9388        }
9389        self.state.market_filter.pop();
9390        self.state.market_menu_index = 0;
9391        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9392            let source = source.clone();
9393            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9394        }
9395    }
9396
9397    /// Clears filter text, blurs search, or returns false if already idle.
9398    pub fn clear_or_blur_market_filter(&mut self) -> bool {
9399        if self.state.market_filter_focused {
9400            if !self.state.market_filter.is_empty() {
9401                self.state.market_filter.clear();
9402                self.state.market_menu_index = 0;
9403                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9404                    let source = source.clone();
9405                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9406                }
9407                return true;
9408            }
9409            self.state.market_filter_focused = false;
9410            return true;
9411        }
9412        if !self.state.market_filter.is_empty() {
9413            self.state.market_filter.clear();
9414            self.state.market_menu_index = 0;
9415            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9416                let source = source.clone();
9417                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9418            }
9419            return true;
9420        }
9421        false
9422    }
9423
9424    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9425        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9426            return self.market_confirm_buy(listing_id, qty).await;
9427        }
9428        let Some(panel) = self.state.market_panel.clone() else {
9429            return Ok(());
9430        };
9431        let indices = self.state.market_filtered_listing_indices();
9432        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9433            return Ok(());
9434        };
9435        let Some(listing) = panel.listings.get(raw_idx) else {
9436            return Ok(());
9437        };
9438        if listing.mine {
9439            self.seq += 1;
9440            self.session
9441                .submit_intent(Intent::MarketDelist {
9442                    entity_id: self.state.entity_id,
9443                    npc_id: panel.npc_id.clone(),
9444                    listing_id: listing.listing_id,
9445                    dest: flatland_protocol::GoodsLocation::Person,
9446                    seq: self.seq,
9447                })
9448                .await?;
9449            self.state.intents_sent += 1;
9450            return Ok(());
9451        }
9452        if listing.npc_price {
9453            self.state
9454                .push_log("NPC-price listings are bought by merchants only.");
9455            return Ok(());
9456        }
9457        let qty = 1u32.min(listing.quantity).max(1);
9458        let line = listing.unit_price_copper.saturating_mul(qty as u64);
9459        self.state.market_buy_confirm = Some((
9460            listing.listing_id,
9461            qty,
9462            listing.unit_price_copper,
9463            line,
9464            listing.display_name.clone(),
9465        ));
9466        Ok(())
9467    }
9468
9469    pub async fn market_confirm_buy(
9470        &mut self,
9471        listing_id: uuid::Uuid,
9472        quantity: u32,
9473    ) -> anyhow::Result<()> {
9474        let Some(panel) = self.state.market_panel.clone() else {
9475            self.state.market_buy_confirm = None;
9476            return Ok(());
9477        };
9478        self.state.market_buy_confirm = None;
9479        self.seq += 1;
9480        self.session
9481            .submit_intent(Intent::MarketBuy {
9482                entity_id: self.state.entity_id,
9483                npc_id: panel.npc_id,
9484                listing_id,
9485                quantity,
9486                dest: flatland_protocol::GoodsLocation::Person,
9487                seq: self.seq,
9488            })
9489            .await?;
9490        self.state.intents_sent += 1;
9491        Ok(())
9492    }
9493
9494    /// Begin the list wizard (`l` in market browse).
9495    pub fn market_begin_list(&mut self) {
9496        if self.state.market_panel.is_none() {
9497            return;
9498        }
9499        let sources = self.state.market_list_source_options();
9500        if sources.is_empty() {
9501            self.state.push_log("Nothing to list from.");
9502            return;
9503        }
9504        // One source (person only) → skip straight to item pick when it has stacks.
9505        if sources.len() == 1 {
9506            let (source, _) = sources[0].clone();
9507            let opts = self.state.market_list_item_options(&source);
9508            if opts.is_empty() {
9509                self.state.push_log("Nothing loose to list.");
9510                return;
9511            }
9512            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9513            self.state.market_buy_confirm = None;
9514            return;
9515        }
9516        self.state.market_buy_confirm = None;
9517        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9518    }
9519
9520    pub fn market_ui_back(&mut self) {
9521        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9522            MarketUiMode::Browse => MarketUiMode::Browse,
9523            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9524            MarketUiMode::ListPick { .. } => {
9525                if self.state.market_list_source_options().len() <= 1 {
9526                    MarketUiMode::Browse
9527                } else {
9528                    MarketUiMode::ListSource { index: 0 }
9529                }
9530            }
9531            MarketUiMode::ListAmount {
9532                source,
9533                pick_index,
9534                ..
9535            } => MarketUiMode::ListPick {
9536                source,
9537                index: pick_index,
9538            },
9539            MarketUiMode::ListPricingMode {
9540                source,
9541                item_instance_id,
9542                label,
9543                max_qty,
9544                quantity,
9545                ..
9546            } => {
9547                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9548                MarketUiMode::ListAmount {
9549                    source,
9550                    pick_index: 0,
9551                    item_instance_id,
9552                    label,
9553                    max_qty,
9554                    input,
9555                }
9556            }
9557            MarketUiMode::ListPrice {
9558                source,
9559                item_instance_id,
9560                label,
9561                max_qty,
9562                quantity,
9563                ..
9564            } => MarketUiMode::ListPricingMode {
9565                source,
9566                item_instance_id,
9567                label,
9568                quantity,
9569                max_qty,
9570                index: 1,
9571            },
9572        };
9573    }
9574
9575    pub fn market_list_move(&mut self, delta: i32) {
9576        match &self.state.market_ui_mode {
9577            MarketUiMode::ListSource { index } => {
9578                let n = self.state.market_list_source_options().len();
9579                if n == 0 {
9580                    return;
9581                }
9582                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9583                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9584            }
9585            MarketUiMode::ListPricingMode { index, .. } => {
9586                let next = (*index as i32 + delta).rem_euclid(2) as usize;
9587                if let MarketUiMode::ListPricingMode { index, .. } =
9588                    &mut self.state.market_ui_mode
9589                {
9590                    *index = next;
9591                }
9592            }
9593            MarketUiMode::ListPick { source, index } => {
9594                let opts = self.state.market_list_item_options(source);
9595                let n = opts.len();
9596                if n == 0 {
9597                    return;
9598                }
9599                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9600                self.state.market_ui_mode = MarketUiMode::ListPick {
9601                    source: source.clone(),
9602                    index: next,
9603                };
9604            }
9605            _ => {}
9606        }
9607    }
9608
9609    pub fn market_list_amount_append_char(&mut self, c: char) {
9610        if !c.is_ascii_digit() {
9611            return;
9612        }
9613        match &mut self.state.market_ui_mode {
9614            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9615                if input.len() < 12 {
9616                    input.push(c);
9617                }
9618            }
9619            _ => {}
9620        }
9621    }
9622
9623    pub fn market_list_amount_backspace(&mut self) {
9624        match &mut self.state.market_ui_mode {
9625            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9626                input.pop();
9627            }
9628            _ => {}
9629        }
9630    }
9631
9632    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9633        match self.state.market_ui_mode.clone() {
9634            MarketUiMode::Browse => Ok(()),
9635            MarketUiMode::ListSource { index } => {
9636                let sources = self.state.market_list_source_options();
9637                let Some((source, _)) = sources.get(index).cloned() else {
9638                    return Ok(());
9639                };
9640                let opts = self.state.market_list_item_options(&source);
9641                if opts.is_empty() {
9642                    self.state.push_log("Nothing to list from that source.");
9643                    return Ok(());
9644                }
9645                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9646                Ok(())
9647            }
9648            MarketUiMode::ListPick { source, index } => {
9649                let opts = self.state.market_list_item_options(&source);
9650                let Some(opt) = opts.get(index) else {
9651                    self.state.push_log("Nothing to list.");
9652                    self.state.market_ui_mode = MarketUiMode::Browse;
9653                    return Ok(());
9654                };
9655                self.state.market_ui_mode = MarketUiMode::ListAmount {
9656                    source,
9657                    pick_index: index,
9658                    item_instance_id: opt.item_instance_id,
9659                    label: opt.label.clone(),
9660                    max_qty: opt.quantity.max(1),
9661                    input: String::new(),
9662                };
9663                Ok(())
9664            }
9665            MarketUiMode::ListAmount {
9666                source,
9667                item_instance_id,
9668                label,
9669                max_qty,
9670                input,
9671                ..
9672            } => {
9673                let Some(qty_opt) = parse_storage_quantity(&input) else {
9674                    self.state.push_log("Enter a quantity (blank = all).");
9675                    return Ok(());
9676                };
9677                if let Some(q) = qty_opt {
9678                    if q > max_qty {
9679                        self.state
9680                            .push_log(format!("Only {max_qty} available."));
9681                        return Ok(());
9682                    }
9683                }
9684                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9685                    source,
9686                    item_instance_id,
9687                    label,
9688                    quantity: qty_opt,
9689                    max_qty,
9690                    index: 0,
9691                };
9692                Ok(())
9693            }
9694            MarketUiMode::ListPricingMode {
9695                source,
9696                item_instance_id,
9697                label,
9698                quantity,
9699                max_qty,
9700                index,
9701            } => {
9702                if index == 0 {
9703                    return self
9704                        .submit_market_list_intent(
9705                            source,
9706                            item_instance_id,
9707                            quantity,
9708                            0,
9709                            true,
9710                            &label,
9711                        )
9712                        .await;
9713                }
9714                self.state.market_ui_mode = MarketUiMode::ListPrice {
9715                    source,
9716                    item_instance_id,
9717                    label,
9718                    quantity,
9719                    max_qty,
9720                    input: String::new(),
9721                };
9722                Ok(())
9723            }
9724            MarketUiMode::ListPrice {
9725                source,
9726                item_instance_id,
9727                label,
9728                quantity,
9729                input,
9730                ..
9731            } => {
9732                let price = input.trim().parse::<u64>().unwrap_or(0);
9733                if price == 0 {
9734                    self.state.push_log("Enter a unit price of at least 1 copper.");
9735                    return Ok(());
9736                }
9737                self.submit_market_list_intent(
9738                    source,
9739                    item_instance_id,
9740                    quantity,
9741                    price,
9742                    false,
9743                    &label,
9744                )
9745                .await
9746            }
9747        }
9748    }
9749
9750    async fn submit_market_list_intent(
9751        &mut self,
9752        source: MarketListSourceKind,
9753        item_instance_id: uuid::Uuid,
9754        quantity: Option<u32>,
9755        unit_price_copper: u64,
9756        npc_price: bool,
9757        label: &str,
9758    ) -> anyhow::Result<()> {
9759        let Some(panel) = self.state.market_panel.clone() else {
9760            self.state.market_ui_mode = MarketUiMode::Browse;
9761            return Ok(());
9762        };
9763        let goods = match source {
9764            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9765            MarketListSourceKind::TownStorage { building_id } => {
9766                flatland_protocol::GoodsLocation::TownStorage { building_id }
9767            }
9768        };
9769        self.seq += 1;
9770        self.session
9771            .submit_intent(Intent::MarketList {
9772                entity_id: self.state.entity_id,
9773                npc_id: panel.npc_id,
9774                source: goods,
9775                item_instance_id,
9776                quantity,
9777                unit_price_copper,
9778                npc_price,
9779                seq: self.seq,
9780            })
9781            .await?;
9782        self.state.intents_sent += 1;
9783        if npc_price {
9784            self.state.push_log(format!("Listing {label} at NPC price…"));
9785        } else {
9786            self.state
9787                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
9788        }
9789        self.state.market_ui_mode = MarketUiMode::Browse;
9790        Ok(())
9791    }
9792
9793    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
9794    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9795        let return_to_verbs = self.state.npc_verb_target.is_some();
9796        self.close_shop_menu().await?;
9797        if return_to_verbs {
9798            self.state.show_npc_verb_menu = true;
9799        }
9800        Ok(())
9801    }
9802
9803    pub fn shop_tab_toggle(&mut self) {
9804        self.state.shop_tab = match self.state.shop_tab {
9805            ShopTab::Buy => ShopTab::Sell,
9806            ShopTab::Sell => ShopTab::Buy,
9807        };
9808        self.state.shop_menu_index = 0;
9809        if self.state.shop_tab == ShopTab::Sell {
9810            self.state.shop_quantity_set_max();
9811        }
9812        self.state.clamp_shop_selection();
9813    }
9814
9815    pub fn shop_menu_move(&mut self, delta: i32) {
9816        self.state.shop_menu_move(delta);
9817    }
9818
9819    pub fn shop_quantity_adjust(&mut self, delta: i32) {
9820        self.state.shop_quantity_adjust(delta);
9821    }
9822
9823    pub fn shop_quantity_set_max(&mut self) {
9824        self.state.shop_quantity_set_max();
9825    }
9826
9827    pub fn shop_quantity_set_min(&mut self) {
9828        self.state.shop_quantity_set_min();
9829    }
9830
9831    pub fn toggle_quest_menu(&mut self) {
9832        self.state.show_quest_menu = !self.state.show_quest_menu;
9833        if self.state.show_quest_menu {
9834            self.state.quest_menu_index = 0;
9835            self.state.quest_withdraw_confirm = false;
9836            self.state.show_workers_menu = false;
9837        }
9838    }
9839
9840    pub fn toggle_workers_menu(&mut self) {
9841        if self.state.show_workers_menu {
9842            self.close_workers_menu_ui();
9843        } else {
9844            self.state.show_workers_menu = true;
9845            self.state.workers_menu_index = 0;
9846            self.state.show_quest_menu = false;
9847            self.close_worker_give_picker();
9848            self.close_worker_give_target_picker();
9849            self.close_worker_take_picker();
9850            self.close_worker_teach_picker();
9851            self.cancel_worker_rename();
9852        }
9853    }
9854
9855    /// Close workers UI layers without network (caller should release attend).
9856    pub fn close_workers_menu_ui(&mut self) {
9857        self.state.show_workers_menu = false;
9858        self.close_worker_give_picker();
9859        self.close_worker_give_target_picker();
9860        self.close_worker_take_picker();
9861        self.close_worker_teach_picker();
9862        self.cancel_worker_rename();
9863    }
9864
9865    /// Open the workers menu focused on `instance_id` and pause that worker's job.
9866    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9867        let Some(idx) = self
9868            .state
9869            .hired_workers
9870            .iter()
9871            .position(|w| w.instance_id == instance_id)
9872        else {
9873            anyhow::bail!("worker not found");
9874        };
9875        let label = self.state.hired_workers[idx].label.clone();
9876        self.state.show_workers_menu = true;
9877        self.state.workers_menu_index = idx;
9878        self.state.show_quest_menu = false;
9879        self.close_worker_give_picker();
9880        self.close_worker_give_target_picker();
9881        self.close_worker_take_picker();
9882        self.close_worker_teach_picker();
9883        self.cancel_worker_rename();
9884        self.set_worker_attending(instance_id, true).await?;
9885        self.state
9886            .push_log(format!("Managing {label} — job paused while menu is open"));
9887        Ok(())
9888    }
9889
9890    /// Close workers menu and release any attend pause.
9891    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9892        self.close_workers_menu_ui();
9893        self.release_worker_attend().await
9894    }
9895
9896    async fn set_worker_attending(
9897        &mut self,
9898        instance_id: &str,
9899        attending: bool,
9900    ) -> anyhow::Result<()> {
9901        if attending {
9902            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9903                return Ok(());
9904            }
9905            // Switch target: release previous first.
9906            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9907                if prev != instance_id {
9908                    self.send_attend_hired_worker(&prev, false).await?;
9909                }
9910            }
9911            self.send_attend_hired_worker(instance_id, true).await?;
9912            self.state.attending_worker_instance_id = Some(instance_id.to_string());
9913        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9914            self.send_attend_hired_worker(instance_id, false).await?;
9915            self.state.attending_worker_instance_id = None;
9916        }
9917        Ok(())
9918    }
9919
9920    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9921        let Some(id) = self.state.attending_worker_instance_id.take() else {
9922            return Ok(());
9923        };
9924        self.send_attend_hired_worker(&id, false).await
9925    }
9926
9927    async fn send_attend_hired_worker(
9928        &mut self,
9929        worker_instance_id: &str,
9930        attending: bool,
9931    ) -> anyhow::Result<()> {
9932        self.seq += 1;
9933        self.session
9934            .submit_intent(Intent::AttendHiredWorker {
9935                entity_id: self.state.entity_id,
9936                worker_instance_id: worker_instance_id.to_string(),
9937                attending,
9938                seq: self.seq,
9939            })
9940            .await?;
9941        self.state.intents_sent += 1;
9942        Ok(())
9943    }
9944
9945    pub fn workers_menu_move(&mut self, delta: i32) {
9946        let n = self.state.hired_workers.len();
9947        if n == 0 {
9948            return;
9949        }
9950        let idx = self.state.workers_menu_index as i32;
9951        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9952    }
9953
9954    pub fn toggle_workers_menu_compact(&mut self) {
9955        self.state.workers_menu_compact = !self.state.workers_menu_compact;
9956        let mut cfg = crate::client_config::ClientConfig::load();
9957        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9958    }
9959
9960    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9961        let Some(worker) = self
9962            .state
9963            .hired_workers
9964            .get(self.state.workers_menu_index)
9965            .cloned()
9966        else {
9967            anyhow::bail!("no worker selected");
9968        };
9969        self.seq += 1;
9970        self.session
9971            .submit_intent(Intent::DismissWorker {
9972                entity_id: self.state.entity_id,
9973                worker_instance_id: worker.instance_id.clone(),
9974                seq: self.seq,
9975            })
9976            .await?;
9977        self.state.intents_sent += 1;
9978        self.state
9979            .hired_workers
9980            .retain(|w| w.instance_id != worker.instance_id);
9981        if self.state.workers_menu_index >= self.state.hired_workers.len() {
9982            self.state.workers_menu_index = self
9983                .state
9984                .hired_workers
9985                .len()
9986                .saturating_sub(1);
9987        }
9988        self.state.push_log(format!("Dismissed {}", worker.label));
9989        Ok(())
9990    }
9991
9992    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9993        let Some(worker) = self
9994            .state
9995            .hired_workers
9996            .get(self.state.workers_menu_index)
9997            .cloned()
9998        else {
9999            anyhow::bail!("no worker selected");
10000        };
10001        let mode = match worker.mode {
10002            flatland_protocol::WorkerModeView::Companion => "job_loop",
10003            flatland_protocol::WorkerModeView::JobLoop => "idle",
10004            flatland_protocol::WorkerModeView::Idle => "companion",
10005        };
10006        self.seq += 1;
10007        self.session
10008            .submit_intent(Intent::SetWorkerMode {
10009                entity_id: self.state.entity_id,
10010                worker_instance_id: worker.instance_id,
10011                mode: mode.into(),
10012                seq: self.seq,
10013            })
10014            .await?;
10015        self.state.intents_sent += 1;
10016        Ok(())
10017    }
10018
10019    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
10020        if self.state.hired_workers.is_empty() {
10021            return self.hire_worker_laborer().await;
10022        }
10023        self.workers_toggle_mode_selected().await
10024    }
10025
10026    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
10027    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
10028    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10029        let row = self
10030            .state
10031            .inventory_selected_row()
10032            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
10033            .clone();
10034        if row.from != flatland_protocol::InventoryLocation::Root {
10035            anyhow::bail!("select a carried item to give");
10036        }
10037        let Some(instance_id) = row.stack.item_instance_id else {
10038            anyhow::bail!("that stack can't be given");
10039        };
10040        let options = self.nearby_worker_give_targets();
10041        if options.is_empty() {
10042            anyhow::bail!(
10043                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10044            );
10045        }
10046        let item_label = row
10047            .stack
10048            .display_name
10049            .as_deref()
10050            .unwrap_or(&row.stack.template_id)
10051            .to_string();
10052        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10053            item_instance_id: instance_id,
10054            item_label,
10055            quantity: None,
10056            options,
10057        });
10058        self.state.worker_give_target_picker_index = 0;
10059        self.state.show_worker_give_target_picker = true;
10060        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
10061        self.state.show_inventory_menu = false;
10062        Ok(())
10063    }
10064
10065    /// Hired workers within give/take range, nearest first.
10066    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10067        let (px, py, _) = self.state.player_position_with_z();
10068        let mut options: Vec<WorkerGiveTargetOption> = self
10069            .state
10070            .hired_workers
10071            .iter()
10072            .filter_map(|w| {
10073                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10074                if dist > WORKER_GIVE_RANGE_M {
10075                    return None;
10076                }
10077                Some(WorkerGiveTargetOption {
10078                    instance_id: w.instance_id.clone(),
10079                    label: w.label.clone(),
10080                    distance_m: dist,
10081                })
10082            })
10083            .collect();
10084        options.sort_by(|a, b| {
10085            a.distance_m
10086                .partial_cmp(&b.distance_m)
10087                .unwrap_or(std::cmp::Ordering::Equal)
10088        });
10089        options
10090    }
10091
10092    pub fn close_worker_give_target_picker(&mut self) {
10093        self.state.show_worker_give_target_picker = false;
10094        self.state.worker_give_target_picker = None;
10095        self.state.worker_give_target_picker_index = 0;
10096    }
10097
10098    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10099        let Some(picker) = &self.state.worker_give_target_picker else {
10100            return;
10101        };
10102        let n = picker.options.len();
10103        if n == 0 {
10104            return;
10105        }
10106        let idx = self.state.worker_give_target_picker_index as i32;
10107        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10108    }
10109
10110    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10111        let Some(picker) = self.state.worker_give_target_picker.clone() else {
10112            anyhow::bail!("give target picker not open");
10113        };
10114        let Some(opt) = picker
10115            .options
10116            .get(self.state.worker_give_target_picker_index)
10117            .cloned()
10118        else {
10119            anyhow::bail!("no worker selected");
10120        };
10121        let Some(worker) = self
10122            .state
10123            .hired_workers
10124            .iter()
10125            .find(|w| w.instance_id == opt.instance_id)
10126            .cloned()
10127        else {
10128            self.close_worker_give_target_picker();
10129            anyhow::bail!("worker no longer hired");
10130        };
10131        self.give_item_to_worker(
10132            &worker.instance_id,
10133            &worker.label,
10134            worker.x,
10135            worker.y,
10136            picker.item_instance_id,
10137            &picker.item_label,
10138            picker.quantity,
10139        )
10140        .await?;
10141        self.close_worker_give_target_picker();
10142        Ok(())
10143    }
10144
10145    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
10146    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10147        self.open_worker_give_target_picker()
10148    }
10149
10150    /// Open the workers-menu give picker for the selected worker (must be in range).
10151    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10152        let Some(worker) = self
10153            .state
10154            .hired_workers
10155            .get(self.state.workers_menu_index)
10156            .cloned()
10157        else {
10158            anyhow::bail!("select a hired worker first");
10159        };
10160        let (px, py, _) = self.state.player_position_with_z();
10161        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10162        if dist > WORKER_GIVE_RANGE_M {
10163            anyhow::bail!(
10164                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10165                worker.label
10166            );
10167        }
10168        let options = self.state.giveable_inventory_options();
10169        if options.is_empty() {
10170            anyhow::bail!("nothing in inventory to give");
10171        }
10172        self.state.worker_give_picker = Some(WorkerGivePicker {
10173            worker_instance_id: worker.instance_id,
10174            worker_label: worker.label,
10175            options,
10176        });
10177        self.state.worker_give_picker_index = 0;
10178        self.state.show_worker_give_picker = true;
10179        Ok(())
10180    }
10181
10182    pub fn close_worker_give_picker(&mut self) {
10183        self.state.show_worker_give_picker = false;
10184        self.state.worker_give_picker = None;
10185        self.state.worker_give_picker_index = 0;
10186    }
10187
10188    pub fn worker_give_picker_move(&mut self, delta: i32) {
10189        let Some(picker) = &self.state.worker_give_picker else {
10190            return;
10191        };
10192        let n = picker.options.len();
10193        if n == 0 {
10194            return;
10195        }
10196        let idx = self.state.worker_give_picker_index as i32;
10197        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10198    }
10199
10200    /// Confirm the selected row in the workers-menu give picker.
10201    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10202        let Some(picker) = self.state.worker_give_picker.clone() else {
10203            anyhow::bail!("give picker not open");
10204        };
10205        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
10206            anyhow::bail!("no item selected");
10207        };
10208        let Some(worker) = self
10209            .state
10210            .hired_workers
10211            .iter()
10212            .find(|w| w.instance_id == picker.worker_instance_id)
10213            .cloned()
10214        else {
10215            self.close_worker_give_picker();
10216            anyhow::bail!("worker no longer hired");
10217        };
10218        self.give_item_to_worker(
10219            &worker.instance_id,
10220            &worker.label,
10221            worker.x,
10222            worker.y,
10223            opt.item_instance_id,
10224            &opt.label,
10225            None,
10226        )
10227        .await?;
10228        // Refresh options (stack may be gone / reduced) or close when empty.
10229        let options = self.state.giveable_inventory_options();
10230        if options.is_empty() {
10231            self.close_worker_give_picker();
10232        } else {
10233            self.state.worker_give_picker = Some(WorkerGivePicker {
10234                worker_instance_id: picker.worker_instance_id,
10235                worker_label: picker.worker_label,
10236                options,
10237            });
10238            if self.state.worker_give_picker_index
10239                >= self
10240                    .state
10241                    .worker_give_picker
10242                    .as_ref()
10243                    .map(|p| p.options.len())
10244                    .unwrap_or(0)
10245            {
10246                self.state.worker_give_picker_index = self
10247                    .state
10248                    .worker_give_picker
10249                    .as_ref()
10250                    .map(|p| p.options.len().saturating_sub(1))
10251                    .unwrap_or(0);
10252            }
10253        }
10254        Ok(())
10255    }
10256
10257    /// Open the workers-menu teach picker for the selected worker (must be in range).
10258    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10259        let Some(worker) = self
10260            .state
10261            .hired_workers
10262            .get(self.state.workers_menu_index)
10263            .cloned()
10264        else {
10265            anyhow::bail!("select a hired worker first");
10266        };
10267        let (px, py, _) = self.state.player_position_with_z();
10268        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10269        if dist > WORKER_GIVE_RANGE_M {
10270            anyhow::bail!(
10271                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10272                worker.label
10273            );
10274        }
10275        let options = self.state.teachable_blueprint_options(&worker);
10276        if options.is_empty() {
10277            anyhow::bail!("no recipes you know that {} still needs", worker.label);
10278        }
10279        self.state.worker_teach_picker = Some(WorkerTeachPicker {
10280            worker_instance_id: worker.instance_id,
10281            worker_label: worker.label,
10282            worker_level: worker.level,
10283            options,
10284        });
10285        self.state.worker_teach_picker_index = 0;
10286        self.state.show_worker_teach_picker = true;
10287        Ok(())
10288    }
10289
10290    pub fn close_worker_teach_picker(&mut self) {
10291        self.state.show_worker_teach_picker = false;
10292        self.state.worker_teach_picker = None;
10293        self.state.worker_teach_picker_index = 0;
10294    }
10295
10296    pub fn worker_teach_picker_move(&mut self, delta: i32) {
10297        let Some(picker) = &self.state.worker_teach_picker else {
10298            return;
10299        };
10300        let n = picker.options.len();
10301        if n == 0 {
10302            return;
10303        }
10304        let idx = self.state.worker_teach_picker_index as i32;
10305        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10306    }
10307
10308    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10309        let Some(picker) = self.state.worker_teach_picker.clone() else {
10310            anyhow::bail!("teach picker not open");
10311        };
10312        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
10313            anyhow::bail!("nothing selected");
10314        };
10315        if !opt.level_ok {
10316            anyhow::bail!(
10317                "{} needs level {} (is level {})",
10318                picker.worker_label,
10319                opt.min_level,
10320                opt.worker_level
10321            );
10322        }
10323        if !opt.can_afford {
10324            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10325        }
10326        let Some(worker) = self
10327            .state
10328            .hired_workers
10329            .iter()
10330            .find(|w| w.instance_id == picker.worker_instance_id)
10331            .cloned()
10332        else {
10333            anyhow::bail!("worker gone");
10334        };
10335        let (px, py, _) = self.state.player_position_with_z();
10336        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10337        if dist > WORKER_GIVE_RANGE_M {
10338            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10339        }
10340        self.seq += 1;
10341        self.session
10342            .submit_intent(Intent::TeachWorkerBlueprint {
10343                entity_id: self.state.entity_id,
10344                worker_instance_id: picker.worker_instance_id.clone(),
10345                blueprint_id: opt.blueprint_id.clone(),
10346                seq: self.seq,
10347            })
10348            .await?;
10349        self.state.intents_sent += 1;
10350        self.state.push_log(format!(
10351            "Teaching {} to {} ({} cp)",
10352            opt.label, picker.worker_label, opt.cost_copper
10353        ));
10354        self.close_worker_teach_picker();
10355        Ok(())
10356    }
10357
10358    async fn give_item_to_worker(
10359        &mut self,
10360        worker_instance_id: &str,
10361        worker_label: &str,
10362        worker_x: f32,
10363        worker_y: f32,
10364        item_instance_id: uuid::Uuid,
10365        item_label: &str,
10366        quantity: Option<u32>,
10367    ) -> anyhow::Result<()> {
10368        let (px, py, _) = self.state.player_position_with_z();
10369        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10370        if dist > WORKER_GIVE_RANGE_M {
10371            anyhow::bail!("worker {worker_label} too far — stand next to them");
10372        }
10373        self.seq += 1;
10374        self.session
10375            .submit_intent(Intent::GiveWorkerItem {
10376                entity_id: self.state.entity_id,
10377                worker_instance_id: worker_instance_id.to_string(),
10378                item_instance_id,
10379                quantity,
10380                seq: self.seq,
10381            })
10382            .await?;
10383        self.state.intents_sent += 1;
10384        self.state
10385            .push_log(format!("Gave {item_label} to {worker_label}"));
10386        Ok(())
10387    }
10388
10389    /// Open the workers-menu take picker for the selected worker (must be in range).
10390    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10391        let Some(worker) = self
10392            .state
10393            .hired_workers
10394            .get(self.state.workers_menu_index)
10395            .cloned()
10396        else {
10397            anyhow::bail!("select a hired worker first");
10398        };
10399        let (px, py, _) = self.state.player_position_with_z();
10400        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10401        if dist > WORKER_GIVE_RANGE_M {
10402            anyhow::bail!(
10403                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10404                worker.label
10405            );
10406        }
10407        let options = Self::worker_inventory_options(&worker);
10408        if options.is_empty() {
10409            anyhow::bail!("{} isn't carrying anything", worker.label);
10410        }
10411        let initial_qty = options
10412            .first()
10413            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10414            .unwrap_or(1);
10415        self.state.worker_take_picker = Some(WorkerTakePicker {
10416            worker_instance_id: worker.instance_id,
10417            worker_label: worker.label,
10418            options,
10419            quantity: initial_qty,
10420        });
10421        self.state.worker_take_picker_index = 0;
10422        self.state.show_worker_take_picker = true;
10423        Ok(())
10424    }
10425
10426    fn worker_inventory_options(
10427        worker: &flatland_protocol::HiredWorkerView,
10428    ) -> Vec<WorkerGiveOption> {
10429        worker
10430            .inventory
10431            .iter()
10432            .filter_map(|stack| {
10433                let item_instance_id = stack.item_instance_id?;
10434                let label = stack
10435                    .display_name
10436                    .clone()
10437                    .unwrap_or_else(|| stack.template_id.clone());
10438                let label = if stack.quantity > 1 {
10439                    format!("{label} ×{}", stack.quantity)
10440                } else {
10441                    label
10442                };
10443                Some(WorkerGiveOption {
10444                    item_instance_id,
10445                    label,
10446                    quantity: stack.quantity,
10447                    template_id: stack.template_id.clone(),
10448                })
10449            })
10450            .collect()
10451    }
10452
10453    pub fn close_worker_take_picker(&mut self) {
10454        self.state.show_worker_take_picker = false;
10455        self.state.worker_take_picker = None;
10456        self.state.worker_take_picker_index = 0;
10457    }
10458
10459    pub fn worker_take_picker_move(&mut self, delta: i32) {
10460        let Some(picker) = &self.state.worker_take_picker else {
10461            return;
10462        };
10463        let n = picker.options.len();
10464        if n == 0 {
10465            return;
10466        }
10467        let idx = self.state.worker_take_picker_index as i32;
10468        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10469        self.clamp_worker_take_quantity();
10470    }
10471
10472    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10473        let Some(picker) = &mut self.state.worker_take_picker else {
10474            return;
10475        };
10476        let max = picker
10477            .options
10478            .get(self.state.worker_take_picker_index)
10479            .map(|o| o.quantity.max(1))
10480            .unwrap_or(1);
10481        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10482        picker.quantity = next as u32;
10483    }
10484
10485    pub fn worker_take_picker_set_quantity_max(&mut self) {
10486        let Some(picker) = &mut self.state.worker_take_picker else {
10487            return;
10488        };
10489        let max = picker
10490            .options
10491            .get(self.state.worker_take_picker_index)
10492            .map(|o| o.quantity.max(1))
10493            .unwrap_or(1);
10494        picker.quantity = max;
10495    }
10496
10497    pub fn worker_take_picker_set_quantity_min(&mut self) {
10498        let Some(picker) = &mut self.state.worker_take_picker else {
10499            return;
10500        };
10501        picker.quantity = 1;
10502        self.clamp_worker_take_quantity();
10503    }
10504
10505    fn clamp_worker_take_quantity(&mut self) {
10506        let Some(picker) = &mut self.state.worker_take_picker else {
10507            return;
10508        };
10509        let max = picker
10510            .options
10511            .get(self.state.worker_take_picker_index)
10512            .map(|o| o.quantity.max(1))
10513            .unwrap_or(1);
10514        if picker.quantity == 0 || picker.quantity > max {
10515            picker.quantity = if max > 1 { 1 } else { max };
10516        }
10517    }
10518
10519    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10520        let Some(picker) = self.state.worker_take_picker.clone() else {
10521            anyhow::bail!("take picker not open");
10522        };
10523        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
10524            anyhow::bail!("no item selected");
10525        };
10526        let Some(worker) = self
10527            .state
10528            .hired_workers
10529            .iter()
10530            .find(|w| w.instance_id == picker.worker_instance_id)
10531            .cloned()
10532        else {
10533            self.close_worker_take_picker();
10534            anyhow::bail!("worker no longer hired");
10535        };
10536        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10537        let intent_qty = if qty >= opt.quantity {
10538            None
10539        } else {
10540            Some(qty)
10541        };
10542        self.take_item_from_worker(
10543            &worker.instance_id,
10544            &worker.label,
10545            worker.x,
10546            worker.y,
10547            opt.item_instance_id,
10548            &opt.label,
10549            intent_qty,
10550        )
10551        .await?;
10552        // Leave the picker open — server Interaction / hired-workers sync refresh
10553        // options. Do not optimistic-strip stacks (rejects used to look like success).
10554        Ok(())
10555    }
10556
10557    async fn take_item_from_worker(
10558        &mut self,
10559        worker_instance_id: &str,
10560        worker_label: &str,
10561        worker_x: f32,
10562        worker_y: f32,
10563        item_instance_id: uuid::Uuid,
10564        item_label: &str,
10565        quantity: Option<u32>,
10566    ) -> anyhow::Result<()> {
10567        let (px, py, _) = self.state.player_position_with_z();
10568        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10569        if dist > WORKER_GIVE_RANGE_M {
10570            anyhow::bail!("worker {worker_label} too far — stand next to them");
10571        }
10572        self.seq += 1;
10573        self.session
10574            .submit_intent(Intent::TakeWorkerItem {
10575                entity_id: self.state.entity_id,
10576                worker_instance_id: worker_instance_id.to_string(),
10577                item_instance_id,
10578                quantity,
10579                seq: self.seq,
10580            })
10581            .await?;
10582        self.state.intents_sent += 1;
10583        let qty_note = quantity
10584            .map(|q| format!(" ×{q}"))
10585            .unwrap_or_default();
10586        self.state
10587            .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
10588        Ok(())
10589    }
10590
10591    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
10592        if !self.state.has_worker_lodging() {
10593            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
10594        }
10595        self.seq += 1;
10596        self.session
10597            .submit_intent(Intent::HireWorker {
10598                entity_id: self.state.entity_id,
10599                def_id: "worker_laborer".into(),
10600                wage_copper_per_interval: 8,
10601                lodging_container_id: None,
10602                job_yaml: None,
10603                seq: self.seq,
10604            })
10605            .await?;
10606        self.state.intents_sent += 1;
10607        Ok(())
10608    }
10609
10610    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
10611        let Some(worker) = self
10612            .state
10613            .hired_workers
10614            .get(self.state.workers_menu_index)
10615            .cloned()
10616        else {
10617            anyhow::bail!("select a hired worker first");
10618        };
10619        let lodging = worker.lodging_container_id.clone().or_else(|| {
10620            crate::worker_route_editor::owned_lodging_container_ids(
10621                &self.state.placed_containers,
10622                self.state.character_id,
10623            )
10624            .into_iter()
10625            .next()
10626            .map(|(id, _)| id)
10627        });
10628        let label = worker.label.clone();
10629        let editor = if let Some(route) = &worker.route {
10630            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
10631                worker.instance_id,
10632                worker.label,
10633                route,
10634                lodging,
10635            )
10636        } else {
10637            crate::worker_route_editor::WorkerRouteEditorState::new(
10638                worker.instance_id,
10639                worker.label,
10640                lodging,
10641            )
10642        };
10643        self.state.worker_route_editor = Some(editor);
10644        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10645            if let Some(collapsed) =
10646                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
10647            {
10648                ed.panel_collapsed = collapsed;
10649            }
10650        }
10651        self.state.show_workers_menu = false;
10652        self.state.push_log(format!(
10653            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
10654        ));
10655        Ok(())
10656    }
10657
10658    pub fn close_worker_route_editor(&mut self) {
10659        self.state.worker_route_editor = None;
10660    }
10661
10662    pub fn worker_route_editor_toggle_panel(&mut self) {
10663        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10664            ed.toggle_panel_collapsed();
10665            let collapsed = ed.panel_collapsed;
10666            let mut cfg = crate::client_config::ClientConfig::load();
10667            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
10668        }
10669    }
10670
10671    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
10672        let n = {
10673            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10674                return;
10675            };
10676            ed.append_waypoint(x, y, z);
10677            ed.stop_count()
10678        };
10679        self.state
10680            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
10681    }
10682
10683    // ---- picker candidate snapshots ----------------------------------
10684
10685    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
10686        let (px, py, _) = self.state.player_position_with_z();
10687        let inside = self.state.effective_inside_building();
10688        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
10689            &self.state.placed_containers,
10690            &self.state.buildings,
10691            self.state.character_id,
10692            px,
10693            py,
10694            &self.state.hired_workers,
10695            inside.as_deref(),
10696        )
10697    }
10698
10699    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
10700        self.state.route_editor_node_candidates()
10701    }
10702
10703    fn re_open_harvest_picker(
10704        &mut self,
10705        index: usize,
10706        picked: std::collections::BTreeSet<String>,
10707    ) {
10708        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
10709        let nodes = self.state.route_editor_node_candidates();
10710        let index = if nodes.is_empty() {
10711            ROUTE_PICKER_DONE_ROW
10712        } else {
10713            index.max(1).min(nodes.len())
10714        };
10715        self.re_open_sheet(S::HarvestPicker {
10716            index,
10717            picked,
10718            nodes,
10719        });
10720    }
10721
10722    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10723        let (px, py, _) = self.state.player_position_with_z();
10724        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10725    }
10726
10727    fn re_template_candidates(&self) -> Vec<String> {
10728        let mut extra = Vec::new();
10729        if let Some(ed) = self.state.worker_route_editor.as_ref() {
10730            for stop in &ed.stops {
10731                match stop {
10732                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
10733                        filter: Some(filter),
10734                        ..
10735                    } => extra.extend(filter.iter().cloned()),
10736                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10737                        extra.push(template.clone());
10738                    }
10739                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10740                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10741                            extra.push(bp.output.clone());
10742                            for input in &bp.inputs {
10743                                extra.push(input.template_id.clone());
10744                            }
10745                        }
10746                    }
10747                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10748                        for it in items {
10749                            extra.push(it.template.clone());
10750                        }
10751                    }
10752                    _ => {}
10753                }
10754            }
10755            // Worker-known recipe outputs even if the player hasn't learned them yet.
10756            if let Some(worker) = self
10757                .state
10758                .hired_workers
10759                .iter()
10760                .find(|w| w.instance_id == ed.worker_instance_id)
10761            {
10762                for recipe in &worker.known_blueprint_ids {
10763                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10764                        extra.push(bp.output.clone());
10765                    }
10766                }
10767            }
10768        }
10769        crate::worker_route_editor::route_item_template_candidates(
10770            &self.state.placed_containers,
10771            self.state.character_id,
10772            &self.state.inventory,
10773            &self.state.blueprints,
10774            &self.state.resource_nodes,
10775            &extra,
10776        )
10777    }
10778
10779    fn re_blueprint_ids(&self) -> Vec<String> {
10780        let worker_known: Option<&[String]> = self
10781            .state
10782            .worker_route_editor
10783            .as_ref()
10784            .and_then(|ed| {
10785                self.state
10786                    .hired_workers
10787                    .iter()
10788                    .find(|w| w.instance_id == ed.worker_instance_id)
10789            })
10790            .map(|w| w.known_blueprint_ids.as_slice());
10791        crate::worker_route_editor::worker_craft_blueprint_ids(
10792            &self.state.blueprints,
10793            worker_known,
10794        )
10795    }
10796
10797    fn re_bed_candidates(&self) -> Vec<(String, String)> {
10798        crate::worker_route_editor::owned_lodging_container_ids(
10799            &self.state.placed_containers,
10800            self.state.character_id,
10801        )
10802    }
10803
10804    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10805        self.state
10806            .placed_containers
10807            .iter()
10808            .find(|c| c.id == container_id)
10809            .map(|c| c.contents.clone())
10810            .unwrap_or_default()
10811    }
10812
10813    // ---- sheet navigation (`plans/33`) --------------------------------
10814
10815    fn re_sheet_supports_filter(&self) -> bool {
10816        use crate::worker_route_editor::RouteEditorSheet as S;
10817        self.state
10818            .worker_route_editor
10819            .as_ref()
10820            .is_some_and(|ed| {
10821                matches!(
10822                    ed.sheet,
10823                    S::HarvestPicker { .. }
10824                        | S::SellItem { .. }
10825                        | S::DepositFilter { .. }
10826                        | S::WithdrawItems { .. }
10827                        | S::WithdrawContainers { .. }
10828                        | S::DepositContainers { .. }
10829                        | S::SellNpcs { .. }
10830                        | S::CraftBlueprint { .. }
10831                        | S::BedPicker { .. }
10832                )
10833            })
10834    }
10835
10836    /// Whether a sheet row is shown under the current `/` filter.
10837    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10838        use crate::worker_route_editor::{
10839            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10840            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10841        };
10842        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10843            return false;
10844        };
10845        let filter = &ed.sheet_filter;
10846        match &ed.sheet {
10847            S::HarvestPicker { nodes, .. } => {
10848                harvest_picker_row_matches(nodes, row, filter)
10849            }
10850            S::SellItem { templates, .. } => {
10851                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10852                    return true;
10853                }
10854                let slot = row.saturating_sub(2);
10855                templates.get(slot).is_some_and(|t| {
10856                    let label = self.state.template_display_name(t);
10857                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10858                })
10859            }
10860            S::DepositFilter { rows, .. } => {
10861                if row >= rows.len() {
10862                    return true;
10863                }
10864                rows.get(row).is_some_and(|(t, _)| {
10865                    let label = self.state.template_display_name(t);
10866                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10867                })
10868            }
10869            S::WithdrawItems { lines, .. } => {
10870                if row >= lines.len() {
10871                    return true;
10872                }
10873                lines.get(row).is_some_and(|l| {
10874                    let label = self.state.template_display_name(&l.template);
10875                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10876                })
10877            }
10878            S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10879                .re_container_candidates()
10880                .get(row)
10881                .is_some_and(|c| {
10882                    list_filter_row_matches(
10883                        filter,
10884                        Some(c.dist),
10885                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10886                    )
10887                }),
10888            S::SellNpcs { .. } => {
10889                if row == 0 {
10890                    return true;
10891                }
10892                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10893                    list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10894                })
10895            }
10896            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10897                let label = self
10898                    .state
10899                    .blueprints
10900                    .iter()
10901                    .find(|b| &b.id == id)
10902                    .map(|b| {
10903                        if b.label.is_empty() {
10904                            id.as_str()
10905                        } else {
10906                            b.label.as_str()
10907                        }
10908                    })
10909                    .unwrap_or(id.as_str());
10910                list_filter_row_matches(filter, None, &[id.as_str(), label])
10911            }),
10912            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10913                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10914            }),
10915            _ => true,
10916        }
10917    }
10918
10919    fn re_sheet_clamp_index(&mut self) {
10920        let count = self.re_sheet_row_count();
10921        if count == 0 {
10922            return;
10923        }
10924        let cur = self.re_sheet_index();
10925        if self.re_sheet_row_visible(cur) {
10926            return;
10927        }
10928        for offset in 1..count {
10929            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10930                self.re_sheet_set_index(cur + offset);
10931                return;
10932            }
10933            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10934                self.re_sheet_set_index(cur - offset);
10935                return;
10936            }
10937        }
10938    }
10939
10940    fn re_sheet_set_index(&mut self, index: usize) {
10941        use crate::worker_route_editor::RouteEditorSheet as S;
10942        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10943            return;
10944        };
10945        match &mut ed.sheet {
10946            S::AddMenu { index: slot }
10947            | S::WaypointMenu { index: slot }
10948            | S::HarvestPicker { index: slot, .. }
10949            | S::WithdrawContainers { index: slot }
10950            | S::DepositContainers { index: slot }
10951            | S::SellNpcs { index: slot }
10952            | S::CraftBlueprint { index: slot }
10953            | S::BedPicker { index: slot }
10954            | S::FarmPlotPicker { index: slot, .. }
10955            | S::FarmPlantSeed { index: slot, .. }
10956            | S::WithdrawItems { index: slot, .. }
10957            | S::DepositFilter { index: slot, .. }
10958            | S::SellItem { index: slot, .. } => *slot = index,
10959            _ => {}
10960        }
10961    }
10962
10963    pub fn re_focus_sheet_filter(&mut self) {
10964        if !self.re_sheet_supports_filter() {
10965            return;
10966        }
10967        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10968            ed.sheet_filter_focused = true;
10969        }
10970    }
10971
10972    pub fn re_blur_sheet_filter_keep_text(&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_focused = false;
10980        self.re_sheet_clamp_index();
10981    }
10982
10983    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10984        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10985            return false;
10986        };
10987        if ed.sheet_filter_focused {
10988            ed.sheet_filter_focused = false;
10989            self.re_sheet_clamp_index();
10990            return true;
10991        }
10992        if !ed.sheet_filter.is_empty() {
10993            ed.sheet_filter.clear();
10994            self.re_sheet_clamp_index();
10995            return true;
10996        }
10997        false
10998    }
10999
11000    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
11001        if ch.is_control() {
11002            return;
11003        }
11004        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11005            return;
11006        };
11007        if !ed.sheet_filter_focused {
11008            return;
11009        }
11010        ed.sheet_filter.push(ch);
11011        self.re_sheet_set_index(0);
11012        self.re_sheet_clamp_index();
11013    }
11014
11015    pub fn re_sheet_filter_backspace(&mut self) {
11016        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11017            return;
11018        };
11019        if !ed.sheet_filter_focused {
11020            return;
11021        }
11022        ed.sheet_filter.pop();
11023        self.re_sheet_set_index(0);
11024        self.re_sheet_clamp_index();
11025    }
11026
11027    /// Row count of the current sheet (for cursor wrapping).
11028    pub fn re_sheet_row_count(&self) -> usize {
11029        use crate::worker_route_editor::{
11030            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
11031        };
11032        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11033            return 0;
11034        };
11035        match &ed.sheet {
11036            S::Stops => ed.stops.len(),
11037            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
11038            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
11039            S::WaypointMapPick => 0,
11040            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
11041            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11042                self.re_container_candidates().len()
11043            }
11044            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
11045            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
11046            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
11047            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
11048            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11049            S::WaitEntry { .. } => 1,
11050            S::BedPicker { .. } => self.re_bed_candidates().len(),
11051            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11052            S::FarmPlantSeed { seeds, .. } => seeds.len(),
11053        }
11054    }
11055
11056    /// Current sheet cursor index (0 for sheets without one).
11057    pub fn re_sheet_index(&self) -> usize {
11058        use crate::worker_route_editor::RouteEditorSheet as S;
11059        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11060            return 0;
11061        };
11062        match &ed.sheet {
11063            S::AddMenu { index }
11064            | S::WaypointMenu { index }
11065            | S::HarvestPicker { index, .. }
11066            | S::WithdrawContainers { index }
11067            | S::DepositContainers { index }
11068            | S::SellNpcs { index }
11069            | S::CraftBlueprint { index }
11070            | S::BedPicker { index }
11071            | S::FarmPlotPicker { index, .. }
11072            | S::FarmPlantSeed { index, .. }
11073            | S::WithdrawItems { index, .. }
11074            | S::DepositFilter { index, .. }
11075            | S::SellItem { index, .. } => *index,
11076            _ => 0,
11077        }
11078    }
11079
11080    /// Move the current sheet's cursor, wrapping within its rows.
11081    pub fn re_sheet_move(&mut self, delta: i32) {
11082        let count = self.re_sheet_row_count();
11083        if count == 0 {
11084            return;
11085        }
11086        let cur = self.re_sheet_index();
11087        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11088        self.re_sheet_set_index(next);
11089    }
11090
11091    pub fn re_sheet_page(&mut self, pages: i32) {
11092        let count = self.re_sheet_row_count();
11093        if count == 0 {
11094            return;
11095        }
11096        let cur = self.re_sheet_index();
11097        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11098        self.re_sheet_set_index(next);
11099    }
11100
11101    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
11102    pub fn re_sheet_adjust(&mut self, delta: i32) {
11103        use crate::worker_route_editor::RouteEditorSheet as S;
11104        let index = self.re_sheet_index();
11105        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11106            return;
11107        };
11108        match &mut ed.sheet {
11109            S::WithdrawItems { lines, .. } => {
11110                if let Some(line) = lines.get_mut(index) {
11111                    line.adjust_qty(delta);
11112                }
11113            }
11114            S::WaitEntry { ticks } => {
11115                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11116            }
11117            _ => {}
11118        }
11119    }
11120
11121    pub fn re_sheet_back(&mut self) {
11122        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11123            return;
11124        };
11125        use crate::worker_route_editor::RouteEditorSheet as S;
11126        let was_editing = ed.editing_index.is_some();
11127        let from_top_picker = matches!(
11128            ed.sheet,
11129            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11130        );
11131        ed.sheet_back();
11132        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11133            // Chest retarget commits on pick; Esc here just ends the edit sheet.
11134            self.state
11135                .push_log("Route: left edit sheet — press s to save current stops".to_string());
11136        }
11137    }
11138
11139    /// True when the editor is at its root sheet (Esc should close it).
11140    pub fn re_at_root_sheet(&self) -> bool {
11141        self.state
11142            .worker_route_editor
11143            .as_ref()
11144            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
11145    }
11146
11147    pub fn re_open_add_menu(&mut self) {
11148        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11149            ed.open_add_menu();
11150        }
11151    }
11152
11153    pub fn re_open_bed_picker(&mut self) {
11154        let beds = self.re_bed_candidates();
11155        if beds.is_empty() {
11156            self.state
11157                .push_log("Route: place a camp bed first".to_string());
11158            return;
11159        }
11160        let current = self
11161            .state
11162            .worker_route_editor
11163            .as_ref()
11164            .and_then(|ed| ed.lodging_container_id.clone());
11165        let index = current
11166            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11167            .unwrap_or(0);
11168        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11169    }
11170
11171    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11172        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11173            ed.open_sheet(sheet);
11174        }
11175    }
11176
11177    /// Confirm a sheet's stop and log the outcome.
11178    fn re_confirm_stop(
11179        &mut self,
11180        stop: crate::worker_route_editor::WorkerRouteStop,
11181        what: String,
11182    ) {
11183        let appended = self
11184            .state
11185            .worker_route_editor
11186            .as_mut()
11187            .is_some_and(|ed| ed.confirm_stop(stop));
11188        if appended {
11189            self.state.push_log(format!("Route: + {what}"));
11190        } else {
11191            self.state
11192                .push_log(format!("Route: {what} already in route — selected it"));
11193        }
11194    }
11195
11196    fn re_open_withdraw_items(&mut self, container_id: String) {
11197        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11198        let contents = self.re_container_contents(&container_id);
11199        // When editing, keep the stop's item picks even if the player retargets
11200        // to a different (possibly empty) chest — otherwise Done has nothing to
11201        // confirm and the edit appears to "revert".
11202        let existing = self
11203            .state
11204            .worker_route_editor
11205            .as_ref()
11206            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11207            .and_then(|stop| match stop {
11208                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11209                _ => None,
11210            })
11211            .unwrap_or_default();
11212        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11213        // Commit the new chest immediately while editing so Esc-before-Done still
11214        // keeps the retarget (items update only when Done is pressed).
11215        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11216            let _ = ed.retarget_withdraw_container(container_id.clone());
11217        }
11218        self.re_open_sheet(S::WithdrawItems {
11219            container_id,
11220            lines,
11221            index: 0,
11222        });
11223    }
11224
11225    fn re_withdraw_items_activate(&mut self, index: usize) {
11226        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11227        enum Outcome {
11228            Cycled,
11229            Confirmed(String),
11230            Empty,
11231        }
11232        let outcome = {
11233            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11234                return;
11235            };
11236            let S::WithdrawItems {
11237                container_id,
11238                lines,
11239                index: sheet_index,
11240            } = &mut ed.sheet
11241            else {
11242                return;
11243            };
11244            *sheet_index = index;
11245            if index < lines.len() {
11246                lines[index].cycle();
11247                Outcome::Cycled
11248            } else {
11249                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11250                if items.is_empty() {
11251                    Outcome::Empty
11252                } else {
11253                    let stop = WorkerRouteStop::WithdrawFrom {
11254                        container_id: container_id.clone(),
11255                        items,
11256                    };
11257                    let summary = stop.summary();
11258                    ed.confirm_stop(stop);
11259                    Outcome::Confirmed(summary)
11260                }
11261            }
11262        };
11263        match outcome {
11264            Outcome::Cycled => {}
11265            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11266            Outcome::Empty => self
11267                .state
11268                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
11269        }
11270    }
11271
11272    fn re_open_deposit_filter(&mut self, container_id: String) {
11273        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11274        // Preserve filter when retargeting the deposit chest while editing.
11275        let existing_filter = self
11276            .state
11277            .worker_route_editor
11278            .as_ref()
11279            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11280            .and_then(|stop| match stop {
11281                WorkerRouteStop::DepositAt { filter, .. } => {
11282                    Some(filter.clone().unwrap_or_default())
11283                }
11284                _ => None,
11285            });
11286        let mut candidates = self.re_template_candidates();
11287        if let Some(ref chosen) = existing_filter {
11288            for t in chosen {
11289                if !candidates.iter().any(|c| c == t) {
11290                    candidates.push(t.clone());
11291                }
11292            }
11293            candidates.sort();
11294            candidates.dedup();
11295        }
11296        let rows: Vec<(String, bool)> = match existing_filter {
11297            Some(chosen) => candidates
11298                .iter()
11299                .map(|t| (t.clone(), chosen.contains(t)))
11300                .collect(),
11301            None => candidates.into_iter().map(|t| (t, false)).collect(),
11302        };
11303        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11304            let _ = ed.retarget_deposit_container(container_id.clone());
11305        }
11306        self.re_open_sheet(S::DepositFilter {
11307            container_id,
11308            rows,
11309            index: 0,
11310        });
11311    }
11312
11313    fn re_deposit_filter_activate(&mut self, index: usize) {
11314        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11315        let mut confirmed: Option<String> = None;
11316        {
11317            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11318                return;
11319            };
11320            let S::DepositFilter {
11321                container_id,
11322                rows,
11323                index: sheet_index,
11324            } = &mut ed.sheet
11325            else {
11326                return;
11327            };
11328            *sheet_index = index;
11329            if index < rows.len() {
11330                rows[index].1 = !rows[index].1;
11331            } else {
11332                // "Done" row.
11333                let chosen: Vec<String> = rows
11334                    .iter()
11335                    .filter(|(_, on)| *on)
11336                    .map(|(t, _)| t.clone())
11337                    .collect();
11338                let filter = if chosen.is_empty() { None } else { Some(chosen) };
11339                let stop = WorkerRouteStop::DepositAt {
11340                    container_id: container_id.clone(),
11341                    filter,
11342                };
11343                confirmed = Some(stop.summary());
11344                ed.confirm_stop(stop);
11345            }
11346        }
11347        if let Some(what) = confirmed {
11348            self.state.push_log(format!("Route: + {what}"));
11349        }
11350    }
11351
11352    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11353        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11354        let templates = self.re_template_candidates();
11355        if templates.is_empty() {
11356            self.state.push_log(
11357                "Route: no item templates available — learn a craft recipe or place a harvest node first"
11358                    .to_string(),
11359            );
11360            return;
11361        }
11362        // Prefill when editing an existing sell stop.
11363        let (pre_npc, pre_template, pre_all) = self
11364            .state
11365            .worker_route_editor
11366            .as_ref()
11367            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11368            .and_then(|stop| match stop {
11369                WorkerRouteStop::TradeWith {
11370                    npc_id,
11371                    template,
11372                    sell_all,
11373                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11374                _ => None,
11375            })
11376            .unwrap_or((None, None, true));
11377        let npc_id = npc_id.or(pre_npc);
11378        let mut picked = std::collections::BTreeSet::new();
11379        if let Some(t) = pre_template {
11380            picked.insert(t);
11381        }
11382        self.re_open_sheet(S::SellItem {
11383            npc_id,
11384            templates,
11385            index: if picked.is_empty() {
11386                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11387            } else {
11388                2
11389            },
11390            sell_all: pre_all,
11391            picked,
11392        });
11393    }
11394
11395    fn re_sell_item_activate(&mut self, index: usize) {
11396        use crate::worker_route_editor::{
11397            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11398        };
11399        let mut batch_log: Option<String> = None;
11400        {
11401            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11402                return;
11403            };
11404            let S::SellItem {
11405                npc_id,
11406                templates,
11407                index: sheet_index,
11408                sell_all,
11409                picked,
11410            } = &mut ed.sheet
11411            else {
11412                return;
11413            };
11414            *sheet_index = index;
11415            if index == ROUTE_PICKER_DONE_ROW {
11416                if picked.is_empty() {
11417                    batch_log = Some(
11418                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
11419                    );
11420                } else {
11421                    let picks: Vec<String> = picked.iter().cloned().collect();
11422                    let npc = npc_id.clone();
11423                    let all = *sell_all;
11424                    let added = ed.confirm_trade_picks(npc, &picks, all);
11425                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
11426                }
11427            } else if index == SELL_ITEM_TOGGLE_ROW {
11428                *sell_all = !*sell_all;
11429            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11430                if picked.contains(template) {
11431                    picked.remove(template);
11432                } else {
11433                    picked.insert(template.clone());
11434                }
11435            }
11436        }
11437        if let Some(msg) = batch_log {
11438            self.state.push_log(msg);
11439        }
11440    }
11441
11442    /// Enter on the stop list: open the selected stop's sheet, prefilled.
11443    pub fn re_edit_selected_stop(&mut self) {
11444        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11445        let Some(stop) = self
11446            .state
11447            .worker_route_editor
11448            .as_ref()
11449            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11450        else {
11451            self.state
11452                .push_log("Route: no stop selected — press a to add one".to_string());
11453            return;
11454        };
11455        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11456            ed.begin_edit_selected();
11457        }
11458        match stop {
11459            WorkerRouteStop::Waypoint { .. } => {
11460                self.re_open_sheet(S::WaypointMenu { index: 0 });
11461            }
11462            WorkerRouteStop::HarvestNode { node_id } => {
11463                let nodes = self.state.route_editor_node_candidates();
11464                if nodes.is_empty() {
11465                    self.re_cancel_edit();
11466                    self.state
11467                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
11468                } else {
11469                    let mut picked = std::collections::BTreeSet::new();
11470                    picked.insert(node_id.clone());
11471                    let index = nodes
11472                        .iter()
11473                        .position(|n| n.id == node_id)
11474                        .map(|i| i + 1)
11475                        .unwrap_or(1);
11476                    self.re_open_harvest_picker(index, picked);
11477                }
11478            }
11479            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11480                // Open the container picker so the player can retarget storage
11481                // (previously jumped straight to items on the same chest).
11482                let containers = self.re_container_candidates();
11483                if containers.is_empty() {
11484                    self.re_cancel_edit();
11485                    self.state
11486                        .push_log("Route: place a storage chest first".to_string());
11487                } else {
11488                    let index = containers
11489                        .iter()
11490                        .position(|c| c.id == container_id)
11491                        .unwrap_or(0);
11492                    self.re_open_sheet(S::WithdrawContainers { index });
11493                }
11494            }
11495            WorkerRouteStop::DepositAt { container_id, .. } => {
11496                let containers = self.re_container_candidates();
11497                if containers.is_empty() {
11498                    self.re_cancel_edit();
11499                    self.state
11500                        .push_log("Route: place a storage chest first".to_string());
11501                } else {
11502                    let index = containers
11503                        .iter()
11504                        .position(|c| c.id == container_id)
11505                        .unwrap_or(0);
11506                    self.re_open_sheet(S::DepositContainers { index });
11507                }
11508            }
11509            WorkerRouteStop::TradeWith { npc_id, .. } => {
11510                let npcs = self.re_npc_candidates();
11511                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
11512                let index = npc_id
11513                    .as_ref()
11514                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11515                    .unwrap_or(0);
11516                self.re_open_sheet(S::SellNpcs { index });
11517            }
11518            WorkerRouteStop::CraftAt { blueprint, .. } => {
11519                let bps = self.re_blueprint_ids();
11520                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11521                if bps.is_empty() {
11522                    self.re_cancel_edit();
11523                    self.state
11524                        .push_log("Route: no known blueprints to retarget".to_string());
11525                } else {
11526                    self.re_open_sheet(S::CraftBlueprint { index });
11527                }
11528            }
11529            WorkerRouteStop::CultivatePlot { .. } => {
11530                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
11531            }
11532            WorkerRouteStop::PlantPlot { .. } => {
11533                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11534            }
11535            WorkerRouteStop::HarvestPlot { .. } => {
11536                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
11537            }
11538            WorkerRouteStop::RestIfNeeded => {
11539                self.re_cancel_edit();
11540                self.state
11541                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
11542            }
11543            WorkerRouteStop::Wait { wait_ticks } => {
11544                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
11545            }
11546        }
11547    }
11548
11549    fn re_cancel_edit(&mut self) {
11550        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11551            ed.editing_index = None;
11552        }
11553    }
11554
11555    // ---- mouse clicks inside the overlay ------------------------------
11556
11557    pub fn worker_route_editor_ui_click(
11558        &mut self,
11559        click: crate::worker_route_editor::RouteEditorClick,
11560    ) {
11561        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
11562        match click {
11563            RouteEditorClick::SelectStop(i) => {
11564                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11565                    ed.sheet = S::Stops;
11566                    ed.select_stop(i);
11567                }
11568            }
11569            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
11570            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
11571            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
11572        }
11573    }
11574
11575    /// Activate (Enter / click) a row of the current sheet.
11576    pub fn re_sheet_row_activate(&mut self, row: usize) {
11577        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11578        let Some(sheet) = self
11579            .state
11580            .worker_route_editor
11581            .as_ref()
11582            .map(|ed| ed.sheet.clone())
11583        else {
11584            return;
11585        };
11586        match sheet {
11587            S::Stops => {
11588                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11589                    ed.select_stop(row);
11590                }
11591            }
11592            S::AddMenu { .. } => match row {
11593                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
11594                1 => {
11595                    if self.re_node_candidates().is_empty() {
11596                        self.state
11597                            .push_log("Route: no harvestable nodes visible in this region".to_string());
11598                    } else {
11599                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
11600                    }
11601                }
11602                2 | 3 => {
11603                    if self.re_container_candidates().is_empty() {
11604                        self.state
11605                            .push_log("Route: place a storage chest first".to_string());
11606                    } else if row == 2 {
11607                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
11608                    } else {
11609                        self.re_open_sheet(S::DepositContainers { index: 0 });
11610                    }
11611                }
11612                4 => {
11613                    if self.re_template_candidates().is_empty() {
11614                        self.state.push_log(
11615                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
11616                                .to_string(),
11617                        );
11618                    } else {
11619                        self.re_open_sheet(S::SellNpcs { index: 0 });
11620                    }
11621                }
11622                5 => {
11623                    if self.re_blueprint_ids().is_empty() {
11624                        self.state.push_log(
11625                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
11626                                .to_string(),
11627                        );
11628                    } else {
11629                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
11630                    }
11631                }
11632                6 => self.re_confirm_stop(
11633                    WorkerRouteStop::RestIfNeeded,
11634                    "rest at lodging (if needed)".into(),
11635                ),
11636                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
11637                8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
11638                9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
11639                10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
11640                _ => {}
11641            },
11642            S::WaypointMenu { .. } => match row {
11643                0 => {
11644                    let (x, y, z) = self.state.player_position_with_z();
11645                    let stop = WorkerRouteStop::Waypoint { x, y, z };
11646                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11647                }
11648                1 => {
11649                    self.re_open_sheet(S::WaypointMapPick);
11650                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
11651                }
11652                _ => {}
11653            },
11654            S::HarvestPicker { .. } => {
11655                let mut log: Option<String> = None;
11656                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11657                    let S::HarvestPicker {
11658                        index: sheet_index,
11659                        picked,
11660                        nodes,
11661                    } = &mut ed.sheet
11662                    else {
11663                        return;
11664                    };
11665                    *sheet_index = row;
11666                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
11667                        if picked.is_empty() {
11668                            log = Some(
11669                                "Route: pick at least one node (Space toggles, Done confirms)"
11670                                    .into(),
11671                            );
11672                        } else {
11673                            let ids: Vec<String> = picked.iter().cloned().collect();
11674                            let added = ed.confirm_harvest_picks(&ids);
11675                            log = Some(format!("Route: + {added} harvest stop(s)"));
11676                        }
11677                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
11678                        if picked.contains(&n.id) {
11679                            picked.remove(&n.id);
11680                        } else {
11681                            picked.insert(n.id.clone());
11682                        }
11683                    }
11684                }
11685                if let Some(msg) = log {
11686                    self.state.push_log(msg);
11687                }
11688            }
11689            S::WithdrawContainers { .. } => {
11690                let containers = self.re_container_candidates();
11691                if let Some(c) = containers.get(row) {
11692                    let id = c.id.clone();
11693                    self.re_open_withdraw_items(id);
11694                }
11695            }
11696            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
11697            S::DepositContainers { .. } => {
11698                let containers = self.re_container_candidates();
11699                if let Some(c) = containers.get(row) {
11700                    let id = c.id.clone();
11701                    self.re_open_deposit_filter(id);
11702                }
11703            }
11704            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
11705            S::SellNpcs { .. } => {
11706                let npcs = self.re_npc_candidates();
11707                let npc_id = if row == 0 {
11708                    None
11709                } else {
11710                    npcs.get(row - 1).map(|n| n.id.clone())
11711                };
11712                if row == 0 || npc_id.is_some() {
11713                    self.re_open_sell_item(npc_id);
11714                }
11715            }
11716            S::SellItem { .. } => self.re_sell_item_activate(row),
11717            S::CraftBlueprint { .. } => {
11718                let bps = self.re_blueprint_ids();
11719                if let Some(bp) = bps.get(row) {
11720                    let stop = WorkerRouteStop::CraftAt {
11721                        device: "hand".into(),
11722                        blueprint: bp.clone(),
11723                        qty: None,
11724                    };
11725                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11726                }
11727            }
11728            S::WaitEntry { ticks } => {
11729                let stop = WorkerRouteStop::Wait {
11730                    wait_ticks: ticks,
11731                };
11732                self.re_confirm_stop(stop, format!("wait {ticks}t"));
11733            }
11734            S::BedPicker { .. } => {
11735                let beds = self.re_bed_candidates();
11736                if let Some((id, name)) = beds.get(row) {
11737                    let (id, name) = (id.clone(), name.clone());
11738                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11739                        ed.lodging_container_id = Some(id.clone());
11740                        ed.sheet = S::Stops;
11741                    }
11742                    self.state
11743                        .push_log(format!("Route: rest bed set to {name}"));
11744                }
11745            }
11746            S::FarmPlotPicker { action, .. } => {
11747                let plots = self.re_farm_plot_candidates();
11748                let Some(plot) = plots.get(row).cloned() else {
11749                    return;
11750                };
11751                match action {
11752                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
11753                        let label = plot_route_label(&plot);
11754                        self.re_confirm_stop(
11755                            WorkerRouteStop::CultivatePlot {
11756                                plot_id: plot.plot_id,
11757                            },
11758                            format!("cultivate {label}"),
11759                        );
11760                    }
11761                    crate::worker_route_editor::FarmPlotAction::Harvest => {
11762                        let label = plot_route_label(&plot);
11763                        self.re_confirm_stop(
11764                            WorkerRouteStop::HarvestPlot {
11765                                plot_id: plot.plot_id,
11766                            },
11767                            format!("harvest {label}"),
11768                        );
11769                    }
11770                    crate::worker_route_editor::FarmPlotAction::Plant => {
11771                        let seeds = self.re_farm_seed_candidates();
11772                        if seeds.is_empty() {
11773                            self.state.push_log(
11774                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11775                            );
11776                            return;
11777                        }
11778                        self.re_open_sheet(S::FarmPlantSeed {
11779                            plot_id: plot.plot_id,
11780                            seeds,
11781                            index: 0,
11782                        });
11783                    }
11784                }
11785            }
11786            S::FarmPlantSeed { plot_id, seeds, .. } => {
11787                if let Some(seed) = seeds.get(row).cloned() {
11788                    self.re_confirm_stop(
11789                        WorkerRouteStop::PlantPlot {
11790                            plot_id,
11791                            seed_template: seed.clone(),
11792                        },
11793                        format!("plant {seed}"),
11794                    );
11795                }
11796            }
11797            S::WaypointMapPick => {}
11798        }
11799    }
11800
11801    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11802        use crate::worker_route_editor::RouteEditorSheet as S;
11803        if self.re_farm_plot_candidates().is_empty() {
11804            self.state.push_log(
11805                "Route: no farmable plots visible — claim land or get farm access first",
11806            );
11807            return;
11808        }
11809        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11810    }
11811
11812    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11813        self.state
11814            .property_plots
11815            .iter()
11816            .filter(|p| p.is_mine || p.may_farm)
11817            .cloned()
11818            .collect()
11819    }
11820
11821    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
11822    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
11823    /// and known crop seeds so routes can withdraw-then-plant.
11824    fn re_farm_seed_candidates(&self) -> Vec<String> {
11825        let mut set = std::collections::BTreeSet::new();
11826        let looks_like_seed = |id: &str| {
11827            id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11828        };
11829        for (id, _, _) in self.state.farm_seed_entries() {
11830            set.insert(id);
11831        }
11832        for c in &self.state.placed_containers {
11833            let mine = match (self.state.character_id, c.owner_character_id) {
11834                (Some(a), Some(b)) => a == b,
11835                _ => false,
11836            };
11837            if !mine {
11838                continue;
11839            }
11840            for s in &c.contents {
11841                if s.quantity > 0
11842                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11843                {
11844                    set.insert(s.template_id.clone());
11845                }
11846            }
11847        }
11848        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11849            for stop in &ed.stops {
11850                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11851                {
11852                    for it in items {
11853                        if looks_like_seed(&it.template) {
11854                            set.insert(it.template.clone());
11855                        }
11856                    }
11857                }
11858                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11859                    seed_template, ..
11860                } = stop
11861                {
11862                    if !seed_template.is_empty() {
11863                        set.insert(seed_template.clone());
11864                    }
11865                }
11866            }
11867        }
11868        for id in self.state.inventory_hints.keys() {
11869            if looks_like_seed(id) {
11870                set.insert(id.clone());
11871            }
11872        }
11873        // Authored crop seeds (always offer so withdraw-then-plant routes work).
11874        for id in ["potato_seed", "carrot_seed"] {
11875            set.insert(id.to_string());
11876        }
11877        set.into_iter().collect()
11878    }
11879
11880    // ---- map clicks (sheet-scoped accelerators) ------------------------
11881
11882    /// Map click while the route editor is open. When a picker sheet is open
11883    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
11884    /// container, NPC clicks the merchant, node clicks the harvest target);
11885    /// otherwise the click quick-adds the nearest target (context-aware).
11886    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11887        use crate::worker_route_editor as wre;
11888        use wre::RouteEditorSheet as S;
11889        if self.state.worker_route_editor.is_none() {
11890            return;
11891        }
11892        let sheet = self
11893            .state
11894            .worker_route_editor
11895            .as_ref()
11896            .map(|ed| ed.sheet.clone())
11897            .unwrap_or(S::Stops);
11898        match sheet {
11899            S::WaypointMapPick => {
11900                let (_, _, z) = self.state.player_position_with_z();
11901                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11902                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11903                // Stay in map-pick mode when adding (not editing) for fast pathing.
11904                let editing = self
11905                    .state
11906                    .worker_route_editor
11907                    .as_ref()
11908                    .is_some_and(|ed| ed.editing_index.is_some());
11909                if !editing {
11910                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11911                        ed.sheet = S::WaypointMapPick;
11912                    }
11913                }
11914            }
11915            S::HarvestPicker { .. } => {
11916                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11917                    let mut log: Option<String> = None;
11918                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11919                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11920                            return;
11921                        };
11922                        let selected = if picked.contains(&node.id) {
11923                            picked.remove(&node.id);
11924                            false
11925                        } else {
11926                            picked.insert(node.id.clone());
11927                            true
11928                        };
11929                        log = Some(format!(
11930                            "Route: {} {}",
11931                            if selected { "selected" } else { "deselected" },
11932                            resource_node_route_label(node)
11933                        ));
11934                    }
11935                    if let Some(msg) = log {
11936                        self.state.push_log(msg);
11937                    }
11938                }
11939            }
11940            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11941                // Chest click picks (or switches) the withdraw source.
11942                let inside = self.state.effective_inside_building();
11943                if let Some(cid) = wre::pick_storage_container_at(
11944                    &self.state.placed_containers,
11945                    self.state.character_id,
11946                    x,
11947                    y,
11948                    inside.as_deref(),
11949                ) {
11950                    self.re_open_withdraw_items(cid);
11951                }
11952            }
11953            S::DepositContainers { .. } | S::DepositFilter { .. } => {
11954                let inside = self.state.effective_inside_building();
11955                if let Some(cid) = wre::pick_storage_container_at(
11956                    &self.state.placed_containers,
11957                    self.state.character_id,
11958                    x,
11959                    y,
11960                    inside.as_deref(),
11961                ) {
11962                    self.re_open_deposit_filter(cid);
11963                }
11964            }
11965            S::SellNpcs { .. } => {
11966                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11967                    self.re_open_sell_item(Some(npc_id));
11968                }
11969            }
11970            S::SellItem { .. } => {
11971                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11972                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11973                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11974                            *slot = Some(npc_id.clone());
11975                        }
11976                    }
11977                    self.state
11978                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11979                }
11980            }
11981            // Stop list / other sheets: context-aware quick add.
11982            _ => self.worker_route_editor_quick_add_click(x, y),
11983        }
11984    }
11985
11986    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
11987    /// node wins; duplicates select the existing stop; a click on a merchant
11988    /// while a sell stop is selected pins it.
11989    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11990        use crate::worker_route_editor as wre;
11991        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11992            let dx = ax - bx;
11993            let dy = ay - by;
11994            (dx * dx + dy * dy).sqrt()
11995        };
11996
11997        // 1. Retarget the selected stop when the click names its target:
11998        //    sell stop → pin the merchant; withdraw stop → set the source chest.
11999        let selected_stop_kind = self
12000            .state
12001            .worker_route_editor
12002            .as_ref()
12003            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
12004            .map(|s| match s {
12005                wre::WorkerRouteStop::TradeWith { .. } => 1,
12006                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
12007                _ => 0,
12008            })
12009            .unwrap_or(0);
12010        if selected_stop_kind == 1 {
12011            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12012                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12013                    ed.set_selected_trade_npc(npc_id.clone());
12014                }
12015                self.state
12016                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12017                return;
12018            }
12019        }
12020        if selected_stop_kind == 2 {
12021            let inside = self.state.effective_inside_building();
12022            if let Some(cid) = wre::pick_storage_container_at(
12023                &self.state.placed_containers,
12024                self.state.character_id,
12025                x,
12026                y,
12027                inside.as_deref(),
12028            ) {
12029                let name = self
12030                    .state
12031                    .placed_containers
12032                    .iter()
12033                    .find(|c| c.id == cid)
12034                    .map(|c| c.display_name.clone())
12035                    .unwrap_or_else(|| "container".into());
12036                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12037                    ed.set_selected_withdraw_container(cid.clone());
12038                }
12039                self.state
12040                    .push_log(format!("Route: withdraw source → {name}"));
12041                return;
12042            }
12043        }
12044
12045        // 2. Nearest candidate of any kind wins. Category order is the
12046        //    tie-break for exact overlaps (bed over chest over merchant over node).
12047        enum Target {
12048            Bed(String),
12049            Container(String),
12050            Npc(String, String),
12051            Node(String, String),
12052        }
12053        let mut best: Option<(f32, u8, Target)> = None;
12054        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12055            let better = match best {
12056                None => true,
12057                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
12058            };
12059            if better {
12060                *best = Some((d, rank, t));
12061            }
12062        };
12063        let inside = self.state.effective_inside_building();
12064        if let Some(bed_id) = wre::pick_lodging_container_at(
12065            &self.state.placed_containers,
12066            self.state.character_id,
12067            x,
12068            y,
12069            inside.as_deref(),
12070        ) {
12071            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12072                // A bed that's already the rest bed doubles as a storage chest —
12073                // a second click on it means "deposit here", not "set bed again".
12074                let already_bed = self
12075                    .state
12076                    .worker_route_editor
12077                    .as_ref()
12078                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
12079                if already_bed {
12080                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
12081                } else {
12082                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12083                }
12084            }
12085        }
12086        if let Some(cid) = wre::pick_storage_container_at(
12087            &self.state.placed_containers,
12088            self.state.character_id,
12089            x,
12090            y,
12091            inside.as_deref(),
12092        ) {
12093            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12094                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12095            }
12096        }
12097        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12098            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12099                consider(
12100                    dist(x, y, n.x, n.y),
12101                    2,
12102                    Target::Npc(npc_id, label),
12103                    &mut best,
12104                );
12105            }
12106        }
12107        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12108            let d = dist(x, y, node.x, node.y);
12109            let label = resource_node_route_label(node);
12110            consider(
12111                d,
12112                3,
12113                Target::Node(node.id.clone(), label),
12114                &mut best,
12115            );
12116        }
12117
12118        match best.map(|(_, _, t)| t) {
12119            Some(Target::Bed(bed_id)) => {
12120                let name = self
12121                    .state
12122                    .placed_containers
12123                    .iter()
12124                    .find(|c| c.id == bed_id)
12125                    .map(|c| c.display_name.clone())
12126                    .unwrap_or_else(|| "camp bed".into());
12127                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12128                    ed.lodging_container_id = Some(bed_id.clone());
12129                }
12130                self.state
12131                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12132            }
12133            Some(Target::Container(cid)) => {
12134                let name = self
12135                    .state
12136                    .placed_containers
12137                    .iter()
12138                    .find(|c| c.id == cid)
12139                    .map(|c| c.display_name.clone())
12140                    .unwrap_or_else(|| "container".into());
12141                let added = self
12142                    .state
12143                    .worker_route_editor
12144                    .as_mut()
12145                    .is_some_and(|ed| ed.append_deposit_at(&cid));
12146                if added {
12147                    self.state
12148                        .push_log(format!("Route: + deposit at {name} ({cid})"));
12149                } else {
12150                    self.state.push_log(format!(
12151                        "Route: {name} already in route — selected it (d to remove)"
12152                    ));
12153                }
12154            }
12155            Some(Target::Npc(npc_id, label)) => {
12156                // Quick-add sell: first storage template (the full sell flow
12157                // lives in the a → Sell sheet).
12158                let template = self.re_template_candidates().into_iter().next();
12159                let Some(template) = template else {
12160                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
12161                    return;
12162                };
12163                let added = self
12164                    .state
12165                    .worker_route_editor
12166                    .as_mut()
12167                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
12168                if added {
12169                    self.state
12170                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12171                } else {
12172                    self.state.push_log(format!(
12173                        "Route: {label} already sells {template} — selected it (d to remove)"
12174                    ));
12175                }
12176            }
12177            Some(Target::Node(id, label)) => {
12178                let added = self
12179                    .state
12180                    .worker_route_editor
12181                    .as_mut()
12182                    .is_some_and(|ed| ed.append_harvest_node(&id));
12183                if added {
12184                    self.state
12185                        .push_log(format!("Route: + harvest node {label}"));
12186                } else {
12187                    self.state.push_log(format!(
12188                        "Route: {label} already in route — selected it (d to remove)"
12189                    ));
12190                }
12191            }
12192            None => {}
12193        }
12194    }
12195
12196    pub fn worker_route_editor_select(&mut self, delta: i32) {
12197        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12198            return;
12199        };
12200        if ed.stops.is_empty() {
12201            return;
12202        }
12203        let n = ed.stops.len() as i32;
12204        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12205        ed.selected_stop_index = next;
12206    }
12207
12208    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12209        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12210            return;
12211        };
12212        if delta < 0 {
12213            ed.move_selected_up();
12214        } else if delta > 0 {
12215            ed.move_selected_down();
12216        }
12217    }
12218
12219    pub fn worker_route_editor_delete_selected(&mut self) {
12220        let removed = self
12221            .state
12222            .worker_route_editor
12223            .as_mut()
12224            .is_some_and(|ed| {
12225                let before = ed.stop_count();
12226                ed.remove_selected_stop();
12227                ed.stop_count() < before
12228            });
12229        if removed {
12230            self.state.push_log("Route: removed selected stop");
12231        }
12232    }
12233
12234    /// Clear all stops. Saving afterwards parks the worker in idle mode
12235    /// instead of leaving it in a broken job loop.
12236    pub fn worker_route_editor_clear_stops(&mut self) {
12237        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12238            return;
12239        };
12240        if ed.stops.is_empty() {
12241            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
12242            return;
12243        }
12244        ed.stops.clear();
12245        ed.selected_stop_index = 0;
12246        self.state
12247            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
12248    }
12249
12250    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12251        if self.state.pending_worker_job_ack.is_some() {
12252            anyhow::bail!("route save still pending — wait for server ack");
12253        }
12254        let Some(ed) = self.state.worker_route_editor.clone() else {
12255            anyhow::bail!("route editor not open");
12256        };
12257        // Empty route = stand down: park the worker in idle mode rather than
12258        // erroring out or leaving it marching a ghost loop.
12259        let (job_yaml, idle) = if ed.stops.is_empty() {
12260            (ed.build_idle_job_yaml(), true)
12261        } else {
12262            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12263        };
12264        let worker_id = ed.worker_instance_id.clone();
12265        let route_view = if idle {
12266            None
12267        } else {
12268            Some(ed.to_route_view())
12269        };
12270        let mode = if idle {
12271            flatland_protocol::WorkerModeView::Idle
12272        } else {
12273            flatland_protocol::WorkerModeView::JobLoop
12274        };
12275        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12276            .state
12277            .hired_workers
12278            .iter()
12279            .find(|w| w.instance_id == worker_id)
12280            .map(|w| {
12281                (
12282                    w.route.clone(),
12283                    w.mode,
12284                    w.step_label.clone(),
12285                    w.last_error.clone(),
12286                )
12287            })
12288            .unwrap_or((
12289                None,
12290                flatland_protocol::WorkerModeView::Idle,
12291                String::new(),
12292                None,
12293            ));
12294        self.seq += 1;
12295        let seq = self.seq;
12296        self.session
12297            .submit_intent(Intent::SetWorkerJob {
12298                entity_id: self.state.entity_id,
12299                worker_instance_id: worker_id.clone(),
12300                job_yaml,
12301                seq,
12302            })
12303            .await?;
12304        self.state.intents_sent += 1;
12305        if let Some(w) = self
12306            .state
12307            .hired_workers
12308            .iter_mut()
12309            .find(|w| w.instance_id == worker_id)
12310        {
12311            w.route = route_view;
12312            w.mode = mode;
12313            w.last_error = None;
12314            if idle {
12315                w.step_label.clear();
12316                w.route_stop_index = None;
12317            }
12318        }
12319        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12320            seq,
12321            worker_instance_id: worker_id,
12322            worker_label: ed.worker_label.clone(),
12323            idle,
12324            stop_count: ed.stops.len(),
12325            prev_route,
12326            prev_mode,
12327            prev_step_label,
12328            prev_last_error,
12329        });
12330        self.state.push_log(format!(
12331            "Route: saving for {}… (waiting for server)",
12332            ed.worker_label
12333        ));
12334        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
12335        Ok(())
12336    }
12337    pub fn quest_menu_move(&mut self, delta: i32) {
12338        let n = self.state.active_quest_entries().len();
12339        if n == 0 {
12340            return;
12341        }
12342        let idx = self.state.quest_menu_index as i32;
12343        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12344    }
12345
12346    pub fn quest_menu_page(&mut self, pages: i32) {
12347        let n = self.state.active_quest_entries().len();
12348        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12349    }
12350
12351    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12352        let Some(offer) = self.state.pending_quest_offer.clone() else {
12353            anyhow::bail!("no quest offer");
12354        };
12355        self.seq += 1;
12356        let seq = self.seq;
12357        self.session
12358            .submit_intent(Intent::AcceptQuest {
12359                entity_id: self.state.entity_id,
12360                quest_id: offer.quest_id,
12361                seq,
12362            })
12363            .await?;
12364        self.state.intents_sent += 1;
12365        Ok(())
12366    }
12367
12368    pub fn quest_offer_decline(&mut self) {
12369        self.state.show_quest_offer = false;
12370        self.state.pending_quest_offer = None;
12371        if !self.state.show_npc_chat
12372            && !self.state.show_shop_menu
12373            && self.state.npc_verb_target.is_some()
12374        {
12375            self.state.show_npc_verb_menu = true;
12376        }
12377    }
12378
12379    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12380        if !self.state.show_quest_menu {
12381            return Ok(());
12382        }
12383        let active: Vec<_> = self
12384            .state
12385            .active_quest_entries()
12386            .into_iter()
12387            .cloned()
12388            .collect();
12389        let Some(entry) = active.get(self.state.quest_menu_index) else {
12390            return Ok(());
12391        };
12392        if self.state.quest_withdraw_confirm {
12393            if !entry.can_withdraw {
12394                anyhow::bail!("quest cannot be withdrawn");
12395            }
12396            self.seq += 1;
12397            let seq = self.seq;
12398            self.session
12399                .submit_intent(Intent::WithdrawQuest {
12400                    entity_id: self.state.entity_id,
12401                    quest_id: entry.quest_id.clone(),
12402                    seq,
12403                })
12404                .await?;
12405            self.state.intents_sent += 1;
12406            self.state.quest_withdraw_confirm = false;
12407            return Ok(());
12408        }
12409        self.seq += 1;
12410        let seq = self.seq;
12411        self.session
12412            .submit_intent(Intent::TrackQuest {
12413                entity_id: self.state.entity_id,
12414                quest_id: entry.quest_id.clone(),
12415                seq,
12416            })
12417            .await?;
12418        self.state.intents_sent += 1;
12419        Ok(())
12420    }
12421
12422    pub fn quest_request_withdraw(&mut self) {
12423        if self.state.show_quest_menu {
12424            self.state.quest_withdraw_confirm = true;
12425        }
12426    }
12427
12428    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12429        if !self.state.is_alive() {
12430            anyhow::bail!("you are dead");
12431        }
12432        let Some(catalog) = self.state.shop_catalog.clone() else {
12433            anyhow::bail!("no shop open");
12434        };
12435        self.seq += 1;
12436        let seq = self.seq;
12437        match self.state.shop_tab {
12438            ShopTab::Buy => {
12439                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12440                    anyhow::bail!("nothing selected");
12441                };
12442                if offer.already_owned {
12443                    anyhow::bail!("already owned");
12444                }
12445                self.session
12446                    .submit_intent(Intent::ShopBuy {
12447                        entity_id: self.state.entity_id,
12448                        npc_id: catalog.npc_id.clone(),
12449                        offer_id: offer.offer_id.clone(),
12450                        quantity: self.state.shop_quantity,
12451                        seq,
12452                    })
12453                    .await?;
12454            }
12455            ShopTab::Sell => {
12456                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12457                    anyhow::bail!("nothing to sell");
12458                };
12459                if line.quantity == 0 {
12460                    anyhow::bail!("you have no {}", line.label);
12461                }
12462                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12463                self.session
12464                    .submit_intent(Intent::ShopSell {
12465                        entity_id: self.state.entity_id,
12466                        npc_id: catalog.npc_id.clone(),
12467                        template_id: line.template_id.clone(),
12468                        quantity,
12469                        seq,
12470                    })
12471                    .await?;
12472            }
12473        }
12474        self.state.intents_sent += 1;
12475        Ok(())
12476    }
12477
12478    pub fn craft_menu_move(&mut self, delta: i32) {
12479        let n = self.state.blueprints.len();
12480        if n == 0 {
12481            return;
12482        }
12483        let idx = self.state.craft_menu_index as i32;
12484        let next = (idx + delta).rem_euclid(n as i32);
12485        self.state.craft_menu_index = next as usize;
12486        self.state.clamp_craft_batch_quantity();
12487    }
12488
12489    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12490        self.state.craft_batch_adjust_quantity(delta);
12491    }
12492
12493    pub fn craft_batch_set_max(&mut self) {
12494        self.state.craft_batch_set_max();
12495    }
12496
12497    pub fn craft_batch_set_min(&mut self) {
12498        self.state.craft_batch_set_min();
12499    }
12500
12501    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12502        let Some(blueprint) = self
12503            .state
12504            .blueprints
12505            .get(self.state.craft_menu_index)
12506            .cloned()
12507        else {
12508            anyhow::bail!("no blueprints known");
12509        };
12510        if !self.state.can_craft_blueprint(&blueprint) {
12511            let hint = self
12512                .state
12513                .craft_missing_hint(&blueprint)
12514                .unwrap_or_else(|| "missing materials".into());
12515            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12516        }
12517        let count = self.state.craft_batch_quantity;
12518        let max = self.state.max_craft_batches(&blueprint);
12519        if max == 0 {
12520            anyhow::bail!("cannot craft {}", blueprint.label);
12521        }
12522        let batches = count.min(max);
12523        self.craft(&blueprint.id, Some(batches)).await?;
12524        self.state.show_craft_menu = false;
12525        Ok(())
12526    }
12527
12528    pub async fn move_by(
12529        &mut self,
12530        forward: f32,
12531        strafe: f32,
12532        vertical: f32,
12533        sprint: bool,
12534    ) -> anyhow::Result<()> {
12535        if !self.state.is_alive() {
12536            anyhow::bail!("you are dead");
12537        }
12538        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
12539            self.last_move_forward = forward;
12540            self.last_move_strafe = strafe;
12541        }
12542        self.seq += 1;
12543        self.session
12544            .submit_intent(Intent::Move {
12545                entity_id: self.state.entity_id,
12546                forward,
12547                strafe,
12548                vertical,
12549                sprint,
12550                seq: self.seq,
12551            })
12552            .await?;
12553        self.state.intents_sent += 1;
12554        Ok(())
12555    }
12556
12557    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
12558        if !self.state.connected {
12559            crate::harvest_trace!("harvest_nearest rejected: not connected");
12560            anyhow::bail!("not connected");
12561        }
12562        if !self.state.is_alive() {
12563            crate::harvest_trace!("harvest_nearest rejected: player dead");
12564            anyhow::bail!("you are dead");
12565        }
12566        if self.state.harvest_in_progress {
12567            if self.state.harvest_state_stale() {
12568                self.state.clear_harvest_state();
12569            } else {
12570                anyhow::bail!("already harvesting");
12571            }
12572        }
12573        let (px, py) = self
12574            .state
12575            .player
12576            .as_ref()
12577            .map(|p| (p.transform.position.x, p.transform.position.y))
12578            .unwrap_or((0.0, 0.0));
12579
12580        let available = self
12581            .state
12582            .resource_nodes
12583            .iter()
12584            .filter(|n| !n.harvest_off)
12585            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12586            .count();
12587        let node_id = self
12588            .state
12589            .resource_nodes
12590            .iter()
12591            .filter(|n| !n.harvest_off)
12592            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12593            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
12594            .min_by(|a, b| {
12595                let da = distance(px, py, a.x, a.y);
12596                let db = distance(px, py, b.x, b.y);
12597                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
12598            })
12599            .map(|n| n.id.clone());
12600
12601        let Some(node_id) = node_id else {
12602            let has_loot = self
12603                .state
12604                .ground_drops
12605                .iter()
12606                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12607            if has_loot {
12608                return self.pickup_nearest().await;
12609            }
12610            anyhow::bail!(
12611                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
12612            );
12613        };
12614
12615        self.seq += 1;
12616        let seq = self.seq;
12617        crate::harvest_trace!(
12618            entity_id = self.state.entity_id,
12619            node_id = %node_id,
12620            seq,
12621            px,
12622            py,
12623            available_nodes = available,
12624            "submitting harvest intent"
12625        );
12626        self.session
12627            .submit_intent(Intent::Harvest {
12628                entity_id: self.state.entity_id,
12629                node_id,
12630                seq,
12631            })
12632            .await?;
12633        self.state.intents_sent += 1;
12634        self.state.harvest_in_progress = true;
12635        self.state.harvest_started_at = Some(Instant::now());
12636        self.state.push_log("Harvesting…");
12637        crate::harvest_trace!(
12638            entity_id = self.state.entity_id,
12639            seq,
12640            "harvest intent queued to session"
12641        );
12642        Ok(())
12643    }
12644
12645    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
12646        if !self.state.is_alive() {
12647            anyhow::bail!("you are dead");
12648        }
12649        let blueprint_id = self
12650            .state
12651            .blueprints
12652            .iter()
12653            .find(|bp| self.state.can_craft_blueprint(bp))
12654            .map(|bp| bp.id.clone())
12655            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
12656        self.craft(&blueprint_id, None).await
12657    }
12658
12659    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
12660        if !self.state.is_alive() {
12661            anyhow::bail!("you are dead");
12662        }
12663        self.seq += 1;
12664        self.session
12665            .submit_intent(Intent::Craft {
12666                entity_id: self.state.entity_id,
12667                blueprint_id: blueprint_id.to_string(),
12668                count,
12669                seq: self.seq,
12670            })
12671            .await?;
12672        self.state.intents_sent += 1;
12673        let (label, batches) = self
12674            .state
12675            .blueprints
12676            .iter()
12677            .find(|b| b.id == blueprint_id)
12678            .map(|b| {
12679                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
12680                (b.label.as_str(), n)
12681            })
12682            .unwrap_or((blueprint_id, count.unwrap_or(1)));
12683        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
12684        Ok(())
12685    }
12686
12687    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
12688        if !self.state.is_alive() {
12689            anyhow::bail!("you are dead");
12690        }
12691        let target_id = match self.state.nearest_interact_target() {
12692            Some(id) => id,
12693            None => {
12694                anyhow::bail!("nothing to interact with nearby");
12695            }
12696        };
12697        if self.state.npcs.iter().any(|n| n.id == target_id) {
12698            self.state.show_npc_verb_menu = true;
12699            self.state.npc_verb_target = Some(target_id);
12700            self.state.npc_verb_index = 0;
12701            return Ok(());
12702        }
12703        if self
12704            .state
12705            .hired_workers
12706            .iter()
12707            .any(|w| w.instance_id == target_id)
12708        {
12709            return self.open_workers_menu_for(&target_id).await;
12710        }
12711        if let Ok(peer_id) = target_id.parse::<EntityId>() {
12712            if self
12713                .state
12714                .hired_workers
12715                .iter()
12716                .any(|w| w.entity_id == peer_id)
12717            {
12718                if let Some(w) = self
12719                    .state
12720                    .hired_workers
12721                    .iter()
12722                    .find(|w| w.entity_id == peer_id)
12723                {
12724                    let id = w.instance_id.clone();
12725                    return self.open_workers_menu_for(&id).await;
12726                }
12727            }
12728            if let Some(entity) = self
12729                .state
12730                .entities
12731                .iter()
12732                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
12733            {
12734                self.state
12735                    .player_verbs
12736                    .open_for(peer_id, &entity.label);
12737                return Ok(());
12738            }
12739        }
12740        self.seq += 1;
12741        self.session
12742            .submit_intent(Intent::Interact {
12743                entity_id: self.state.entity_id,
12744                target_id: target_id.clone(),
12745                seq: self.seq,
12746            })
12747            .await?;
12748        self.state.intents_sent += 1;
12749        Ok(())
12750    }
12751
12752    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
12753    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12754        if !self.state.is_alive() {
12755            anyhow::bail!("you are dead");
12756        }
12757        let (px, py) = self.state.player_position();
12758        let has_loot = self
12759            .state
12760            .ground_drops
12761            .iter()
12762            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12763        if has_loot {
12764            return self.pickup_nearest().await;
12765        }
12766        if self
12767            .state
12768            .placed_containers
12769            .iter()
12770            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12771        {
12772            return self.pickup_nearest_container().await;
12773        }
12774
12775        if let Some(plot) = self.state.my_plot_under_player().cloned() {
12776            // Double-tap f within 1.2s sells; otherwise farm the plot.
12777            const SELL_WINDOW: Duration = Duration::from_millis(1200);
12778            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12779                && self
12780                    .state
12781                    .sell_plot_armed_at
12782                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12783            if sell_armed {
12784                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12785            }
12786            self.state.sell_plot_confirm = None;
12787            self.state.sell_plot_armed_at = None;
12788
12789            // Prefer farm actions on your own land unless an NPC/player/door/board
12790            // is in interact range (those still win).
12791            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12792                self.state.npcs.iter().any(|n| n.id == id)
12793                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12794                    || self.state.doors.iter().any(|d| d.id == id)
12795                    || self.state.interactables.iter().any(|i| {
12796                        i.id == id
12797                            && matches!(
12798                                i.kind.as_str(),
12799                                "quest_board" | "well" | "exit" | "enter"
12800                            )
12801                    })
12802                    || id.parse::<EntityId>().is_ok_and(|eid| {
12803                        self.state
12804                            .entities
12805                            .iter()
12806                            .any(|e| e.id == eid && e.id != self.state.entity_id)
12807                    })
12808            });
12809            if !blocking_interact {
12810                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
12811                match self.harvest_nearest().await {
12812                    Ok(()) => return Ok(()),
12813                    Err(err) => {
12814                        let msg = err.to_string();
12815                        if !(msg.contains("no harvestable")
12816                            || msg.contains("press p")
12817                            || msg.contains("press f")
12818                            || msg.contains("nothing"))
12819                        {
12820                            return Err(err);
12821                        }
12822                    }
12823                }
12824                return Ok(());
12825            }
12826        }
12827        if self.state.nearest_interact_target().is_some() {
12828            return self.interact_nearest().await;
12829        }
12830        // Prefer a clear "move closer" when a board is visible but out of reach,
12831        // instead of silently falling through to harvest.
12832        if let Some((label, dist)) = self.state.nearest_quest_board() {
12833            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12834                anyhow::bail!(
12835                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12836                );
12837            }
12838        }
12839
12840        match self.harvest_nearest().await {
12841            Ok(()) => Ok(()),
12842            Err(err) => {
12843                let msg = err.to_string();
12844                if msg.contains("no harvestable")
12845                    || msg.contains("press p")
12846                    || msg.contains("press f")
12847                {
12848                    anyhow::bail!(
12849                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12850                    );
12851                }
12852                Err(err)
12853            }
12854        }
12855    }
12856
12857    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
12858    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12859        if !self.state.is_alive() {
12860            anyhow::bail!("you are dead");
12861        }
12862        if self.state.claim_mode.is_some() {
12863            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12864        }
12865        let zone = self
12866            .state
12867            .free_property_zone_under_player()
12868            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12869        let zone_id = zone.id.clone();
12870        let label = zone
12871            .label
12872            .as_deref()
12873            .filter(|s| !s.trim().is_empty())
12874            .unwrap_or(zone.id.as_str())
12875            .to_string();
12876        self.enter_claim_mode(&zone_id);
12877        self.state
12878            .push_log(format!(
12879                "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12880            ));
12881        Ok(())
12882    }
12883
12884    /// Enter claim mode over a property zone (default 4×4 or min size).
12885    pub fn enter_claim_mode(&mut self, zone_id: &str) {
12886        let Some(zone) = self
12887            .state
12888            .property_zones
12889            .iter()
12890            .find(|z| z.id == zone_id)
12891            .cloned()
12892        else {
12893            self.state.push_log("unknown property zone");
12894            return;
12895        };
12896        self.state.sell_plot_confirm = None;
12897        self.state.sell_plot_armed_at = None;
12898        let min_area = self
12899            .state
12900            .property_plot_settings
12901            .as_ref()
12902            .map(|s| s.min_plot_area_m2)
12903            .unwrap_or(4.0)
12904            .max(1.0);
12905        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12906        let side = 4u32.max(min_side);
12907        let (px, py) = self.state.player_position();
12908        let anchor_x = px.floor();
12909        let anchor_y = py.floor();
12910        self.state.claim_mode = Some(ClaimModeState {
12911            zone_id: zone.id.clone(),
12912            width_m: side,
12913            height_m: side,
12914            anchor_x,
12915            anchor_y,
12916        });
12917        let label = zone
12918            .label
12919            .as_deref()
12920            .filter(|s| !s.trim().is_empty())
12921            .unwrap_or(zone.id.as_str());
12922        self.state.push_log(format!(
12923            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12924        ));
12925    }
12926
12927    pub fn cancel_claim_mode(&mut self) {
12928        if self.state.claim_mode.take().is_some() {
12929            self.state.push_log("Claim cancelled");
12930        }
12931    }
12932
12933    /// Enter relocate mode for a placed container (1×1 ghost).
12934    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12935        if !self.state.is_alive() {
12936            anyhow::bail!("you are dead");
12937        }
12938        if self.state.relocate_mode.is_some() {
12939            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12940        }
12941        if self.state.claim_mode.is_some() {
12942            anyhow::bail!("finish or cancel claim mode first");
12943        }
12944        let chest = self
12945            .state
12946            .placed_containers
12947            .iter()
12948            .find(|c| c.id == container_id)
12949            .cloned()
12950            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12951        let (px, py) = self.state.player_position();
12952        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12953            anyhow::bail!("too far from {}", chest.display_name);
12954        }
12955        if chest.locked && !chest.accessible {
12956            anyhow::bail!(
12957                "need the matching key for {} before moving it",
12958                chest.display_name
12959            );
12960        }
12961        let label = if chest.display_name.trim().is_empty() {
12962            chest.template_id.clone()
12963        } else {
12964            chest.display_name.clone()
12965        };
12966        self.state.relocate_mode = Some(RelocateModeState {
12967            container_id: chest.id.clone(),
12968            label: label.clone(),
12969            cursor_x: chest.x.floor() + 0.5,
12970            cursor_y: chest.y.floor() + 0.5,
12971        });
12972        self.state.push_log(format!(
12973            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12974        ));
12975        Ok(())
12976    }
12977
12978    /// World `Shift+m`: relocate nearest owned/accessible placed container.
12979    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12980        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12981            anyhow::bail!("no chest nearby to relocate");
12982        };
12983        if chest.locked && !chest.accessible {
12984            anyhow::bail!(
12985                "need the matching key for {} before moving it",
12986                chest.display_name
12987            );
12988        }
12989        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
12990        // Server enforces ownership; client starts on nearest accessible chest in range.
12991        self.begin_relocate_container(&chest.id)
12992    }
12993
12994    pub fn cancel_relocate_mode(&mut self) {
12995        if self.state.relocate_mode.take().is_some() {
12996            self.state.push_log("Relocate cancelled");
12997        }
12998    }
12999
13000    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
13001        let Some(mode) = self.state.relocate_mode.as_mut() else {
13002            return;
13003        };
13004        let max_x = self.state.world_width_m.max(1.0);
13005        let max_y = self.state.world_height_m.max(1.0);
13006        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
13007        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
13008        mode.cursor_x = nx.floor() + 0.5;
13009        mode.cursor_y = ny.floor() + 0.5;
13010    }
13011
13012    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
13013        let Some(mode) = self.state.relocate_mode.as_mut() else {
13014            return;
13015        };
13016        let max_x = self.state.world_width_m.max(1.0);
13017        let max_y = self.state.world_height_m.max(1.0);
13018        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
13019        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
13020    }
13021
13022    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
13023        if !self.state.is_alive() {
13024            anyhow::bail!("you are dead");
13025        }
13026        let Some(mode) = self.state.relocate_mode.clone() else {
13027            anyhow::bail!("not relocating");
13028        };
13029        let (px, py) = self.state.player_position();
13030        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
13031        if dist > 8.0 {
13032            anyhow::bail!("destination too far (max 8 m)");
13033        }
13034        self.seq += 1;
13035        self.session
13036            .submit_intent(Intent::MovePlacedContainer {
13037                entity_id: self.state.entity_id,
13038                container_id: mode.container_id.clone(),
13039                x: mode.cursor_x,
13040                y: mode.cursor_y,
13041                seq: self.seq,
13042            })
13043            .await?;
13044        self.state.intents_sent += 1;
13045        self.state.relocate_mode = None;
13046        self.state
13047            .push_log(format!("Moving {}…", mode.label));
13048        Ok(())
13049    }
13050
13051    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13052        let Some(mode) = self.state.claim_mode.as_mut() else {
13053            return;
13054        };
13055        mode.width_m = w.max(1);
13056        mode.height_m = h.max(1);
13057    }
13058
13059    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13060        let Some(mode) = self.state.claim_mode.as_mut() else {
13061            return;
13062        };
13063        let w = (mode.width_m as i32 + dw).max(1) as u32;
13064        let h = (mode.height_m as i32 + dh).max(1) as u32;
13065        mode.width_m = w;
13066        mode.height_m = h;
13067    }
13068
13069    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
13070    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13071        let Some(mode) = self.state.claim_mode.as_mut() else {
13072            return;
13073        };
13074        let max_x = self.state.world_width_m.max(1.0);
13075        let max_y = self.state.world_height_m.max(1.0);
13076        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13077        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13078        mode.anchor_x = nx.floor();
13079        mode.anchor_y = ny.floor();
13080    }
13081
13082    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13083        if !self.state.is_alive() {
13084            anyhow::bail!("you are dead");
13085        }
13086        let Some(mode) = self.state.claim_mode.clone() else {
13087            anyhow::bail!("not in claim mode");
13088        };
13089        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13090            self.state.claim_quote()
13091        else {
13092            anyhow::bail!("cannot quote claim");
13093        };
13094        if !valid {
13095            anyhow::bail!(reason);
13096        }
13097        if !can_afford {
13098            anyhow::bail!(
13099                "not enough copper (need {})",
13100                crate::currency::format_copper(purchase)
13101            );
13102        }
13103        let (x0, y0, x1, y1) = self
13104            .state
13105            .claim_footprint_rect()
13106            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13107        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13108        self.seq += 1;
13109        self.session
13110            .submit_intent(Intent::BuyPlot {
13111                entity_id: self.state.entity_id,
13112                zone_id: mode.zone_id,
13113                x0,
13114                y0,
13115                x1,
13116                y1,
13117                seq: self.seq,
13118            })
13119            .await?;
13120        self.state.intents_sent += 1;
13121        self.state.claim_mode = None;
13122        self.state
13123            .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
13124        Ok(())
13125    }
13126
13127    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13128        if !self.state.is_alive() {
13129            anyhow::bail!("you are dead");
13130        }
13131        let zone_id = self
13132            .state
13133            .claim_mode
13134            .as_ref()
13135            .map(|m| m.zone_id.clone())
13136            .or_else(|| {
13137                self.state
13138                    .free_property_zone_under_player()
13139                    .map(|z| z.id.clone())
13140            })
13141            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13142        self.seq += 1;
13143        self.session
13144            .submit_intent(Intent::BuyPlotAllFree {
13145                entity_id: self.state.entity_id,
13146                zone_id,
13147                seq: self.seq,
13148            })
13149            .await?;
13150        self.state.intents_sent += 1;
13151        self.state.claim_mode = None;
13152        self.state.push_log("Claiming largest free plot…");
13153        Ok(())
13154    }
13155
13156    pub async fn confirm_sell_plot_to_crown(
13157        &mut self,
13158        plot_id: uuid::Uuid,
13159    ) -> anyhow::Result<()> {
13160        if !self.state.is_alive() {
13161            anyhow::bail!("you are dead");
13162        }
13163        self.seq += 1;
13164        self.session
13165            .submit_intent(Intent::SellPlotToCrown {
13166                entity_id: self.state.entity_id,
13167                plot_id,
13168                seq: self.seq,
13169            })
13170            .await?;
13171        self.state.intents_sent += 1;
13172        self.state.sell_plot_confirm = None;
13173        self.state.sell_plot_armed_at = None;
13174        self.state.push_log("Selling plot to the crown…");
13175        Ok(())
13176    }
13177
13178    pub async fn set_plot_farm_public(
13179        &mut self,
13180        plot_id: uuid::Uuid,
13181        public: bool,
13182        public_tax_discount_bps: u32,
13183    ) -> anyhow::Result<()> {
13184        self.seq += 1;
13185        self.session
13186            .submit_intent(Intent::SetPlotFarmPublic {
13187                entity_id: self.state.entity_id,
13188                plot_id,
13189                public,
13190                public_tax_discount_bps,
13191                seq: self.seq,
13192            })
13193            .await?;
13194        self.state.intents_sent += 1;
13195        Ok(())
13196    }
13197
13198    pub async fn plot_farm_allow_upsert(
13199        &mut self,
13200        plot_id: uuid::Uuid,
13201        character_id: Option<uuid::Uuid>,
13202        character_name: String,
13203        tax_discount_bps: u32,
13204    ) -> anyhow::Result<()> {
13205        self.seq += 1;
13206        self.session
13207            .submit_intent(Intent::PlotFarmAllowUpsert {
13208                entity_id: self.state.entity_id,
13209                plot_id,
13210                character_id,
13211                character_name,
13212                tax_discount_bps,
13213                seq: self.seq,
13214            })
13215            .await?;
13216        self.state.intents_sent += 1;
13217        Ok(())
13218    }
13219
13220    pub async fn plot_farm_allow_remove(
13221        &mut self,
13222        plot_id: uuid::Uuid,
13223        character_id: uuid::Uuid,
13224    ) -> anyhow::Result<()> {
13225        self.seq += 1;
13226        self.session
13227            .submit_intent(Intent::PlotFarmAllowRemove {
13228                entity_id: self.state.entity_id,
13229                plot_id,
13230                character_id,
13231                seq: self.seq,
13232            })
13233            .await?;
13234        self.state.intents_sent += 1;
13235        Ok(())
13236    }
13237
13238    pub fn open_farm_access_panel(&mut self) {
13239        let Some(plot) = self.state.my_plot_under_player() else {
13240            self.state
13241                .push_log("Stand on your deed plot to manage farm access");
13242            return;
13243        };
13244        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13245        self.state.farm_access_index = 0;
13246        self.state.show_farm_access = true;
13247    }
13248
13249    pub fn close_farm_access_panel(&mut self) {
13250        self.state.show_farm_access = false;
13251        self.state.farm_access_name_draft.clear();
13252        self.state.farm_access_index = 0;
13253    }
13254
13255    pub fn farm_access_move(&mut self, delta: i32) {
13256        let n = self.farm_access_row_count().max(1);
13257        let idx = self.state.farm_access_index as i32 + delta;
13258        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13259    }
13260
13261    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13262        let Some(plot) = self.state.my_plot_under_player() else {
13263            return vec![FarmAccessRow::PublicToggle];
13264        };
13265        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13266        for g in &plot.farm_allow {
13267            rows.push(FarmAccessRow::AllowRemove {
13268                character_id: g.character_id,
13269                label: if g.character_label.trim().is_empty() {
13270                    g.character_id.to_string()[..8].to_string()
13271                } else {
13272                    g.character_label.clone()
13273                },
13274                tax_discount_bps: g.tax_discount_bps,
13275            });
13276        }
13277        for e in &self.state.entities {
13278            if e.id == self.state.entity_id || e.label.trim().is_empty() {
13279                continue;
13280            }
13281            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13282                continue;
13283            }
13284            if self
13285                .state
13286                .npcs
13287                .iter()
13288                .any(|n| n.id == e.label || n.label == e.label)
13289            {
13290                continue;
13291            }
13292            if plot
13293                .farm_allow
13294                .iter()
13295                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13296            {
13297                continue;
13298            }
13299            rows.push(FarmAccessRow::NearbyAdd {
13300                name: e.label.clone(),
13301            });
13302        }
13303        rows
13304    }
13305
13306    pub fn farm_access_row_count(&self) -> usize {
13307        self.farm_access_rows().len().max(1)
13308    }
13309
13310    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13311        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13312            self.close_farm_access_panel();
13313            return Ok(());
13314        };
13315        let rows = self.farm_access_rows();
13316        let Some(row) = rows.get(self.state.farm_access_index) else {
13317            return Ok(());
13318        };
13319        match row {
13320            FarmAccessRow::PublicToggle => {
13321                self.set_plot_farm_public(
13322                    plot.plot_id,
13323                    !plot.farm_public,
13324                    plot.public_tax_discount_bps,
13325                )
13326                .await
13327            }
13328            FarmAccessRow::PublicDiscount => Ok(()),
13329            FarmAccessRow::AllowRemove { character_id, .. } => {
13330                self.plot_farm_allow_remove(plot.plot_id, *character_id)
13331                    .await
13332            }
13333            FarmAccessRow::NearbyAdd { name } => {
13334                let disc = self
13335                    .state
13336                    .farm_access_discount_bps
13337                    .max(plot.public_tax_discount_bps);
13338                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13339                    .await
13340            }
13341        }
13342    }
13343
13344    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13345        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13346            return Ok(());
13347        };
13348        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13349        self.state.farm_access_discount_bps = next;
13350        self.state.farm_access_index = 1;
13351        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13352            .await
13353    }
13354
13355    /// Till the cell under you on a farmable plot (`c`).
13356    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13357        if self.state.farmable_plot_under_player().is_none() {
13358            anyhow::bail!("stand on a farmable plot to cultivate");
13359        }
13360        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13361            let (px, py) = self.state.player_position();
13362            if self
13363                .state
13364                .terrain_at(px, py)
13365                .is_some_and(|k| k == TerrainKindView::Tilled)
13366            {
13367                anyhow::bail!("already tilled — stand on bare soil and press c");
13368            }
13369            anyhow::bail!("cannot till this cell — move onto soil on your plot");
13370        };
13371        self.cultivate_at(tx, ty).await
13372    }
13373
13374    /// Plant seeds on empty tilled soil under you (`p`).
13375    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13376        if self.state.farmable_plot_under_player().is_none() {
13377            anyhow::bail!("stand on a farmable plot to plant");
13378        }
13379        if !self.state.underfoot_free_tilled_plant_slot() {
13380            anyhow::bail!("stand on empty tilled soil and press p");
13381        }
13382        let seeds = self.state.farm_seed_entries();
13383        if seeds.is_empty() {
13384            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13385        }
13386        if seeds.len() == 1 {
13387            return self.plant_seeds(seeds[0].0.clone(), 1).await;
13388        }
13389        self.open_plant_menu();
13390        Ok(())
13391    }
13392
13393    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
13394    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13395        let Some(plot) = self.state.my_plot_under_player() else {
13396            anyhow::bail!("stand on your plot to build");
13397        };
13398        if plot.building_id.is_some() {
13399            anyhow::bail!("this plot already has a building");
13400        }
13401        let building_now = self
13402            .state
13403            .timed_channel
13404            .as_ref()
13405            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13406        if !building_now && self.state.building_materials.is_empty() {
13407            anyhow::bail!("no building materials loaded — wait a moment and try again");
13408        }
13409        self.state.show_plot_build_menu = true;
13410        self.state.show_craft_menu = false;
13411        self.state.show_shop_menu = false;
13412        self.state.shop_catalog = None;
13413        self.state.show_stats = false;
13414        self.state.show_inventory_menu = false;
13415        self.state.plot_build_focus_wall = true;
13416        let walls = self.state.plot_build_wall_options().len();
13417        let roofs = self.state.plot_build_roof_options().len();
13418        if walls > 0 {
13419            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13420        } else {
13421            self.state.plot_build_wall_index = 0;
13422        }
13423        if roofs > 0 {
13424            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13425        } else {
13426            self.state.plot_build_roof_index = 0;
13427        }
13428        Ok(())
13429    }
13430
13431    pub fn close_plot_build_menu(&mut self) {
13432        self.state.show_plot_build_menu = false;
13433    }
13434
13435    pub fn plot_build_menu_move(&mut self, delta: i32) {
13436        let walls = self.state.plot_build_wall_options();
13437        let roofs = self.state.plot_build_roof_options();
13438        if self.state.plot_build_focus_wall {
13439            if walls.is_empty() {
13440                return;
13441            }
13442            let n = walls.len() as i32;
13443            let cur = self.state.plot_build_wall_index as i32;
13444            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13445        } else {
13446            if roofs.is_empty() {
13447                return;
13448            }
13449            let n = roofs.len() as i32;
13450            let cur = self.state.plot_build_roof_index as i32;
13451            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13452        }
13453    }
13454
13455    pub fn plot_build_menu_toggle_focus(&mut self) {
13456        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13457    }
13458
13459    /// Confirm selection from the B menu.
13460    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13461        let wall = self
13462            .state
13463            .plot_build_selected_wall()
13464            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13465            .id
13466            .clone();
13467        let roof = self
13468            .state
13469            .plot_build_selected_roof()
13470            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13471            .id
13472            .clone();
13473        // Keep the B menu open so it switches to the in-progress panel.
13474        self.start_plot_build(&wall, &roof).await
13475    }
13476
13477    /// Cancel an in-progress plot build (materials returned).
13478    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13479        self.seq += 1;
13480        self.session
13481            .submit_intent(Intent::CancelPlotBuild {
13482                entity_id: self.state.entity_id,
13483                seq: self.seq,
13484            })
13485            .await?;
13486        self.state.intents_sent += 1;
13487        Ok(())
13488    }
13489
13490    /// Start timed shell craft on the plot's solid tilled rectangle.
13491    pub async fn start_plot_build(
13492        &mut self,
13493        wall_material_id: &str,
13494        roof_material_id: &str,
13495    ) -> anyhow::Result<()> {
13496        let Some(plot) = self.state.my_plot_under_player() else {
13497            anyhow::bail!("stand on your plot to build");
13498        };
13499        if plot.building_id.is_some() {
13500            anyhow::bail!("this plot already has a building");
13501        }
13502        let plot_id = plot.plot_id;
13503        self.seq += 1;
13504        self.session
13505            .submit_intent(Intent::StartPlotBuild {
13506                entity_id: self.state.entity_id,
13507                plot_id,
13508                wall_material_id: wall_material_id.to_string(),
13509                roof_material_id: roof_material_id.to_string(),
13510                seq: self.seq,
13511            })
13512            .await?;
13513        self.state.intents_sent += 1;
13514        Ok(())
13515    }
13516
13517    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
13518    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13519        let (px, py) = self.state.player_position();
13520        let mut best: Option<(f32, String, bool)> = None;
13521        for d in &self.state.doors {
13522            if d.lock_id.is_none() {
13523                continue;
13524            }
13525            let dist = (d.x - px).hypot(d.y - py);
13526            if dist > 3.5 {
13527                continue;
13528            }
13529            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13530                best = Some((dist, d.id.clone(), d.locked));
13531            }
13532        }
13533        let Some((_, door_id, locked_now)) = best else {
13534            anyhow::bail!("no lockable door nearby");
13535        };
13536        let locked = !locked_now;
13537        self.seq += 1;
13538        self.session
13539            .submit_intent(Intent::SetDoorLocked {
13540                entity_id: self.state.entity_id,
13541                door_id,
13542                locked,
13543                seq: self.seq,
13544            })
13545            .await?;
13546        self.state.intents_sent += 1;
13547        Ok(())
13548    }
13549
13550    /// Enter through a nearby open player-building door (`Enter`).
13551    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
13552        if !self.state.is_alive() {
13553            anyhow::bail!("you are dead");
13554        }
13555        if self.state.effective_inside_building().is_some() {
13556            anyhow::bail!("already inside");
13557        }
13558        let (px, py) = self.state.player_position();
13559        let mut best: Option<(f32, String)> = None;
13560        for d in &self.state.doors {
13561            if !d.open || d.locked {
13562                continue;
13563            }
13564            let player_house = self
13565                .state
13566                .buildings
13567                .iter()
13568                .find(|b| b.id == d.building_id)
13569                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13570            if !player_house {
13571                continue;
13572            }
13573            let dist = (d.x - px).hypot(d.y - py);
13574            if dist > 3.5 {
13575                continue;
13576            }
13577            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13578                best = Some((dist, d.id.clone()));
13579            }
13580        }
13581        let Some((_, door_id)) = best else {
13582            anyhow::bail!("no open house door nearby — open with f first");
13583        };
13584        self.seq += 1;
13585        self.session
13586            .submit_intent(Intent::EnterBuildingDoor {
13587                entity_id: self.state.entity_id,
13588                door_id,
13589                seq: self.seq,
13590            })
13591            .await?;
13592        self.state.intents_sent += 1;
13593        Ok(())
13594    }
13595
13596    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
13597    /// Works even when the door is closed or locked.
13598    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
13599        if !self.state.is_alive() {
13600            anyhow::bail!("you are dead");
13601        }
13602        let Some(bid) = self.state.effective_inside_building() else {
13603            anyhow::bail!("not inside a building");
13604        };
13605        let (px, py) = self.state.player_position();
13606        let mut best: Option<(f32, String)> = None;
13607        for d in &self.state.doors {
13608            if d.building_id != bid || d.portal.is_none() {
13609                continue;
13610            }
13611            let player_house = self
13612                .state
13613                .buildings
13614                .iter()
13615                .find(|b| b.id == d.building_id)
13616                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13617            if !player_house {
13618                continue;
13619            }
13620            let dist = (d.x - px).hypot(d.y - py);
13621            if dist > 1.5 {
13622                continue;
13623            }
13624            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13625                best = Some((dist, d.id.clone()));
13626            }
13627        }
13628        let Some((_, door_id)) = best else {
13629            anyhow::bail!("stand by the door to exit");
13630        };
13631        self.seq += 1;
13632        self.session
13633            .submit_intent(Intent::ExitBuildingDoor {
13634                entity_id: self.state.entity_id,
13635                door_id,
13636                seq: self.seq,
13637            })
13638            .await?;
13639        self.state.intents_sent += 1;
13640        Ok(())
13641    }
13642
13643    /// Confirm interior layout for your player building (copper charged on server).
13644    pub async fn confirm_interior_edit(
13645        &mut self,
13646        building_id: String,
13647        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
13648        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
13649    ) -> anyhow::Result<()> {
13650        self.seq += 1;
13651        self.session
13652            .submit_intent(Intent::ConfirmInteriorEdit {
13653                entity_id: self.state.entity_id,
13654                building_id,
13655                rooms,
13656                room_doors,
13657                seq: self.seq,
13658            })
13659            .await?;
13660        self.state.intents_sent += 1;
13661        Ok(())
13662    }
13663
13664    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
13665        if !self.state.is_alive() {
13666            anyhow::bail!("you are dead");
13667        }
13668        self.seq += 1;
13669        self.session
13670            .submit_intent(Intent::Cultivate {
13671                entity_id: self.state.entity_id,
13672                x,
13673                y,
13674                seq: self.seq,
13675            })
13676            .await?;
13677        self.state.intents_sent += 1;
13678        Ok(())
13679    }
13680
13681    pub async fn plant_seeds(
13682        &mut self,
13683        seed_template_id: String,
13684        quantity: u32,
13685    ) -> anyhow::Result<()> {
13686        if !self.state.is_alive() {
13687            anyhow::bail!("you are dead");
13688        }
13689        self.seq += 1;
13690        self.session
13691            .submit_intent(Intent::PlantSeeds {
13692                entity_id: self.state.entity_id,
13693                seed_template_id: seed_template_id.clone(),
13694                quantity,
13695                seq: self.seq,
13696            })
13697            .await?;
13698        self.state.intents_sent += 1;
13699        self.state
13700            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
13701        Ok(())
13702    }
13703
13704    pub fn open_plant_menu(&mut self) {
13705        if self.state.farm_seed_entries().is_empty() {
13706            self.state.push_log("No seeds in inventory to plant");
13707            return;
13708        }
13709        self.state.show_plant_menu = true;
13710        self.state.plant_menu_index = 0;
13711        self.state.plant_quantity = 1;
13712        self.state.clamp_plant_menu();
13713    }
13714
13715    pub fn close_plant_menu(&mut self) {
13716        self.state.show_plant_menu = false;
13717    }
13718
13719    pub fn plant_menu_move(&mut self, delta: i32) {
13720        let n = self.state.farm_seed_entries().len();
13721        if n == 0 {
13722            return;
13723        }
13724        let idx = self.state.plant_menu_index as i32 + delta;
13725        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
13726        self.state.clamp_plant_menu();
13727    }
13728
13729    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
13730        let next = self.state.plant_quantity as i32 + delta;
13731        self.state.plant_quantity = next.max(1) as u32;
13732        self.state.clamp_plant_menu();
13733    }
13734
13735    pub fn plant_menu_set_quantity_max(&mut self) {
13736        if let Some((_, max, _)) = self.state.plant_menu_selection() {
13737            self.state.plant_quantity = max;
13738        }
13739        self.state.clamp_plant_menu();
13740    }
13741
13742    pub fn plant_menu_set_quantity_min(&mut self) {
13743        self.state.plant_quantity = 1;
13744        self.state.clamp_plant_menu();
13745    }
13746
13747    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
13748        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
13749            self.close_plant_menu();
13750            anyhow::bail!("no seeds to plant");
13751        };
13752        self.close_plant_menu();
13753        self.plant_seeds(seed, qty).await?;
13754        self.state.push_log(format!("Planted {qty}× {label}"));
13755        Ok(())
13756    }
13757
13758    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
13759    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
13760    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
13761        if !self.state.is_alive() {
13762            anyhow::bail!("you are dead");
13763        }
13764        let binding = self
13765            .state
13766            .hotbar_ability(slot)
13767            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
13768            .to_string();
13769        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
13770            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
13771            if qty == 0 {
13772                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
13773            }
13774            return self.use_item(template_id).await;
13775        }
13776        let ability_id = binding;
13777        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
13778            return self
13779                .cast_ability(&ability_id, Some(self.state.entity_id))
13780                .await;
13781        }
13782        let is_heal = ability_id == "heal_touch"
13783            || self
13784                .state
13785                .ability_meta
13786                .get(&ability_id)
13787                .map(|meta| meta.is_heal)
13788                .unwrap_or(false);
13789        let target = if is_heal {
13790            Some(
13791                self.state
13792                    .target_for_slot(2)
13793                    .unwrap_or(self.state.entity_id),
13794            )
13795        } else {
13796            self.state
13797                .target_for_slot(1)
13798                .or_else(|| self.state.target_for_slot(2))
13799        };
13800        let Some(target_id) = target else {
13801            anyhow::bail!("no target — Tab to select, then press the hotbar key");
13802        };
13803        self.cast_ability(&ability_id, Some(target_id)).await
13804    }
13805
13806    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
13807    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
13808    pub async fn set_hotbar_slot(
13809        &mut self,
13810        slot: u8,
13811        ability_id: Option<&str>,
13812    ) -> anyhow::Result<()> {
13813        if !self.state.is_alive() {
13814            anyhow::bail!("you are dead");
13815        }
13816        if !(1..=9).contains(&slot) {
13817            anyhow::bail!("hotbar slot must be 1–9");
13818        }
13819        let ability_id = ability_id
13820            .map(str::trim)
13821            .filter(|id| !id.is_empty())
13822            .map(str::to_string);
13823        self.seq += 1;
13824        self.session
13825            .submit_intent(Intent::SetHotbarSlot {
13826                entity_id: self.state.entity_id,
13827                slot,
13828                ability_id: ability_id.clone(),
13829                seq: self.seq,
13830            })
13831            .await?;
13832        self.state.intents_sent += 1;
13833        let idx = (slot - 1) as usize;
13834        if self.state.hotbar.len() < 9 {
13835            self.state.hotbar.resize(9, None);
13836        }
13837        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
13838            *slot_mut = ability_id.clone();
13839        }
13840        match ability_id {
13841            Some(id) => {
13842                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
13843                    format!("use {tid}")
13844                } else {
13845                    id
13846                };
13847                self.state.push_log(format!("Hotbar {slot} → {label}"))
13848            }
13849            None => self.state.push_log(format!("Hotbar {slot} cleared")),
13850        }
13851        Ok(())
13852    }
13853
13854    pub fn npc_verb_options(&self) -> Vec<&'static str> {
13855        self.state.npc_verb_options()
13856    }
13857
13858    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
13859        let Some(npc_id) = self.state.npc_verb_target.clone() else {
13860            return Ok(());
13861        };
13862        let options = self.npc_verb_options();
13863        let choice = options
13864            .get(self.state.npc_verb_index)
13865            .copied()
13866            .unwrap_or("Talk");
13867        self.seq += 1;
13868        match choice {
13869            "Trade" | "Bank" | "Storage" | "Market" => {
13870                self.session
13871                    .submit_intent(Intent::Interact {
13872                        entity_id: self.state.entity_id,
13873                        target_id: npc_id,
13874                        seq: self.seq,
13875                    })
13876                    .await?;
13877            }
13878            _ => {
13879                self.session
13880                    .submit_intent(Intent::NpcTalkOpen {
13881                        entity_id: self.state.entity_id,
13882                        npc_id,
13883                        seq: self.seq,
13884                    })
13885                    .await?;
13886            }
13887        }
13888        self.state.intents_sent += 1;
13889        Ok(())
13890    }
13891
13892    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
13893        let Some(chat) = self.state.npc_chat.clone() else {
13894            return Ok(());
13895        };
13896        let message = chat.input.trim().to_string();
13897        if message.is_empty() || chat.pending {
13898            return Ok(());
13899        }
13900        if let Some(c) = self.state.npc_chat.as_mut() {
13901            c.lines.push(format!("You: {message}"));
13902            c.input.clear();
13903            c.pending = true;
13904        }
13905        self.seq += 1;
13906        self.session
13907            .submit_intent(Intent::NpcTalkSay {
13908                entity_id: self.state.entity_id,
13909                npc_id: chat.npc_id,
13910                message,
13911                seq: self.seq,
13912            })
13913            .await?;
13914        self.state.intents_sent += 1;
13915        Ok(())
13916    }
13917
13918    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
13919        let return_to_verbs = self.state.npc_verb_target.is_some();
13920        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
13921            self.state.show_npc_chat = false;
13922            if return_to_verbs {
13923                self.state.show_npc_verb_menu = true;
13924            }
13925            return Ok(());
13926        };
13927        self.seq += 1;
13928        self.session
13929            .submit_intent(Intent::NpcTalkClose {
13930                entity_id: self.state.entity_id,
13931                npc_id,
13932                seq: self.seq,
13933            })
13934            .await?;
13935        self.state.intents_sent += 1;
13936        self.state.show_npc_chat = false;
13937        self.state.npc_chat = None;
13938        if return_to_verbs {
13939            self.state.show_npc_verb_menu = true;
13940        }
13941        Ok(())
13942    }
13943
13944    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
13945    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
13946        if self.state.show_quest_offer
13947            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
13948        {
13949            self.quest_offer_decline();
13950            return Ok(());
13951        }
13952        if self.state.show_npc_chat {
13953            return self.npc_talk_close().await;
13954        }
13955        if self.state.show_shop_menu {
13956            return self.back_from_shop_menu().await;
13957        }
13958        if self.state.bank_panel.is_some() {
13959            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
13960                self.bank_transfer_back();
13961                return Ok(());
13962            }
13963            return self.close_bank_panel().await;
13964        }
13965        if self.state.storage_panel.is_some() {
13966            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
13967                self.storage_ui_back();
13968                return Ok(());
13969            }
13970            return self.close_storage_panel().await;
13971        }
13972        if self.state.market_panel.is_some() {
13973            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
13974                self.market_ui_back();
13975                return Ok(());
13976            }
13977            if self.state.market_buy_confirm.is_some() {
13978                self.state.market_buy_confirm = None;
13979                return Ok(());
13980            }
13981            return self.close_market_panel().await;
13982        }
13983        if self.state.show_npc_verb_menu {
13984            self.state.show_npc_verb_menu = false;
13985            self.state.npc_verb_target = None;
13986        }
13987        Ok(())
13988    }
13989
13990    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
13991        self.seq += 1;
13992        self.session
13993            .submit_intent(Intent::TestDamage {
13994                entity_id: self.state.entity_id,
13995                amount,
13996                seq: self.seq,
13997            })
13998            .await?;
13999        self.state.intents_sent += 1;
14000        Ok(())
14001    }
14002
14003    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
14004        self.cycle_combat_target_slot(1, reverse).await
14005    }
14006
14007    pub async fn cycle_combat_target_slot(
14008        &mut self,
14009        slot_index: u8,
14010        reverse: bool,
14011    ) -> anyhow::Result<()> {
14012        if !self.state.is_alive() {
14013            anyhow::bail!("you are dead");
14014        }
14015        let candidates = self.state.candidates_for_slot(slot_index);
14016        if candidates.is_empty() {
14017            anyhow::bail!("no targets nearby");
14018        }
14019        let current = self.state.target_for_slot(slot_index);
14020        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
14021        let next_idx = match idx {
14022            None => 0,
14023            Some(i) if reverse => {
14024                if i == 0 {
14025                    candidates.len() - 1
14026                } else {
14027                    i - 1
14028                }
14029            }
14030            Some(i) => (i + 1) % candidates.len(),
14031        };
14032        if idx == Some(next_idx) && candidates.len() == 1 {
14033            self.clear_combat_target_slot(slot_index).await?;
14034            return Ok(());
14035        }
14036        let (target_id, label) = candidates[next_idx].clone();
14037        self.set_combat_target_slot(slot_index, target_id, &label)
14038            .await
14039    }
14040
14041    pub async fn set_combat_target_slot(
14042        &mut self,
14043        slot_index: u8,
14044        target_id: EntityId,
14045        label: &str,
14046    ) -> anyhow::Result<()> {
14047        if !self.state.is_alive() {
14048            anyhow::bail!("you are dead");
14049        }
14050        self.seq += 1;
14051        self.session
14052            .submit_intent(Intent::SetTargetSlot {
14053                entity_id: self.state.entity_id,
14054                slot_index,
14055                target_id,
14056                seq: self.seq,
14057            })
14058            .await?;
14059        self.state.intents_sent += 1;
14060        if slot_index == 1 {
14061            self.state.combat_target = Some(target_id);
14062            self.state.combat_target_label = Some(label.to_string());
14063        }
14064        self.state
14065            .push_log(format!("Slot {slot_index} target: {label}"));
14066        Ok(())
14067    }
14068
14069    pub async fn set_combat_target(
14070        &mut self,
14071        target_id: EntityId,
14072        label: &str,
14073    ) -> anyhow::Result<()> {
14074        self.set_combat_target_slot(1, target_id, label).await
14075    }
14076
14077    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14078        if slot_index == 1 && self.state.combat_target.is_none() {
14079            return Ok(());
14080        }
14081        self.seq += 1;
14082        self.session
14083            .submit_intent(Intent::ClearTargetSlot {
14084                entity_id: self.state.entity_id,
14085                slot_index,
14086                seq: self.seq,
14087            })
14088            .await?;
14089        if slot_index == 1 {
14090            self.state.combat_target = None;
14091            self.state.combat_target_label = None;
14092        }
14093        self.state.intents_sent += 1;
14094        self.state
14095            .push_log(format!("Slot {slot_index} target cleared"));
14096        Ok(())
14097    }
14098
14099    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14100        self.clear_combat_target_slot(1).await
14101    }
14102
14103    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14104        if !self.state.is_alive() {
14105            anyhow::bail!("you are dead");
14106        }
14107        self.seq += 1;
14108        self.session
14109            .submit_intent(Intent::AdvanceRotation {
14110                entity_id: self.state.entity_id,
14111                slot_index,
14112                seq: self.seq,
14113            })
14114            .await?;
14115        self.state.intents_sent += 1;
14116        Ok(())
14117    }
14118
14119    pub async fn assign_slot_preset(
14120        &mut self,
14121        slot_index: u8,
14122        preset_id: &str,
14123    ) -> anyhow::Result<()> {
14124        if !self.state.is_alive() {
14125            anyhow::bail!("you are dead");
14126        }
14127        self.seq += 1;
14128        self.session
14129            .submit_intent(Intent::AssignSlotPreset {
14130                entity_id: self.state.entity_id,
14131                slot_index,
14132                preset_id: preset_id.to_string(),
14133                seq: self.seq,
14134            })
14135            .await?;
14136        self.state.intents_sent += 1;
14137        if let Some(slot) = self
14138            .state
14139            .combat_slots
14140            .iter_mut()
14141            .find(|s| s.slot_index == slot_index)
14142        {
14143            slot.preset_id = Some(preset_id.to_string());
14144            if let Some(preset) = self
14145                .state
14146                .rotation_presets
14147                .iter()
14148                .find(|p| p.id == preset_id)
14149            {
14150                slot.preset_label = Some(preset.label.clone());
14151                slot.rotation = preset.abilities.clone();
14152                slot.rotation_index = 0;
14153            }
14154        }
14155        self.state
14156            .push_log(format!("T{slot_index} loadout → {preset_id}"));
14157        Ok(())
14158    }
14159
14160    pub async fn cast_ability(
14161        &mut self,
14162        ability_id: &str,
14163        target_id: Option<EntityId>,
14164    ) -> anyhow::Result<()> {
14165        if !self.state.is_alive() {
14166            anyhow::bail!("you are dead");
14167        }
14168        let allows_ground = self.state.ability_allows_ground(ability_id);
14169        let requires_ground = self.state.ability_requires_ground(ability_id);
14170        if requires_ground && self.state.ground_target.is_none() {
14171            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14172        }
14173        let (resolved_target_id, target_point) = if allows_ground {
14174            if let Some((x, y, z)) = self.state.ground_target {
14175                (
14176                    target_id.unwrap_or(self.state.entity_id),
14177                    Some(flatland_protocol::AimPoint { x, y, z }),
14178                )
14179            } else {
14180                (
14181                    target_id
14182                        .or_else(|| self.state.target_for_slot(2))
14183                        .or_else(|| self.state.target_for_slot(1))
14184                        .unwrap_or(self.state.entity_id),
14185                    None,
14186                )
14187            }
14188        } else {
14189            (
14190                target_id
14191                    .or_else(|| self.state.target_for_slot(2))
14192                    .or_else(|| self.state.target_for_slot(1))
14193                    .unwrap_or(self.state.entity_id),
14194                None,
14195            )
14196        };
14197        self.seq += 1;
14198        self.session
14199            .submit_intent(Intent::Cast {
14200                entity_id: self.state.entity_id,
14201                ability_id: ability_id.to_string(),
14202                target_id: resolved_target_id,
14203                target_point,
14204                seq: self.seq,
14205            })
14206            .await?;
14207        self.state.intents_sent += 1;
14208        match target_point {
14209            Some(point) => self.state.push_log(format!(
14210                "Cast {ability_id} → ({:.1}, {:.1})",
14211                point.x, point.y
14212            )),
14213            None => self
14214                .state
14215                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14216        }
14217        Ok(())
14218    }
14219
14220    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14221        self.seq += 1;
14222        self.session
14223            .submit_intent(Intent::UpsertRotationPreset {
14224                entity_id: self.state.entity_id,
14225                preset: preset.clone(),
14226                seq: self.seq,
14227            })
14228            .await?;
14229        self.state.intents_sent += 1;
14230        if let Some(existing) = self
14231            .state
14232            .rotation_presets
14233            .iter_mut()
14234            .find(|p| p.id == preset.id)
14235        {
14236            *existing = preset.clone();
14237        } else {
14238            self.state.rotation_presets.push(preset.clone());
14239        }
14240        for slot in &mut self.state.combat_slots {
14241            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14242                slot.preset_label = Some(preset.label.clone());
14243                slot.rotation = preset.abilities.clone();
14244            }
14245        }
14246        self.state
14247            .push_log(format!("Saved rotation: {}", preset.label));
14248        Ok(())
14249    }
14250
14251    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14252        self.seq += 1;
14253        self.session
14254            .submit_intent(Intent::DeleteRotationPreset {
14255                entity_id: self.state.entity_id,
14256                preset_id: preset_id.to_string(),
14257                seq: self.seq,
14258            })
14259            .await?;
14260        self.state.intents_sent += 1;
14261        self.state.rotation_presets.retain(|p| p.id != preset_id);
14262        for slot in &mut self.state.combat_slots {
14263            if slot.preset_id.as_deref() == Some(preset_id) {
14264                slot.preset_id = None;
14265                slot.preset_label = None;
14266                slot.rotation.clear();
14267                slot.rotation_index = 0;
14268            }
14269        }
14270        self.state
14271            .push_log(format!("Deleted rotation: {preset_id}"));
14272        Ok(())
14273    }
14274
14275    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14276        if !self.state.is_alive() {
14277            anyhow::bail!("you are dead");
14278        }
14279        let enabled = !self
14280            .state
14281            .combat_slots
14282            .iter()
14283            .find(|s| s.slot_index == slot_index)
14284            .map(|s| s.auto_enabled)
14285            .unwrap_or(false);
14286        self.seq += 1;
14287        self.session
14288            .submit_intent(Intent::SetAutoAttack {
14289                entity_id: self.state.entity_id,
14290                slot_index,
14291                enabled,
14292                seq: self.seq,
14293            })
14294            .await?;
14295        if slot_index == 1 {
14296            self.state.auto_attack = enabled;
14297        }
14298        self.state.intents_sent += 1;
14299        self.state.push_log(format!(
14300            "T{slot_index} auto {}",
14301            if enabled { "ON" } else { "OFF" }
14302        ));
14303        Ok(())
14304    }
14305
14306    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14307        if !self.state.connected {
14308            anyhow::bail!("not connected");
14309        }
14310        if !self.state.is_alive() {
14311            anyhow::bail!("you are dead");
14312        }
14313        let (px, py) = self.state.player_position();
14314        if self
14315            .state
14316            .ground_drops
14317            .iter()
14318            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14319        {
14320            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14321        }
14322        self.seq += 1;
14323        self.session
14324            .submit_intent(Intent::Pickup {
14325                entity_id: self.state.entity_id,
14326                drop_id: None,
14327                seq: self.seq,
14328            })
14329            .await?;
14330        self.state.intents_sent += 1;
14331        Ok(())
14332    }
14333
14334    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
14335        self.toggle_auto_attack_slot(1).await
14336    }
14337
14338    pub async fn dodge(&mut self) -> anyhow::Result<()> {
14339        if !self.state.is_alive() {
14340            anyhow::bail!("you are dead");
14341        }
14342        let (forward, strafe) = self.last_move_axes();
14343        self.seq += 1;
14344        self.session
14345            .submit_intent(Intent::Dodge {
14346                entity_id: self.state.entity_id,
14347                forward,
14348                strafe,
14349                seq: self.seq,
14350            })
14351            .await?;
14352        self.state.intents_sent += 1;
14353        self.state.push_log("Dodge!");
14354        Ok(())
14355    }
14356
14357    pub async fn lunge(&mut self) -> anyhow::Result<()> {
14358        if !self.state.is_alive() {
14359            anyhow::bail!("you are dead");
14360        }
14361        let (forward, strafe) = self.last_move_axes();
14362        self.seq += 1;
14363        self.session
14364            .submit_intent(Intent::Lunge {
14365                entity_id: self.state.entity_id,
14366                forward,
14367                strafe,
14368                seq: self.seq,
14369            })
14370            .await?;
14371        self.state.intents_sent += 1;
14372        self.state.push_log("Lunge!");
14373        Ok(())
14374    }
14375
14376    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14377        if !self.state.is_alive() {
14378            anyhow::bail!("you are dead");
14379        }
14380        self.seq += 1;
14381        self.session
14382            .submit_intent(Intent::DirectionalJump {
14383                entity_id: self.state.entity_id,
14384                forward,
14385                strafe,
14386                seq: self.seq,
14387            })
14388            .await?;
14389        self.state.intents_sent += 1;
14390        self.state.push_log("Jump!");
14391        Ok(())
14392    }
14393
14394    /// Remembered WASD axes for lunge when not currently moving.
14395    pub fn last_move_axes(&self) -> (f32, f32) {
14396        (self.last_move_forward, self.last_move_strafe)
14397    }
14398
14399    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14400        if !self.state.is_alive() {
14401            anyhow::bail!("you are dead");
14402        }
14403        self.seq += 1;
14404        self.session
14405            .submit_intent(Intent::Block {
14406                entity_id: self.state.entity_id,
14407                enabled,
14408                seq: self.seq,
14409            })
14410            .await?;
14411        self.state.intents_sent += 1;
14412        if enabled {
14413            self.state.push_log("Blocking");
14414        }
14415        Ok(())
14416    }
14417
14418    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14419        if !self.state.is_alive() {
14420            anyhow::bail!("you are dead");
14421        }
14422        self.seq += 1;
14423        self.session
14424            .submit_intent(Intent::EquipMainhand {
14425                entity_id: self.state.entity_id,
14426                template_id,
14427                instance_id: None,
14428                seq: self.seq,
14429            })
14430            .await?;
14431        self.state.intents_sent += 1;
14432        Ok(())
14433    }
14434
14435    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
14436    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14437        let idx = self.state.equip_menu_index;
14438        let slots = equip_paperdoll_rows(&self.state);
14439        let Some(row) = slots.get(idx) else {
14440            return Ok(());
14441        };
14442        match row {
14443            EquipPaperdollRow::Body { slot, filled } => {
14444                if *filled {
14445                    self.equip_worn(*slot, None).await
14446                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14447                    self.equip_worn(*slot, Some(inst)).await
14448                } else {
14449                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
14450                    Ok(())
14451                }
14452            }
14453            EquipPaperdollRow::Mainhand { filled } => {
14454                if *filled {
14455                    self.unequip_mainhand().await
14456                } else if let Some(tid) = first_inventory_weapon(&self.state) {
14457                    self.equip_mainhand(Some(tid)).await
14458                } else {
14459                    self.state.push_log("No weapon in inventory".to_string());
14460                    Ok(())
14461                }
14462            }
14463            EquipPaperdollRow::Offhand { filled, locked } => {
14464                if *locked {
14465                    self.state
14466                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
14467                    Ok(())
14468                } else if *filled {
14469                    self.unequip_offhand().await
14470                } else if let Some(tid) = first_inventory_offhand(&self.state) {
14471                    self.equip_offhand(Some(tid)).await
14472                } else {
14473                    self.state
14474                        .push_log("No offhand item in inventory".to_string());
14475                    Ok(())
14476                }
14477            }
14478        }
14479    }
14480
14481    pub async fn say(
14482        &mut self,
14483        channel: flatland_protocol::ChatChannel,
14484        text: &str,
14485    ) -> anyhow::Result<()> {
14486        self.say_to(channel, text, None).await
14487    }
14488
14489    pub async fn say_to(
14490        &mut self,
14491        channel: flatland_protocol::ChatChannel,
14492        text: &str,
14493        to_entity: Option<EntityId>,
14494    ) -> anyhow::Result<()> {
14495        self.seq += 1;
14496        self.session
14497            .submit_intent(Intent::Say {
14498                entity_id: self.state.entity_id,
14499                channel,
14500                text: text.to_string(),
14501                to_entity,
14502                seq: self.seq,
14503            })
14504            .await?;
14505        self.state.intents_sent += 1;
14506        Ok(())
14507    }
14508
14509    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14510        let Some(peer) = self.state.player_verbs.target_entity else {
14511            return Ok(());
14512        };
14513        let label = self.state.player_verbs.target_label.clone();
14514        let choice = crate::social::PlayerVerbState::options()
14515            .get(self.state.player_verbs.index)
14516            .copied()
14517            .unwrap_or("Whisper");
14518        self.state.player_verbs.close();
14519        match choice {
14520            "Trade" => {
14521                // Only request — peer must accept (Y in chat). Do not auto-respond;
14522                // TradeRespond requires a pending inbound request from the peer.
14523                self.seq += 1;
14524                self.session
14525                    .submit_intent(Intent::TradeRequest {
14526                        entity_id: self.state.entity_id,
14527                        peer_entity_id: peer,
14528                        seq: self.seq,
14529                    })
14530                    .await?;
14531                self.state.intents_sent += 1;
14532                self.state
14533                    .social_chat
14534                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
14535            }
14536            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
14537            _ => self.state.social_chat.focus_nearby(),
14538        }
14539        Ok(())
14540    }
14541
14542    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
14543        let Some(pending) = self.state.social_chat.pending_trade.take() else {
14544            return Ok(());
14545        };
14546        self.seq += 1;
14547        self.session
14548            .submit_intent(Intent::TradeRespond {
14549                entity_id: self.state.entity_id,
14550                peer_entity_id: pending.from_entity,
14551                accept,
14552                seq: self.seq,
14553            })
14554            .await?;
14555        self.state.intents_sent += 1;
14556        if accept {
14557            self.state
14558                .social_chat
14559                .push_system(format!("Accepted trade with {}", pending.from_name));
14560        } else {
14561            self.state
14562                .social_chat
14563                .push_system(format!("Declined trade with {}", pending.from_name));
14564        }
14565        Ok(())
14566    }
14567
14568    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
14569        let text = self.state.social_chat.buffer.trim().to_string();
14570        if text.is_empty() {
14571            return Ok(());
14572        }
14573        self.state.social_chat.buffer.clear();
14574        if crate::social::is_chat_slash_line(&text) {
14575            match crate::social::parse_chat_slash(&text) {
14576                Some(cmd) => return self.apply_chat_slash(cmd).await,
14577                None => {
14578                    self.state.social_chat.push_system(format!(
14579                        "Unknown command — {}",
14580                        crate::social::chat_slash_help_text()
14581                    ));
14582                    return Ok(());
14583                }
14584            }
14585        }
14586        let thread = self.state.social_chat.thread;
14587        let channel = thread.channel();
14588        let to = thread.to_entity();
14589        if let Some(peer) = to {
14590            let label = self.state.social_chat.peer_label.clone();
14591            self.state
14592                .social_chat
14593                .remember_whisper_peer(peer, &label, channel);
14594        }
14595        self.say_to(channel, &text, to).await
14596    }
14597
14598    async fn apply_chat_slash(
14599        &mut self,
14600        cmd: crate::social::ChatSlashCommand,
14601    ) -> anyhow::Result<()> {
14602        use crate::social::{chat_slash_help_text, ChatSlashCommand};
14603        match cmd {
14604            ChatSlashCommand::Help => {
14605                self.state
14606                    .social_chat
14607                    .push_system(chat_slash_help_text().to_string());
14608                Ok(())
14609            }
14610            ChatSlashCommand::Nearby { message } => {
14611                self.state.social_chat.focus_nearby();
14612                self.state
14613                    .social_chat
14614                    .push_system("Nearby speech — everyone close can hear");
14615                if let Some(msg) = message {
14616                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
14617                        .await
14618                } else {
14619                    Ok(())
14620                }
14621            }
14622            ChatSlashCommand::Reply { message } => {
14623                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14624                    self.state.social_chat.push_system(
14625                        "No one to reply to — wait for a whisper, or /whisper Name",
14626                    );
14627                    return Ok(());
14628                };
14629                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
14630                self.state
14631                    .social_chat
14632                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
14633                self.state.social_chat.push_system(format!(
14634                    "Replying to {} — type and Enter · /nearby",
14635                    peer.label
14636                ));
14637                if let Some(msg) = message {
14638                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
14639                } else {
14640                    Ok(())
14641                }
14642            }
14643            ChatSlashCommand::Whisper { name, message } => {
14644                let (peer_id, label, stone) = if let Some(name) = name {
14645                    match self.resolve_whisper_target(&name) {
14646                        Ok(t) => t,
14647                        Err(err) => {
14648                            self.state.social_chat.push_system(err);
14649                            return Ok(());
14650                        }
14651                    }
14652                } else {
14653                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14654                        self.state.social_chat.push_system(
14655                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
14656                        );
14657                        return Ok(());
14658                    };
14659                    (
14660                        peer.entity_id,
14661                        peer.label,
14662                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
14663                    )
14664                };
14665                self.state
14666                    .social_chat
14667                    .set_whisper_thread(peer_id, &label, stone);
14668                let channel = if stone {
14669                    flatland_protocol::ChatChannel::WhisperStone
14670                } else {
14671                    flatland_protocol::ChatChannel::Whisper
14672                };
14673                if let Some(msg) = message {
14674                    self.state.social_chat.push_system(format!(
14675                        "Whisper → {label}"
14676                    ));
14677                    self.say_to(channel, &msg, Some(peer_id)).await
14678                } else {
14679                    self.state.social_chat.push_system(format!(
14680                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
14681                    ));
14682                    Ok(())
14683                }
14684            }
14685        }
14686    }
14687
14688    /// Resolve `/whisper Name` against AOI players (label match).
14689    fn resolve_whisper_target(
14690        &self,
14691        name: &str,
14692    ) -> Result<(EntityId, String, bool), String> {
14693        let needle = name.trim().to_ascii_lowercase();
14694        if needle.is_empty() {
14695            return Err("Usage: /whisper Name [message]".into());
14696        }
14697        let mut candidates: Vec<(EntityId, String)> = self
14698            .state
14699            .entities
14700            .iter()
14701            .filter(|e| e.id != self.state.entity_id)
14702            .filter(|e| !e.label.trim().is_empty())
14703            .filter(|e| e.vitals.is_some())
14704            .filter(|e| {
14705                !self
14706                    .state
14707                    .npcs
14708                    .iter()
14709                    .any(|n| n.id == e.id.to_string())
14710            })
14711            .filter(|e| {
14712                !self
14713                    .state
14714                    .hired_workers
14715                    .iter()
14716                    .any(|w| w.entity_id == e.id)
14717            })
14718            .map(|e| (e.id, e.label.clone()))
14719            .collect();
14720
14721        // Also allow matching the last whisper peer by name even if they left AOI briefly.
14722        if let Some(last) = &self.state.social_chat.last_whisper_peer {
14723            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
14724                candidates.push((last.entity_id, last.label.clone()));
14725            }
14726        }
14727
14728        let exact: Vec<_> = candidates
14729            .iter()
14730            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
14731            .cloned()
14732            .collect();
14733        let pool = if exact.len() == 1 {
14734            exact
14735        } else if exact.len() > 1 {
14736            return Err(format!(
14737                "Several players named '{name}' nearby — move closer and try again"
14738            ));
14739        } else {
14740            let starts: Vec<_> = candidates
14741                .iter()
14742                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
14743                .cloned()
14744                .collect();
14745            if starts.len() == 1 {
14746                starts
14747            } else if starts.len() > 1 {
14748                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
14749                return Err(format!(
14750                    "Ambiguous name '{name}' — matches: {}",
14751                    names.join(", ")
14752                ));
14753            } else {
14754                let contains: Vec<_> = candidates
14755                    .iter()
14756                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
14757                    .cloned()
14758                    .collect();
14759                if contains.len() == 1 {
14760                    contains
14761                } else if contains.is_empty() {
14762                    return Err(format!(
14763                        "No player matching '{name}' in range — get closer or check the spelling"
14764                    ));
14765                } else {
14766                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
14767                    return Err(format!(
14768                        "Ambiguous name '{name}' — matches: {}",
14769                        names.join(", ")
14770                    ));
14771                }
14772            }
14773        };
14774
14775        let (id, label) = pool.into_iter().next().unwrap();
14776        let stone = self
14777            .state
14778            .social_chat
14779            .last_whisper_peer
14780            .as_ref()
14781            .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
14782        Ok((id, label, stone))
14783    }
14784
14785    pub async fn trade_present_selected(
14786        &mut self,
14787        item_instance_id: uuid::Uuid,
14788    ) -> anyhow::Result<()> {
14789        self.trade_present_quantity(item_instance_id, None).await
14790    }
14791
14792    pub async fn trade_present_quantity(
14793        &mut self,
14794        item_instance_id: uuid::Uuid,
14795        quantity: Option<u32>,
14796    ) -> anyhow::Result<()> {
14797        self.seq += 1;
14798        self.session
14799            .submit_intent(Intent::TradePresent {
14800                entity_id: self.state.entity_id,
14801                item_instance_id,
14802                quantity,
14803                seq: self.seq,
14804            })
14805            .await?;
14806        self.state.intents_sent += 1;
14807        self.state.trade_ui.qty_entry = None;
14808        self.state.trade_ui.picking_inventory = false;
14809        Ok(())
14810    }
14811
14812    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
14813    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
14814        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
14815            let qty = self.state.trade_ui.present_quantity();
14816            return self
14817                .trade_present_quantity(entry.item_instance_id, qty)
14818                .await;
14819        }
14820        if !self.state.trade_ui.picking_inventory {
14821            return Ok(());
14822        }
14823        let stacks = self.state.trade_presentable_stacks();
14824        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
14825            return Ok(());
14826        };
14827        let Some(id) = stack.item_instance_id else {
14828            return Ok(());
14829        };
14830        let label = stack
14831            .display_name
14832            .clone()
14833            .unwrap_or_else(|| stack.template_id.clone());
14834        if stack.quantity <= 1 {
14835            self.trade_present_quantity(id, Some(1)).await
14836        } else {
14837            self.state
14838                .trade_ui
14839                .begin_qty_entry(id, label, stack.quantity);
14840            Ok(())
14841        }
14842    }
14843
14844    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
14845        self.seq += 1;
14846        self.session
14847            .submit_intent(Intent::TradeSetReady {
14848                entity_id: self.state.entity_id,
14849                ready,
14850                seq: self.seq,
14851            })
14852            .await?;
14853        self.state.intents_sent += 1;
14854        Ok(())
14855    }
14856
14857    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
14858        self.seq += 1;
14859        self.session
14860            .submit_intent(Intent::TradeCancel {
14861                entity_id: self.state.entity_id,
14862                seq: self.seq,
14863            })
14864            .await?;
14865        self.state.intents_sent += 1;
14866        self.state.trade_ui.close();
14867        Ok(())
14868    }
14869
14870    pub async fn destroy_whisper_stone(
14871        &mut self,
14872        item_instance_id: uuid::Uuid,
14873    ) -> anyhow::Result<()> {
14874        self.seq += 1;
14875        self.session
14876            .submit_intent(Intent::DestroyWhisperStone {
14877                entity_id: self.state.entity_id,
14878                item_instance_id,
14879                seq: self.seq,
14880            })
14881            .await?;
14882        self.state.intents_sent += 1;
14883        Ok(())
14884    }
14885
14886    pub async fn stop(&mut self) -> anyhow::Result<()> {
14887        self.seq += 1;
14888        self.session
14889            .submit_intent(Intent::Stop {
14890                entity_id: self.state.entity_id,
14891                seq: self.seq,
14892            })
14893            .await?;
14894        self.state.intents_sent += 1;
14895        Ok(())
14896    }
14897
14898    pub fn disconnect(&self) {
14899        self.session.disconnect();
14900    }
14901}
14902
14903fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14904    let dx = ax - bx;
14905    let dy = ay - by;
14906    (dx * dx + dy * dy).sqrt()
14907}
14908
14909#[cfg(test)]
14910mod tests {
14911    use std::collections::BTreeMap;
14912
14913    use super::*;
14914    use flatland_protocol::{
14915        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
14916    };
14917
14918    fn sample_state() -> GameState {
14919        let mut state = GameState {
14920            session_id: 1,
14921            entity_id: 1,
14922            character_id: None,
14923            tick: 0,
14924            chunk_rev: 0,
14925            content_rev: 0,
14926            publish_rev: 0,
14927            entities: vec![EntityState {
14928                id: 1,
14929                label: "You".into(),
14930                transform: Transform {
14931                    position: WorldCoord::surface(128.0, 128.0),
14932                    yaw: 0.0,
14933                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14934                },
14935                vitals: None,
14936                attributes: None,
14937                skills: None,
14938                inside_building: None,
14939                tile_id: None,
14940                paperdoll_ref: None,
14941                draw_scale: 1.0,
14942                presentation_state: None,
14943                sprite_mode: None,
14944                progression_xp: None,
14945                combat_cues: vec![],
14946                statuses: vec![],
14947            }],
14948            player: None,
14949            resource_nodes: vec![ResourceNodeView {
14950                id: "oak-1".into(),
14951                label: "Oak".into(),
14952                x: 130.0,
14953                y: 128.0,
14954                z: 0.0,
14955                item_template: "oak_log".into(),
14956                state: ResourceNodeState::Available,
14957                blocking: true,
14958                blocking_radius_m: 0.8,
14959                harvest_off: false,
14960                tile_id: None,
14961                                yaw: 0.0,
14962                pitch: 0.0,
14963                roll: 0.0,
14964                draw_scale: 1.0,
14965                sprite_mode: None,
14966                growth_progress: None,
14967                presentation_state: None,
14968                channel_start_tick: None,
14969                channel_end_tick: None,
14970                harvest_drop_templates: vec![],
14971            }],
14972            ground_drops: vec![],
14973            placed_containers: vec![],
14974            buildings: vec![BuildingView {
14975                id: "broker-hut".into(),
14976                label: "Broker".into(),
14977                x: 148.0,
14978                y: 118.0,
14979                width_m: 8.0,
14980                depth_m: 6.0,
14981                interior_blueprint: Some("broker_hut".into()),
14982                tags: vec![],
14983                market_boundary_zone_ids: vec![],
14984                market_max_volume: None,
14985                wall_set: None,
14986                roof_set: None,
14987            }],
14988            doors: vec![flatland_protocol::DoorView {
14989                id: "door-1".into(),
14990                building_id: "broker-hut".into(),
14991                x: 148.0,
14992                y: 118.0,
14993                open: false,
14994                portal: Some("front".into()),
14995            locked: false,
14996            accessible: true,
14997            lock_id: None,
14998        }],
14999            interior_map: None,
15000            npcs: vec![],
15001            blueprints: vec![],
15002            building_materials: vec![],
15003            world_x0: 0.0,
15004            world_y0: 0.0,
15005            world_width_m: 256.0,
15006            world_height_m: 256.0,
15007            terrain_zones: Vec::new(),
15008            z_platforms: Vec::new(),
15009            z_transitions: Vec::new(),
15010            z_bands_outdoor_backup: None,
15011            world_clock: flatland_protocol::WorldClock::default(),
15012            inventory: std::collections::HashMap::new(),
15013            inventory_hints: std::collections::HashMap::new(),
15014            logs: VecDeque::new(),
15015            intents_sent: 0,
15016            ticks_received: 0,
15017            connected: true,
15018            disconnect_reason: None,
15019            show_stats: false,
15020            hud_log_hidden: false,
15021            show_equip_menu: false,
15022            equip_menu_index: 0,
15023            show_craft_menu: false,
15024            show_plot_build_menu: false,
15025            plot_build_focus_wall: true,
15026            plot_build_wall_index: 0,
15027            plot_build_roof_index: 0,
15028            craft_menu_index: 0,
15029            craft_batch_quantity: 1,
15030            show_shop_menu: false,
15031            shop_catalog: None,
15032            bank_panel: None,
15033            bank_menu_index: 0,
15034            bank_ui_mode: BankUiMode::Menu,
15035            storage_panel: None,
15036                market_panel: None,
15037            market_menu_index: 0,
15038            market_filter: String::new(),
15039            market_filter_focused: false,
15040            market_category_filter: None,
15041            market_buy_confirm: None,
15042            market_ui_mode: MarketUiMode::Browse,
15043            storage_menu_index: 0,
15044            storage_ui_mode: StorageUiMode::Menu,
15045            shop_tab: ShopTab::default(),
15046            shop_menu_index: 0,
15047            shop_quantity: 1,
15048            shop_trade_log: VecDeque::new(),
15049            show_npc_verb_menu: false,
15050            npc_verb_target: None,
15051            npc_verb_index: 0,
15052            player_verbs: crate::social::PlayerVerbState::default(),
15053            social_chat: crate::social::SocialChatState::default(),
15054            trade_ui: crate::social::TradeUiState::default(),
15055            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15056            show_npc_chat: false,
15057            npc_chat: None,
15058            show_inventory_menu: false,
15059            inventory_menu_index: 0,
15060            inventory_tab: InventoryTab::OnPerson,
15061            inventory_filter: String::new(),
15062            inventory_filter_focused: false,
15063            show_move_picker: false,
15064            show_rename_prompt: false,
15065            rename_plot_id: None,
15066            highlighted_plot_id: None,
15067            show_worker_rename: false,
15068            rename_buffer: String::new(),
15069            move_picker_index: 0,
15070            move_picker: None,
15071            show_grant_picker: false,
15072            grant_picker_index: 0,
15073            grant_picker: None,
15074            show_destroy_picker: false,
15075            destroy_confirm_pending: false,
15076            destroy_picker: None,
15077            combat_target: None,
15078            combat_target_label: None,
15079            ground_target: None,
15080            combat_fx: Vec::new(),
15081            property_zones: Vec::new(),
15082            tax_zones: Vec::new(),
15083            growth_zones: Vec::new(),
15084            biome_zones: Vec::new(),
15085            terrain_kind_nav: Vec::new(),
15086            property_plots: Vec::new(),
15087            property_plot_settings: None,
15088            claim_mode: None,
15089            relocate_mode: None,
15090            sell_plot_confirm: None,
15091            sell_plot_armed_at: None,
15092            show_plant_menu: false,
15093            plant_menu_index: 0,
15094            show_farm_access: false,
15095            farm_access_name_draft: String::new(),
15096            farm_access_discount_bps: 0,
15097            farm_access_index: 0,
15098            plant_quantity: 1,
15099            in_combat: false,
15100            auto_attack: true,
15101            combat_has_los: false,
15102            attack_cd_ticks: 0,
15103            gcd_ticks: 0,
15104            weapon_ability_id: "unarmed".into(),
15105            mainhand_template_id: None,
15106            mainhand_label: None,
15107            mainhand_instance_id: None,
15108            offhand_template_id: None,
15109            offhand_label: None,
15110            offhand_instance_id: None,
15111            mainhand_hand_slots: 1,
15112            defense: None,
15113            worn: BTreeMap::new(),
15114            carry_mass: 0.0,
15115            carry_mass_max: 0.0,
15116            encumbrance: flatland_protocol::EncumbranceState::Light,
15117            inventory_stacks: Vec::new(),
15118            keychain_stacks: Vec::new(),
15119            whisper_pouch_stacks: Vec::new(),
15120            combat_target_detail: None,
15121                statuses: Vec::new(),
15122            cast_progress: None,
15123            timed_channel: None,
15124            plot_build_offer: None,
15125            ability_cooldowns: Vec::new(),
15126            blocking_active: false,
15127            max_target_slots: 1,
15128            combat_slots: Vec::new(),
15129            rotation_presets: Vec::new(),
15130            known_abilities: Vec::new(),
15131            ability_meta: std::collections::HashMap::new(),
15132            ability_mastery: std::collections::HashMap::new(),
15133            hotbar: vec![None; 9],
15134            max_abilities_per_rotation: 0,
15135            show_loadout_menu: false,
15136            show_keychain_menu: false,
15137            keychain_menu_index: 0,
15138            show_rotation_editor: false,
15139            loadout_menu_index: 0,
15140            loadout_hotbar_slot: 1,
15141            loadout_ability_index: 0,
15142            loadout_focus_presets: false,
15143            rotation_editor: RotationEditorState::default(),
15144            harvest_in_progress: false,
15145            harvest_started_at: None,
15146            pending_craft_ack: None,
15147            pending_worker_job_ack: None,
15148            attending_worker_instance_id: None,
15149            quest_log: Vec::new(),
15150            interactables: Vec::new(),
15151            ledger: None,
15152            career: None,
15153            character_sheet_tab: CharacterSheetTab::Character,
15154            ledger_period: LedgerPeriod::Day,
15155            show_quest_offer: false,
15156            pending_quest_offer: None,
15157            show_quest_menu: false,
15158            quest_menu_index: 0,
15159            quest_withdraw_confirm: false,
15160            hired_workers: Vec::new(),
15161            show_workers_menu: false,
15162            workers_menu_index: 0,
15163            workers_menu_compact: false,
15164            worker_step_display: BTreeMap::new(),
15165            worker_error_display: BTreeMap::new(),
15166            show_worker_give_picker: false,
15167            worker_give_picker_index: 0,
15168            worker_give_picker: None,
15169            show_worker_give_target_picker: false,
15170            worker_give_target_picker_index: 0,
15171            worker_give_target_picker: None,
15172            show_worker_take_picker: false,
15173            worker_take_picker_index: 0,
15174            worker_take_picker: None,
15175            show_worker_teach_picker: false,
15176            worker_teach_picker_index: 0,
15177            worker_teach_picker: None,
15178            worker_route_editor: None,
15179            progression_curve: None,
15180        };
15181        state.player = state.entities.first().cloned();
15182        state
15183    }
15184
15185    #[test]
15186    fn whisper_cancels_when_peer_walks_out_of_range() {
15187        let mut state = sample_state();
15188        state.player = state.entities.first().cloned();
15189        let mut peer = state.entities[0].clone();
15190        peer.id = 2;
15191        peer.label = "Ada".into();
15192        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
15193        state.entities.push(peer.clone());
15194        state.social_chat.focus_whisper(2, "Ada");
15195        state.refresh_whisper_range();
15196        assert!(matches!(
15197            state.social_chat.thread,
15198            crate::social::ChatThreadKind::Whisper { peer: 2 }
15199        ));
15200
15201        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
15202        state.entities[1] = peer;
15203        state.refresh_whisper_range();
15204        assert_eq!(
15205            state.social_chat.thread,
15206            crate::social::ChatThreadKind::Nearby
15207        );
15208        assert!(!state.social_chat.input_focused);
15209    }
15210
15211    #[test]
15212    fn probe_use_world_hired_worker_manage() {
15213        let mut state = sample_state();
15214        state.hired_workers.push(flatland_protocol::HiredWorkerView {
15215            instance_id: "worker-1".into(),
15216            entity_id: 42,
15217            def_id: "worker_laborer".into(),
15218            label: "Sam".into(),
15219            x: 129.0,
15220            y: 128.0,
15221            z: 0.0,
15222            mode: flatland_protocol::WorkerModeView::JobLoop,
15223            state: flatland_protocol::WorkerStateView::Working,
15224            step_label: "cultivate".into(),
15225            vitals: flatland_protocol::WorkerVitalsSummary {
15226                health_pct: 100.0,
15227                stamina_pct: 100.0,
15228            },
15229            carry_pct: 0.0,
15230            last_error: None,
15231            wage_copper_per_interval: 1,
15232            effective_wage_copper: 1,
15233            wage_meters_walked: 0.0,
15234            lodging_container_id: None,
15235            route: None,
15236            route_stop_index: None,
15237            known_blueprint_ids: Vec::new(),
15238            level: 1,
15239            worker_xp: 0.0,
15240            inventory: Vec::new(),
15241            issue_hint: None,
15242        });
15243        let probe = state.probe_use_world();
15244        let primary = probe.primary.expect("primary");
15245        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15246        assert_eq!(primary.id, "worker-1");
15247        assert!(primary.hint_line().contains("Manage"));
15248        assert!(primary.hint_line().contains("Sam"));
15249        assert_eq!(
15250            state.nearest_interact_target().as_deref(),
15251            Some("worker-1")
15252        );
15253    }
15254
15255    #[test]
15256    fn market_clerk_verb_options_include_market() {
15257        let mut state = sample_state();
15258        state.npcs.push(flatland_protocol::NpcView {
15259            id: "mira_market".into(),
15260            label: "Mira".into(),
15261            role: "market_clerk".into(),
15262            x: 129.0,
15263            y: 128.0,
15264            building_id: Some("town_market".into()),
15265            entity_id: None,
15266            life_state: None,
15267            hp_pct: None,
15268            can_trade: false,
15269            tile_id: None,
15270            behavior_state: None,
15271            presentation_state: None,
15272            sprite_mode: None,
15273            paperdoll_ref: None,
15274        draw_scale: 1.0,
15275        });
15276        state.npc_verb_target = Some("mira_market".into());
15277        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15278    }
15279
15280    #[test]
15281    fn market_list_excludes_currency_stacks() {
15282        let mut state = sample_state();
15283        state.inventory_stacks = vec![
15284            flatland_protocol::ItemStack {
15285                template_id: "copper_coin".into(),
15286                quantity: 50,
15287                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15288                display_name: Some("Copper Coin".into()),
15289                ..Default::default()
15290            },
15291            flatland_protocol::ItemStack {
15292                template_id: "oak_log".into(),
15293                quantity: 2,
15294                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15295                display_name: Some("Oak Log".into()),
15296                ..Default::default()
15297            },
15298            flatland_protocol::ItemStack {
15299                template_id: "whisper_stone".into(),
15300                quantity: 1,
15301                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15302                display_name: Some("Whisper Stone".into()),
15303                category: Some("quest".into()),
15304                listable: Some(false),
15305                ..Default::default()
15306            },
15307        ];
15308        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15309        assert_eq!(opts.len(), 1);
15310        assert!(opts[0].label.contains("Oak"));
15311    }
15312
15313    #[test]
15314    fn market_browse_filters_by_category_and_search() {
15315        let mut state = sample_state();
15316        state.market_panel = Some(flatland_protocol::MarketPanel {
15317            npc_id: "mira_market".into(),
15318            npc_label: "Mira".into(),
15319            building_id: "town_market".into(),
15320            building_label: "Town Market".into(),
15321            used_volume: 0.0,
15322            max_volume: 100.0,
15323            listings: vec![
15324                flatland_protocol::MarketListingView {
15325                    listing_id: uuid::Uuid::from_u128(1),
15326                    seller_character_id: uuid::Uuid::from_u128(2),
15327                    seller_label: "Ada".into(),
15328                    hall_building_id: "town_market".into(),
15329                    hall_label: "Town Market".into(),
15330                    template_id: "oak_log".into(),
15331                    display_name: "Oak Log".into(),
15332                    category: "resource".into(),
15333                    quantity: 3,
15334                    unit_price_copper: 10,
15335                    line_total_copper: 30,
15336                    npc_price: false,
15337                    mine: false,
15338                },
15339                flatland_protocol::MarketListingView {
15340                    listing_id: uuid::Uuid::from_u128(3),
15341                    seller_character_id: uuid::Uuid::from_u128(2),
15342                    seller_label: "Ada".into(),
15343                    hall_building_id: "town_market".into(),
15344                    hall_label: "Town Market".into(),
15345                    template_id: "short_sword".into(),
15346                    display_name: "Short Sword".into(),
15347                    category: "weapon".into(),
15348                    quantity: 1,
15349                    unit_price_copper: 100,
15350                    line_total_copper: 100,
15351                    npc_price: false,
15352                    mine: false,
15353                },
15354            ],
15355            tax_bps: 0,
15356            tax_flat_copper: 0,
15357            list_vaults: vec![],
15358        });
15359        assert_eq!(state.market_filtered_listing_indices().len(), 2);
15360        state.market_category_filter = Some("Weapons");
15361        let weapons = state.market_filtered_listing_indices();
15362        assert_eq!(weapons.len(), 1);
15363        assert_eq!(
15364            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15365            "Short Sword"
15366        );
15367        state.market_category_filter = None;
15368        state.market_filter = "oak".into();
15369        let oak = state.market_filtered_listing_indices();
15370        assert_eq!(oak.len(), 1);
15371        assert_eq!(
15372            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15373            "Oak Log"
15374        );
15375    }
15376
15377    #[test]
15378    fn market_list_source_includes_person_and_vaults() {
15379        let mut state = sample_state();
15380        let item_id = uuid::Uuid::from_u128(1);
15381        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15382            template_id: "oak_log".into(),
15383            quantity: 2,
15384            item_instance_id: Some(item_id),
15385            display_name: Some("Oak Log".into()),
15386            ..Default::default()
15387        }];
15388        state.market_panel = Some(flatland_protocol::MarketPanel {
15389            npc_id: "mira_market".into(),
15390            npc_label: "Mira".into(),
15391            building_id: "town_market".into(),
15392            building_label: "Town Market".into(),
15393            used_volume: 0.0,
15394            max_volume: 100.0,
15395            listings: vec![],
15396            tax_bps: 0,
15397            tax_flat_copper: 0,
15398            list_vaults: vec![flatland_protocol::MarketListVault {
15399                building_id: "town_storage".into(),
15400                building_label: "Town Storage".into(),
15401                contents: vec![flatland_protocol::ItemStack {
15402                    template_id: "lumber".into(),
15403                    quantity: 1,
15404                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
15405                    display_name: Some("Lumber".into()),
15406                    ..Default::default()
15407                }],
15408            }],
15409        });
15410        let sources = state.market_list_source_options();
15411        assert_eq!(sources.len(), 2);
15412        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15413        assert!(matches!(
15414            sources[1].0,
15415            MarketListSourceKind::TownStorage { .. }
15416        ));
15417        assert!(sources[1].1.contains("Town Storage"));
15418    }
15419
15420    #[test]
15421    fn probe_use_world_npc_beats_nearby_loot() {
15422        let mut state = sample_state();
15423        state.npcs.push(flatland_protocol::NpcView {
15424            id: "ada".into(),
15425            label: "Ada".into(),
15426            role: "broker".into(),
15427            x: 129.0,
15428            y: 128.0,
15429            building_id: None,
15430            entity_id: None,
15431            life_state: None,
15432            hp_pct: None,
15433            can_trade: true,
15434            tile_id: None,
15435            behavior_state: None,
15436            presentation_state: None,
15437            sprite_mode: None,
15438            paperdoll_ref: None,
15439        draw_scale: 1.0,
15440        });
15441        state.ground_drops.push(flatland_protocol::GroundDropView {
15442            id: "d1".into(),
15443            template_id: "lumber".into(),
15444            quantity: 1,
15445            x: 128.5,
15446            y: 128.0,
15447            z: 0.0,
15448            tile_id: None,
15449            display_name: None,
15450            yaw: 0.0,
15451            pitch: 0.0,
15452            roll: 0.0,
15453            draw_scale: 1.0,
15454        });
15455        let probe = state.probe_use_world();
15456        let primary = probe.primary.expect("primary");
15457        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15458        assert_eq!(primary.id, "ada");
15459    }
15460
15461    #[test]
15462    fn probe_use_world_harvest_when_in_range() {
15463        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
15464        let probe = state.probe_use_world();
15465        assert!(
15466            probe.primary.is_none(),
15467            "oak is 2m away, out of harvest range"
15468        );
15469        assert!(probe
15470            .candidates
15471            .iter()
15472            .any(|c| c.kind == crate::UseWorldKind::Harvest));
15473
15474        let mut state = sample_state();
15475        state.resource_nodes[0].x = 129.0;
15476        let probe = state.probe_use_world();
15477        let primary = probe.primary.expect("primary");
15478        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15479    }
15480
15481    #[test]
15482    fn probe_use_world_door_uses_building_label() {
15483        let mut state = sample_state();
15484        state.doors[0].x = 129.0;
15485        state.doors[0].y = 128.0;
15486        let probe = state.probe_use_world();
15487        let primary = probe.primary.expect("primary");
15488        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
15489        assert_eq!(primary.label, "Broker");
15490        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
15491    }
15492
15493    #[test]
15494    fn empty_entity_tick_preserves_welcome_snapshot() {
15495        let mut state = sample_state();
15496        state.inventory.insert("carrot".into(), 3);
15497        let delta = TickDelta {
15498            tick: 1,
15499            entities: vec![],
15500            resource_nodes: vec![],
15501            ground_drops: vec![],
15502            placed_containers: vec![],
15503            buildings: vec![],
15504            doors: vec![],
15505            interior_map: None,
15506            npcs: vec![],
15507            inventory: vec![],
15508            blueprints: vec![],
15509            building_materials: vec![],
15510            world_clock: flatland_protocol::WorldClock::default(),
15511            combat: None,
15512            quest_log: vec![],
15513            hired_workers: Vec::new(),
15514            interactables: vec![],
15515            ledger: None,
15516            career: None,
15517            combat_fx: Vec::new(),
15518            property_plots: Vec::new(),
15519        terrain_overlays: Vec::new(),
15520        };
15521
15522        state.apply_tick_fields(&delta, 1);
15523
15524        assert_eq!(state.entities.len(), 1);
15525        assert!(state.player.is_some());
15526        assert_eq!(state.inventory.get("carrot"), Some(&3));
15527        assert_eq!(state.resource_nodes.len(), 1);
15528    }
15529
15530    #[test]
15531    fn tick_preserves_world_layers_when_delta_omits_them() {
15532        let mut state = sample_state();
15533        let delta = TickDelta {
15534            tick: 1,
15535            entities: state.entities.clone(),
15536            resource_nodes: vec![],
15537            ground_drops: vec![],
15538            placed_containers: vec![],
15539            buildings: vec![],
15540            doors: vec![],
15541            interior_map: None,
15542            npcs: vec![],
15543            inventory: vec![],
15544            blueprints: vec![],
15545            building_materials: vec![],
15546            world_clock: flatland_protocol::WorldClock::default(),
15547            combat: None,
15548            quest_log: vec![],
15549            hired_workers: Vec::new(),
15550            interactables: vec![],
15551            ledger: None,
15552            career: None,
15553            combat_fx: Vec::new(),
15554            property_plots: Vec::new(),
15555        terrain_overlays: Vec::new(),
15556        };
15557
15558        state.apply_tick_fields(&delta, 1);
15559
15560        assert_eq!(state.resource_nodes.len(), 1);
15561        assert_eq!(state.buildings.len(), 1);
15562        assert_eq!(state.doors.len(), 1);
15563    }
15564
15565    #[test]
15566    fn tick_updates_resource_nodes_when_server_sends_them() {
15567        let mut state = sample_state();
15568        let delta = TickDelta {
15569            tick: 1,
15570            entities: state.entities.clone(),
15571            resource_nodes: vec![ResourceNodeView {
15572                id: "oak-1".into(),
15573                label: "Oak".into(),
15574                x: 130.0,
15575                y: 128.0,
15576                z: 0.0,
15577                item_template: "oak_log".into(),
15578                state: ResourceNodeState::Cooldown,
15579                blocking: true,
15580                blocking_radius_m: 0.8,
15581                harvest_off: false,
15582                tile_id: None,
15583                                yaw: 0.0,
15584                pitch: 0.0,
15585                roll: 0.0,
15586                draw_scale: 1.0,
15587                sprite_mode: None,
15588                growth_progress: None,
15589                presentation_state: None,
15590                channel_start_tick: None,
15591                channel_end_tick: None,
15592                harvest_drop_templates: vec![],
15593            }],
15594            buildings: vec![],
15595            doors: vec![],
15596            interior_map: None,
15597            npcs: vec![],
15598            inventory: vec![],
15599            blueprints: vec![],
15600            building_materials: vec![],
15601            world_clock: flatland_protocol::WorldClock::default(),
15602            ground_drops: vec![],
15603            placed_containers: vec![],
15604            combat: None,
15605            quest_log: vec![],
15606            hired_workers: Vec::new(),
15607            interactables: vec![],
15608            ledger: None,
15609            career: None,
15610            combat_fx: Vec::new(),
15611            property_plots: Vec::new(),
15612        terrain_overlays: Vec::new(),
15613        };
15614
15615        state.apply_tick_fields(&delta, 1);
15616
15617        assert!(matches!(
15618            state.resource_nodes[0].state,
15619            ResourceNodeState::Cooldown
15620        ));
15621    }
15622
15623    #[test]
15624    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
15625        let mut state = GameState {
15626            session_id: 1,
15627            entity_id: 1,
15628            character_id: None,
15629            tick: 0,
15630            chunk_rev: 0,
15631            content_rev: 0,
15632            publish_rev: 0,
15633            entities: vec![EntityState {
15634                id: 1,
15635                label: "You".into(),
15636                transform: Transform {
15637                    position: WorldCoord::surface(4.5, 2.0),
15638                    yaw: 0.0,
15639                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15640                },
15641                vitals: None,
15642                attributes: None,
15643                skills: None,
15644                inside_building: Some("broker_hut".into()),
15645                tile_id: None,
15646                paperdoll_ref: None,
15647                draw_scale: 1.0,
15648                presentation_state: None,
15649                sprite_mode: None,
15650                progression_xp: None,
15651                combat_cues: vec![],
15652                statuses: vec![],
15653            }],
15654            player: None,
15655            resource_nodes: vec![],
15656            ground_drops: vec![],
15657            placed_containers: vec![],
15658            buildings: vec![BuildingView {
15659                id: "broker_hut".into(),
15660                label: "Broker".into(),
15661                x: 158.0,
15662                y: 124.0,
15663                width_m: 8.0,
15664                depth_m: 6.0,
15665                interior_blueprint: Some("broker_hut".into()),
15666                tags: vec![],
15667                market_boundary_zone_ids: vec![],
15668                market_max_volume: None,
15669                wall_set: None,
15670                roof_set: None,
15671            }],
15672            doors: vec![flatland_protocol::DoorView {
15673                id: "broker_hut_exit".into(),
15674                building_id: "broker_hut".into(),
15675                x: 4.3,
15676                y: 0.9,
15677                open: true,
15678                portal: Some("front".into()),
15679            locked: false,
15680            accessible: true,
15681            lock_id: None,
15682        }],
15683            interior_map: None,
15684            npcs: vec![flatland_protocol::NpcView {
15685                id: "ada_broker".into(),
15686                label: "Ada".into(),
15687                x: 4.5,
15688                y: 2.0,
15689                building_id: Some("broker_hut".into()),
15690                role: "broker".into(),
15691                entity_id: None,
15692                life_state: None,
15693                hp_pct: None,
15694                can_trade: true,
15695                tile_id: None,
15696                behavior_state: None,
15697                presentation_state: None,
15698                sprite_mode: None,
15699                paperdoll_ref: None,
15700            draw_scale: 1.0,
15701            }],
15702            blueprints: vec![],
15703            building_materials: vec![],
15704            world_x0: 0.0,
15705            world_y0: 0.0,
15706            world_width_m: 256.0,
15707            world_height_m: 256.0,
15708            terrain_zones: Vec::new(),
15709            z_platforms: Vec::new(),
15710            z_transitions: Vec::new(),
15711            z_bands_outdoor_backup: None,
15712            world_clock: flatland_protocol::WorldClock::default(),
15713            inventory: std::collections::HashMap::new(),
15714            inventory_hints: std::collections::HashMap::new(),
15715            logs: VecDeque::new(),
15716            intents_sent: 0,
15717            ticks_received: 0,
15718            connected: true,
15719            disconnect_reason: None,
15720            show_stats: false,
15721            hud_log_hidden: false,
15722            show_equip_menu: false,
15723            equip_menu_index: 0,
15724            show_craft_menu: false,
15725            show_plot_build_menu: false,
15726            plot_build_focus_wall: true,
15727            plot_build_wall_index: 0,
15728            plot_build_roof_index: 0,
15729            craft_menu_index: 0,
15730            craft_batch_quantity: 1,
15731            show_shop_menu: false,
15732            shop_catalog: None,
15733            bank_panel: None,
15734            bank_menu_index: 0,
15735            bank_ui_mode: BankUiMode::Menu,
15736            storage_panel: None,
15737                market_panel: None,
15738            market_menu_index: 0,
15739            market_filter: String::new(),
15740            market_filter_focused: false,
15741            market_category_filter: None,
15742            market_buy_confirm: None,
15743            market_ui_mode: MarketUiMode::Browse,
15744            storage_menu_index: 0,
15745            storage_ui_mode: StorageUiMode::Menu,
15746            shop_tab: ShopTab::default(),
15747            shop_menu_index: 0,
15748            shop_quantity: 1,
15749            shop_trade_log: VecDeque::new(),
15750            show_npc_verb_menu: false,
15751            npc_verb_target: None,
15752            npc_verb_index: 0,
15753            player_verbs: crate::social::PlayerVerbState::default(),
15754            social_chat: crate::social::SocialChatState::default(),
15755            trade_ui: crate::social::TradeUiState::default(),
15756            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15757            show_npc_chat: false,
15758            npc_chat: None,
15759            show_inventory_menu: false,
15760            inventory_menu_index: 0,
15761            inventory_tab: InventoryTab::OnPerson,
15762            inventory_filter: String::new(),
15763            inventory_filter_focused: false,
15764            show_move_picker: false,
15765            show_rename_prompt: false,
15766            rename_plot_id: None,
15767            highlighted_plot_id: None,
15768            show_worker_rename: false,
15769            rename_buffer: String::new(),
15770            move_picker_index: 0,
15771            move_picker: None,
15772            show_grant_picker: false,
15773            grant_picker_index: 0,
15774            grant_picker: None,
15775            show_destroy_picker: false,
15776            destroy_confirm_pending: false,
15777            destroy_picker: None,
15778            combat_target: None,
15779            combat_target_label: None,
15780            ground_target: None,
15781            combat_fx: Vec::new(),
15782            property_zones: Vec::new(),
15783            tax_zones: Vec::new(),
15784            growth_zones: Vec::new(),
15785            biome_zones: Vec::new(),
15786            terrain_kind_nav: Vec::new(),
15787            property_plots: Vec::new(),
15788            property_plot_settings: None,
15789            claim_mode: None,
15790            relocate_mode: None,
15791            sell_plot_confirm: None,
15792            sell_plot_armed_at: None,
15793            show_plant_menu: false,
15794            plant_menu_index: 0,
15795            show_farm_access: false,
15796            farm_access_name_draft: String::new(),
15797            farm_access_discount_bps: 0,
15798            farm_access_index: 0,
15799            plant_quantity: 1,
15800            in_combat: false,
15801            auto_attack: true,
15802            combat_has_los: false,
15803            attack_cd_ticks: 0,
15804            gcd_ticks: 0,
15805            weapon_ability_id: "unarmed".into(),
15806            mainhand_template_id: None,
15807            mainhand_label: None,
15808            mainhand_instance_id: None,
15809            offhand_template_id: None,
15810            offhand_label: None,
15811            offhand_instance_id: None,
15812            mainhand_hand_slots: 1,
15813            defense: None,
15814            worn: BTreeMap::new(),
15815            carry_mass: 0.0,
15816            carry_mass_max: 0.0,
15817            encumbrance: flatland_protocol::EncumbranceState::Light,
15818            inventory_stacks: Vec::new(),
15819            keychain_stacks: Vec::new(),
15820            whisper_pouch_stacks: Vec::new(),
15821            combat_target_detail: None,
15822                statuses: Vec::new(),
15823            cast_progress: None,
15824            timed_channel: None,
15825            plot_build_offer: None,
15826            ability_cooldowns: Vec::new(),
15827            blocking_active: false,
15828            max_target_slots: 1,
15829            combat_slots: Vec::new(),
15830            rotation_presets: Vec::new(),
15831            known_abilities: Vec::new(),
15832            ability_meta: std::collections::HashMap::new(),
15833            ability_mastery: std::collections::HashMap::new(),
15834            hotbar: vec![None; 9],
15835            max_abilities_per_rotation: 0,
15836            show_loadout_menu: false,
15837            show_keychain_menu: false,
15838            keychain_menu_index: 0,
15839            show_rotation_editor: false,
15840            loadout_menu_index: 0,
15841            loadout_hotbar_slot: 1,
15842            loadout_ability_index: 0,
15843            loadout_focus_presets: false,
15844            rotation_editor: RotationEditorState::default(),
15845            harvest_in_progress: false,
15846            harvest_started_at: None,
15847            pending_craft_ack: None,
15848            pending_worker_job_ack: None,
15849            attending_worker_instance_id: None,
15850            quest_log: Vec::new(),
15851            interactables: Vec::new(),
15852            ledger: None,
15853            career: None,
15854            character_sheet_tab: CharacterSheetTab::Character,
15855            ledger_period: LedgerPeriod::Day,
15856            show_quest_offer: false,
15857            pending_quest_offer: None,
15858            show_quest_menu: false,
15859            quest_menu_index: 0,
15860            quest_withdraw_confirm: false,
15861            hired_workers: Vec::new(),
15862            show_workers_menu: false,
15863            workers_menu_index: 0,
15864            workers_menu_compact: false,
15865            worker_step_display: BTreeMap::new(),
15866            worker_error_display: BTreeMap::new(),
15867            show_worker_give_picker: false,
15868            worker_give_picker_index: 0,
15869            worker_give_picker: None,
15870            show_worker_give_target_picker: false,
15871            worker_give_target_picker_index: 0,
15872            worker_give_target_picker: None,
15873            show_worker_take_picker: false,
15874            worker_take_picker_index: 0,
15875            worker_take_picker: None,
15876            show_worker_teach_picker: false,
15877            worker_teach_picker_index: 0,
15878            worker_teach_picker: None,
15879            worker_route_editor: None,
15880            progression_curve: None,
15881        };
15882        state.player = state.entities.first().cloned();
15883        assert_eq!(
15884            state.nearest_interact_target().as_deref(),
15885            Some("ada_broker")
15886        );
15887    }
15888
15889    #[test]
15890    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
15891        let mut state = sample_state();
15892        // Player is at (128, 128) per sample_state(). One chest just inside
15893        // CONTAINER_RANGE_M, one clearly beyond it.
15894        state.placed_containers = vec![
15895            flatland_protocol::PlacedContainerView {
15896                id: "near".into(),
15897                template_id: "wooden_chest_small".into(),
15898                display_name: "Wooden Chest".into(),
15899                x: 130.0,
15900                y: 128.0,
15901                z: 0.0,
15902                locked: true,
15903                accessible: true,
15904                owner_character_id: None,
15905                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
15906                lock_id: None,
15907                capacity_volume: None,
15908                item_instance_id: Some(uuid::Uuid::from_u128(1)),
15909                tile_id: None,
15910                worker_lodging_capacity: None,
15911                blocking: false,
15912                blocking_radius_m: 0.0,
15913            building_id: None,
15914            },
15915            flatland_protocol::PlacedContainerView {
15916                id: "far".into(),
15917                template_id: "wooden_chest_small".into(),
15918                display_name: "Distant Chest".into(),
15919                x: 128.0 + CONTAINER_RANGE_M + 5.0,
15920                y: 128.0,
15921                z: 0.0,
15922                locked: false,
15923                accessible: true,
15924                owner_character_id: None,
15925                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
15926                lock_id: None,
15927                capacity_volume: None,
15928                item_instance_id: Some(uuid::Uuid::from_u128(2)),
15929                tile_id: None,
15930                worker_lodging_capacity: None,
15931                blocking: false,
15932                blocking_radius_m: 0.0,
15933            building_id: None,
15934            },
15935        ];
15936
15937        let nearby = state.nearby_containers();
15938        assert_eq!(
15939            nearby.len(),
15940            1,
15941            "far chest must not appear once out of range"
15942        );
15943        assert_eq!(nearby[0].view.id, "near");
15944        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
15945        assert!(nearby[0].rows[0].is_chest_shell);
15946
15947        // The same chest, but locked and inaccessible (no key held), must hide
15948        // contents but still show the selectable chest shell row.
15949        state.placed_containers[0].accessible = false;
15950        let nearby = state.nearby_containers();
15951        assert_eq!(nearby.len(), 1);
15952        assert_eq!(nearby[0].rows.len(), 1);
15953        assert!(nearby[0].rows[0].is_chest_shell);
15954    }
15955
15956    #[test]
15957    fn chest_pickup_destinations_offer_person_and_worn_bag() {
15958        let mut state = sample_state();
15959        let back_id = uuid::Uuid::from_u128(42);
15960        state.worn.insert(
15961            BodySlot::Back,
15962            flatland_protocol::ItemStack {
15963                template_id: "travel_backpack".into(),
15964                quantity: 1,
15965                item_instance_id: Some(back_id),
15966                props: Default::default(),
15967                status_bindings: Vec::new(),
15968                contents: Vec::new(),
15969                display_name: Some("Travel Backpack".into()),
15970                category: Some("container".into()),
15971                base_mass: Some(2.5),
15972                base_volume: Some(12.0),
15973                capacity_volume: Some(80.0),
15974                stackable: Some(false),
15975                world_placeable: Some(false),
15976                worker_lodging_capacity: None,
15977                equip_slot: None,
15978                armor_physical: None,
15979                resists: vec![],
15980                hand_slots: None,
15981            listable: None,
15982        },
15983        );
15984        let opts = state.chest_pickup_destinations("chest-1");
15985        assert!(matches!(
15986            opts.first().map(|o| &o.kind),
15987            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
15988        ));
15989        assert!(opts.iter().any(|o| matches!(
15990            &o.kind,
15991            MoveOptionKind::PickupPlaced {
15992                nest_parent_instance_id: None,
15993                ..
15994            }
15995        )));
15996        assert!(opts.iter().any(|o| matches!(
15997            &o.kind,
15998            MoveOptionKind::PickupPlaced {
15999                nest_parent_instance_id: Some(id),
16000                ..
16001            } if *id == back_id
16002        )));
16003        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16004    }
16005
16006    #[test]
16007    fn placed_container_public_label_hides_owner_custom_name() {
16008        let owner = uuid::Uuid::from_u128(99);
16009        let mut state = sample_state();
16010        state.character_id = Some(uuid::Uuid::from_u128(1));
16011        state.inventory_hints.insert(
16012            "wooden_chest_medium".into(),
16013            InventoryHint {
16014                display_name: "Medium Wooden Chest".into(),
16015                category: "container".into(),
16016                base_mass: None,
16017                base_volume: None,
16018                capacity_volume: None,
16019                stackable: false,
16020                listable: true,
16021            },
16022        );
16023        let chest = flatland_protocol::PlacedContainerView {
16024            id: "c1".into(),
16025            template_id: "wooden_chest_medium".into(),
16026            display_name: "Barry's Loot #a3f2".into(),
16027            x: 128.0,
16028            y: 128.0,
16029            z: 0.0,
16030            locked: false,
16031            accessible: true,
16032            owner_character_id: Some(owner),
16033            contents: vec![],
16034            lock_id: None,
16035            capacity_volume: None,
16036            item_instance_id: None,
16037            tile_id: None,
16038            worker_lodging_capacity: None,
16039            blocking: false,
16040            blocking_radius_m: 0.0,
16041            building_id: None,
16042        };
16043        assert_eq!(
16044            state.placed_container_public_label(&chest),
16045            "Medium Wooden Chest"
16046        );
16047        state.character_id = Some(owner);
16048        assert_eq!(
16049            state.placed_container_public_label(&chest),
16050            "Barry's Loot #a3f2"
16051        );
16052    }
16053
16054    #[test]
16055    fn location_context_shows_crop_growth_percent_not_depleted() {
16056        let mut state = sample_state();
16057        state.player = state.entities.first().cloned();
16058        state.resource_nodes[0].label = "Carrot (growing)".into();
16059        state.resource_nodes[0].x = 128.2;
16060        state.resource_nodes[0].y = 128.0;
16061        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16062        state.resource_nodes[0].growth_progress = Some(0.47);
16063        let lines = state.location_context_lines();
16064        let line = lines
16065            .iter()
16066            .find(|l| l.text.contains("Carrot"))
16067            .map(|l| l.text.as_str())
16068            .unwrap_or("");
16069        assert!(
16070            line.contains("(growing, 47%)"),
16071            "expected growth percent, got: {line}"
16072        );
16073        assert!(
16074            !line.contains("depleted"),
16075            "growing crop should not show depleted: {line}"
16076        );
16077    }
16078
16079    #[test]
16080    fn resource_node_near_action_suffix_prefers_growth() {
16081        let node = ResourceNodeView {
16082            id: "crop".into(),
16083            label: "Wheat".into(),
16084            x: 0.0,
16085            y: 0.0,
16086            z: 0.0,
16087            item_template: "wheat".into(),
16088            state: ResourceNodeState::Cooldown,
16089            blocking: false,
16090            blocking_radius_m: 0.0,
16091            harvest_off: false,
16092            tile_id: None,
16093                        yaw: 0.0,
16094            pitch: 0.0,
16095            roll: 0.0,
16096            draw_scale: 1.0,
16097            sprite_mode: None,
16098            growth_progress: Some(0.12),
16099            presentation_state: None,
16100            channel_start_tick: None,
16101            channel_end_tick: None,
16102            harvest_drop_templates: vec![],
16103        };
16104        assert_eq!(
16105            resource_node_near_action_suffix(&node),
16106            " (growing, 12%)"
16107        );
16108    }
16109
16110    #[test]
16111    fn location_context_lists_nearby_resource_node() {
16112        let mut state = sample_state();
16113        state.player = state.entities.first().cloned();
16114        state.resource_nodes[0].x = 128.2;
16115        state.resource_nodes[0].y = 128.0;
16116        let lines = state.location_context_lines();
16117        assert!(
16118            lines
16119                .iter()
16120                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16121            "expected resource node in context: {:?}",
16122            lines
16123        );
16124    }
16125
16126    #[test]
16127    fn quest_board_usable_within_board_radius() {
16128        let mut state = sample_state();
16129        state.player = state.entities.first().cloned();
16130        state.interactables = vec![flatland_protocol::InteractableView {
16131            id: "board-1".into(),
16132            kind: "quest_board".into(),
16133            label: "Town Quest Board".into(),
16134            x: 130.5,
16135            y: 128.0,
16136            z: 0.0,
16137            board_id: Some("starter_town_board".into()),
16138        }];
16139        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
16140        assert_eq!(
16141            state.nearest_interact_target().as_deref(),
16142            Some("board-1"),
16143            "quest board should be selectable at ~2.5m"
16144        );
16145        let lines = state.location_context_lines();
16146        assert!(
16147            lines
16148                .iter()
16149                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16150            "HUD should advertise f when board is in range: {:?}",
16151            lines
16152        );
16153    }
16154
16155    #[test]
16156    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
16157        let mut state = sample_state();
16158        state.worn.insert(
16159            BodySlot::Back,
16160            flatland_protocol::ItemStack {
16161                template_id: "travel_backpack".into(),
16162                quantity: 1,
16163                item_instance_id: Some(uuid::Uuid::from_u128(3)),
16164                props: Default::default(),
16165                status_bindings: Vec::new(),
16166                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16167                display_name: None,
16168                category: None,
16169                base_mass: None,
16170                base_volume: None,
16171                capacity_volume: None,
16172                stackable: None,
16173                world_placeable: None,
16174                worker_lodging_capacity: None,
16175                equip_slot: None,
16176                armor_physical: None,
16177                resists: vec![],
16178                hand_slots: None,
16179            listable: None,
16180        },
16181        );
16182        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16183        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16184            id: "chest-1".into(),
16185            template_id: "wooden_chest_small".into(),
16186            display_name: "Wooden Chest".into(),
16187            x: 129.0,
16188            y: 128.0,
16189            z: 0.0,
16190            locked: false,
16191            accessible: true,
16192            owner_character_id: None,
16193            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16194            lock_id: None,
16195            capacity_volume: None,
16196            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16197            tile_id: None,
16198            worker_lodging_capacity: None,
16199            blocking: false,
16200            blocking_radius_m: 0.0,
16201            building_id: None,
16202        }];
16203
16204        state.inventory_tab = InventoryTab::OnPerson;
16205        let rows = state.inventory_selectable_rows();
16206        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16207        assert_eq!(
16208            sections,
16209            vec![
16210                InventorySection::Worn,   // backpack shell
16211                InventorySection::Worn,   // iron_ore nested in backpack
16212                InventorySection::Person, // lumber
16213            ]
16214        );
16215        assert_eq!(rows[0].stack.template_id, "travel_backpack");
16216        assert!(rows[0].is_equip_shell);
16217        assert_eq!(rows[1].stack.template_id, "iron_ore");
16218        assert_eq!(rows[1].depth, 1);
16219        assert_eq!(rows[2].stack.template_id, "lumber");
16220
16221        let lines = state.inventory_browser_lines();
16222        assert!(lines.iter().any(|l| matches!(
16223            l,
16224            InventoryBrowserLine::Section(s) if s.contains("Worn")
16225        )));
16226        assert!(lines.iter().any(|l| matches!(
16227            l,
16228            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16229                || text.contains("backpack")
16230        )));
16231        assert!(!lines.iter().any(|l| matches!(
16232            l,
16233            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16234        )));
16235
16236        state.inventory_tab = InventoryTab::Nearby;
16237        let nearby_rows = state.inventory_selectable_rows();
16238        assert_eq!(nearby_rows.len(), 2);
16239        assert!(nearby_rows[0].is_chest_shell);
16240        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16241        let nearby_lines = state.inventory_browser_lines();
16242        assert!(nearby_lines.iter().any(|l| matches!(
16243            l,
16244            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16245        )));
16246    }
16247
16248    #[test]
16249    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16250        let mut state = sample_state();
16251        let back_id = uuid::Uuid::from_u128(5);
16252        state.worn.insert(
16253            BodySlot::Back,
16254            flatland_protocol::ItemStack {
16255                template_id: "travel_backpack".into(),
16256                quantity: 1,
16257                item_instance_id: Some(back_id),
16258                props: Default::default(),
16259                status_bindings: Vec::new(),
16260                contents: Vec::new(),
16261                display_name: None,
16262                category: Some("container".into()),
16263                base_mass: None,
16264                base_volume: None,
16265                capacity_volume: Some(80.0),
16266                stackable: None,
16267                world_placeable: None,
16268                worker_lodging_capacity: None,
16269                equip_slot: None,
16270                armor_physical: None,
16271                resists: vec![],
16272                hand_slots: None,
16273            listable: None,
16274        },
16275        );
16276        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16277            id: "chest-1".into(),
16278            template_id: "wooden_chest_small".into(),
16279            display_name: "Wooden Chest".into(),
16280            x: 129.0,
16281            y: 128.0,
16282            z: 0.0,
16283            locked: false,
16284            accessible: true,
16285            owner_character_id: None,
16286            contents: Vec::new(),
16287            lock_id: None,
16288            capacity_volume: None,
16289            item_instance_id: Some(uuid::Uuid::from_u128(6)),
16290            tile_id: None,
16291            worker_lodging_capacity: None,
16292            blocking: false,
16293            blocking_radius_m: 0.0,
16294            building_id: None,
16295        }];
16296
16297        // Item currently sitting loose on the person (Root): backpack + nearby
16298        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
16299        let opts = state.move_destinations_for(
16300            &flatland_protocol::InventoryLocation::Root,
16301            None,
16302            None,
16303            "lumber",
16304        );
16305        assert!(!opts.iter().any(|o| matches!(
16306            &o.kind,
16307            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16308        )));
16309        assert!(opts.iter().any(|o| matches!(
16310            &o.kind,
16311            MoveOptionKind::Move { location, parent_instance_id, .. }
16312                if *location == flatland_protocol::InventoryLocation::Worn {
16313                    slot: BodySlot::Back,
16314                } && *parent_instance_id == Some(back_id)
16315        )));
16316        assert!(opts.iter().any(|o| matches!(
16317            &o.kind,
16318            MoveOptionKind::Move { location, .. }
16319                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16320        )));
16321        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16322        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16323
16324        // Item currently inside the worn backpack: the backpack itself must be
16325        // excluded from its own destination list (can't move an item into the
16326        // container it's already in).
16327        let from_backpack = flatland_protocol::InventoryLocation::Worn {
16328            slot: BodySlot::Back,
16329        };
16330        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16331        assert!(!opts.iter().any(|o| matches!(
16332            &o.kind,
16333            MoveOptionKind::Move { location, parent_instance_id, .. }
16334                if *location == from_backpack && *parent_instance_id == Some(back_id)
16335        )));
16336        assert!(opts.iter().any(|o| matches!(
16337            &o.kind,
16338            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16339        )));
16340    }
16341
16342    #[test]
16343    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16344        let mut state = sample_state();
16345        // Insert out of display order — BTreeMap iteration must still yield the
16346        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
16347        state.worn.insert(
16348            BodySlot::Waist,
16349            flatland_protocol::ItemStack {
16350                template_id: "simple_belt".into(),
16351                quantity: 1,
16352                item_instance_id: Some(uuid::Uuid::from_u128(10)),
16353                props: Default::default(),
16354                status_bindings: Vec::new(),
16355                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16356                display_name: None,
16357                category: Some("container".into()),
16358                base_mass: None,
16359                base_volume: None,
16360                capacity_volume: None,
16361                stackable: None,
16362                world_placeable: None,
16363                worker_lodging_capacity: None,
16364                equip_slot: None,
16365                armor_physical: None,
16366                resists: vec![],
16367                hand_slots: None,
16368            listable: None,
16369        },
16370        );
16371        state.worn.insert(
16372            BodySlot::Head,
16373            flatland_protocol::ItemStack {
16374                template_id: "cloth_cap".into(),
16375                quantity: 1,
16376                item_instance_id: Some(uuid::Uuid::from_u128(11)),
16377                props: Default::default(),
16378                status_bindings: Vec::new(),
16379                contents: Vec::new(),
16380                display_name: None,
16381                category: Some("armor".into()),
16382                base_mass: None,
16383                base_volume: None,
16384                capacity_volume: None,
16385                stackable: None,
16386                world_placeable: None,
16387                worker_lodging_capacity: None,
16388                equip_slot: None,
16389                armor_physical: None,
16390                resists: vec![],
16391                hand_slots: None,
16392            listable: None,
16393        },
16394        );
16395        state.worn.insert(
16396            BodySlot::Back,
16397            flatland_protocol::ItemStack {
16398                template_id: "travel_backpack".into(),
16399                quantity: 1,
16400                item_instance_id: Some(uuid::Uuid::from_u128(12)),
16401                props: Default::default(),
16402                status_bindings: Vec::new(),
16403                contents: Vec::new(),
16404                display_name: None,
16405                category: Some("container".into()),
16406                base_mass: None,
16407                base_volume: None,
16408                capacity_volume: None,
16409                stackable: None,
16410                world_placeable: None,
16411                worker_lodging_capacity: None,
16412                equip_slot: None,
16413                armor_physical: None,
16414                resists: vec![],
16415                hand_slots: None,
16416            listable: None,
16417        },
16418        );
16419
16420        let rows = state.worn_rows();
16421        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
16422        assert_eq!(rows.len(), 4);
16423        assert_eq!(rows[0].stack.template_id, "cloth_cap");
16424        assert!(rows[0].is_equip_shell);
16425        assert_eq!(rows[1].stack.template_id, "travel_backpack");
16426        assert!(rows[1].is_equip_shell);
16427        assert_eq!(rows[2].stack.template_id, "simple_belt");
16428        assert!(rows[2].is_equip_shell);
16429        assert_eq!(rows[3].stack.template_id, "leather_pouch");
16430        assert_eq!(rows[3].depth, 1);
16431        assert!(!rows[3].is_equip_shell);
16432    }
16433
16434    #[test]
16435    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16436        let mut state = sample_state();
16437        state.worn.insert(
16438            BodySlot::Waist,
16439            flatland_protocol::ItemStack {
16440                template_id: "simple_belt".into(),
16441                quantity: 1,
16442                item_instance_id: Some(uuid::Uuid::from_u128(20)),
16443                props: Default::default(),
16444                status_bindings: Vec::new(),
16445                contents: Vec::new(),
16446                display_name: Some("Simple Belt".into()),
16447                category: Some("container".into()),
16448                base_mass: None,
16449                base_volume: None,
16450                capacity_volume: None,
16451                stackable: None,
16452                world_placeable: None,
16453                worker_lodging_capacity: None,
16454                equip_slot: None,
16455                armor_physical: None,
16456                resists: vec![],
16457                hand_slots: None,
16458            listable: None,
16459        },
16460        );
16461        state.worn.insert(
16462            BodySlot::Head,
16463            flatland_protocol::ItemStack {
16464                template_id: "cloth_cap".into(),
16465                quantity: 1,
16466                item_instance_id: Some(uuid::Uuid::from_u128(21)),
16467                props: Default::default(),
16468                status_bindings: Vec::new(),
16469                contents: Vec::new(),
16470                display_name: Some("Cloth Cap".into()),
16471                category: Some("armor".into()),
16472                base_mass: None,
16473                base_volume: None,
16474                capacity_volume: None,
16475                stackable: None,
16476                world_placeable: None,
16477                worker_lodging_capacity: None,
16478                equip_slot: None,
16479                armor_physical: None,
16480                resists: vec![],
16481                hand_slots: None,
16482            listable: None,
16483        },
16484        );
16485
16486        let opts = state.move_destinations_for(
16487            &flatland_protocol::InventoryLocation::Root,
16488            None,
16489            None,
16490            "leather_pouch",
16491        );
16492        assert!(
16493            opts.iter().any(|o| matches!(
16494                &o.kind,
16495                MoveOptionKind::Move { location, .. }
16496                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16497            )),
16498            "belt loop must be offered when moving a pouch"
16499        );
16500        assert!(
16501            !opts.iter().any(|o| matches!(
16502                &o.kind,
16503                MoveOptionKind::Move { location, .. }
16504                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
16505            )),
16506            "armor slots can't hold other items and must not appear as move destinations"
16507        );
16508        let belt_opt = opts
16509            .iter()
16510            .find(|o| matches!(
16511                &o.kind,
16512                MoveOptionKind::Move { location, .. }
16513                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16514            ))
16515            .unwrap();
16516        assert!(belt_opt.label.contains("belt loop"));
16517
16518        let opts = state.move_destinations_for(
16519            &flatland_protocol::InventoryLocation::Root,
16520            None,
16521            None,
16522            "lumber",
16523        );
16524        assert!(
16525            !opts.iter().any(|o| o.label.contains("belt loop")),
16526            "loose materials must not target the belt shell — only nested pouches"
16527        );
16528    }
16529
16530    #[test]
16531    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
16532        let mut state = sample_state();
16533        let belt_id = uuid::Uuid::from_u128(30);
16534        let pouch_id = uuid::Uuid::from_u128(31);
16535        state.worn.insert(
16536            BodySlot::Waist,
16537            flatland_protocol::ItemStack {
16538                template_id: "simple_belt".into(),
16539                quantity: 1,
16540                item_instance_id: Some(belt_id),
16541                props: Default::default(),
16542                status_bindings: Vec::new(),
16543                world_placeable: None,
16544                worker_lodging_capacity: None,
16545                equip_slot: None,
16546                armor_physical: None,
16547                resists: vec![],
16548                hand_slots: None,
16549                contents: vec![flatland_protocol::ItemStack {
16550                    template_id: "dimensional_pouch".into(),
16551                    quantity: 1,
16552                    item_instance_id: Some(pouch_id),
16553                    props: Default::default(),
16554                    status_bindings: Vec::new(),
16555                    contents: Vec::new(),
16556                    display_name: Some("Dimensional Pouch".into()),
16557                    category: Some("container".into()),
16558                    base_mass: None,
16559                    base_volume: None,
16560                    capacity_volume: Some(200.0),
16561                    stackable: None,
16562                    world_placeable: None,
16563                    worker_lodging_capacity: None,
16564                    equip_slot: None,
16565                    armor_physical: None,
16566                    resists: vec![],
16567                    hand_slots: None,
16568            listable: None,
16569        }],
16570                display_name: Some("Simple Belt".into()),
16571                category: Some("container".into()),
16572                base_mass: None,
16573                base_volume: None,
16574                capacity_volume: None,
16575                stackable: None,
16576            listable: None,
16577        },
16578        );
16579
16580        let opts = state.move_destinations_for(
16581            &flatland_protocol::InventoryLocation::Root,
16582            None,
16583            None,
16584            "iron_ore",
16585        );
16586        assert!(
16587            opts.iter().any(|o| matches!(
16588                &o.kind,
16589                MoveOptionKind::Move {
16590                    location,
16591                    parent_instance_id,
16592                    ..
16593                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16594                    && *parent_instance_id == Some(pouch_id)
16595            )),
16596            "dimensional pouch clipped on belt must accept loose items"
16597        );
16598        assert!(
16599            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
16600            "destination label should name the pouch"
16601        );
16602    }
16603
16604    #[test]
16605    fn container_volume_label_on_placed_chest_shell() {
16606        let mut state = sample_state();
16607        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16608            id: "chest-1".into(),
16609            template_id: "wooden_chest_small".into(),
16610            display_name: "Camp Chest".into(),
16611            x: 129.0,
16612            y: 128.0,
16613            z: 0.0,
16614            locked: false,
16615            accessible: true,
16616            owner_character_id: None,
16617            contents: vec![flatland_protocol::ItemStack {
16618                template_id: "iron_ore".into(),
16619                quantity: 2,
16620                item_instance_id: None,
16621                props: Default::default(),
16622                status_bindings: Vec::new(),
16623                contents: Vec::new(),
16624                display_name: None,
16625                category: None,
16626                base_mass: None,
16627                base_volume: Some(2.0),
16628                capacity_volume: None,
16629                stackable: None,
16630                world_placeable: None,
16631                worker_lodging_capacity: None,
16632                equip_slot: None,
16633                armor_physical: None,
16634                resists: vec![],
16635                hand_slots: None,
16636            listable: None,
16637        }],
16638            lock_id: None,
16639            capacity_volume: Some(60.0),
16640            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16641            tile_id: None,
16642            worker_lodging_capacity: None,
16643            blocking: false,
16644            blocking_radius_m: 0.0,
16645            building_id: None,
16646        }];
16647        let nearby = state.nearby_containers();
16648        let label = state.container_volume_label(&nearby[0].rows[0]);
16649        assert!(
16650            label.contains("vol 4/60"),
16651            "expected used/cap in label, got {label}"
16652        );
16653        assert!(
16654            label.contains("56 free"),
16655            "expected free space, got {label}"
16656        );
16657    }
16658
16659    #[test]
16660    fn key_pair_chest_label_from_placed_lock_id() {
16661        let mut state = sample_state();
16662        let owner = uuid::Uuid::from_u128(77);
16663        state.character_id = Some(owner);
16664        let lock = uuid::Uuid::from_u128(99).to_string();
16665        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16666            id: "chest-1".into(),
16667            template_id: "wooden_chest_small".into(),
16668            display_name: "Barry's Loot #a3f2".into(),
16669            x: 129.0,
16670            y: 128.0,
16671            z: 0.0,
16672            locked: true,
16673            accessible: true,
16674            owner_character_id: Some(owner),
16675            contents: Vec::new(),
16676            lock_id: Some(lock.clone()),
16677            capacity_volume: None,
16678            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16679            tile_id: None,
16680            worker_lodging_capacity: None,
16681            blocking: false,
16682            blocking_radius_m: 0.0,
16683            building_id: None,
16684        }];
16685        let key_id = uuid::Uuid::from_u128(5);
16686        let key = flatland_protocol::ItemStack {
16687            template_id: KEY_TEMPLATE.into(),
16688            quantity: 1,
16689            item_instance_id: Some(key_id),
16690            props: BTreeMap::from([
16691                (PROP_OPENS_LOCK_ID.into(), lock),
16692                (
16693                    PROP_OPENS_CONTAINER_NAME.into(),
16694                    "Barry's Loot #a3f2".into(),
16695                ),
16696            ]),
16697            status_bindings: Vec::new(),
16698            contents: Vec::new(),
16699            display_name: Some("Container Key".into()),
16700            category: Some("key".into()),
16701            base_mass: None,
16702            base_volume: None,
16703            capacity_volume: None,
16704            stackable: None,
16705            world_placeable: None,
16706            worker_lodging_capacity: None,
16707            equip_slot: None,
16708            armor_physical: None,
16709            resists: vec![],
16710            hand_slots: None,
16711            listable: None,
16712        };
16713        state.inventory_stacks = vec![key.clone()];
16714        assert_eq!(
16715            state.key_pair_chest_label(&key).as_deref(),
16716            Some("Barry's Loot #a3f2")
16717        );
16718        assert!(state.key_drop_blocked(&key));
16719    }
16720
16721    #[test]
16722    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
16723        let mut state = sample_state();
16724        let lock = uuid::Uuid::from_u128(101).to_string();
16725        let key = flatland_protocol::ItemStack {
16726            template_id: KEY_TEMPLATE.into(),
16727            quantity: 1,
16728            item_instance_id: Some(uuid::Uuid::from_u128(7)),
16729            props: BTreeMap::from([
16730                (PROP_OPENS_LOCK_ID.into(), lock),
16731                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
16732            ]),
16733            status_bindings: Vec::new(),
16734            contents: Vec::new(),
16735            display_name: None,
16736            category: Some("key".into()),
16737            base_mass: None,
16738            base_volume: None,
16739            capacity_volume: None,
16740            stackable: None,
16741            world_placeable: None,
16742            worker_lodging_capacity: None,
16743            equip_slot: None,
16744            armor_physical: None,
16745            resists: vec![],
16746            hand_slots: None,
16747            listable: None,
16748        };
16749        state.placed_containers.clear();
16750        assert_eq!(
16751            state.key_pair_chest_label(&key).as_deref(),
16752            Some("Camp Stash")
16753        );
16754    }
16755
16756    #[test]
16757    fn key_drop_allowed_when_paired_chest_unlocked() {
16758        let mut state = sample_state();
16759        let lock = uuid::Uuid::from_u128(100).to_string();
16760        let key_id = uuid::Uuid::from_u128(6);
16761        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16762            id: "chest-1".into(),
16763            template_id: "wooden_chest_small".into(),
16764            display_name: "Camp Chest".into(),
16765            x: 129.0,
16766            y: 128.0,
16767            z: 0.0,
16768            locked: false,
16769            accessible: true,
16770            owner_character_id: None,
16771            contents: Vec::new(),
16772            lock_id: Some(lock.clone()),
16773            capacity_volume: None,
16774            item_instance_id: None,
16775            tile_id: None,
16776            worker_lodging_capacity: None,
16777            blocking: false,
16778            blocking_radius_m: 0.0,
16779            building_id: None,
16780        }];
16781        let key = flatland_protocol::ItemStack {
16782            template_id: KEY_TEMPLATE.into(),
16783            quantity: 1,
16784            item_instance_id: Some(key_id),
16785            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
16786            status_bindings: Vec::new(),
16787            contents: Vec::new(),
16788            display_name: None,
16789            category: Some("key".into()),
16790            base_mass: None,
16791            base_volume: None,
16792            capacity_volume: None,
16793            stackable: None,
16794            world_placeable: None,
16795            worker_lodging_capacity: None,
16796                equip_slot: None,
16797                armor_physical: None,
16798                resists: vec![],
16799                hand_slots: None,
16800            listable: None,
16801        };
16802        state.inventory_stacks = vec![key.clone()];
16803        assert!(!state.key_drop_blocked(&key));
16804        let opts = state.move_destinations_for(
16805            &flatland_protocol::InventoryLocation::Root,
16806            None,
16807            Some(key_id),
16808            KEY_TEMPLATE,
16809        );
16810        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
16811    }
16812
16813    #[test]
16814    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
16815        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
16816
16817        let mut state = sample_state();
16818        let curve = ProgressionCurve::default();
16819        let bootstrap = ProgressionXp::bootstrap_new(
16820            curve.baseline_display,
16821            curve.xp_base,
16822            curve.xp_growth,
16823        );
16824        let mut fresh = bootstrap.clone();
16825        fresh.strength += 0.08;
16826        if let Some(player) = state.player.as_mut() {
16827            player.progression_xp = Some(bootstrap);
16828        }
16829
16830        let combat = CombatHud {
16831            progression_xp: Some(fresh.clone()),
16832            progression_baseline: curve.baseline_display,
16833            progression_xp_base: curve.xp_base,
16834            progression_xp_growth: curve.xp_growth,
16835            attributes: state.player.as_ref().and_then(|p| p.attributes),
16836            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
16837            ..CombatHud::default()
16838        };
16839        state.apply_combat_hud(&combat);
16840
16841        let xp = state
16842            .player
16843            .as_ref()
16844            .and_then(|p| p.progression_xp.as_ref())
16845            .expect("xp");
16846        assert!((xp.strength - fresh.strength).abs() < 0.001);
16847        assert!(state.progression_curve.is_some());
16848    }
16849
16850    #[test]
16851    fn combat_hud_syncs_known_abilities_and_hotbar() {
16852        use flatland_protocol::CombatHud;
16853
16854        let mut state = sample_state();
16855        let combat = CombatHud {
16856            known_abilities: vec!["unarmed".into(), "fireball".into()],
16857            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
16858            max_abilities_per_rotation: 4,
16859            ability_id: "short_sword_slash".into(),
16860            ..CombatHud::default()
16861        };
16862        state.apply_combat_hud(&combat);
16863
16864        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
16865        assert_eq!(state.hotbar_ability(1), Some("fireball"));
16866        assert_eq!(state.hotbar_ability(2), None);
16867        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
16868        assert_eq!(state.max_abilities_per_rotation, 4);
16869        let choices = state.loadout_ability_choices();
16870        assert!(choices.iter().any(|a| a == "short_sword_slash"));
16871        assert!(choices.iter().any(|a| a == "fireball"));
16872    }
16873
16874    #[test]
16875    fn loadout_hotbar_choices_include_inventory_consumables() {
16876        let mut state = sample_state();
16877        state.known_abilities = vec!["unarmed".into()];
16878        state.weapon_ability_id = "unarmed".into();
16879        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16880            template_id: "bottle_of_water".into(),
16881            quantity: 3,
16882            item_instance_id: Some(uuid::Uuid::from_u128(9)),
16883            display_name: Some("Bottle of Water".into()),
16884            category: Some("consumable".into()),
16885            ..Default::default()
16886        }];
16887        state.inventory.insert("bottle_of_water".into(), 3);
16888        state.inventory_hints.insert(
16889            "bottle_of_water".into(),
16890            InventoryHint {
16891                display_name: "Bottle of Water".into(),
16892                category: "consumable".into(),
16893                ..Default::default()
16894            },
16895        );
16896
16897        let choices = state.loadout_hotbar_choices();
16898        assert!(choices.iter().any(|c| c.binding == "unarmed"));
16899        let water = choices
16900            .iter()
16901            .find(|c| c.binding == "item:bottle_of_water")
16902            .expect("water binding");
16903        assert_eq!(water.meta.as_deref(), Some("use"));
16904        assert!(water.label.contains("Water"));
16905        assert_eq!(
16906            state.hotbar_slot_label(1),
16907            None,
16908            "unbound until set"
16909        );
16910        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
16911        assert_eq!(
16912            state.hotbar_slot_label(5).as_deref(),
16913            Some("Bottle of Water×3")
16914        );
16915    }
16916
16917    #[test]
16918    fn storage_store_options_excludes_hand_equipped() {
16919        let mut state = sample_state();
16920        let sword_id = uuid::Uuid::from_u128(11);
16921        let ore_id = uuid::Uuid::from_u128(22);
16922        state.inventory_stacks = vec![
16923            flatland_protocol::ItemStack {
16924                template_id: "short_sword".into(),
16925                quantity: 1,
16926                item_instance_id: Some(sword_id),
16927                display_name: Some("Short Sword".into()),
16928                category: Some("weapon".into()),
16929                ..Default::default()
16930            },
16931            flatland_protocol::ItemStack {
16932                template_id: "iron_ore".into(),
16933                quantity: 5,
16934                item_instance_id: Some(ore_id),
16935                display_name: Some("Iron Ore".into()),
16936                category: Some("resource".into()),
16937                ..Default::default()
16938            },
16939        ];
16940        state.mainhand_template_id = Some("short_sword".into());
16941        state.mainhand_instance_id = Some(sword_id);
16942
16943        let opts = state.storage_store_options();
16944        assert_eq!(opts.len(), 1);
16945        assert_eq!(opts[0].item_instance_id, ore_id);
16946        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
16947    }
16948
16949    #[test]
16950    fn loose_consumable_move_picker_offers_use_and_storage() {
16951        let mut state = sample_state();
16952        let inst = uuid::Uuid::from_u128(77);
16953        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16954            template_id: "carrot".into(),
16955            quantity: 2,
16956            item_instance_id: Some(inst),
16957            props: Default::default(),
16958            status_bindings: Vec::new(),
16959            contents: Vec::new(),
16960            display_name: Some("Wild Carrot".into()),
16961            category: Some("consumable".into()),
16962            base_mass: None,
16963            base_volume: None,
16964            capacity_volume: None,
16965            stackable: Some(true),
16966            world_placeable: None,
16967            worker_lodging_capacity: None,
16968                equip_slot: None,
16969                armor_physical: None,
16970                resists: vec![],
16971                hand_slots: None,
16972            listable: None,
16973        }];
16974        state.inventory_hints.insert(
16975            "carrot".into(),
16976            InventoryHint {
16977                display_name: "Wild Carrot".into(),
16978                category: "consumable".into(),
16979                base_mass: Some(0.15),
16980                base_volume: Some(0.3),
16981                capacity_volume: None,
16982                stackable: true,
16983                listable: true,
16984            },
16985        );
16986        state.show_inventory_menu = true;
16987        state.inventory_menu_index = 0;
16988
16989        let row = state.inventory_selected_row().expect("carrot row");
16990        let mut options = state.move_destinations_for(
16991            &row.from,
16992            row.from_parent_instance_id,
16993            row.stack.item_instance_id,
16994            &row.stack.template_id,
16995        );
16996        if row.from == flatland_protocol::InventoryLocation::Root
16997            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
16998        {
16999            options.insert(
17000                0,
17001                MoveOption {
17002                    label: "Use (eat / drink)".into(),
17003                    kind: MoveOptionKind::Use,
17004                },
17005            );
17006        }
17007
17008        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
17009        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
17010        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
17011    }
17012
17013    #[test]
17014    fn inventory_category_group_order_is_stable() {
17015        assert_eq!(inventory_category_group("weapon").0, "Weapons");
17016        assert_eq!(inventory_category_group("armor").0, "Armor");
17017        assert_eq!(inventory_category_group("consumable").0, "Consumables");
17018        assert_eq!(inventory_category_group("resource").0, "Resources");
17019        assert_eq!(inventory_category_group("container").0, "Containers");
17020        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
17021        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
17022    }
17023
17024    #[test]
17025    fn page_list_index_clamps_without_wrap() {
17026        assert_eq!(page_list_index(0, -1, 25), 0);
17027        assert_eq!(page_list_index(0, 1, 25), 10);
17028        assert_eq!(page_list_index(12, 1, 25), 22);
17029        assert_eq!(page_list_index(22, 1, 25), 24);
17030        assert_eq!(page_list_index(5, 1, 0), 0);
17031        assert_eq!(page_list_index(3, -1, 8), 0);
17032    }
17033
17034    #[test]
17035    fn inventory_filter_hides_non_matching_person_items() {
17036        let mut state = sample_state();
17037        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17038        sword.display_name = Some("Iron Sword".into());
17039        sword.category = Some("weapon".into());
17040        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
17041        herb.display_name = Some("Wild Herb".into());
17042        herb.category = Some("consumable".into());
17043        state.inventory_stacks = vec![sword, herb];
17044        state.inventory_tab = InventoryTab::OnPerson;
17045        state.inventory_filter = "sword".into();
17046
17047        let rows = state.inventory_selectable_rows();
17048        assert_eq!(rows.len(), 1);
17049        assert_eq!(rows[0].stack.template_id, "iron_sword");
17050
17051        let lines = state.inventory_browser_lines();
17052        assert!(lines.iter().any(|l| matches!(
17053            l,
17054            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17055        )));
17056        assert!(!lines.iter().any(|l| matches!(
17057            l,
17058            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17059        )));
17060    }
17061
17062    #[test]
17063    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17064        let mut state = sample_state();
17065        let id_a = uuid::Uuid::from_u128(0xa1);
17066        let id_b = uuid::Uuid::from_u128(0xb2);
17067        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17068        sword_a.display_name = Some("Iron Sword".into());
17069        sword_a.category = Some("weapon".into());
17070        sword_a.item_instance_id = Some(id_a);
17071        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17072        sword_b.display_name = Some("Iron Sword".into());
17073        sword_b.category = Some("weapon".into());
17074        sword_b.item_instance_id = Some(id_b);
17075        state.inventory_stacks = vec![sword_a, sword_b];
17076        state.inventory_tab = InventoryTab::OnPerson;
17077
17078        let lines = state.inventory_browser_lines();
17079        let items: Vec<_> = lines
17080            .iter()
17081            .filter_map(|l| match l {
17082                InventoryBrowserLine::Item {
17083                    title,
17084                    instance_tooltip,
17085                    ..
17086                } => Some((title.clone(), instance_tooltip.clone())),
17087                _ => None,
17088            })
17089            .collect();
17090        assert_eq!(items.len(), 2);
17091        for (title, tip) in &items {
17092            assert!(
17093                !title.contains('#'),
17094                "title should not show instance suffix: {title}"
17095            );
17096            assert!(
17097                tip.is_some(),
17098                "two identical rows should expose instance on hover"
17099            );
17100        }
17101
17102        state.inventory_stacks.pop();
17103        let lines = state.inventory_browser_lines();
17104        let one = lines.iter().find_map(|l| match l {
17105            InventoryBrowserLine::Item {
17106                title,
17107                instance_tooltip,
17108                ..
17109            } => Some((title.clone(), instance_tooltip.clone())),
17110            _ => None,
17111        });
17112        let (title, tip) = one.expect("one sword row");
17113        assert!(!title.contains('#'));
17114        assert!(tip.is_none(), "single row should not need instance tooltip");
17115    }
17116
17117    #[test]
17118    fn inventory_person_rows_group_by_category() {
17119        let mut state = sample_state();
17120        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17121        sword.category = Some("weapon".into());
17122        sword.display_name = Some("Iron Sword".into());
17123        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17124        ore.category = Some("resource".into());
17125        ore.display_name = Some("Iron Ore".into());
17126        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17127        potion.category = Some("consumable".into());
17128        potion.display_name = Some("Health Potion".into());
17129        state.inventory_stacks = vec![ore, potion, sword];
17130        state.inventory_tab = InventoryTab::OnPerson;
17131
17132        let lines = state.inventory_browser_lines();
17133        let labels: Vec<&str> = lines
17134            .iter()
17135            .filter_map(|l| match l {
17136                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17137                _ => None,
17138            })
17139            .collect();
17140        assert!(
17141            labels.iter().any(|s| s.contains("Weapons")),
17142            "expected Weapons group: {labels:?}"
17143        );
17144        assert!(labels.iter().any(|s| s.contains("Consumables")));
17145        assert!(labels.iter().any(|s| s.contains("Resources")));
17146
17147        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17148        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
17149        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17150        assert!(weapon_pos < consumable_pos);
17151        assert!(consumable_pos < resource_pos);
17152    }
17153
17154    #[test]
17155    fn inventory_tab_cycle_resets_selection() {
17156        let mut state = sample_state();
17157        state.inventory_tab = InventoryTab::OnPerson;
17158        state.inventory_menu_index = 3;
17159        state.inventory_tab = state.inventory_tab.cycle(true);
17160        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17161        // Client method resets index; enum cycle alone does not — verify cycle labels.
17162        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17163        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17164        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17165        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17166    }
17167
17168    #[test]
17169    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17170        assert_eq!(parse_bank_copper_amount(""), Some(0));
17171        assert_eq!(parse_bank_copper_amount("  "), Some(0));
17172        assert_eq!(parse_bank_copper_amount("0"), Some(0));
17173        assert_eq!(parse_bank_copper_amount("250"), Some(250));
17174        assert_eq!(parse_bank_copper_amount("nope"), None);
17175    }
17176
17177    #[test]
17178    fn parse_storage_quantity_blank_and_zero_mean_all() {
17179        assert_eq!(parse_storage_quantity(""), Some(None));
17180        assert_eq!(parse_storage_quantity("  "), Some(None));
17181        assert_eq!(parse_storage_quantity("0"), Some(None));
17182        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17183        assert_eq!(parse_storage_quantity("nope"), None);
17184    }
17185
17186    #[test]
17187    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17188        assert!(worker_error_is_hud_noise("path stuck — repathing"));
17189        assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
17190        assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
17191        // Real problem: player may need to place lodging / fix assignment.
17192        assert!(!worker_error_is_hud_noise(
17193            "path stuck — no lodging to reset to"
17194        ));
17195    }
17196
17197    #[test]
17198    fn leaving_building_restores_outdoor_z_bands() {
17199        use flatland_protocol::{InteriorMapView, ZPlatformView};
17200
17201        let mut state = sample_state();
17202        state.z_platforms.clear();
17203        state.z_transitions.clear();
17204        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
17205        state.interior_map = Some(InteriorMapView {
17206            building_id: "broker_hut".into(),
17207            blueprint_id: "broker_hut".into(),
17208            background_color: "#000".into(),
17209            default_floor_color: None,
17210            floor_height_m: 3.0,
17211            z_platforms: vec![ZPlatformView {
17212                id: "floor_0".into(),
17213                z: 0.0,
17214                x0: 0.0,
17215                y0: 0.0,
17216                x1: 8.0,
17217                y1: 8.0,
17218            }],
17219            z_transitions: vec![],
17220            rooms: vec![],
17221            room_doors: vec![],
17222        });
17223        state.sync_interior_map_context();
17224        assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
17225        assert!(state.z_bands_outdoor_backup.is_some());
17226
17227        state.player.as_mut().unwrap().inside_building = None;
17228        state.sync_interior_map_context();
17229        assert!(
17230            state.z_platforms.is_empty(),
17231            "leaving must restore outdoor bands (empty), not leave interior platforms"
17232        );
17233        assert!(state.z_bands_outdoor_backup.is_none());
17234        assert!(state.interior_map.is_none());
17235    }
17236
17237    #[test]
17238    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
17239        let node = ResourceNodeView {
17240            id: "crop-carrot-1_copy10".into(),
17241            label: "crop-carrot-1_copy10".into(),
17242            x: 0.0,
17243            y: 0.0,
17244            z: 0.0,
17245            item_template: "carrot".into(),
17246            state: ResourceNodeState::Available,
17247            blocking: false,
17248            blocking_radius_m: 0.5,
17249            harvest_off: false,
17250            tile_id: None,
17251            yaw: 0.0,
17252            pitch: 0.0,
17253            roll: 0.0,
17254            draw_scale: 1.0,
17255            sprite_mode: None,
17256            growth_progress: None,
17257            presentation_state: None,
17258            channel_start_tick: None,
17259            channel_end_tick: None,
17260            harvest_drop_templates: vec![],
17261        };
17262        let label = super::resource_node_route_label(&node);
17263        assert!(label.starts_with("Carrot ("), "got {label}");
17264        assert!(label.ends_with(')'), "got {label}");
17265
17266        let mut named = node;
17267        named.label = "Sweet Pad".into();
17268        named.id = "crop-carrot-a3f2b1c0".into();
17269        assert_eq!(
17270            super::resource_node_route_label(&named),
17271            "Sweet Pad (b1c0)"
17272        );
17273    }
17274
17275    #[test]
17276    fn plot_public_label_uses_owner_zone_and_label() {
17277        let plot = flatland_protocol::PropertyPlotView {
17278            plot_id: uuid::Uuid::nil(),
17279            property_zone_id: "zone_a".into(),
17280            zone_label: Some("Starter Town East 1".into()),
17281            deed_instance_id: uuid::Uuid::nil(),
17282            x0: 0.0,
17283            y0: 0.0,
17284            x1: 4.0,
17285            y1: 4.0,
17286            upkeep_copper_per_day: 1,
17287            arrears_days: 0,
17288            is_mine: true,
17289            may_farm: true,
17290            purchase_basis_copper: 0,
17291            farm_public: false,
17292            public_tax_discount_bps: 0,
17293            farm_allow: vec![],
17294            owner_character_id: None,
17295            owner_label: Some("Madsin".into()),
17296            building_id: None,
17297            plot_code: "xyz1234a".into(),
17298            label: "Food Pad".into(),
17299        };
17300        assert_eq!(
17301            super::plot_public_label(&plot),
17302            "Madsin — Starter Town East 1 — Food Pad"
17303        );
17304    }
17305}