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    /// Unit price in copper.
697    ListPrice {
698        source: MarketListSourceKind,
699        item_instance_id: uuid::Uuid,
700        label: String,
701        /// Resolved quantity to list (`None` = all / omit on wire).
702        quantity: Option<u32>,
703        max_qty: u32,
704        input: String,
705    },
706}
707
708/// One selectable stack in a storage store/take/ship picker.
709#[derive(Debug, Clone)]
710pub struct StoragePickOption {
711    pub item_instance_id: uuid::Uuid,
712    pub label: String,
713    pub quantity: u32,
714    /// Catalog category when known (market list filters).
715    pub category: String,
716}
717
718/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
719/// the browser (empty when locked without the matching key).
720#[derive(Debug, Clone)]
721pub struct NearbyContainer {
722    pub view: flatland_protocol::PlacedContainerView,
723    pub distance_m: f32,
724    pub rows: Vec<InventoryRow>,
725}
726
727/// One key row in the keychain overlay (carried vs stowed).
728#[derive(Debug, Clone)]
729pub struct KeychainEntry {
730    pub stack: flatland_protocol::ItemStack,
731    pub stowed: bool,
732}
733
734/// A destination the currently-picked item could be moved to.
735#[derive(Debug, Clone)]
736pub struct MoveOption {
737    pub label: String,
738    pub kind: MoveOptionKind,
739}
740
741#[derive(Debug, Clone, PartialEq)]
742pub enum MoveOptionKind {
743    Move {
744        location: flatland_protocol::InventoryLocation,
745        parent_instance_id: Option<uuid::Uuid>,
746    },
747    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
748    PickupPlaced {
749        container_id: String,
750        nest_location: flatland_protocol::InventoryLocation,
751        nest_parent_instance_id: Option<uuid::Uuid>,
752    },
753    /// Enter map relocate mode for a placed chest (no pickup).
754    RelocatePlaced {
755        container_id: String,
756    },
757    /// Eat/drink a loose consumable (one unit per use).
758    Use,
759    /// Open grant-target picker for `grants_item_status` consumables.
760    GrantApply,
761    Drop,
762    /// Sell the plot tied to a property deed back to the crown.
763    SellPlotToCrown {
764        plot_id: uuid::Uuid,
765    },
766    Cancel,
767}
768
769/// One navigable row in the deed farm-access panel.
770#[derive(Debug, Clone, PartialEq)]
771pub enum FarmAccessRow {
772    PublicToggle,
773    PublicDiscount,
774    AllowRemove {
775        character_id: uuid::Uuid,
776        label: String,
777        tax_discount_bps: u32,
778    },
779    NearbyAdd {
780        name: String,
781    },
782}
783
784/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
785#[derive(Debug, Clone)]
786pub struct GrantTargetPicker {
787    pub grant_instance_id: uuid::Uuid,
788    pub grant_label: String,
789    pub effect_id: String,
790    pub mode: String,
791    pub options: Vec<GrantTargetOption>,
792    pub filter: String,
793    pub filter_focused: bool,
794}
795
796#[derive(Debug, Clone)]
797pub struct GrantTargetOption {
798    pub label: String,
799    pub target_instance_id: uuid::Uuid,
800}
801
802/// Active "move to…" destination picker state for the selected inventory item.
803#[derive(Debug, Clone)]
804pub struct MovePicker {
805    pub item_instance_id: uuid::Uuid,
806    pub from: flatland_protocol::InventoryLocation,
807    pub item_label: String,
808    pub template_id: String,
809    pub stack_quantity: u32,
810    pub quantity: u32,
811    pub options: Vec<MoveOption>,
812    pub filter: String,
813    pub filter_focused: bool,
814}
815
816/// Active permanent-delete picker for the selected inventory item.
817#[derive(Debug, Clone)]
818pub struct DestroyPicker {
819    pub item_instance_id: uuid::Uuid,
820    pub from: flatland_protocol::InventoryLocation,
821    pub item_label: String,
822    pub stack_quantity: u32,
823    pub quantity: u32,
824}
825
826/// One giveable stack in the workers-menu give picker.
827#[derive(Debug, Clone)]
828pub struct WorkerGiveOption {
829    pub item_instance_id: uuid::Uuid,
830    pub label: String,
831    pub quantity: u32,
832    pub template_id: String,
833}
834
835/// Give an on-person inventory stack to the selected hired worker.
836#[derive(Debug, Clone)]
837pub struct WorkerGivePicker {
838    pub worker_instance_id: String,
839    pub worker_label: String,
840    pub options: Vec<WorkerGiveOption>,
841}
842
843/// Nearby hired worker choice when giving a selected inventory stack (`g`).
844#[derive(Debug, Clone)]
845pub struct WorkerGiveTargetOption {
846    pub instance_id: String,
847    pub label: String,
848    pub distance_m: f32,
849}
850
851/// Pick which nearby worker receives the selected inventory item.
852#[derive(Debug, Clone)]
853pub struct WorkerGiveTargetPicker {
854    pub item_instance_id: uuid::Uuid,
855    pub item_label: String,
856    pub quantity: Option<u32>,
857    pub options: Vec<WorkerGiveTargetOption>,
858}
859
860/// Take an item from a hired worker back into the employer's inventory.
861#[derive(Debug, Clone)]
862pub struct WorkerTakePicker {
863    pub worker_instance_id: String,
864    pub worker_label: String,
865    pub options: Vec<WorkerGiveOption>,
866    /// How many of the selected stack to take (1..=stack quantity).
867    pub quantity: u32,
868}
869
870/// Max distance (m) to hand an item to a hired worker.
871pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
872
873/// One teachable blueprint in the workers-menu teach picker.
874#[derive(Debug, Clone)]
875pub struct WorkerTeachOption {
876    pub blueprint_id: String,
877    pub label: String,
878    pub cost_copper: u64,
879    pub min_level: u32,
880    pub worker_level: u32,
881    pub can_afford: bool,
882    pub level_ok: bool,
883}
884
885/// Teach a known blueprint to the selected hired worker.
886#[derive(Debug, Clone)]
887pub struct WorkerTeachPicker {
888    pub worker_instance_id: String,
889    pub worker_label: String,
890    pub worker_level: u32,
891    pub options: Vec<WorkerTeachOption>,
892}
893
894/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
895/// do not thrash the UI.
896#[derive(Debug, Clone, Default)]
897pub struct StickyWorkerStep {
898    shown: String,
899    pending: String,
900    pending_since: Option<Instant>,
901}
902
903impl StickyWorkerStep {
904    fn from_label(label: String) -> Self {
905        Self {
906            shown: label.clone(),
907            pending: label,
908            pending_since: Some(Instant::now()),
909        }
910    }
911
912    fn observe(&mut self, label: &str, now: Instant) {
913        let pending_since = self.pending_since.unwrap_or(now);
914        if label == self.pending {
915            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
916            {
917                self.shown = self.pending.clone();
918            }
919            return;
920        }
921        self.pending = label.to_string();
922        self.pending_since = Some(now);
923        // Empty → first value, or first observation: show immediately.
924        if self.shown.is_empty() {
925            self.shown = self.pending.clone();
926        }
927    }
928}
929
930/// Last non-transient worker error — held so route flicker stays readable.
931/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
932#[derive(Debug, Clone, Default)]
933pub struct StickyWorkerError {
934    message: String,
935    last_seen: Option<Instant>,
936}
937
938impl StickyWorkerError {
939    fn observe(&mut self, err: Option<&str>, now: Instant) {
940        if let Some(e) = err {
941            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
942                self.message = e.to_string();
943                self.last_seen = Some(now);
944            }
945            return;
946        }
947        if let Some(seen) = self.last_seen {
948            if now.duration_since(seen) > WORKER_ERROR_HOLD {
949                self.message.clear();
950                self.last_seen = None;
951            }
952        }
953    }
954
955    pub fn shown(&self, now: Instant) -> Option<&str> {
956        if self.message.is_empty() {
957            return None;
958        }
959        let seen = self.last_seen?;
960        if now.duration_since(seen) > WORKER_ERROR_HOLD {
961            return None;
962        }
963        Some(self.message.as_str())
964    }
965}
966
967/// First non-transient worker issue for the status bar (strike, route error, etc.).
968/// Routine recovery noise (path failures → lodging) stays in the game log only.
969pub fn worker_attention_line(state: &GameState) -> Option<String> {
970    use flatland_protocol::WorkerStateView;
971    let now = Instant::now();
972    for w in &state.hired_workers {
973        if matches!(w.state, WorkerStateView::Strike) {
974            return Some(format!(
975                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
976                w.label
977            ));
978        }
979        if let Some(err) = state
980            .worker_error_display
981            .get(&w.instance_id)
982            .and_then(|s| s.shown(now))
983        {
984            if !worker_error_is_hud_noise(err) {
985                return Some(format!("Worker {}: {err}", w.label));
986            }
987        }
988        if let Some(err) = &w.last_error {
989            if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
990                return Some(format!("Worker {}: {err}", w.label));
991            }
992        }
993    }
994    None
995}
996
997/// True when a worker `last_error` is transient noise (soft-skip chatter).
998pub fn worker_error_is_transient(err: &str) -> bool {
999    let e = err.to_ascii_lowercase();
1000    e.contains("continuing route")
1001        || e.contains("storage full")
1002        || e.starts_with("nothing to withdraw")
1003}
1004
1005/// True for routine worker recoveries that should not paint the status bar.
1006/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1007pub fn worker_error_is_hud_noise(err: &str) -> bool {
1008    let e = err.to_ascii_lowercase();
1009    e.contains("returned to lodging after path")
1010        || e.contains("path failure")
1011        || e.contains("no path to")
1012        || e.contains("pathfinding")
1013        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1014        || e.contains("repathing")
1015        || e.contains("nudged clear")
1016}
1017
1018/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1019#[derive(Debug, Clone)]
1020pub struct PendingWorkerJobAck {
1021    pub seq: u32,
1022    pub worker_instance_id: String,
1023    pub worker_label: String,
1024    pub idle: bool,
1025    pub stop_count: usize,
1026    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1027    pub prev_mode: flatland_protocol::WorkerModeView,
1028    pub prev_step_label: String,
1029    pub prev_last_error: Option<String>,
1030}
1031
1032fn push_inventory_rows(
1033    rows: &mut Vec<InventoryRow>,
1034    depth: usize,
1035    stack: &flatland_protocol::ItemStack,
1036    from: &flatland_protocol::InventoryLocation,
1037    from_parent_instance_id: Option<uuid::Uuid>,
1038    section: InventorySection,
1039) {
1040    push_inventory_rows_filtered(
1041        rows,
1042        depth,
1043        stack,
1044        from,
1045        from_parent_instance_id,
1046        section,
1047        "",
1048    );
1049}
1050
1051fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1052    if filter.is_empty() {
1053        return true;
1054    }
1055    let f = filter.to_ascii_lowercase();
1056    let name = stack
1057        .display_name
1058        .as_deref()
1059        .unwrap_or("")
1060        .to_ascii_lowercase();
1061    let tid = stack.template_id.to_ascii_lowercase();
1062    name.contains(&f)
1063        || tid.contains(&f)
1064        || stack
1065            .contents
1066            .iter()
1067            .any(|c| stack_matches_filter(c, filter))
1068}
1069
1070fn push_inventory_rows_filtered(
1071    rows: &mut Vec<InventoryRow>,
1072    depth: usize,
1073    stack: &flatland_protocol::ItemStack,
1074    from: &flatland_protocol::InventoryLocation,
1075    from_parent_instance_id: Option<uuid::Uuid>,
1076    section: InventorySection,
1077    filter: &str,
1078) {
1079    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1080        return;
1081    }
1082    let self_hit = filter.is_empty() || {
1083        let f = filter.to_ascii_lowercase();
1084        let name = stack
1085            .display_name
1086            .as_deref()
1087            .unwrap_or("")
1088            .to_ascii_lowercase();
1089        let tid = stack.template_id.to_ascii_lowercase();
1090        name.contains(&f) || tid.contains(&f)
1091    };
1092    rows.push(InventoryRow {
1093        depth,
1094        stack: stack.clone(),
1095        from: from.clone(),
1096        from_parent_instance_id,
1097        is_equip_shell: false,
1098        is_chest_shell: false,
1099        section,
1100    });
1101    for child in &stack.contents {
1102        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1103            push_inventory_rows_filtered(
1104                rows,
1105                depth + 1,
1106                child,
1107                from,
1108                stack.item_instance_id,
1109                section,
1110                if self_hit { "" } else { filter },
1111            );
1112        }
1113    }
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1117pub enum ShopTab {
1118    #[default]
1119    Buy,
1120    Sell,
1121}
1122
1123#[derive(Debug, Clone)]
1124pub struct NpcChatState {
1125    pub npc_id: String,
1126    pub npc_label: String,
1127    pub lines: Vec<String>,
1128    pub input: String,
1129    pub pending: bool,
1130    pub talk_depth: flatland_protocol::NpcTalkDepth,
1131    pub trade_allowed: bool,
1132    pub banner: Option<String>,
1133}
1134
1135impl Default for NpcChatState {
1136    fn default() -> Self {
1137        Self {
1138            npc_id: String::new(),
1139            npc_label: String::new(),
1140            lines: Vec::new(),
1141            input: String::new(),
1142            pending: false,
1143            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1144            trade_allowed: true,
1145            banner: None,
1146        }
1147    }
1148}
1149
1150#[derive(Debug, Clone)]
1151pub struct GameState {
1152    pub session_id: SessionId,
1153    pub entity_id: EntityId,
1154    /// Logged-in character — used to show owner-only container labels.
1155    pub character_id: Option<uuid::Uuid>,
1156    pub tick: Tick,
1157    pub chunk_rev: u64,
1158    pub content_rev: u64,
1159    pub publish_rev: u64,
1160    pub entities: Vec<EntityState>,
1161    pub player: Option<EntityState>,
1162    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1163    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1164    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1165    pub buildings: Vec<BuildingView>,
1166    pub doors: Vec<DoorView>,
1167    pub interior_map: Option<InteriorMapView>,
1168    pub npcs: Vec<NpcView>,
1169    pub blueprints: Vec<BlueprintView>,
1170    /// Outdoor play AABB origin (meters).
1171    pub world_x0: f32,
1172    pub world_y0: f32,
1173    pub world_width_m: f32,
1174    pub world_height_m: f32,
1175    pub terrain_zones: Vec<TerrainZoneView>,
1176    pub z_platforms: Vec<ZPlatformView>,
1177    pub z_transitions: Vec<ZTransitionView>,
1178    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1179    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1180    #[doc(hidden)]
1181    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1182    pub world_clock: flatland_protocol::WorldClock,
1183    pub inventory: std::collections::HashMap<String, u32>,
1184    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1185    pub logs: VecDeque<String>,
1186    pub intents_sent: u64,
1187    pub ticks_received: u64,
1188    pub connected: bool,
1189    pub disconnect_reason: Option<String>,
1190    pub show_stats: bool,
1191    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1192    pub hud_log_hidden: bool,
1193    pub show_equip_menu: bool,
1194    pub equip_menu_index: usize,
1195    pub show_craft_menu: bool,
1196    pub craft_menu_index: usize,
1197    /// How many timed crafts to queue when confirming the craft menu.
1198    pub craft_batch_quantity: u32,
1199    pub show_shop_menu: bool,
1200    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1201    pub bank_panel: Option<flatland_protocol::BankPanel>,
1202    pub bank_menu_index: usize,
1203    pub bank_ui_mode: BankUiMode,
1204    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1205    pub market_panel: Option<flatland_protocol::MarketPanel>,
1206    /// Selected row among filtered market browse listings.
1207    pub market_menu_index: usize,
1208    /// Browse / list-pick text filter (`/` to focus).
1209    pub market_filter: String,
1210    pub market_filter_focused: bool,
1211    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1212    pub market_category_filter: Option<&'static str>,
1213    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1214    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1215    pub market_ui_mode: MarketUiMode,
1216    pub storage_menu_index: usize,
1217    pub storage_ui_mode: StorageUiMode,
1218    pub shop_tab: ShopTab,
1219    pub shop_menu_index: usize,
1220    pub shop_quantity: u32,
1221    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1222    pub shop_trade_log: VecDeque<String>,
1223    pub show_npc_verb_menu: bool,
1224    pub npc_verb_target: Option<String>,
1225    pub npc_verb_index: usize,
1226    /// Nearby player Speak / Whisper / Trade dock.
1227    pub player_verbs: crate::social::PlayerVerbState,
1228    pub social_chat: crate::social::SocialChatState,
1229    pub trade_ui: crate::social::TradeUiState,
1230    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1231    pub show_npc_chat: bool,
1232    pub npc_chat: Option<NpcChatState>,
1233    pub show_inventory_menu: bool,
1234    pub inventory_menu_index: usize,
1235    pub inventory_tab: InventoryTab,
1236    pub inventory_filter: String,
1237    pub inventory_filter_focused: bool,
1238    pub show_move_picker: bool,
1239    pub move_picker_index: usize,
1240    pub move_picker: Option<MovePicker>,
1241    pub show_grant_picker: bool,
1242    pub grant_picker_index: usize,
1243    pub grant_picker: Option<GrantTargetPicker>,
1244    pub show_destroy_picker: bool,
1245    pub destroy_confirm_pending: bool,
1246    pub destroy_picker: Option<DestroyPicker>,
1247    /// Rename prompt for a selected container (`n` in inventory).
1248    pub show_rename_prompt: bool,
1249    /// Rename prompt for a hired worker (`n` in workers menu).
1250    pub show_worker_rename: bool,
1251    pub rename_buffer: String,
1252    /// Slot-1 combat target (mirrors server after SetTarget).
1253    pub combat_target: Option<EntityId>,
1254    pub combat_target_label: Option<String>,
1255    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1256    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1257    pub ground_target: Option<(f32, f32, f32)>,
1258    /// Active combat footprints from the server (`plans/39`).
1259    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1260    /// Authored crown property zones (claimable land) — plan 40.
1261    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1262    /// Tax overlays for claim cost premium preview.
1263    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1264    /// Growth / fertility overlays (farming, respawn).
1265    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1266    /// Climate / biome overlays.
1267    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1268    /// Claimed property plots near the observer.
1269    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1270    /// Server knobs for client-side claim quotes.
1271    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1272    /// Local claim-footprint editor (not server state).
1273    pub claim_mode: Option<ClaimModeState>,
1274    /// Local relocate ghost for a placed chest/lodging (not server state).
1275    pub relocate_mode: Option<RelocateModeState>,
1276    /// Second `f` within a short window confirms selling this plot back to the crown.
1277    pub sell_plot_confirm: Option<uuid::Uuid>,
1278    /// When `sell_plot_confirm` was armed (for double-tap window).
1279    pub sell_plot_armed_at: Option<Instant>,
1280    /// Choose seed type when planting on owned tilled soil (`f`).
1281    pub show_plant_menu: bool,
1282    pub plant_menu_index: usize,
1283    /// Deed-holder farm access panel (public toggle + allow-list).
1284    pub show_farm_access: bool,
1285    /// Draft name for adding a tenant from the farm-access panel.
1286    pub farm_access_name_draft: String,
1287    /// Public / grant discount draft (bps) while editing farm access.
1288    pub farm_access_discount_bps: u32,
1289    /// Selected row in the farm-access panel (0 = public toggle).
1290    pub farm_access_index: usize,
1291    pub plant_quantity: u32,
1292    pub in_combat: bool,
1293    pub auto_attack: bool,
1294    pub combat_has_los: bool,
1295    pub attack_cd_ticks: u64,
1296    pub gcd_ticks: u64,
1297    pub weapon_ability_id: String,
1298    pub mainhand_template_id: Option<String>,
1299    pub mainhand_label: Option<String>,
1300    pub offhand_template_id: Option<String>,
1301    pub offhand_label: Option<String>,
1302    pub mainhand_hand_slots: u8,
1303    pub defense: Option<flatland_protocol::DefenseHud>,
1304    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1305    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1306    pub carry_mass: f32,
1307    pub carry_mass_max: f32,
1308    pub encumbrance: flatland_protocol::EncumbranceState,
1309    /// Full nested inventory stacks from the server (root only; worn are separate).
1310    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1311    /// Keys stowed on the virtual keychain (zero carry mass).
1312    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1313    /// Whisper stones in the pouch (zero carry mass).
1314    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1315    /// Active status effects on the local player (buff/debuff strip).
1316    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1317    pub combat_target_detail: Option<CombatTargetHud>,
1318    pub cast_progress: Option<CastProgressHud>,
1319    /// Till / plant / other non-combat timed channels on the local player.
1320    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1321    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1322    pub blocking_active: bool,
1323    pub max_target_slots: u8,
1324    pub combat_slots: Vec<CombatSlotHud>,
1325    pub rotation_presets: Vec<RotationPreset>,
1326    /// Learned abilities from combat HUD (usable casts).
1327    pub known_abilities: Vec<String>,
1328    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1329    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1330    /// Server-persisted hotbar bindings (index 0 = key 1).
1331    pub hotbar: Vec<Option<String>>,
1332    /// Max abilities allowed in one rotation (mind score).
1333    pub max_abilities_per_rotation: u8,
1334    pub show_loadout_menu: bool,
1335    pub show_keychain_menu: bool,
1336    pub keychain_menu_index: usize,
1337    pub show_rotation_editor: bool,
1338    /// Selected rotation preset in the loadout menu.
1339    pub loadout_menu_index: usize,
1340    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1341    pub loadout_hotbar_slot: u8,
1342    /// Selected known-ability row in the loadout menu.
1343    pub loadout_ability_index: usize,
1344    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1345    pub loadout_focus_presets: bool,
1346    pub rotation_editor: RotationEditorState,
1347    /// True after a harvest intent is accepted until result/reject/disconnect.
1348    pub harvest_in_progress: bool,
1349    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1350    pub harvest_started_at: Option<Instant>,
1351    /// Craft log deferred until the server acks the craft intent.
1352    pub pending_craft_ack: Option<(u32, String, u32)>,
1353    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1354    pub interactables: Vec<flatland_protocol::InteractableView>,
1355    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1356    pub career: Option<flatland_protocol::PlayerCareerView>,
1357    pub character_sheet_tab: CharacterSheetTab,
1358    pub ledger_period: LedgerPeriod,
1359    pub show_quest_offer: bool,
1360    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1361    pub show_quest_menu: bool,
1362    pub quest_menu_index: usize,
1363    pub quest_withdraw_confirm: bool,
1364    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1365    pub show_workers_menu: bool,
1366    pub workers_menu_index: usize,
1367    /// Workers panel: one-line rows instead of full cards (more on screen).
1368    pub workers_menu_compact: bool,
1369    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1370    /// Public so out-of-crate tests can construct [`GameState`].
1371    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1372    /// Held worker route errors (readable when server clears `last_error` each tick).
1373    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1374    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1375    pub show_worker_give_picker: bool,
1376    pub worker_give_picker_index: usize,
1377    pub worker_give_picker: Option<WorkerGivePicker>,
1378    /// Inventory `g` — pick which nearby worker receives the selected stack.
1379    pub show_worker_give_target_picker: bool,
1380    pub worker_give_target_picker_index: usize,
1381    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1382    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1383    pub show_worker_take_picker: bool,
1384    pub worker_take_picker_index: usize,
1385    pub worker_take_picker: Option<WorkerTakePicker>,
1386    /// Teach-blueprint sheet opened from the workers menu (`t`).
1387    pub show_worker_teach_picker: bool,
1388    pub worker_teach_picker_index: usize,
1389    pub worker_teach_picker: Option<WorkerTeachPicker>,
1390    /// Active harvest-route editor (`h` → `e` on a worker).
1391    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1392    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1393    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1394    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1395    pub attending_worker_instance_id: Option<String>,
1396    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1397    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1398}
1399
1400impl GameState {
1401    pub fn push_log(&mut self, line: impl Into<String>) {
1402        self.logs.push_back(line.into());
1403        while self.logs.len() > MAX_LOG_LINES {
1404            self.logs.pop_front();
1405        }
1406    }
1407
1408    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1409        self.shop_trade_log.push_back(line.into());
1410        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1411            self.shop_trade_log.pop_front();
1412        }
1413    }
1414
1415    pub fn clear_shop_trade_log(&mut self) {
1416        self.shop_trade_log.clear();
1417    }
1418
1419    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1420        if !self.show_shop_menu {
1421            return;
1422        }
1423        let msg = notice.message.trim();
1424        if msg.is_empty() {
1425            return;
1426        }
1427        if notice.coins_delta != 0
1428            || msg.starts_with("Bought ")
1429            || msg.starts_with("Sold ")
1430            || msg.contains("taught you how to craft")
1431            || msg.starts_with("need ")
1432        {
1433            self.push_shop_trade_log(msg);
1434        }
1435    }
1436
1437    pub fn is_alive(&self) -> bool {
1438        self.player
1439            .as_ref()
1440            .and_then(|p| p.vitals)
1441            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1442            .unwrap_or(true)
1443    }
1444
1445    /// Verb menu entries for the current `npc_verb_target`.
1446    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1447        let Some(ref id) = self.npc_verb_target else {
1448            return vec![];
1449        };
1450        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1451            return vec!["Talk"];
1452        };
1453        let role = npc.role.as_str();
1454        if Self::npc_role_is_bank(role) {
1455            return vec!["Bank", "Talk"];
1456        }
1457        if Self::npc_role_is_storage(role) {
1458            return vec!["Storage", "Talk"];
1459        }
1460        if Self::npc_role_is_market(role) {
1461            return vec!["Market", "Talk"];
1462        }
1463        if npc.can_trade || Self::npc_role_can_trade(role) {
1464            vec!["Talk", "Trade"]
1465        } else {
1466            vec!["Talk"]
1467        }
1468    }
1469
1470    fn npc_role_can_trade(role: &str) -> bool {
1471        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1472    }
1473
1474    fn npc_role_is_bank(role: &str) -> bool {
1475        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1476    }
1477
1478    fn npc_role_is_storage(role: &str) -> bool {
1479        role.eq_ignore_ascii_case("storage_manager")
1480    }
1481
1482    fn npc_role_is_market(role: &str) -> bool {
1483        role.eq_ignore_ascii_case("market_clerk")
1484    }
1485
1486    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1487        vec![
1488            "Deposit…",
1489            "Withdraw…",
1490            "Deposit all",
1491            "Withdraw all",
1492            "Transfer…",
1493        ]
1494    }
1495
1496    pub fn storage_menu_options(&self) -> Vec<String> {
1497        let mut opts = vec!["Store…".into(), "Take…".into()];
1498        if let Some(panel) = &self.storage_panel {
1499            for dest in &panel.ship_destinations {
1500                opts.push(format!(
1501                    "Ship → {} ({} cp / {} ticks)",
1502                    dest.label, dest.fee_copper, dest.travel_ticks
1503                ));
1504            }
1505        }
1506        opts
1507    }
1508
1509    /// Loose on-person stacks eligible for town-storage store.
1510    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1511        self.person_rows()
1512            .into_iter()
1513            .filter(|r| r.depth == 0)
1514            .filter_map(|r| {
1515                let id = r.stack.item_instance_id?;
1516                Some(StoragePickOption {
1517                    item_instance_id: id,
1518                    label: storage_stack_label(&r.stack),
1519                    quantity: r.stack.quantity,
1520                    category: r.stack.category.clone().unwrap_or_default(),
1521                })
1522            })
1523            .collect()
1524    }
1525
1526    /// Vault stacks eligible for take / ship.
1527    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1528        let Some(panel) = &self.storage_panel else {
1529            return Vec::new();
1530        };
1531        panel
1532            .contents
1533            .iter()
1534            .filter_map(|s| {
1535                let id = s.item_instance_id?;
1536                Some(StoragePickOption {
1537                    item_instance_id: id,
1538                    label: storage_stack_label(s),
1539                    quantity: s.quantity,
1540                    category: s.category.clone().unwrap_or_default(),
1541                })
1542            })
1543            .collect()
1544    }
1545
1546    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1547    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1548        let mut opts = Vec::new();
1549        if !self
1550            .market_list_item_options(&MarketListSourceKind::Person)
1551            .is_empty()
1552        {
1553            opts.push((MarketListSourceKind::Person, "On person".into()));
1554        }
1555        if let Some(panel) = &self.market_panel {
1556            for vault in &panel.list_vaults {
1557                let source = MarketListSourceKind::TownStorage {
1558                    building_id: vault.building_id.clone(),
1559                };
1560                if self.market_list_item_options(&source).is_empty() {
1561                    continue;
1562                }
1563                let label = if vault.building_label.is_empty() {
1564                    format!("Town storage ({})", vault.building_id)
1565                } else {
1566                    format!("Town storage — {}", vault.building_label)
1567                };
1568                opts.push((source, label));
1569            }
1570        }
1571        opts
1572    }
1573
1574    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1575    pub fn market_list_item_options(
1576        &self,
1577        source: &MarketListSourceKind,
1578    ) -> Vec<StoragePickOption> {
1579        let filter = self.market_filter.as_str();
1580        let cat_filter = self.market_category_filter;
1581        let mut opts: Vec<StoragePickOption> = match source {
1582            MarketListSourceKind::Person => self
1583                .person_rows()
1584                .into_iter()
1585                .filter(|r| r.depth == 0)
1586                .filter(|r| self.stack_is_market_listable(&r.stack))
1587                .filter_map(|r| {
1588                    let id = r.stack.item_instance_id?;
1589                    Some(StoragePickOption {
1590                        item_instance_id: id,
1591                        label: storage_stack_label(&r.stack),
1592                        quantity: r.stack.quantity,
1593                        category: r
1594                            .stack
1595                            .category
1596                            .clone()
1597                            .or_else(|| {
1598                                self.inventory_item_category(&r.stack.template_id)
1599                                    .map(str::to_string)
1600                            })
1601                            .unwrap_or_default(),
1602                    })
1603                })
1604                .collect(),
1605            MarketListSourceKind::TownStorage { building_id } => {
1606                let Some(panel) = &self.market_panel else {
1607                    return Vec::new();
1608                };
1609                let Some(vault) = panel
1610                    .list_vaults
1611                    .iter()
1612                    .find(|v| &v.building_id == building_id)
1613                else {
1614                    return Vec::new();
1615                };
1616                vault
1617                    .contents
1618                    .iter()
1619                    .filter(|s| self.stack_is_market_listable(s))
1620                    .filter_map(|s| {
1621                        let id = s.item_instance_id?;
1622                        Some(StoragePickOption {
1623                            item_instance_id: id,
1624                            label: storage_stack_label(s),
1625                            quantity: s.quantity,
1626                            category: s
1627                                .category
1628                                .clone()
1629                                .or_else(|| {
1630                                    self.inventory_item_category(&s.template_id)
1631                                        .map(str::to_string)
1632                                })
1633                                .unwrap_or_default(),
1634                        })
1635                    })
1636                    .collect()
1637            }
1638        };
1639        opts.retain(|o| {
1640            if !list_label_matches(&o.label, filter) {
1641                return false;
1642            }
1643            if let Some(group) = cat_filter {
1644                inventory_category_group(&o.category).0 == group
1645            } else {
1646                true
1647            }
1648        });
1649        opts
1650    }
1651
1652    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1653        if crate::currency::is_currency(&stack.template_id) {
1654            return false;
1655        }
1656        if let Some(flag) = stack.listable {
1657            return flag;
1658        }
1659        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1660            return hint.listable;
1661        }
1662        let cat = stack
1663            .category
1664            .as_deref()
1665            .or_else(|| self.inventory_item_category(&stack.template_id))
1666            .unwrap_or("");
1667        category_default_listable(cat)
1668    }
1669
1670    /// Category group labels present in the current browse book or list-pick source.
1671    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1672        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1673        match &self.market_ui_mode {
1674            MarketUiMode::ListPick { source, .. } => {
1675                let raw: Vec<_> = match source {
1676                    MarketListSourceKind::Person => self
1677                        .person_rows()
1678                        .into_iter()
1679                        .filter(|r| r.depth == 0)
1680                        .filter(|r| self.stack_is_market_listable(&r.stack))
1681                        .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1682                        .map(|r| {
1683                            r.stack
1684                                .category
1685                                .clone()
1686                                .or_else(|| {
1687                                    self.inventory_item_category(&r.stack.template_id)
1688                                        .map(str::to_string)
1689                                })
1690                                .unwrap_or_default()
1691                        })
1692                        .collect(),
1693                    MarketListSourceKind::TownStorage { building_id } => self
1694                        .market_panel
1695                        .as_ref()
1696                        .and_then(|p| {
1697                            p.list_vaults
1698                                .iter()
1699                                .find(|v| &v.building_id == building_id)
1700                        })
1701                        .map(|vault| {
1702                            vault
1703                                .contents
1704                                .iter()
1705                                .filter(|s| self.stack_is_market_listable(s))
1706                                .filter(|s| {
1707                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
1708                                })
1709                                .map(|s| {
1710                                    s.category
1711                                        .clone()
1712                                        .or_else(|| {
1713                                            self.inventory_item_category(&s.template_id)
1714                                                .map(str::to_string)
1715                                        })
1716                                        .unwrap_or_default()
1717                                })
1718                                .collect::<Vec<_>>()
1719                        })
1720                        .unwrap_or_default(),
1721                };
1722                for category in raw {
1723                    let (label, ord) = inventory_category_group(&category);
1724                    seen.insert(ord, label);
1725                }
1726            }
1727            _ => {
1728                if let Some(panel) = &self.market_panel {
1729                    for listing in &panel.listings {
1730                        if !list_label_matches(&listing.display_name, &self.market_filter)
1731                            && !list_label_matches(&listing.seller_label, &self.market_filter)
1732                        {
1733                            continue;
1734                        }
1735                        let (label, ord) = inventory_category_group(&listing.category);
1736                        seen.insert(ord, label);
1737                    }
1738                }
1739            }
1740        }
1741        seen.into_values().collect()
1742    }
1743
1744    /// Indices into `market_panel.listings` after category + text filter.
1745    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1746        let Some(panel) = &self.market_panel else {
1747            return Vec::new();
1748        };
1749        let filter = self.market_filter.as_str();
1750        let cat_filter = self.market_category_filter;
1751        panel
1752            .listings
1753            .iter()
1754            .enumerate()
1755            .filter(|(_, listing)| {
1756                if !list_label_matches(&listing.display_name, filter)
1757                    && !list_label_matches(&listing.seller_label, filter)
1758                    && !list_label_matches(&listing.template_id, filter)
1759                {
1760                    return false;
1761                }
1762                if let Some(group) = cat_filter {
1763                    inventory_category_group(&listing.category).0 == group
1764                } else {
1765                    true
1766                }
1767            })
1768            .map(|(i, _)| i)
1769            .collect()
1770    }
1771
1772    pub fn clear_harvest_state(&mut self) {
1773        self.harvest_in_progress = false;
1774        self.harvest_started_at = None;
1775    }
1776
1777    fn harvest_state_stale(&self) -> bool {
1778        match self.harvest_started_at {
1779            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1780            None => self.harvest_in_progress,
1781        }
1782    }
1783
1784    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1785        self.player.as_ref().and_then(|p| p.vitals)
1786    }
1787
1788    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1789        let materials_ok = blueprint.inputs.iter().all(|input| {
1790            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1791        });
1792        let tools_ok = blueprint
1793            .required_tools
1794            .iter()
1795            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1796        let station_ok = match blueprint.station.as_deref() {
1797            None | Some("hand") => true,
1798            Some(tag) => self.player_at_station_tag(tag),
1799        };
1800        materials_ok && tools_ok && station_ok
1801    }
1802
1803    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1804        if !self.can_craft_blueprint(blueprint) {
1805            return 0;
1806        }
1807        let mut limit = u32::MAX;
1808        for input in &blueprint.inputs {
1809            if input.quantity == 0 {
1810                continue;
1811            }
1812            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1813            limit = limit.min(have / input.quantity);
1814        }
1815        for tool in &blueprint.required_tools {
1816            if tool.consumed {
1817                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1818                limit = limit.min(have);
1819            }
1820        }
1821        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1822        if CRAFT_STAMINA_COST > 0.0 {
1823            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1824        }
1825        limit
1826    }
1827
1828    pub fn clamp_craft_batch_quantity(&mut self) {
1829        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1830            self.craft_batch_quantity = 1;
1831            return;
1832        };
1833        let max = self.max_craft_batches(bp).max(1);
1834        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1835    }
1836
1837    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1838        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1839            return;
1840        };
1841        let max = self.max_craft_batches(&bp).max(1);
1842        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1843        self.craft_batch_quantity = next as u32;
1844    }
1845
1846    pub fn craft_batch_set_max(&mut self) {
1847        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1848            return;
1849        };
1850        let max = self.max_craft_batches(&bp);
1851        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1852    }
1853
1854    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1855        let preserve_ui = self.show_shop_menu;
1856        let tab = self.shop_tab;
1857        let index = self.shop_menu_index;
1858        let qty = self.shop_quantity;
1859
1860        self.show_shop_menu = true;
1861        self.bank_panel = None;
1862        self.show_craft_menu = false;
1863        self.show_inventory_menu = false;
1864        self.show_stats = false;
1865        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1866            self.npc_verb_target = Some(catalog.npc_id.clone());
1867        }
1868        self.shop_catalog = Some(catalog);
1869
1870        if preserve_ui {
1871            self.shop_tab = tab;
1872            self.shop_menu_index = index;
1873            self.shop_quantity = qty;
1874        } else {
1875            self.shop_tab = ShopTab::Buy;
1876            self.shop_menu_index = 0;
1877            self.shop_quantity = 1;
1878            self.clear_shop_trade_log();
1879        }
1880        self.show_npc_verb_menu = false;
1881        self.clamp_shop_selection();
1882    }
1883
1884    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1885        let same_teller = self
1886            .bank_panel
1887            .as_ref()
1888            .is_some_and(|p| p.npc_id == panel.npc_id);
1889        self.bank_panel = Some(panel);
1890        self.storage_panel = None;
1891        self.market_panel = None;
1892        self.shop_catalog = None;
1893        self.show_shop_menu = false;
1894        self.show_craft_menu = false;
1895        self.show_inventory_menu = false;
1896        self.show_stats = false;
1897        self.show_npc_verb_menu = false;
1898        self.show_npc_chat = false;
1899        self.npc_chat = None;
1900        if !same_teller {
1901            self.bank_menu_index = 0;
1902            self.bank_ui_mode = BankUiMode::Menu;
1903        }
1904        if let Some(panel) = &self.bank_panel {
1905            if self.npc_verb_target.is_none() {
1906                self.npc_verb_target = Some(panel.npc_id.clone());
1907            }
1908        }
1909    }
1910
1911    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1912        let same_manager = self
1913            .storage_panel
1914            .as_ref()
1915            .is_some_and(|p| p.npc_id == panel.npc_id);
1916        self.storage_panel = Some(panel);
1917        self.bank_panel = None;
1918        self.market_panel = None;
1919        self.bank_ui_mode = BankUiMode::Menu;
1920        self.shop_catalog = None;
1921        self.show_shop_menu = false;
1922        self.show_craft_menu = false;
1923        self.show_inventory_menu = false;
1924        self.show_stats = false;
1925        self.show_npc_verb_menu = false;
1926        self.show_npc_chat = false;
1927        self.npc_chat = None;
1928        if !same_manager {
1929            self.storage_menu_index = 0;
1930            self.storage_ui_mode = StorageUiMode::Menu;
1931        } else {
1932            self.clamp_storage_pick_index();
1933        }
1934        if let Some(panel) = &self.storage_panel {
1935            if self.npc_verb_target.is_none() {
1936                self.npc_verb_target = Some(panel.npc_id.clone());
1937            }
1938        }
1939    }
1940
1941    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
1942        self.market_panel = Some(panel);
1943        self.bank_panel = None;
1944        self.storage_panel = None;
1945        self.shop_catalog = None;
1946        self.show_shop_menu = false;
1947        self.show_craft_menu = false;
1948        self.show_inventory_menu = false;
1949        self.show_stats = false;
1950        self.show_npc_verb_menu = false;
1951        self.show_npc_chat = false;
1952        self.npc_chat = None;
1953        self.market_menu_index = 0;
1954        self.market_buy_confirm = None;
1955        self.market_ui_mode = MarketUiMode::Browse;
1956        self.market_filter.clear();
1957        self.market_filter_focused = false;
1958        self.market_category_filter = None;
1959        if let Some(panel) = &self.market_panel {
1960            if self.npc_verb_target.is_none() {
1961                self.npc_verb_target = Some(panel.npc_id.clone());
1962            }
1963        }
1964    }
1965
1966    pub fn clear_market_panel(&mut self) {
1967        self.market_panel = None;
1968        self.market_menu_index = 0;
1969        self.market_buy_confirm = None;
1970        self.market_ui_mode = MarketUiMode::Browse;
1971        self.market_filter.clear();
1972        self.market_filter_focused = false;
1973        self.market_category_filter = None;
1974    }
1975
1976    pub fn clear_bank_panel(&mut self) {
1977        self.bank_panel = None;
1978        self.bank_menu_index = 0;
1979        self.bank_ui_mode = BankUiMode::Menu;
1980    }
1981
1982    pub fn clear_storage_panel(&mut self) {
1983        self.storage_panel = None;
1984        self.storage_menu_index = 0;
1985        self.storage_ui_mode = StorageUiMode::Menu;
1986    }
1987
1988    fn clamp_storage_pick_index(&mut self) {
1989        match &self.storage_ui_mode {
1990            StorageUiMode::StorePick { index } => {
1991                let n = self.storage_store_options().len();
1992                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1993                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1994            }
1995            StorageUiMode::TakePick { index } => {
1996                let n = self.storage_vault_options().len();
1997                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1998                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1999            }
2000            StorageUiMode::ShipPick {
2001                dest_building_id,
2002                dest_label,
2003                index,
2004            } => {
2005                let n = self.storage_vault_options().len();
2006                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2007                self.storage_ui_mode = StorageUiMode::ShipPick {
2008                    dest_building_id: dest_building_id.clone(),
2009                    dest_label: dest_label.clone(),
2010                    index: next,
2011                };
2012            }
2013            StorageUiMode::Menu
2014            | StorageUiMode::StoreAmount { .. }
2015            | StorageUiMode::TakeAmount { .. }
2016            | StorageUiMode::ShipAmount { .. } => {}
2017        }
2018    }
2019
2020    pub fn shop_list_len(&self) -> usize {
2021        let Some(catalog) = &self.shop_catalog else {
2022            return 0;
2023        };
2024        match self.shop_tab {
2025            ShopTab::Buy => catalog.sells.len(),
2026            ShopTab::Sell => catalog.buys.len(),
2027        }
2028    }
2029
2030    pub fn shop_menu_move(&mut self, delta: i32) {
2031        let n = self.shop_list_len();
2032        if n == 0 {
2033            return;
2034        }
2035        let idx = self.shop_menu_index as i32;
2036        let next = (idx + delta).rem_euclid(n as i32);
2037        self.shop_menu_index = next as usize;
2038        self.clamp_shop_quantity();
2039    }
2040
2041    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2042        let max = self.shop_quantity_max();
2043        if max == 0 {
2044            self.shop_quantity = 0;
2045            return;
2046        }
2047        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2048        self.shop_quantity = next as u32;
2049    }
2050
2051    pub(crate) fn clamp_shop_selection(&mut self) {
2052        let n = self.shop_list_len();
2053        if n == 0 {
2054            self.shop_menu_index = 0;
2055        } else {
2056            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2057        }
2058        self.clamp_shop_quantity();
2059    }
2060
2061    fn shop_quantity_max(&self) -> u32 {
2062        let Some(catalog) = &self.shop_catalog else {
2063            return 1;
2064        };
2065        match self.shop_tab {
2066            ShopTab::Buy => {
2067                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2068                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2069                        return 1;
2070                    }
2071                }
2072                99
2073            }
2074            ShopTab::Sell => catalog
2075                .buys
2076                .get(self.shop_menu_index)
2077                .map(|l| l.quantity)
2078                .unwrap_or(0),
2079        }
2080    }
2081
2082    pub fn shop_quantity_set_max(&mut self) {
2083        self.shop_quantity = self.shop_quantity_max();
2084    }
2085
2086    fn clamp_shop_quantity(&mut self) {
2087        let max = self.shop_quantity_max();
2088        if max == 0 {
2089            self.shop_quantity = 0;
2090        } else {
2091            self.shop_quantity = self.shop_quantity.max(1).min(max);
2092        }
2093    }
2094
2095    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2096        let Some(id) = self.effective_inside_building() else {
2097            return false;
2098        };
2099        self.buildings
2100            .iter()
2101            .find(|b| b.id == id)
2102            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2103    }
2104
2105    /// Short hint for UI when a recipe cannot be started.
2106    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2107        if self.can_craft_blueprint(blueprint) {
2108            return None;
2109        }
2110        let mut missing = Vec::new();
2111        for input in &blueprint.inputs {
2112            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2113            if have < input.quantity {
2114                missing.push(format!(
2115                    "{}×{} (have {have})",
2116                    input.quantity, input.template_id
2117                ));
2118            }
2119        }
2120        for tool in &blueprint.required_tools {
2121            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2122            if have < 1 {
2123                missing.push(format!("tool: {}", tool.item));
2124            }
2125        }
2126        if let Some(station) = blueprint.station.as_deref() {
2127            if station != "hand" && !self.player_at_station_tag(station) {
2128                missing.push(format!("station: {station} (enter building)"));
2129            }
2130        }
2131        if missing.is_empty() {
2132            None
2133        } else {
2134            Some(missing.join(", "))
2135        }
2136    }
2137
2138    pub fn player_entity(&self) -> Option<&EntityState> {
2139        self.player
2140            .as_ref()
2141            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2142    }
2143
2144    /// Apply persisted HUD / workers UI prefs from `client.json`.
2145    pub fn apply_client_ui_prefs(&mut self) {
2146        let cfg = crate::client_config::ClientConfig::load();
2147        if let Some(hidden) = cfg.hud_log_hidden {
2148            self.hud_log_hidden = hidden;
2149        }
2150        if let Some(compact) = cfg.workers_menu_compact {
2151            self.workers_menu_compact = compact;
2152        }
2153    }
2154
2155    pub fn player_position(&self) -> (f32, f32) {
2156        let (x, y, _) = self.player_position_with_z();
2157        (x, y)
2158    }
2159
2160    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2161        if let Some(p) = self.player_entity() {
2162            (
2163                p.transform.position.x,
2164                p.transform.position.y,
2165                p.transform.position.z,
2166            )
2167        } else {
2168            (0.0, 0.0, 0.0)
2169        }
2170    }
2171
2172    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2173        let mut rows: Vec<(String, u32, String)> = self
2174            .inventory
2175            .iter()
2176            .filter(|(_, q)| **q > 0)
2177            .map(|(id, qty)| {
2178                let label = self
2179                    .inventory_hints
2180                    .get(id)
2181                    .map(|h| h.display_name.clone())
2182                    .unwrap_or_else(|| id.clone());
2183                (id.clone(), *qty, label)
2184            })
2185            .collect();
2186        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2187        rows
2188    }
2189
2190    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2191        self.inventory_hints
2192            .get(template_id)
2193            .map(|h| h.category.as_str())
2194            .filter(|c| !c.is_empty())
2195    }
2196
2197    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2198        stack
2199            .props
2200            .get("grants_item_status_effect")
2201            .map(|s| !s.is_empty())
2202            .unwrap_or(false)
2203    }
2204
2205    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2206        stack
2207            .props
2208            .get("grants_item_status_effect")
2209            .map(String::as_str)
2210            .filter(|s| !s.is_empty())
2211    }
2212
2213    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2214        stack
2215            .props
2216            .get("grants_item_status_mode")
2217            .map(String::as_str)
2218            .unwrap_or("on_hit")
2219    }
2220
2221    /// Candidate gear for a grant consumable (inventory + worn).
2222    pub fn grant_target_options(
2223        &self,
2224        grant: &flatland_protocol::ItemStack,
2225    ) -> Vec<GrantTargetOption> {
2226        let mode = Self::grant_mode(grant);
2227        let grant_tags: Vec<&str> = grant
2228            .props
2229            .get("grants_item_status_tags")
2230            .map(|s| {
2231                s.split(',')
2232                    .map(str::trim)
2233                    .filter(|t| !t.is_empty())
2234                    .collect()
2235            })
2236            .unwrap_or_default();
2237        let grant_id = grant.item_instance_id;
2238        let mut out = Vec::new();
2239        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2240            let Some(iid) = stack.item_instance_id else {
2241                return;
2242            };
2243            if Some(iid) == grant_id {
2244                return;
2245            }
2246            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2247                return;
2248            }
2249            if !grant_target_matches_mode(stack, mode) {
2250                return;
2251            }
2252            if !grant_tags_match(stack, &grant_tags) {
2253                return;
2254            }
2255            let name = stack
2256                .display_name
2257                .clone()
2258                .unwrap_or_else(|| stack.template_id.clone());
2259            let bindings = if stack.status_bindings.is_empty() {
2260                String::new()
2261            } else {
2262                format!(
2263                    " · {}",
2264                    stack
2265                        .status_bindings
2266                        .iter()
2267                        .map(|b| b.effect_id.as_str())
2268                        .collect::<Vec<_>>()
2269                        .join(", ")
2270                )
2271            };
2272            out.push(GrantTargetOption {
2273                label: format!("{where_label}: {name}{bindings}"),
2274                target_instance_id: iid,
2275            });
2276        };
2277        fn walk(
2278            stacks: &[flatland_protocol::ItemStack],
2279            where_label: &str,
2280            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2281        ) {
2282            for s in stacks {
2283                push(s, where_label);
2284                if !s.contents.is_empty() {
2285                    let nested = format!(
2286                        "{where_label}/{}",
2287                        s.display_name
2288                            .as_deref()
2289                            .unwrap_or(s.template_id.as_str())
2290                    );
2291                    walk(&s.contents, &nested, push);
2292                }
2293            }
2294        }
2295        walk(&self.inventory_stacks, "Bag", &mut push);
2296        for (slot, stack) in &self.worn {
2297            push(stack, body_slot_label(*slot));
2298            let nest = format!(
2299                "{}/{}",
2300                body_slot_label(*slot),
2301                stack
2302                    .display_name
2303                    .as_deref()
2304                    .unwrap_or(stack.template_id.as_str())
2305            );
2306            walk(&stack.contents, &nest, &mut push);
2307        }
2308        out
2309    }
2310
2311    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2312        self.inventory_hints
2313            .get(template_id)
2314            .and_then(|h| h.base_mass)
2315            .unwrap_or(0.5)
2316    }
2317
2318    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2319        self.inventory_hints
2320            .get(template_id)
2321            .and_then(|h| h.base_volume)
2322            .unwrap_or(1.0)
2323    }
2324
2325    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2326        let unit = stack
2327            .base_mass
2328            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2329        unit * stack.quantity as f32
2330    }
2331
2332    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2333        let unit = stack.base_volume.unwrap_or(1.0);
2334        unit * stack.quantity as f32
2335            + stack
2336                .contents
2337                .iter()
2338                .map(Self::stack_tree_volume)
2339                .sum::<f32>()
2340    }
2341
2342    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2343        contents.iter().map(Self::stack_tree_volume).sum()
2344    }
2345
2346    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2347        self.inventory_hints
2348            .get(template_id)
2349            .and_then(|h| h.capacity_volume)
2350            .filter(|c| *c > 0.0)
2351    }
2352
2353    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2354        stack
2355            .capacity_volume
2356            .filter(|c| *c > 0.0)
2357            .or_else(|| self.template_capacity_volume(&stack.template_id))
2358    }
2359
2360    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2361    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2362        let Some((used, cap)) = self.container_volume_stats(row) else {
2363            return String::new();
2364        };
2365        let free = (cap - used).max(0.0);
2366        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2367    }
2368
2369    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2370        if row.is_chest_shell {
2371            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2372                return None;
2373            };
2374            let chest = self
2375                .placed_containers
2376                .iter()
2377                .find(|c| c.id == *container_id)?;
2378            let cap = self
2379                .stack_capacity_volume(&row.stack)
2380                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2381            let used = if chest.accessible {
2382                Self::contents_used_volume(&chest.contents)
2383            } else {
2384                0.0
2385            };
2386            return Some((used, cap));
2387        }
2388
2389        let cap = self.stack_capacity_volume(&row.stack)?;
2390        let used = Self::contents_used_volume(&row.stack.contents);
2391        Some((used, cap))
2392    }
2393
2394    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2395        if row.is_chest_shell {
2396            return true;
2397        }
2398        if row.is_equip_shell {
2399            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2400        }
2401        self.inventory_item_category(&row.stack.template_id) == Some("container")
2402            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2403    }
2404
2405    fn container_stack_for(
2406        &self,
2407        location: &flatland_protocol::InventoryLocation,
2408        parent_instance_id: Option<uuid::Uuid>,
2409    ) -> Option<flatland_protocol::ItemStack> {
2410        match location {
2411            flatland_protocol::InventoryLocation::Root => {
2412                let pid = parent_instance_id?;
2413                self.find_stack_by_instance(&self.inventory_stacks, pid)
2414            }
2415            flatland_protocol::InventoryLocation::Worn { slot } => {
2416                let worn = self.worn.get(slot)?;
2417                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2418                    Some(worn.clone())
2419                } else {
2420                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2421                }
2422            }
2423            flatland_protocol::InventoryLocation::Placed { container_id } => {
2424                let chest = self
2425                    .placed_containers
2426                    .iter()
2427                    .find(|c| c.id == *container_id)?;
2428                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2429                    Some(flatland_protocol::ItemStack {
2430                        template_id: chest.template_id.clone(),
2431                        quantity: 1,
2432                        item_instance_id: chest.item_instance_id,
2433                        props: Default::default(),
2434                        status_bindings: Vec::new(),
2435                        contents: chest.contents.clone(),
2436                        display_name: Some(chest.display_name.clone()),
2437                        category: Some("container".into()),
2438                        capacity_volume: self
2439                            .inventory_hints
2440                            .get(&chest.template_id)
2441                            .and_then(|h| h.capacity_volume),
2442                        worker_lodging_capacity: chest.worker_lodging_capacity,
2443                        ..Default::default()
2444                    })
2445                } else {
2446                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2447                }
2448            }
2449            flatland_protocol::InventoryLocation::Keychain => None,
2450            flatland_protocol::InventoryLocation::WhisperPouch => None,
2451        }
2452    }
2453
2454    fn find_stack_by_instance(
2455        &self,
2456        stacks: &[flatland_protocol::ItemStack],
2457        instance_id: uuid::Uuid,
2458    ) -> Option<flatland_protocol::ItemStack> {
2459        for stack in stacks {
2460            if stack.item_instance_id == Some(instance_id) {
2461                return Some(stack.clone());
2462            }
2463            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2464                return Some(found);
2465            }
2466        }
2467        None
2468    }
2469
2470    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2471    pub fn max_movable_to(
2472        &self,
2473        template_id: &str,
2474        stack_qty: u32,
2475        from: &flatland_protocol::InventoryLocation,
2476        to: &flatland_protocol::InventoryLocation,
2477        parent_instance_id: Option<uuid::Uuid>,
2478    ) -> u32 {
2479        let unit_vol = self.item_base_volume(template_id);
2480        let unit_mass = self.item_base_mass(template_id);
2481        let mut limit = stack_qty;
2482
2483        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2484            let cap = parent
2485                .capacity_volume
2486                .or_else(|| {
2487                    self.inventory_hints
2488                        .get(&parent.template_id)
2489                        .and_then(|h| h.capacity_volume)
2490                })
2491                .unwrap_or(0.0);
2492            if cap > 0.0 && unit_vol > 0.0 {
2493                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2494                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2495            }
2496        }
2497
2498        let to_person = matches!(
2499            to,
2500            flatland_protocol::InventoryLocation::Root
2501                | flatland_protocol::InventoryLocation::Worn { .. }
2502        );
2503        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2504        if to_person && from_placed && unit_mass > 0.0 {
2505            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2506            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2507                limit = 0;
2508            } else {
2509                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2510            }
2511        }
2512
2513        limit.max(0).min(stack_qty)
2514    }
2515
2516    pub fn move_picker_max_at_selection(&self) -> u32 {
2517        let Some(picker) = &self.move_picker else {
2518            return 1;
2519        };
2520        let Some(opt) = picker.options.get(self.move_picker_index) else {
2521            return picker.stack_quantity;
2522        };
2523        match &opt.kind {
2524            MoveOptionKind::Cancel
2525            | MoveOptionKind::Drop
2526            | MoveOptionKind::Use
2527            | MoveOptionKind::GrantApply
2528            | MoveOptionKind::SellPlotToCrown { .. }
2529            | MoveOptionKind::PickupPlaced { .. }
2530            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2531            MoveOptionKind::Move {
2532                location,
2533                parent_instance_id,
2534            } => self.max_movable_to(
2535                &picker.template_id,
2536                picker.stack_quantity,
2537                &picker.from,
2538                location,
2539                *parent_instance_id,
2540            ),
2541        }
2542    }
2543
2544    pub fn clamp_move_picker_quantity(&mut self) {
2545        let max = self.move_picker_max_at_selection();
2546        if let Some(picker) = &mut self.move_picker {
2547            if max == 0 {
2548                picker.quantity = 1;
2549            } else {
2550                picker.quantity = picker.quantity.clamp(1, max);
2551            }
2552        }
2553    }
2554
2555    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2556        let max = self.move_picker_max_at_selection().max(1);
2557        if let Some(picker) = &mut self.move_picker {
2558            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2559            picker.quantity = next as u32;
2560        }
2561    }
2562
2563    pub fn move_picker_set_quantity_max(&mut self) {
2564        let max = self.move_picker_max_at_selection();
2565        if let Some(picker) = &mut self.move_picker {
2566            picker.quantity = if max == 0 {
2567                1
2568            } else {
2569                max.min(picker.stack_quantity)
2570            };
2571        }
2572    }
2573
2574    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2575        if let Some(picker) = &mut self.destroy_picker {
2576            let max = picker.stack_quantity.max(1);
2577            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2578            picker.quantity = next as u32;
2579        }
2580    }
2581
2582    pub fn destroy_picker_set_quantity_max(&mut self) {
2583        if let Some(picker) = &mut self.destroy_picker {
2584            picker.quantity = picker.stack_quantity.max(1);
2585        }
2586    }
2587
2588    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2589        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2590        (have, have >= need)
2591    }
2592
2593    pub fn currency_display(&self) -> String {
2594        crate::currency::currency_line(&self.inventory)
2595    }
2596
2597    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2598    pub fn in_shallow_water(&self) -> bool {
2599        let (px, py) = self.player_position();
2600        self.terrain_at(px, py)
2601            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2602    }
2603
2604    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2605        self.terrain_zone_at(x, y).map(|z| z.kind)
2606    }
2607
2608    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2609    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2610        use std::cell::RefCell;
2611
2612        const CHUNK: i32 = 8;
2613        thread_local! {
2614            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2615                RefCell::new(None);
2616        }
2617
2618        let zones = &self.terrain_zones;
2619        if zones.is_empty() {
2620            return None;
2621        }
2622        if zones.len() <= 48 {
2623            return zones
2624                .iter()
2625                .enumerate()
2626                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2627                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2628                .map(|(_, z)| z);
2629        }
2630
2631        let ptr = zones.as_ptr();
2632        let len = zones.len();
2633        INDEX.with(|cell| {
2634            let mut slot = cell.borrow_mut();
2635            let stale = match slot.as_ref() {
2636                Some((p, l, _)) => *p != ptr || *l != len,
2637                None => true,
2638            };
2639            if stale {
2640                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2641                    std::collections::HashMap::new();
2642                for (zi, z) in zones.iter().enumerate() {
2643                    let x0 = z.x0.min(z.x1).floor() as i32;
2644                    let y0 = z.y0.min(z.y1).floor() as i32;
2645                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2646                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2647                    let cx0 = x0.div_euclid(CHUNK);
2648                    let cy0 = y0.div_euclid(CHUNK);
2649                    let cx1 = x1.div_euclid(CHUNK);
2650                    let cy1 = y1.div_euclid(CHUNK);
2651                    for cy in cy0..=cy1 {
2652                        for cx in cx0..=cx1 {
2653                            chunks.entry((cx, cy)).or_default().push(zi);
2654                        }
2655                    }
2656                }
2657                *slot = Some((ptr, len, chunks));
2658            }
2659            let chunks = &slot.as_ref().expect("index").2;
2660            let cx = (x.floor() as i32).div_euclid(CHUNK);
2661            let cy = (y.floor() as i32).div_euclid(CHUNK);
2662            let mut best: Option<(usize, &TerrainZoneView)> = None;
2663            if let Some(list) = chunks.get(&(cx, cy)) {
2664                for &zi in list {
2665                    let Some(z) = zones.get(zi) else { continue };
2666                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2667                        continue;
2668                    }
2669                    best = match best {
2670                        None => Some((zi, z)),
2671                        Some((bi, bz)) => {
2672                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2673                                Some((zi, z))
2674                            } else {
2675                                Some((bi, bz))
2676                            }
2677                        }
2678                    };
2679                }
2680            }
2681            best.map(|(_, z)| z)
2682        })
2683    }
2684
2685    /// Ground elevation from terrain zones (m).
2686    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2687        self.terrain_zone_at(x, y)
2688            .map(|z| z.elevation)
2689            .unwrap_or(0.0)
2690    }
2691
2692    /// Walkable z levels at a map column (terrain + platforms).
2693    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2694        const TOL: f32 = 0.35;
2695        let mut levels = vec![self.elevation_at(x, y)];
2696        for p in &self.z_platforms {
2697            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2698                levels.push(p.z);
2699            }
2700        }
2701        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2702        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2703        levels
2704    }
2705
2706    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2707        const TOL: f32 = 0.35;
2708        self.walkable_levels_at(x, y)
2709            .iter()
2710            .any(|&l| (l - z).abs() <= TOL)
2711    }
2712
2713    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2714        let mut top = self.elevation_at(x, y);
2715        for p in &self.z_platforms {
2716            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2717                top = top.max(p.z);
2718            }
2719        }
2720        top
2721    }
2722
2723    /// Authoritative interior context from the server (`inside_building` flag).
2724    pub fn effective_inside_building(&self) -> Option<String> {
2725        self.player_entity().and_then(|p| p.inside_building.clone())
2726    }
2727
2728    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2729        self.inventory_stacks = stacks.to_vec();
2730        self.inventory.clear();
2731        self.inventory_hints.clear();
2732        fn walk(
2733            stacks: &[flatland_protocol::ItemStack],
2734            inventory: &mut std::collections::HashMap<String, u32>,
2735            hints: &mut std::collections::HashMap<String, InventoryHint>,
2736        ) {
2737            for stack in stacks {
2738                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2739                if stack.display_name.is_some()
2740                    || stack.category.is_some()
2741                    || stack.base_mass.is_some()
2742                    || stack.base_volume.is_some()
2743                {
2744                    hints.insert(
2745                        stack.template_id.clone(),
2746                        InventoryHint {
2747                            display_name: stack
2748                                .display_name
2749                                .clone()
2750                                .unwrap_or_else(|| stack.template_id.clone()),
2751                            category: stack.category.clone().unwrap_or_default(),
2752                            base_mass: stack.base_mass,
2753                            base_volume: stack.base_volume,
2754                            capacity_volume: stack.capacity_volume,
2755                            stackable: stack.stackable.unwrap_or(true),
2756                            listable: stack.listable.unwrap_or_else(|| {
2757                                category_default_listable(
2758                                    stack.category.as_deref().unwrap_or(""),
2759                                )
2760                            }),
2761                        },
2762                    );
2763                }
2764                walk(&stack.contents, inventory, hints);
2765            }
2766        }
2767        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2768        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
2769        for item in self.worn.values() {
2770            walk(
2771                std::slice::from_ref(item),
2772                &mut self.inventory,
2773                &mut self.inventory_hints,
2774            );
2775        }
2776    }
2777
2778    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
2779    /// inventory UI updates before the next tick snapshot arrives.
2780    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2781        let subtract_items =
2782            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2783        for stack in &notice.inventory_delta {
2784            if stack.quantity == 0 {
2785                continue;
2786            }
2787            if subtract_items {
2788                crate::currency::drain_template_stacks(
2789                    &mut self.inventory_stacks,
2790                    &stack.template_id,
2791                    stack.quantity,
2792                );
2793                continue;
2794            }
2795            let stackable = self
2796                .inventory_hints
2797                .get(&stack.template_id)
2798                .map(|h| h.stackable)
2799                .or(stack.stackable)
2800                .unwrap_or(true);
2801            if stackable {
2802                if let Some(existing) = self
2803                    .inventory_stacks
2804                    .iter_mut()
2805                    .find(|s| s.template_id == stack.template_id)
2806                {
2807                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
2808                    if stack.display_name.is_some() {
2809                        existing.display_name = stack.display_name.clone();
2810                    }
2811                    if stack.category.is_some() {
2812                        existing.category = stack.category.clone();
2813                    }
2814                    continue;
2815                }
2816            }
2817            self.inventory_stacks.push(stack.clone());
2818        }
2819        if notice.coins_delta != 0 {
2820            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2821        }
2822        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2823            let stacks = self.inventory_stacks.clone();
2824            self.sync_inventory_from_stacks(&stacks);
2825        }
2826        self.record_shop_trade_notice(notice);
2827    }
2828
2829    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
2830    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
2831    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
2832    /// iteration alone gives a stable row order.
2833    pub fn worn_rows(&self) -> Vec<InventoryRow> {
2834        let mut rows = Vec::new();
2835        for (slot, item) in &self.worn {
2836            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2837            rows.push(InventoryRow {
2838                depth: 0,
2839                stack: item.clone(),
2840                from: from.clone(),
2841                from_parent_instance_id: None,
2842                is_equip_shell: true,
2843                is_chest_shell: false,
2844                section: InventorySection::Worn,
2845            });
2846            for child in &item.contents {
2847                push_inventory_rows(
2848                    &mut rows,
2849                    1,
2850                    child,
2851                    &from,
2852                    item.item_instance_id,
2853                    InventorySection::Worn,
2854                );
2855            }
2856        }
2857        rows
2858    }
2859
2860    /// Root on-person stacks that can be handed to a hired worker.
2861    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2862        self.inventory_stacks
2863            .iter()
2864            .filter_map(|stack| {
2865                let item_instance_id = stack.item_instance_id?;
2866                let label = stack
2867                    .display_name
2868                    .clone()
2869                    .unwrap_or_else(|| stack.template_id.clone());
2870                let label = if stack.quantity > 1 {
2871                    format!("{label} ×{}", stack.quantity)
2872                } else {
2873                    label
2874                };
2875                Some(WorkerGiveOption {
2876                    item_instance_id,
2877                    label,
2878                    quantity: stack.quantity,
2879                    template_id: stack.template_id.clone(),
2880                })
2881            })
2882            .collect()
2883    }
2884
2885    /// Employer-known blueprints the selected worker does not yet know.
2886    pub fn teachable_blueprint_options(
2887        &self,
2888        worker: &flatland_protocol::HiredWorkerView,
2889    ) -> Vec<WorkerTeachOption> {
2890        let copper = crate::currency::copper_from_counts(&self.inventory);
2891        let mut options: Vec<WorkerTeachOption> = self
2892            .blueprints
2893            .iter()
2894            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2895            .map(|bp| {
2896                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2897                let cost = bp.worker_train_copper;
2898                WorkerTeachOption {
2899                    blueprint_id: bp.id.clone(),
2900                    label: if bp.label.is_empty() {
2901                        bp.id.clone()
2902                    } else {
2903                        bp.label.clone()
2904                    },
2905                    cost_copper: cost,
2906                    min_level,
2907                    worker_level: worker.level,
2908                    can_afford: copper >= cost,
2909                    level_ok: worker.level >= min_level,
2910                }
2911            })
2912            .collect();
2913        options.sort_by(|a, b| a.label.cmp(&b.label));
2914        options
2915    }
2916
2917    /// Loose on-person inventory (not worn, not inside a placed chest).
2918    /// Root stacks are ordered by category group; nested contents stay under their parent.
2919    pub fn person_rows(&self) -> Vec<InventoryRow> {
2920        self.person_rows_filtered("")
2921    }
2922
2923    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2924        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2925        roots.sort_by(|a, b| {
2926            let ca = a
2927                .category
2928                .as_deref()
2929                .or_else(|| self.inventory_item_category(&a.template_id))
2930                .unwrap_or("");
2931            let cb = b
2932                .category
2933                .as_deref()
2934                .or_else(|| self.inventory_item_category(&b.template_id))
2935                .unwrap_or("");
2936            let ga = inventory_category_group(ca).1;
2937            let gb = inventory_category_group(cb).1;
2938            ga.cmp(&gb).then_with(|| {
2939                let na = a
2940                    .display_name
2941                    .as_deref()
2942                    .unwrap_or(a.template_id.as_str());
2943                let nb = b
2944                    .display_name
2945                    .as_deref()
2946                    .unwrap_or(b.template_id.as_str());
2947                na.cmp(nb)
2948            })
2949        });
2950        let mut rows = Vec::new();
2951        for stack in roots {
2952            push_inventory_rows_filtered(
2953                &mut rows,
2954                0,
2955                stack,
2956                &flatland_protocol::InventoryLocation::Root,
2957                None,
2958                InventorySection::Person,
2959                filter,
2960            );
2961        }
2962        rows
2963    }
2964
2965    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2966        if filter.is_empty() {
2967            return self.worn_rows();
2968        }
2969        let mut rows = Vec::new();
2970        for (slot, item) in &self.worn {
2971            if !stack_matches_filter(item, filter) {
2972                continue;
2973            }
2974            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2975            let self_hit = {
2976                let f = filter.to_ascii_lowercase();
2977                let name = item
2978                    .display_name
2979                    .as_deref()
2980                    .unwrap_or("")
2981                    .to_ascii_lowercase();
2982                let tid = item.template_id.to_ascii_lowercase();
2983                name.contains(&f) || tid.contains(&f)
2984            };
2985            rows.push(InventoryRow {
2986                depth: 0,
2987                stack: item.clone(),
2988                from: from.clone(),
2989                from_parent_instance_id: None,
2990                is_equip_shell: true,
2991                is_chest_shell: false,
2992                section: InventorySection::Worn,
2993            });
2994            for child in &item.contents {
2995                if self_hit || stack_matches_filter(child, filter) {
2996                    push_inventory_rows_filtered(
2997                        &mut rows,
2998                        1,
2999                        child,
3000                        &from,
3001                        item.item_instance_id,
3002                        InventorySection::Worn,
3003                        if self_hit { "" } else { filter },
3004                    );
3005                }
3006            }
3007        }
3008        rows
3009    }
3010
3011    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3012    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3013        let mut rows = self.worn_rows();
3014        rows.extend(self.person_rows());
3015        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3016    }
3017
3018    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3019    /// populated when `accessible` — this is what makes a chest's contents
3020    /// disappear the moment you walk away or it's locked without your key.
3021    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3022        let (px, py) = self.player_position();
3023        let mut list: Vec<NearbyContainer> = self
3024            .placed_containers
3025            .iter()
3026            .filter_map(|c| {
3027                let distance_m = (c.x - px).hypot(c.y - py);
3028                if distance_m > CONTAINER_RANGE_M {
3029                    return None;
3030                }
3031                let mut rows = Vec::new();
3032                let from = flatland_protocol::InventoryLocation::Placed {
3033                    container_id: c.id.clone(),
3034                };
3035                rows.push(InventoryRow {
3036                    depth: 0,
3037                    stack: flatland_protocol::ItemStack {
3038                        template_id: c.template_id.clone(),
3039                        quantity: 1,
3040                        item_instance_id: c.item_instance_id,
3041                        props: Default::default(),
3042                        status_bindings: Vec::new(),
3043                        contents: Vec::new(),
3044                        display_name: Some(c.display_name.clone()),
3045                        category: Some("container".into()),
3046                        capacity_volume: c.capacity_volume,
3047                        worker_lodging_capacity: c.worker_lodging_capacity,
3048                        ..Default::default()
3049                    },
3050                    from: from.clone(),
3051                    from_parent_instance_id: None,
3052                    is_equip_shell: false,
3053                    is_chest_shell: true,
3054                    section: InventorySection::Nearby,
3055                });
3056                if c.accessible {
3057                    for child in &c.contents {
3058                        push_inventory_rows(
3059                            &mut rows,
3060                            1,
3061                            child,
3062                            &from,
3063                            c.item_instance_id,
3064                            InventorySection::Nearby,
3065                        );
3066                    }
3067                }
3068                Some(NearbyContainer {
3069                    view: c.clone(),
3070                    distance_m,
3071                    rows,
3072                })
3073            })
3074            .collect();
3075        list.sort_by(|a, b| {
3076            a.distance_m
3077                .partial_cmp(&b.distance_m)
3078                .unwrap_or(std::cmp::Ordering::Equal)
3079        });
3080        list
3081    }
3082
3083    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3084    pub fn nearest_placed_container(
3085        &self,
3086        max_dist: f32,
3087    ) -> Option<flatland_protocol::PlacedContainerView> {
3088        let (px, py) = self.player_position();
3089        self.placed_containers
3090            .iter()
3091            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3092            .min_by(|a, b| {
3093                let da = (a.x - px).hypot(a.y - py);
3094                let db = (b.x - px).hypot(b.y - py);
3095                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3096            })
3097            .cloned()
3098    }
3099
3100    /// Selectable rows for the **active inventory tab** (and current filter).
3101    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3102    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3103        let filter = self.inventory_filter.as_str();
3104        match self.inventory_tab {
3105            InventoryTab::OnPerson => {
3106                let mut rows = self.worn_rows_filtered(filter);
3107                rows.extend(self.person_rows_filtered(filter));
3108                rows
3109            }
3110            InventoryTab::Nearby => {
3111                let mut rows = Vec::new();
3112                for nc in self.nearby_containers() {
3113                    if filter.is_empty() {
3114                        rows.extend(nc.rows);
3115                        continue;
3116                    }
3117                    let shell = nc.rows.first().cloned();
3118                    let contents: Vec<_> = nc
3119                        .rows
3120                        .iter()
3121                        .skip(1)
3122                        .filter(|r| stack_matches_filter(&r.stack, filter))
3123                        .cloned()
3124                        .collect();
3125                    let shell_hit = shell
3126                        .as_ref()
3127                        .map(|s| stack_matches_filter(&s.stack, filter))
3128                        .unwrap_or(false);
3129                    if shell_hit || !contents.is_empty() {
3130                        if let Some(s) = shell {
3131                            rows.push(s);
3132                        }
3133                        if shell_hit {
3134                            rows.extend(nc.rows.into_iter().skip(1));
3135                        } else {
3136                            rows.extend(contents);
3137                        }
3138                    }
3139                }
3140                rows
3141            }
3142        }
3143    }
3144
3145    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3146        self.inventory_selectable_rows()
3147            .into_iter()
3148            .nth(self.inventory_menu_index)
3149    }
3150
3151    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3152        let cat = self
3153            .inventory_item_category(&row.stack.template_id)
3154            .unwrap_or("");
3155        if cat == "key" {
3156            self.key_inventory_label(&row.stack)
3157        } else {
3158            row.stack
3159                .display_name
3160                .clone()
3161                .unwrap_or_else(|| row.stack.template_id.clone())
3162        }
3163    }
3164
3165    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3166    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3167        let bindings = format_status_bindings_suffix(
3168            &row.stack.status_bindings,
3169            self.tick,
3170            DEFAULT_TICK_HZ,
3171        );
3172        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3173            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3174            let mode = Self::grant_mode(&row.stack);
3175            format!(" [grant {effect} · {mode} — e apply]")
3176        } else {
3177            String::new()
3178        };
3179        let qty = if row.stack.quantity > 1 {
3180            format!(" ×{}", row.stack.quantity)
3181        } else {
3182            String::new()
3183        };
3184        let worn_slot = if row.is_equip_shell {
3185            match row.from {
3186                flatland_protocol::InventoryLocation::Worn { slot } => {
3187                    format!(" ({})", body_slot_label(slot))
3188                }
3189                _ => String::new(),
3190            }
3191        } else {
3192            String::new()
3193        };
3194        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3195    }
3196
3197    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3198        (
3199            row.stack.template_id.clone(),
3200            self.inventory_row_base_label(row),
3201            self.inventory_row_visible_mod_signature(row),
3202        )
3203    }
3204
3205    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3206    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3207        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3208        for row in self.inventory_selectable_rows() {
3209            if row.stack.item_instance_id.is_none() {
3210                continue;
3211            }
3212            let key = self.inventory_row_instance_identity_key(&row);
3213            *counts.entry(key).or_default() += 1;
3214        }
3215        counts
3216            .into_iter()
3217            .filter(|(_, n)| *n > 1)
3218            .map(|(k, _)| k)
3219            .collect()
3220    }
3221
3222    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3223        let hex: String = id
3224            .as_simple()
3225            .to_string()
3226            .chars()
3227            .filter(|c| c.is_ascii_hexdigit())
3228            .collect();
3229        let short = if hex.len() >= 4 {
3230            &hex[hex.len() - 4..]
3231        } else {
3232            hex.as_str()
3233        };
3234        format!("Instance {id} (#{short})")
3235    }
3236
3237    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3238    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3239        let cat = self
3240            .inventory_item_category(&row.stack.template_id)
3241            .unwrap_or("");
3242        let label = self.inventory_row_base_label(row);
3243        let hint: String = if row.is_equip_shell {
3244            " [worn — Enter to unequip]".into()
3245        } else if row.is_chest_shell {
3246            let (locked, lodging_note) = match &row.from {
3247                flatland_protocol::InventoryLocation::Placed { container_id } => {
3248                    let locked = self
3249                        .placed_containers
3250                        .iter()
3251                        .find(|c| c.id == *container_id)
3252                        .map(|c| c.locked)
3253                        .unwrap_or(false);
3254                    let lodging_note = self
3255                        .lodging_occupancy_label(container_id)
3256                        .map(|who| format!(" [lodging: {who}]"))
3257                        .unwrap_or_default();
3258                    (locked, lodging_note)
3259                }
3260                _ => (false, String::new()),
3261            };
3262            if locked {
3263                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3264            } else {
3265                format!(" [Enter pick up · l lock]{lodging_note}")
3266            }
3267        } else if cat == "key" {
3268            self.key_inventory_hint(&row.stack)
3269        } else {
3270            match cat {
3271                "weapon" => " [weapon]".into(),
3272                "container" => " [bag/chest/belt]".into(),
3273                "lodging" => " [worker lodging]".into(),
3274                "armor" => " [armor]".into(),
3275                _ => String::new(),
3276            }
3277        };
3278        let qty = if row.stack.quantity > 1 {
3279            format!(" ×{}", row.stack.quantity)
3280        } else {
3281            String::new()
3282        };
3283        let bindings = format_status_bindings_suffix(
3284            &row.stack.status_bindings,
3285            self.tick,
3286            DEFAULT_TICK_HZ,
3287        );
3288        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3289            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3290            let mode = Self::grant_mode(&row.stack);
3291            format!(" [grant {effect} · {mode} — e apply]")
3292        } else {
3293            String::new()
3294        };
3295        let mass = self.stack_mass(&row.stack);
3296        let mass_kg = (mass >= 0.05).then_some(mass);
3297        let mass_str = mass_kg
3298            .map(|m| format!("  {m:.1} kg"))
3299            .unwrap_or_default();
3300        let volume = self.container_volume_stats(row);
3301        let vol_str = self.container_volume_label(row);
3302
3303        let mut title = label.clone();
3304        title.push_str(&qty);
3305        if row.is_equip_shell {
3306            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3307                title.push_str(&format!(" ({})", body_slot_label(slot)));
3308            }
3309        }
3310
3311        InventoryRowView {
3312            depth: row.depth,
3313            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3314            title: format!("{title}{grant_hint}{bindings}"),
3315            mass_kg,
3316            volume,
3317            instance_tooltip: None,
3318        }
3319    }
3320
3321    fn push_browser_item(
3322        &self,
3323        lines: &mut Vec<InventoryBrowserLine>,
3324        row: &InventoryRow,
3325        global_idx: &mut usize,
3326        target: usize,
3327        highlight: bool,
3328        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3329    ) {
3330        let mut view = self.format_inventory_row(row);
3331        if let Some(id) = row.stack.item_instance_id {
3332            let key = self.inventory_row_instance_identity_key(row);
3333            if ambiguous_instance_keys.contains(&key) {
3334                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3335            }
3336        }
3337        lines.push(InventoryBrowserLine::Item {
3338            selectable_index: *global_idx,
3339            selected: highlight && *global_idx == target,
3340            depth: view.depth,
3341            text: view.text,
3342            title: view.title,
3343            mass_kg: view.mass_kg,
3344            volume: view.volume,
3345            instance_tooltip: view.instance_tooltip,
3346        });
3347        *global_idx += 1;
3348    }
3349
3350    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3351    /// `selectable_index` matching `inventory_menu_index`.
3352    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3353        let mut lines = Vec::new();
3354        let target = self.inventory_menu_index;
3355        let highlight = !self.show_move_picker && !self.show_grant_picker;
3356        let filter = self.inventory_filter.as_str();
3357        let mut global_idx = 0usize;
3358        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3359
3360        match self.inventory_tab {
3361            InventoryTab::OnPerson => {
3362                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3363                let worn = self.worn_rows_filtered(filter);
3364                if worn.is_empty() {
3365                    lines.push(InventoryBrowserLine::Hint(
3366                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3367                    ));
3368                } else {
3369                    for row in &worn {
3370                        if row.is_equip_shell {
3371                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3372                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3373                                    "  {}:",
3374                                    body_slot_label(slot)
3375                                )));
3376                            }
3377                        }
3378                        self.push_browser_item(
3379                            &mut lines,
3380                            row,
3381                            &mut global_idx,
3382                            target,
3383                            highlight,
3384                            &ambiguous_instance_keys,
3385                        );
3386                    }
3387                }
3388
3389                lines.push(InventoryBrowserLine::Blank);
3390                lines.push(InventoryBrowserLine::Section(
3391                    "— On you (loose, not worn) —".into(),
3392                ));
3393                let person = self.person_rows_filtered(filter);
3394                if person.is_empty() {
3395                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3396                } else {
3397                    let mut last_group: Option<&'static str> = None;
3398                    for row in &person {
3399                        if row.depth == 0 {
3400                            let cat = row
3401                                .stack
3402                                .category
3403                                .as_deref()
3404                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3405                                .unwrap_or("");
3406                            let (group, _) = inventory_category_group(cat);
3407                            if last_group != Some(group) {
3408                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3409                                    "  {group}"
3410                                )));
3411                                last_group = Some(group);
3412                            }
3413                        }
3414                        self.push_browser_item(
3415                            &mut lines,
3416                            row,
3417                            &mut global_idx,
3418                            target,
3419                            highlight,
3420                            &ambiguous_instance_keys,
3421                        );
3422                    }
3423                }
3424            }
3425            InventoryTab::Nearby => {
3426                let nearby = self.nearby_containers();
3427                if nearby.is_empty() {
3428                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3429                    lines.push(InventoryBrowserLine::Hint(
3430                        "  (none within reach — walk up to a chest)".into(),
3431                    ));
3432                    lines.push(InventoryBrowserLine::Hint(
3433                        "  Select an on-person item, then m / Enter → move into chest.".into(),
3434                    ));
3435                } else {
3436                    let mut any_visible = false;
3437                    for nc in &nearby {
3438                        let shell = nc.rows.first();
3439                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
3440                            nc.rows.iter().skip(1).collect()
3441                        } else {
3442                            let shell_hit = shell
3443                                .map(|s| {
3444                                    let f = filter.to_ascii_lowercase();
3445                                    let name = s
3446                                        .stack
3447                                        .display_name
3448                                        .as_deref()
3449                                        .unwrap_or("")
3450                                        .to_ascii_lowercase();
3451                                    let tid = s.stack.template_id.to_ascii_lowercase();
3452                                    name.contains(&f) || tid.contains(&f)
3453                                })
3454                                .unwrap_or(false);
3455                            if shell_hit {
3456                                nc.rows.iter().skip(1).collect()
3457                            } else {
3458                                nc.rows
3459                                    .iter()
3460                                    .skip(1)
3461                                    .filter(|r| stack_matches_filter(&r.stack, filter))
3462                                    .collect()
3463                            }
3464                        };
3465                        let shell_visible = filter.is_empty()
3466                            || shell
3467                                .map(|s| stack_matches_filter(&s.stack, filter))
3468                                .unwrap_or(false)
3469                            || !contents.is_empty();
3470                        if !shell_visible && shell.is_some() {
3471                            continue;
3472                        }
3473                        any_visible = true;
3474                        lines.push(InventoryBrowserLine::Blank);
3475                        let lock_note = if nc.view.locked && nc.view.accessible {
3476                            "  unlocked with your key"
3477                        } else if nc.view.locked {
3478                            "  locked"
3479                        } else {
3480                            ""
3481                        };
3482                        lines.push(InventoryBrowserLine::Section(format!(
3483                            "— {} ({:.0}m away){lock_note} —",
3484                            nc.view.display_name, nc.distance_m
3485                        )));
3486                        if !nc.view.accessible {
3487                            lines.push(InventoryBrowserLine::Hint(
3488                                "  locked — need the matching key (l to try)".into(),
3489                            ));
3490                        } else if nc.rows.is_empty() {
3491                            lines.push(InventoryBrowserLine::Hint(
3492                                "  (empty — switch to On person, select an item, m to move in)"
3493                                    .into(),
3494                            ));
3495                        } else if let Some(shell_row) = shell {
3496                            self.push_browser_item(
3497                                &mut lines,
3498                                shell_row,
3499                                &mut global_idx,
3500                                target,
3501                                highlight,
3502                                &ambiguous_instance_keys,
3503                            );
3504                            for row in contents {
3505                                self.push_browser_item(
3506                                    &mut lines,
3507                                    row,
3508                                    &mut global_idx,
3509                                    target,
3510                                    highlight,
3511                                    &ambiguous_instance_keys,
3512                                );
3513                            }
3514                        }
3515                    }
3516                    if !any_visible {
3517                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3518                        lines.push(InventoryBrowserLine::Hint(
3519                            "  (no matching items — clear filter with Esc)".into(),
3520                        ));
3521                    }
3522                }
3523            }
3524        }
3525        lines
3526    }
3527
3528    /// Destinations for picking up a placed chest/crate into inventory.
3529    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3530        let mut opts = Vec::new();
3531        opts.push(MoveOption {
3532            label: "Relocate…".into(),
3533            kind: MoveOptionKind::RelocatePlaced {
3534                container_id: container_id.to_string(),
3535            },
3536        });
3537        opts.push(MoveOption {
3538            label: "On your person (loose)".into(),
3539            kind: MoveOptionKind::PickupPlaced {
3540                container_id: container_id.to_string(),
3541                nest_location: flatland_protocol::InventoryLocation::Root,
3542                nest_parent_instance_id: None,
3543            },
3544        });
3545        for (slot, item) in &self.worn {
3546            if item.category.as_deref() != Some("container") {
3547                continue;
3548            }
3549            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3550                continue;
3551            }
3552            let Some(parent_id) = item.item_instance_id else {
3553                continue;
3554            };
3555            let shell_name = item
3556                .display_name
3557                .clone()
3558                .unwrap_or_else(|| item.template_id.clone());
3559            opts.push(MoveOption {
3560                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3561                kind: MoveOptionKind::PickupPlaced {
3562                    container_id: container_id.to_string(),
3563                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3564                    nest_parent_instance_id: Some(parent_id),
3565                },
3566            });
3567            // Nested pouches inside the worn bag.
3568            Self::append_chest_pickup_nested(
3569                &mut opts,
3570                container_id,
3571                flatland_protocol::InventoryLocation::Worn { slot: *slot },
3572                item,
3573                &format!("in {shell_name}"),
3574            );
3575        }
3576        opts.push(MoveOption {
3577            label: "Cancel".into(),
3578            kind: MoveOptionKind::Cancel,
3579        });
3580        opts
3581    }
3582
3583    fn append_chest_pickup_nested(
3584        opts: &mut Vec<MoveOption>,
3585        container_id: &str,
3586        location: flatland_protocol::InventoryLocation,
3587        parent: &flatland_protocol::ItemStack,
3588        context: &str,
3589    ) {
3590        for child in &parent.contents {
3591            if child.category.as_deref() != Some("container") {
3592                continue;
3593            }
3594            if !Self::is_volume_container_stack(child) {
3595                continue;
3596            }
3597            // Skip placeable nested chests — relocating into another chest is not useful.
3598            if child.world_placeable == Some(true) {
3599                continue;
3600            }
3601            let Some(child_id) = child.item_instance_id else {
3602                continue;
3603            };
3604            let name = child
3605                .display_name
3606                .clone()
3607                .unwrap_or_else(|| child.template_id.clone());
3608            opts.push(MoveOption {
3609                label: format!("{name} ({context})"),
3610                kind: MoveOptionKind::PickupPlaced {
3611                    container_id: container_id.to_string(),
3612                    nest_location: location.clone(),
3613                    nest_parent_instance_id: Some(child_id),
3614                },
3615            });
3616            Self::append_chest_pickup_nested(
3617                opts,
3618                container_id,
3619                location.clone(),
3620                child,
3621                &format!("in {name}"),
3622            );
3623        }
3624    }
3625
3626    /// Build the "move to…" destination list for an item currently at `from`.
3627    pub fn move_destinations_for(
3628        &self,
3629        from: &flatland_protocol::InventoryLocation,
3630        from_parent_instance_id: Option<uuid::Uuid>,
3631        moving_instance_id: Option<uuid::Uuid>,
3632        moving_template_id: &str,
3633    ) -> Vec<MoveOption> {
3634        let mut opts = Vec::new();
3635        if *from != flatland_protocol::InventoryLocation::Root {
3636            opts.push(MoveOption {
3637                label: "On your person (loose)".into(),
3638                kind: MoveOptionKind::Move {
3639                    location: flatland_protocol::InventoryLocation::Root,
3640                    parent_instance_id: None,
3641                },
3642            });
3643        }
3644        for (slot, item) in &self.worn {
3645            if item.category.as_deref() != Some("container") {
3646                continue;
3647            }
3648            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3649            let shell_name = item
3650                .display_name
3651                .clone()
3652                .unwrap_or_else(|| item.template_id.clone());
3653
3654            // Backpack and other worn volume containers — store directly inside the shell.
3655            if *slot != BodySlot::Waist
3656                && item.item_instance_id != moving_instance_id
3657                && Self::is_volume_container_stack(item)
3658            {
3659                Self::push_move_destination(
3660                    &mut opts,
3661                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3662                    location.clone(),
3663                    item.item_instance_id,
3664                    from,
3665                    from_parent_instance_id,
3666                );
3667            }
3668
3669            // Belt loops only accept pouch attachments — not loose materials.
3670            if *slot == BodySlot::Waist
3671                && Self::attaches_to_belt_loop(moving_template_id)
3672                && item.item_instance_id != moving_instance_id
3673            {
3674                Self::push_move_destination(
3675                    &mut opts,
3676                    format!("{shell_name} (belt loop)"),
3677                    location.clone(),
3678                    item.item_instance_id,
3679                    from,
3680                    from_parent_instance_id,
3681                );
3682            }
3683
3684            let context = if *slot == BodySlot::Waist {
3685                format!("on {shell_name}")
3686            } else {
3687                format!("in {shell_name}")
3688            };
3689            Self::append_nested_container_destinations(
3690                &mut opts,
3691                location,
3692                item,
3693                &context,
3694                from,
3695                from_parent_instance_id,
3696                moving_instance_id,
3697            );
3698        }
3699        for nc in self.nearby_containers() {
3700            if !nc.view.accessible {
3701                continue;
3702            }
3703            let location = flatland_protocol::InventoryLocation::Placed {
3704                container_id: nc.view.id.clone(),
3705            };
3706            Self::push_move_destination(
3707                &mut opts,
3708                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3709                location,
3710                nc.view.item_instance_id,
3711                from,
3712                from_parent_instance_id,
3713            );
3714        }
3715        let allow_drop = moving_instance_id
3716            .and_then(|id| self.stack_for_instance(id))
3717            .map(|stack| {
3718                !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3719            })
3720            .unwrap_or(
3721                moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_TEMPLATE,
3722            );
3723        if allow_drop {
3724            opts.push(MoveOption {
3725                label: "Drop on the ground".into(),
3726                kind: MoveOptionKind::Drop,
3727            });
3728        }
3729        opts.push(MoveOption {
3730            label: "Cancel".into(),
3731            kind: MoveOptionKind::Cancel,
3732        });
3733        opts
3734    }
3735
3736    fn is_same_container_dest(
3737        dest_location: &flatland_protocol::InventoryLocation,
3738        dest_parent: Option<uuid::Uuid>,
3739        from: &flatland_protocol::InventoryLocation,
3740        from_parent: Option<uuid::Uuid>,
3741    ) -> bool {
3742        dest_location == from && dest_parent == from_parent
3743    }
3744
3745    fn push_move_destination(
3746        opts: &mut Vec<MoveOption>,
3747        label: String,
3748        location: flatland_protocol::InventoryLocation,
3749        parent_instance_id: Option<uuid::Uuid>,
3750        from: &flatland_protocol::InventoryLocation,
3751        from_parent_instance_id: Option<uuid::Uuid>,
3752    ) {
3753        if Self::is_same_container_dest(
3754            &location,
3755            parent_instance_id,
3756            from,
3757            from_parent_instance_id,
3758        ) {
3759            return;
3760        }
3761        opts.push(MoveOption {
3762            label,
3763            kind: MoveOptionKind::Move {
3764                location,
3765                parent_instance_id,
3766            },
3767        });
3768    }
3769
3770    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3771        stack.capacity_volume.is_some_and(|c| c > 0.0)
3772    }
3773
3774    fn attaches_to_belt_loop(template_id: &str) -> bool {
3775        matches!(template_id, "leather_pouch" | "dimensional_pouch")
3776    }
3777
3778    fn append_nested_container_destinations(
3779        opts: &mut Vec<MoveOption>,
3780        location: flatland_protocol::InventoryLocation,
3781        container: &flatland_protocol::ItemStack,
3782        context: &str,
3783        from: &flatland_protocol::InventoryLocation,
3784        from_parent_instance_id: Option<uuid::Uuid>,
3785        moving_instance_id: Option<uuid::Uuid>,
3786    ) {
3787        for child in &container.contents {
3788            if Self::is_volume_container_stack(child)
3789                && child.item_instance_id != moving_instance_id
3790            {
3791                let name = child
3792                    .display_name
3793                    .clone()
3794                    .unwrap_or_else(|| child.template_id.clone());
3795                Self::push_move_destination(
3796                    opts,
3797                    format!("{name} ({context})"),
3798                    location.clone(),
3799                    child.item_instance_id,
3800                    from,
3801                    from_parent_instance_id,
3802                );
3803            }
3804            let nested_context = format!(
3805                "in {}",
3806                child.display_name.as_deref().unwrap_or(&child.template_id)
3807            );
3808            Self::append_nested_container_destinations(
3809                opts,
3810                location.clone(),
3811                child,
3812                &nested_context,
3813                from,
3814                from_parent_instance_id,
3815                moving_instance_id,
3816            );
3817        }
3818    }
3819
3820    fn clamp_inventory_indices(&mut self) {
3821        let n = self.inventory_selectable_rows().len();
3822        self.inventory_menu_index = if n == 0 {
3823            0
3824        } else {
3825            self.inventory_menu_index.min(n - 1)
3826        };
3827        if let Some(picker) = &self.move_picker {
3828            let pn = picker.options.len();
3829            self.move_picker_index = if pn == 0 {
3830                0
3831            } else {
3832                self.move_picker_index.min(pn - 1)
3833            };
3834        }
3835    }
3836
3837    /// Drop interior map layers whenever the player is outdoors.
3838    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
3839    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
3840    /// force `build_grid` into a full-map z pass on every click until client restart.
3841    fn sync_interior_map_context(&mut self) {
3842        if self.effective_inside_building().is_none() {
3843            self.interior_map = None;
3844            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
3845                self.z_platforms = platforms;
3846                self.z_transitions = transitions;
3847            }
3848            return;
3849        }
3850        self.sync_interior_z_bands();
3851    }
3852
3853    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
3854    fn sync_interior_z_bands(&mut self) {
3855        if self.effective_inside_building().is_some() {
3856            if let Some(map) = &self.interior_map {
3857                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
3858                    if self.z_bands_outdoor_backup.is_none() {
3859                        self.z_bands_outdoor_backup = Some((
3860                            std::mem::take(&mut self.z_platforms),
3861                            std::mem::take(&mut self.z_transitions),
3862                        ));
3863                    }
3864                    self.z_platforms = map.z_platforms.clone();
3865                    self.z_transitions = map.z_transitions.clone();
3866                }
3867            }
3868        }
3869    }
3870
3871    fn apply_snapshot_fields(
3872        &mut self,
3873        snapshot: &flatland_protocol::Snapshot,
3874        entity_id: EntityId,
3875    ) {
3876        self.tick = snapshot.tick;
3877        self.chunk_rev = snapshot.chunk_rev;
3878        self.content_rev = snapshot.content_rev;
3879        self.publish_rev = snapshot.publish_rev;
3880        self.resource_nodes = snapshot.resource_nodes.clone();
3881        self.ground_drops = snapshot.ground_drops.clone();
3882        self.placed_containers = snapshot.placed_containers.clone();
3883        self.world_x0 = snapshot.world_x0;
3884        self.world_y0 = snapshot.world_y0;
3885        self.world_width_m = snapshot.world_width_m;
3886        self.world_height_m = snapshot.world_height_m;
3887        self.world_clock = snapshot.world_clock;
3888        self.terrain_zones = snapshot.terrain_zones.clone();
3889        self.z_platforms = snapshot.z_platforms.clone();
3890        self.z_transitions = snapshot.z_transitions.clone();
3891        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
3892        self.z_bands_outdoor_backup = None;
3893        self.buildings = snapshot.buildings.clone();
3894        self.doors = snapshot.doors.clone();
3895        self.interior_map = snapshot.interior_map.clone();
3896        self.npcs = snapshot.npcs.clone();
3897        self.blueprints = snapshot.blueprints.clone();
3898        self.sync_inventory_from_stacks(&snapshot.inventory);
3899        self.player = snapshot
3900            .entities
3901            .iter()
3902            .find(|e| e.id == entity_id)
3903            .cloned();
3904        self.entities = snapshot.entities.clone();
3905        self.quest_log = snapshot.quest_log.clone();
3906        self.apply_hired_workers(snapshot.hired_workers.clone());
3907        self.interactables = snapshot.interactables.clone();
3908        self.ledger = snapshot.ledger.clone();
3909        self.career = snapshot.career.clone();
3910        self.combat_fx = snapshot.combat_fx.clone();
3911        self.property_zones = snapshot.property_zones.clone();
3912        self.tax_zones = snapshot.tax_zones.clone();
3913        self.growth_zones = snapshot.growth_zones.clone();
3914        self.biome_zones = snapshot.biome_zones.clone();
3915        self.property_plots = snapshot.property_plots.clone();
3916        self.property_plot_settings = snapshot.property_plot_settings.clone();
3917        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
3918        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
3919        if self.effective_inside_building().is_some() {
3920            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
3921        }
3922        self.sync_interior_map_context();
3923        self.refresh_whisper_range();
3924    }
3925
3926    /// Re-derive inventory selection state after a snapshot/tick — chests that
3927    /// went out of range or got locked simply vanish from the row list, and the
3928    /// move picker (if any) closes once its item is no longer reachable.
3929    fn refresh_inventory_ui(&mut self) {
3930        if let Some(picker) = &self.move_picker {
3931            let instance_id = picker.item_instance_id;
3932            let still_exists = self
3933                .inventory_selectable_rows()
3934                .iter()
3935                .any(|r| r.stack.item_instance_id == Some(instance_id));
3936            if !still_exists {
3937                self.move_picker = None;
3938                self.show_move_picker = false;
3939            }
3940        }
3941        if let Some(picker) = &self.destroy_picker {
3942            let instance_id = picker.item_instance_id;
3943            let still_exists = self
3944                .inventory_selectable_rows()
3945                .iter()
3946                .any(|r| r.stack.item_instance_id == Some(instance_id));
3947            if !still_exists {
3948                self.destroy_picker = None;
3949                self.show_destroy_picker = false;
3950                self.destroy_confirm_pending = false;
3951            }
3952        }
3953        self.clamp_inventory_indices();
3954    }
3955
3956    /// Replace the hired-worker list from a snapshot/delta.
3957    ///
3958    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
3959    /// selected worker (and its step line) does not jump when the server rebuilds
3960    /// the list.
3961    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3962        let selected_id = self
3963            .hired_workers
3964            .get(self.workers_menu_index)
3965            .map(|w| w.instance_id.clone());
3966        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3967        let now = Instant::now();
3968        for w in &workers {
3969            let prev_err = self
3970                .hired_workers
3971                .iter()
3972                .find(|p| p.instance_id == w.instance_id)
3973                .and_then(|p| p.last_error.as_deref());
3974            let new_err = w.last_error.as_deref();
3975            if new_err != prev_err {
3976                if let Some(err) = new_err {
3977                    if !worker_error_is_transient(err) {
3978                        self.push_log(format!("Worker {}: {err}", w.label));
3979                    }
3980                }
3981            }
3982        }
3983        let mut next_display = BTreeMap::new();
3984        let mut next_errors = BTreeMap::new();
3985        for w in &workers {
3986            let mut sticky = self
3987                .worker_step_display
3988                .remove(&w.instance_id)
3989                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3990            sticky.observe(&w.step_label, now);
3991            next_display.insert(w.instance_id.clone(), sticky);
3992
3993            let mut err_sticky = self
3994                .worker_error_display
3995                .remove(&w.instance_id)
3996                .unwrap_or_default();
3997            err_sticky.observe(w.last_error.as_deref(), now);
3998            if err_sticky.shown(now).is_some() {
3999                next_errors.insert(w.instance_id.clone(), err_sticky);
4000            }
4001        }
4002        self.worker_step_display = next_display;
4003        self.worker_error_display = next_errors;
4004        self.hired_workers = workers;
4005        self.sync_worker_take_picker_from_hired();
4006        if let Some(id) = selected_id {
4007            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4008                self.workers_menu_index = idx;
4009                return;
4010            }
4011        }
4012        if self.workers_menu_index >= self.hired_workers.len() {
4013            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4014        }
4015    }
4016
4017    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4018    fn sync_worker_take_picker_from_hired(&mut self) {
4019        if !self.show_worker_take_picker {
4020            return;
4021        }
4022        let Some(picker) = self.worker_take_picker.clone() else {
4023            return;
4024        };
4025        let Some(worker) = self
4026            .hired_workers
4027            .iter()
4028            .find(|w| w.instance_id == picker.worker_instance_id)
4029            .cloned()
4030        else {
4031            self.show_worker_take_picker = false;
4032            self.worker_take_picker = None;
4033            self.worker_take_picker_index = 0;
4034            return;
4035        };
4036        let options: Vec<WorkerGiveOption> = worker
4037            .inventory
4038            .iter()
4039            .filter_map(|stack| {
4040                let item_instance_id = stack.item_instance_id?;
4041                let label = stack
4042                    .display_name
4043                    .clone()
4044                    .unwrap_or_else(|| stack.template_id.clone());
4045                let label = if stack.quantity > 1 {
4046                    format!("{label} ×{}", stack.quantity)
4047                } else {
4048                    label
4049                };
4050                Some(WorkerGiveOption {
4051                    item_instance_id,
4052                    label,
4053                    quantity: stack.quantity,
4054                    template_id: stack.template_id.clone(),
4055                })
4056            })
4057            .collect();
4058        if options.is_empty() {
4059            self.show_worker_take_picker = false;
4060            self.worker_take_picker = None;
4061            self.worker_take_picker_index = 0;
4062            return;
4063        }
4064        let prev_id = picker
4065            .options
4066            .get(self.worker_take_picker_index)
4067            .map(|o| o.item_instance_id);
4068        let idx = prev_id
4069            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4070            .unwrap_or(0)
4071            .min(options.len().saturating_sub(1));
4072        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4073        let quantity = picker.quantity.clamp(1, max_qty);
4074        self.worker_take_picker_index = idx;
4075        self.worker_take_picker = Some(WorkerTakePicker {
4076            worker_instance_id: picker.worker_instance_id,
4077            worker_label: picker.worker_label,
4078            options,
4079            quantity,
4080        });
4081    }
4082
4083    /// Held coarse step label for the workers menu (`step:` line).
4084    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4085        self.worker_step_display
4086            .get(worker_instance_id)
4087            .map(|s| s.shown.as_str())
4088            .or_else(|| {
4089                self.hired_workers
4090                    .iter()
4091                    .find(|w| w.instance_id == worker_instance_id)
4092                    .map(|w| w.step_label.as_str())
4093            })
4094            .unwrap_or("")
4095    }
4096
4097    /// Held error line for workers UI (detail + compact).
4098    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4099        let now = Instant::now();
4100        self.worker_error_display
4101            .get(worker_instance_id)
4102            .and_then(|s| s.shown(now))
4103            .or_else(|| {
4104                self.hired_workers
4105                    .iter()
4106                    .find(|w| w.instance_id == worker_instance_id)
4107                    .and_then(|w| w.last_error.as_deref())
4108                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4109            })
4110            .filter(|e| !worker_error_is_hud_noise(e))
4111    }
4112
4113    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4114        self.in_combat = combat.in_combat;
4115        self.auto_attack = combat.auto_attack;
4116        self.combat_has_los = combat.has_los;
4117        self.attack_cd_ticks = combat.attack_cd_ticks;
4118        self.gcd_ticks = combat.gcd_ticks;
4119        self.weapon_ability_id = combat.ability_id.clone();
4120        self.mainhand_template_id = combat.mainhand_template_id.clone();
4121        self.mainhand_label = combat.mainhand_label.clone();
4122        self.offhand_template_id = combat.offhand_template_id.clone();
4123        self.offhand_label = combat.offhand_label.clone();
4124        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4125            1
4126        } else {
4127            combat.mainhand_hand_slots
4128        };
4129        self.defense = combat.defense.clone();
4130        self.worn = combat.worn.iter().cloned().collect();
4131        self.carry_mass = combat.carry_mass;
4132        self.carry_mass_max = combat.carry_mass_max;
4133        self.encumbrance = combat.encumbrance;
4134        self.cast_progress = combat.cast.clone();
4135        self.timed_channel = combat.timed_channel.clone();
4136        self.ability_cooldowns = combat.ability_cooldowns.clone();
4137        self.blocking_active = combat.blocking_active;
4138        self.max_target_slots = combat.max_target_slots.max(1);
4139        self.combat_slots = combat.slots.clone();
4140        self.rotation_presets = combat.rotation_presets.clone();
4141        self.known_abilities = combat.known_abilities.clone();
4142        self.ability_meta = combat
4143            .ability_meta
4144            .iter()
4145            .cloned()
4146            .map(|meta| (meta.id.clone(), meta))
4147            .collect();
4148        self.hotbar = combat.hotbar.clone();
4149        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4150        self.keychain_stacks = combat.keychain.clone();
4151        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4152        self.combat_target_detail = combat.target.clone();
4153        self.statuses = combat.statuses.clone();
4154        self.combat_target = combat.target_entity_id;
4155        if combat.progression_xp_base > 0.0 {
4156            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4157                baseline_display: combat.progression_baseline,
4158                xp_base: combat.progression_xp_base,
4159                xp_growth: combat.progression_xp_growth,
4160            });
4161        }
4162        if let Some(xp) = &combat.progression_xp {
4163            if let Some(player) = &mut self.player {
4164                player.progression_xp = Some(xp.clone());
4165                if let Some(attrs) = combat.attributes {
4166                    player.attributes = Some(attrs);
4167                }
4168                if let Some(skills) = &combat.skills {
4169                    player.skills = Some(skills.clone());
4170                }
4171            }
4172        }
4173        if let Some(label) = &combat.target_label {
4174            self.combat_target_label = Some(label.clone());
4175        } else if let Some(id) = combat.target_entity_id {
4176            self.combat_target_label = self
4177                .entities
4178                .iter()
4179                .find(|e| e.id == id)
4180                .map(|e| e.label.clone())
4181                .or_else(|| self.combat_target_label.clone());
4182        }
4183        self.refresh_inventory_ui();
4184    }
4185
4186    /// Entity id assigned to a combat slot (from server HUD).
4187    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4188        self.combat_slots
4189            .iter()
4190            .find(|s| s.slot_index == slot)
4191            .and_then(|s| s.target_entity_id)
4192            .or_else(|| if slot == 1 { self.combat_target } else { None })
4193    }
4194
4195    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4196    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4197        self.ability_meta
4198            .get(ability_id)
4199            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4200            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4201            // the server validates aim_mode.
4202            .unwrap_or(self.ground_target.is_some())
4203    }
4204
4205    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4206    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4207        self.ability_meta
4208            .get(ability_id)
4209            .map(|meta| meta.aim_mode == "ground")
4210            .unwrap_or(false)
4211    }
4212
4213    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4214    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4215        self.ground_target = Some((x, y, 0.0));
4216    }
4217
4218    /// Clear the local free-aim ground target.
4219    pub fn clear_ground_target(&mut self) {
4220        self.ground_target = None;
4221    }
4222
4223    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4224    /// May be an ability id or `item:<template_id>` consumable binding.
4225    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4226        if !(1..=9).contains(&slot_1_to_9) {
4227            return None;
4228        }
4229        self.hotbar
4230            .get((slot_1_to_9 - 1) as usize)
4231            .and_then(|a| a.as_deref())
4232            .filter(|id| !id.is_empty())
4233    }
4234
4235    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4236    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4237        let binding = self.hotbar_ability(slot_1_to_9)?;
4238        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4239            let name = self
4240                .inventory_hints
4241                .get(template_id)
4242                .map(|h| h.display_name.as_str())
4243                .unwrap_or(template_id);
4244            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4245            Some(format!("{name}×{qty}"))
4246        } else {
4247            Some(binding.to_string())
4248        }
4249    }
4250
4251    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4252    pub fn loadout_ability_choices(&self) -> Vec<String> {
4253        let mut out = self.known_abilities.clone();
4254        let weapon = self.weapon_ability_id.trim();
4255        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4256            out.push(weapon.to_string());
4257        }
4258        out
4259    }
4260
4261    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4262    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4263        let mut out = Vec::new();
4264        for ability in self.loadout_ability_choices() {
4265            let meta = if ability == self.weapon_ability_id {
4266                Some("weapon".into())
4267            } else {
4268                None
4269            };
4270            out.push(LoadoutHotbarChoice {
4271                binding: ability.clone(),
4272                label: ability,
4273                meta,
4274            });
4275        }
4276        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4277        for stack in &self.inventory_stacks {
4278            if Self::stack_is_item_grant(stack) {
4279                continue;
4280            }
4281            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4282                continue;
4283            }
4284            let qty = stack.quantity.max(1);
4285            if let Some((_, _, existing)) = consumables
4286                .iter_mut()
4287                .find(|(id, _, _)| id == &stack.template_id)
4288            {
4289                *existing = existing.saturating_add(qty);
4290            } else {
4291                let label = stack
4292                    .display_name
4293                    .clone()
4294                    .or_else(|| {
4295                        self.inventory_hints
4296                            .get(&stack.template_id)
4297                            .map(|h| h.display_name.clone())
4298                    })
4299                    .unwrap_or_else(|| stack.template_id.clone());
4300                consumables.push((stack.template_id.clone(), label, qty));
4301            }
4302        }
4303        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4304        for (template_id, label, qty) in consumables {
4305            out.push(LoadoutHotbarChoice {
4306                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4307                label: format!("{label} ×{qty}"),
4308                meta: Some("use".into()),
4309            });
4310        }
4311        out
4312    }
4313
4314    /// Hostile wildlife / monsters for T1 (`Tab`).
4315    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4316        self.combat_candidates()
4317    }
4318
4319    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4320    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4321        let (px, py) = self.player_position();
4322        let dist = |id: EntityId| {
4323            self.entities
4324                .iter()
4325                .find(|e| e.id == id)
4326                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4327                .unwrap_or(f32::MAX)
4328        };
4329
4330        let mut allies = Vec::new();
4331        // Self first — heal_touch on T2.
4332        if let Some(me) = self.player.as_ref() {
4333            let alive = me
4334                .vitals
4335                .as_ref()
4336                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4337                .unwrap_or(true);
4338            if alive {
4339                allies.push((self.entity_id, "Yourself".into()));
4340            }
4341        }
4342        for entity in &self.entities {
4343            if entity.id == self.entity_id {
4344                continue;
4345            }
4346            if entity.vitals.is_some() {
4347                let alive = entity
4348                    .vitals
4349                    .as_ref()
4350                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4351                    .unwrap_or(true);
4352                if alive {
4353                    allies.push((entity.id, entity.label.clone()));
4354                }
4355            }
4356        }
4357        allies.sort_by(|(a, _), (b, _)| {
4358            if *a == self.entity_id {
4359                return std::cmp::Ordering::Less;
4360            }
4361            if *b == self.entity_id {
4362                return std::cmp::Ordering::Greater;
4363            }
4364            dist(*a)
4365                .partial_cmp(&dist(*b))
4366                .unwrap_or(std::cmp::Ordering::Equal)
4367        });
4368
4369        let mut monsters = self.combat_candidates();
4370        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4371        allies.into_iter().chain(monsters).collect()
4372    }
4373
4374    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4375        match slot_index {
4376            2 => self.t2_candidates(),
4377            _ => self.t1_candidates(),
4378        }
4379    }
4380
4381    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
4382    pub fn pick_combat_target_at(
4383        &self,
4384        wx: f32,
4385        wy: f32,
4386        slot_index: u8,
4387        radius_m: f32,
4388    ) -> Option<(EntityId, String)> {
4389        let mut best: Option<(f32, EntityId, String)> = None;
4390        for (id, label) in self.candidates_for_slot(slot_index) {
4391            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4392                // NPCs may only appear in NpcView — fall back to npc list coords.
4393                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4394                    let d = distance(wx, wy, npc.x, npc.y);
4395                    if d <= radius_m {
4396                        best = match best {
4397                            Some((bd, _, _)) if bd <= d => best,
4398                            _ => Some((d, id, label)),
4399                        };
4400                    }
4401                }
4402                continue;
4403            };
4404            let d = distance(
4405                wx,
4406                wy,
4407                entity.transform.position.x,
4408                entity.transform.position.y,
4409            );
4410            if d <= radius_m {
4411                best = match best {
4412                    Some((bd, _, _)) if bd <= d => best,
4413                    _ => Some((d, id, label)),
4414                };
4415            }
4416        }
4417        best.map(|(_, id, label)| (id, label))
4418    }
4419
4420    /// Full client reset after Welcome (initial connect or reconnect).
4421    pub(crate) fn restore_from_welcome(
4422        &mut self,
4423        session_id: SessionId,
4424        entity_id: EntityId,
4425        snapshot: &flatland_protocol::Snapshot,
4426    ) {
4427        self.clear_harvest_state();
4428        self.disconnect_reason = None;
4429        self.show_stats = false;
4430        self.show_craft_menu = false;
4431        self.show_shop_menu = false;
4432        self.shop_catalog = None;
4433        self.show_inventory_menu = false;
4434        self.session_id = session_id;
4435        self.entity_id = entity_id;
4436        self.connected = true;
4437        self.apply_snapshot_fields(snapshot, entity_id);
4438        if let Some(combat) = &snapshot.combat {
4439            self.apply_combat_hud(combat);
4440            let stacks = self.inventory_stacks.clone();
4441            self.sync_inventory_from_stacks(&stacks);
4442        }
4443    }
4444
4445    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4446        self.tick = delta.tick;
4447        self.world_clock = delta.world_clock;
4448
4449        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
4450        if delta.entities.is_empty() {
4451            self.ground_drops = delta.ground_drops.clone();
4452            self.combat_fx = delta.combat_fx.clone();
4453            self.property_plots = delta.property_plots.clone();
4454            self.apply_terrain_overlays(&delta.terrain_overlays);
4455            if let Some(combat) = &delta.combat {
4456                self.apply_combat_hud(combat);
4457                let stacks = self.inventory_stacks.clone();
4458                self.sync_inventory_from_stacks(&stacks);
4459            }
4460            // Peer vanished from AOI — drop directed whisper.
4461            self.refresh_whisper_range();
4462            return;
4463        }
4464        if !delta.buildings.is_empty() {
4465            self.buildings = delta.buildings.clone();
4466        }
4467        if !delta.blueprints.is_empty() {
4468            self.blueprints = delta.blueprints.clone();
4469        }
4470        self.sync_inventory_from_stacks(&delta.inventory);
4471
4472        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4473            self.player = Some(updated.clone());
4474        }
4475        self.entities = delta.entities.clone();
4476        if self.player.is_none() {
4477            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4478        }
4479
4480        self.sync_interior_map_context();
4481
4482        // Static world layers: ticks often omit these (indoors, or unchanged).
4483        // Never wipe the welcome snapshot with an empty vec — except when indoors,
4484        // where empty means "no nodes in this building" (clear outdoor leftovers).
4485        if !delta.resource_nodes.is_empty() {
4486            self.resource_nodes = delta.resource_nodes.clone();
4487        } else if delta.interior_map.is_some()
4488            || self.effective_inside_building().is_some()
4489        {
4490            self.resource_nodes = delta.resource_nodes.clone();
4491        }
4492        self.ground_drops = delta.ground_drops.clone();
4493        if self
4494            .player
4495            .as_ref()
4496            .is_none_or(|p| p.inside_building.is_none())
4497        {
4498            self.placed_containers = delta.placed_containers.clone();
4499        }
4500        if !delta.doors.is_empty() {
4501            self.doors = delta.doors.clone();
4502        }
4503        if self.effective_inside_building().is_some() {
4504            if let Some(map) = &delta.interior_map {
4505                self.interior_map = Some(map.clone());
4506            }
4507        } else {
4508            self.interior_map = None;
4509        }
4510        self.sync_interior_z_bands();
4511        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
4512        self.npcs = delta.npcs.clone();
4513        if !delta.quest_log.is_empty() {
4514            self.quest_log = delta.quest_log.clone();
4515        }
4516        self.apply_hired_workers(delta.hired_workers.clone());
4517        if !delta.interactables.is_empty() {
4518            self.interactables = delta.interactables.clone();
4519        }
4520        if delta.ledger.is_some() {
4521            self.ledger = delta.ledger.clone();
4522        }
4523        if delta.career.is_some() {
4524            self.career = delta.career.clone();
4525        }
4526        self.combat_fx = delta.combat_fx.clone();
4527        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
4528        if !delta.property_plots.is_empty() {
4529            self.property_plots = delta.property_plots.clone();
4530        }
4531        self.apply_terrain_overlays(&delta.terrain_overlays);
4532        if let Some(combat) = &delta.combat {
4533            self.apply_combat_hud(combat);
4534            let stacks = self.inventory_stacks.clone();
4535            self.sync_inventory_from_stacks(&stacks);
4536        } else {
4537            self.refresh_inventory_ui();
4538        }
4539        self.refresh_whisper_range();
4540    }
4541
4542    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
4543    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
4544    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4545        self.terrain_zones
4546            .retain(|z| !z.id.starts_with("rt:"));
4547        self.terrain_zones.extend(overlays.iter().cloned());
4548    }
4549
4550    /// End directed whisper when the peer is farther than interact range (or missing).
4551    /// Each client runs this locally so both players drop whisper mode when either walks away.
4552    fn refresh_whisper_range(&mut self) {
4553        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4554            return;
4555        };
4556        let (px, py) = self.player_position();
4557        let in_range = self.entities.iter().any(|e| {
4558            e.id == peer
4559                && distance(
4560                    px,
4561                    py,
4562                    e.transform.position.x,
4563                    e.transform.position.y,
4564                ) <= INTERACTION_RADIUS_M
4565        });
4566        if !in_range {
4567            self.social_chat.cancel_whisper_out_of_range();
4568        }
4569    }
4570
4571    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
4572    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4573        let (px, py) = self.player_position();
4574        let mut out = Vec::new();
4575        for npc in &self.npcs {
4576            let Some(eid) = npc.entity_id else {
4577                continue;
4578            };
4579            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4580            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4581            if alive && has_hp {
4582                out.push((eid, npc.label.clone()));
4583            }
4584        }
4585        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4586            let dist = |id: EntityId| {
4587                self.entities
4588                    .iter()
4589                    .find(|e| e.id == id)
4590                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4591                    .unwrap_or(f32::MAX)
4592            };
4593            dist(*a_id)
4594                .partial_cmp(&dist(*b_id))
4595                .unwrap_or(std::cmp::Ordering::Equal)
4596                .then_with(|| a_label.cmp(b_label))
4597                .then_with(|| a_id.cmp(b_id))
4598        });
4599        out
4600    }
4601
4602    pub fn refresh_combat_target_label(&mut self) {
4603        let Some(id) = self.combat_target else {
4604            return;
4605        };
4606        if let Some((_, label)) = self
4607            .combat_candidates()
4608            .into_iter()
4609            .find(|(eid, _)| *eid == id)
4610        {
4611            self.combat_target_label = Some(label);
4612        } else if let Some(label) = self
4613            .entities
4614            .iter()
4615            .find(|e| e.id == id)
4616            .map(|e| e.label.clone())
4617        {
4618            self.combat_target_label = Some(label);
4619        }
4620    }
4621
4622    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4623        self.quest_log
4624            .iter()
4625            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4626            .collect()
4627    }
4628
4629    /// True when the observer has at least one free lodging slot (max workers = beds).
4630    pub fn has_worker_lodging(&self) -> bool {
4631        self.free_worker_lodging_slots() > 0
4632    }
4633
4634    /// Owned lodging capacity minus currently hired workers.
4635    pub fn free_worker_lodging_slots(&self) -> i64 {
4636        let slots: u32 = self
4637            .placed_containers
4638            .iter()
4639            .filter(|c| match (self.character_id, c.owner_character_id) {
4640                (Some(me), Some(owner)) => me == owner,
4641                (Some(_), None) => false,
4642                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4643            })
4644            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4645            .sum();
4646        let used = self.hired_workers.len() as u32;
4647        slots as i64 - used as i64
4648    }
4649
4650    /// Display names of hired workers assigned to this lodging container.
4651    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4652        let mut names: Vec<String> = self
4653            .hired_workers
4654            .iter()
4655            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4656            .map(|w| w.label.clone())
4657            .collect();
4658        names.sort();
4659        names
4660    }
4661
4662    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
4663    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4664        let is_lodging = self
4665            .placed_containers
4666            .iter()
4667            .find(|c| c.id == container_id)
4668            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4669        if !is_lodging {
4670            return None;
4671        }
4672        let names = self.lodging_occupant_labels(container_id);
4673        Some(if names.is_empty() {
4674            "vacant".into()
4675        } else {
4676            names.join(", ")
4677        })
4678    }
4679
4680    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4681        self.quest_log
4682            .iter()
4683            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4684            .or_else(|| {
4685                self.quest_log
4686                    .iter()
4687                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4688            })
4689    }
4690
4691    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
4692    pub fn nearest_interact_target(&self) -> Option<String> {
4693        let (px, py) = self.player_position();
4694        let inside = self.effective_inside_building();
4695
4696        #[derive(Clone, Copy, PartialEq, Eq)]
4697        enum Kind {
4698            Player,
4699            Npc,
4700            HiredWorker,
4701            QuestBoard,
4702            ExitDoor,
4703            EnterDoor,
4704            Well,
4705            Water,
4706        }
4707
4708        fn kind_priority(kind: Kind) -> u8 {
4709            match kind {
4710                Kind::Player => 0,
4711                Kind::Npc => 0,
4712                Kind::HiredWorker => 0,
4713                Kind::QuestBoard => 1,
4714                Kind::ExitDoor => 2,
4715                Kind::EnterDoor => 3,
4716                Kind::Well => 4,
4717                Kind::Water => 5,
4718            }
4719        }
4720
4721        let mut best: Option<(f32, Kind, String)> = None;
4722
4723        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
4724            if dist > max {
4725                return;
4726            }
4727            let replace = match best {
4728                None => true,
4729                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
4730                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
4731                    kind_priority(kind) < kind_priority(bk)
4732                }
4733                _ => false,
4734            };
4735            if replace {
4736                best = Some((dist, kind, id));
4737            }
4738        };
4739
4740        for npc in &self.npcs {
4741            consider(
4742                distance(px, py, npc.x, npc.y),
4743                INTERACTION_RADIUS_M,
4744                Kind::Npc,
4745                npc.id.clone(),
4746            );
4747        }
4748
4749        for worker in &self.hired_workers {
4750            consider(
4751                distance(px, py, worker.x, worker.y),
4752                INTERACTION_RADIUS_M,
4753                Kind::HiredWorker,
4754                worker.instance_id.clone(),
4755            );
4756        }
4757
4758        for entity in &self.entities {
4759            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
4760            {
4761                continue;
4762            }
4763            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
4764            if self
4765                .hired_workers
4766                .iter()
4767                .any(|w| w.entity_id == entity.id)
4768            {
4769                continue;
4770            }
4771            consider(
4772                distance(
4773                    px,
4774                    py,
4775                    entity.transform.position.x,
4776                    entity.transform.position.y,
4777                ),
4778                INTERACTION_RADIUS_M,
4779                Kind::Player,
4780                entity.id.to_string(),
4781            );
4782        }
4783
4784        for door in &self.doors {
4785            if let Some(ref bid) = inside {
4786                if door.building_id != *bid {
4787                    continue;
4788                }
4789                let is_exit = door.portal.is_some();
4790                let max = if is_exit {
4791                    INTERACTION_RADIUS_M
4792                } else {
4793                    DOOR_INTERACTION_RADIUS_M
4794                };
4795                let kind = if is_exit {
4796                    Kind::ExitDoor
4797                } else {
4798                    Kind::EnterDoor
4799                };
4800                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4801                continue;
4802            }
4803            consider(
4804                distance(px, py, door.x, door.y),
4805                DOOR_INTERACTION_RADIUS_M,
4806                Kind::EnterDoor,
4807                door.id.clone(),
4808            );
4809        }
4810
4811        if inside.is_none() {
4812            for inter in &self.interactables {
4813                if inter.kind == "quest_board" {
4814                    consider(
4815                        distance(px, py, inter.x, inter.y),
4816                        QUEST_BOARD_INTERACTION_RADIUS_M,
4817                        Kind::QuestBoard,
4818                        inter.id.clone(),
4819                    );
4820                }
4821            }
4822            for building in &self.buildings {
4823                if !building.tags.iter().any(|t| t == "well") {
4824                    continue;
4825                }
4826                consider(
4827                    distance(px, py, building.x, building.y),
4828                    INTERACTION_RADIUS_M,
4829                    Kind::Well,
4830                    building.id.clone(),
4831                );
4832            }
4833            if self.in_shallow_water() {
4834                consider(
4835                    0.0,
4836                    INTERACTION_RADIUS_M,
4837                    Kind::Water,
4838                    "water_source".into(),
4839                );
4840            }
4841        }
4842
4843        best.map(|(_, _, id)| id)
4844    }
4845
4846    /// Nearest quest board and distance (any distance), for out-of-range feedback.
4847    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4848        if self.effective_inside_building().is_some() {
4849            return None;
4850        }
4851        let (px, py) = self.player_position();
4852        self.interactables
4853            .iter()
4854            .filter(|i| i.kind == "quest_board")
4855            .map(|i| {
4856                let label = if i.label.is_empty() {
4857                    "Quest board".to_string()
4858                } else {
4859                    i.label.clone()
4860                };
4861                (label, distance(px, py, i.x, i.y))
4862            })
4863            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4864    }
4865
4866    /// Human-readable template name from synced catalog hints.
4867    pub fn template_display_name(&self, template_id: &str) -> String {
4868        self.inventory_hints
4869            .get(template_id)
4870            .map(|h| h.display_name.clone())
4871            .filter(|n| !n.is_empty())
4872            .unwrap_or_else(|| humanize_template_id(template_id))
4873    }
4874
4875    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
4876    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
4877        use crate::worker_route_editor::{
4878            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
4879        };
4880        let lodging = self
4881            .worker_route_editor
4882            .as_ref()
4883            .and_then(|ed| ed.lodging_container_id.as_deref());
4884        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
4885            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
4886            None => node_candidates_stable(&self.resource_nodes),
4887        }
4888    }
4889
4890    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
4891        if dist_m.is_nan() {
4892            return "—".into();
4893        }
4894        let from_bed = self
4895            .worker_route_editor
4896            .as_ref()
4897            .and_then(|ed| ed.lodging_container_id.as_deref())
4898            .and_then(|id| {
4899                self.placed_containers
4900                    .iter()
4901                    .find(|c| c.id == id)
4902                    .map(|c| c.display_name.clone())
4903            });
4904        match from_bed {
4905            Some(bed) => format!("{dist_m:.0}m from {bed}"),
4906            None => format!("{dist_m:.0}m"),
4907        }
4908    }
4909
4910    /// Chest label for the location panel — generic type unless this player owns it.
4911    pub fn placed_container_public_label(
4912        &self,
4913        c: &flatland_protocol::PlacedContainerView,
4914    ) -> String {
4915        let is_owner = match (self.character_id, c.owner_character_id) {
4916            (Some(me), Some(owner)) => me == owner,
4917            _ => false,
4918        };
4919        if is_owner {
4920            c.display_name.clone()
4921        } else {
4922            self.template_display_name(&c.template_id)
4923        }
4924    }
4925
4926    /// Keys on person and stowed on the keychain for the keychain overlay.
4927    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4928        let mut out = Vec::new();
4929        for stack in &self.inventory_stacks {
4930            if stack.template_id == KEY_TEMPLATE {
4931                out.push(KeychainEntry {
4932                    stack: stack.clone(),
4933                    stowed: false,
4934                });
4935            }
4936        }
4937        for stack in &self.keychain_stacks {
4938            if stack.template_id == KEY_TEMPLATE {
4939                out.push(KeychainEntry {
4940                    stack: stack.clone(),
4941                    stowed: true,
4942                });
4943            }
4944        }
4945        out
4946    }
4947
4948    /// Display name of the chest a `container_key` opens, when known on the client.
4949    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4950        if stack.template_id != KEY_TEMPLATE {
4951            return None;
4952        }
4953        if let Some(name) = stack
4954            .props
4955            .get(PROP_OPENS_CONTAINER_NAME)
4956            .filter(|n| !n.is_empty())
4957        {
4958            return Some(name.clone());
4959        }
4960        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4961        self.container_name_for_lock_id(opens)
4962    }
4963
4964    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
4965    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4966        if stack.template_id == KEY_TEMPLATE {
4967            self.template_display_name(KEY_TEMPLATE)
4968        } else {
4969            stack
4970                .display_name
4971                .clone()
4972                .unwrap_or_else(|| stack.template_id.clone())
4973        }
4974    }
4975
4976    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
4977    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4978        if stack.template_id != KEY_TEMPLATE {
4979            return String::new();
4980        }
4981        match self.key_pair_chest_label(stack) {
4982            Some(chest) if self.key_drop_blocked(stack) => {
4983                format!(" [key for {chest} — can't drop while locked]")
4984            }
4985            Some(chest) => format!(" [key for {chest}]"),
4986            None => " [key — unpaired]".into(),
4987        }
4988    }
4989
4990    /// Resolve a lock id to a container label (placed chest or one still on your person).
4991    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4992        for c in &self.placed_containers {
4993            if c.lock_id.as_deref() == Some(lock) {
4994                return Some(c.display_name.clone());
4995            }
4996        }
4997        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4998            self.worn
4999                .values()
5000                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5001        })
5002    }
5003
5004    /// Keys cannot be dropped while their paired chest is locked.
5005    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5006        if stack.template_id != KEY_TEMPLATE {
5007            return false;
5008        }
5009        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5010            return false;
5011        };
5012        for c in &self.placed_containers {
5013            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5014                return true;
5015            }
5016        }
5017        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5018            return true;
5019        }
5020        self.worn
5021            .values()
5022            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5023    }
5024
5025    /// Property deeds cannot be dropped or destroyed (store or trade only).
5026    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5027        stack.template_id == PROPERTY_DEED_TEMPLATE
5028    }
5029
5030    pub fn is_property_deed_template(template_id: &str) -> bool {
5031        template_id == PROPERTY_DEED_TEMPLATE
5032    }
5033
5034    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5035        stack
5036            .props
5037            .get("plot_id")
5038            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5039    }
5040
5041    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5042    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5043        let (px, py) = self.player_position();
5044        let (cx, cy) = self.farm_plot_cell_under_player()?;
5045        let tx = cx as f32 + 0.5;
5046        let ty = cy as f32 + 0.5;
5047        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5048            return None;
5049        }
5050        let kind = self
5051            .terrain_at(tx, ty)
5052            .or_else(|| self.terrain_at(px, py));
5053        if kind == Some(TerrainKindView::Tilled) {
5054            return None;
5055        }
5056        if matches!(
5057            kind,
5058            Some(TerrainKindView::ShallowWater)
5059                | Some(TerrainKindView::DeepWater)
5060                | Some(TerrainKindView::Rock)
5061        ) {
5062            return None;
5063        }
5064        Some((tx, ty))
5065    }
5066
5067    fn container_name_in_stacks(
5068        stacks: &[flatland_protocol::ItemStack],
5069        lock: &str,
5070    ) -> Option<String> {
5071        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5072            for s in stacks {
5073                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5074                    return Some(GameState::stack_container_label(s));
5075                }
5076                if let Some(name) = walk(&s.contents, lock) {
5077                    return Some(name);
5078                }
5079            }
5080            None
5081        }
5082        walk(stacks, lock)
5083    }
5084
5085    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5086        stack
5087            .props
5088            .get(PROP_CUSTOM_NAME)
5089            .cloned()
5090            .or_else(|| stack.display_name.clone())
5091            .unwrap_or_else(|| stack.template_id.clone())
5092    }
5093
5094    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5095        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5096            for s in stacks {
5097                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5098                    return true;
5099                }
5100                if walk(&s.contents, lock) {
5101                    return true;
5102                }
5103            }
5104            false
5105        }
5106        walk(stacks, lock)
5107    }
5108
5109    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5110        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5111            return Some(stack.clone());
5112        }
5113        for worn in self.worn.values() {
5114            if worn.item_instance_id == Some(instance_id) {
5115                return Some(worn.clone());
5116            }
5117            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5118                return Some(stack.clone());
5119            }
5120        }
5121        None
5122    }
5123
5124    /// Highest-`z_order` property zone covering `(x, y)`.
5125    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5126        self.property_zones
5127            .iter()
5128            .enumerate()
5129            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5130            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5131            .map(|(_, z)| z)
5132    }
5133
5134    /// Highest-`z_order` tax zone covering `(x, y)`.
5135    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5136        self.tax_zones
5137            .iter()
5138            .enumerate()
5139            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5140            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5141            .map(|(_, z)| z)
5142    }
5143
5144    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5145    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5146        let mut max_bps = 0u32;
5147        let mut y = y0 + 0.5;
5148        while y < y1 {
5149            let mut x = x0 + 0.5;
5150            while x < x1 {
5151                if let Some(tz) = self.tax_zone_at(x, y) {
5152                    max_bps = max_bps.max(tz.rate_bps);
5153                }
5154                x += 1.0;
5155            }
5156            y += 1.0;
5157        }
5158        max_bps
5159    }
5160
5161    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5162    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5163        let mode = self.claim_mode.as_ref()?;
5164        let w = mode.width_m.max(1) as f32;
5165        let h = mode.height_m.max(1) as f32;
5166        Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5167    }
5168
5169    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5170    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5171        let mode = self.relocate_mode.as_ref()?;
5172        let x0 = mode.cursor_x.floor();
5173        let y0 = mode.cursor_y.floor();
5174        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5175    }
5176
5177    /// Client quote matching server `quote_plot`:
5178    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5179    pub fn claim_quote(
5180        &self,
5181    ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5182        let mode = self.claim_mode.as_ref()?;
5183        let zone = self
5184            .property_zones
5185            .iter()
5186            .find(|z| z.id == mode.zone_id)?;
5187        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5188        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5189        let zone_area = zone_view_area_m2(zone).max(1.0);
5190        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5191        let weight = self
5192            .property_plot_settings
5193            .as_ref()
5194            .map(|s| s.tax_premium_weight)
5195            .unwrap_or(0.5)
5196            .max(0.0);
5197        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5198        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5199        let purchase = ((zone.crown_price_copper as f64)
5200            * (area_frac as f64)
5201            * (premium as f64))
5202            .ceil()
5203            .max(0.0) as u64;
5204        let upkeep = if zone.upkeep_copper_per_day == 0 {
5205            0
5206        } else {
5207            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5208                .ceil()
5209                .max(1.0) as u64
5210        };
5211        let copper = crate::currency::copper_from_counts(&self.inventory);
5212        let can_afford = copper >= purchase;
5213        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5214        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5215    }
5216
5217    fn validate_claim_footprint(
5218        &self,
5219        zone: &flatland_protocol::PropertyZoneView,
5220        x0: f32,
5221        y0: f32,
5222        x1: f32,
5223        y1: f32,
5224        area: f32,
5225    ) -> (bool, String) {
5226        let min_area = self
5227            .property_plot_settings
5228            .as_ref()
5229            .map(|s| s.min_plot_area_m2)
5230            .unwrap_or(4.0);
5231        if area + f32::EPSILON < min_area {
5232            return (false, "plot too small".into());
5233        }
5234        if zone.max_area_m2.is_some_and(|m| area > m) {
5235            return (false, "plot exceeds max area".into());
5236        }
5237        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5238            return (false, "plot must lie inside the property zone".into());
5239        }
5240        if self.property_plots.iter().any(|p| {
5241            rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5242        }) {
5243            return (false, "plot overlaps an existing claim".into());
5244        }
5245        (true, String::new())
5246    }
5247
5248    /// Standing in a property zone on a free cell (not already claimed).
5249    pub fn free_property_zone_under_player(
5250        &self,
5251    ) -> Option<&flatland_protocol::PropertyZoneView> {
5252        let (px, py) = self.player_position();
5253        let zone = self.property_zone_at(px, py)?;
5254        if self
5255            .property_plots
5256            .iter()
5257            .any(|p| point_in_plot(px, py, p))
5258        {
5259            return None;
5260        }
5261        Some(zone)
5262    }
5263
5264    /// Own plot covering the player (`is_mine`).
5265    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5266        let (px, py) = self.player_position();
5267        self.property_plots
5268            .iter()
5269            .find(|p| p.is_mine && point_in_plot(px, py, p))
5270    }
5271
5272    /// Plot under the player that they may farm (deed, grant, or public).
5273    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5274        let (px, py) = self.player_position();
5275        self.property_plots
5276            .iter()
5277            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5278    }
5279
5280    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5281    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5282        if self.farmable_plot_under_player().is_none() {
5283            return None;
5284        }
5285        let (px, py) = self.player_position();
5286        Some((px.floor() as i32, py.floor() as i32))
5287    }
5288
5289    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5290        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5291        self.resource_nodes.iter().any(|n| {
5292            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5293            ncx == cx && ncy == cy
5294                || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5295        })
5296    }
5297
5298    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5299        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5300        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5301            || self
5302                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5303                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5304        if !tilled {
5305            return false;
5306        }
5307        !self.resource_node_occupies_farm_cell(cx, cy)
5308    }
5309
5310    /// Empty tilled soil on the cell the player is standing on (no crop node).
5311    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5312        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5313            return false;
5314        };
5315        self.free_tilled_plant_slot_at(cx, cy)
5316    }
5317
5318    /// True when a free tilled cell (no resource node) is within interact range.
5319    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5320        let (px, py) = self.player_position();
5321        for dy in -2..=2 {
5322            for dx in -2..=2 {
5323                let cx = px.floor() as i32 + dx;
5324                let cy = py.floor() as i32 + dy;
5325                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5326                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5327                    continue;
5328                }
5329                if self.free_tilled_plant_slot_at(cx, cy) {
5330                    return true;
5331                }
5332            }
5333        }
5334        false
5335    }
5336
5337    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5338        stack.quantity > 0
5339            && (stack.props.contains_key("seed_for")
5340                || stack.template_id.ends_with("_seed")
5341                || stack.template_id == "potato_seed"
5342                || stack.template_id == "carrot_seed")
5343    }
5344
5345    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
5346    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5347        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5348        fn walk(
5349            stacks: &[flatland_protocol::ItemStack],
5350            counts: &mut std::collections::HashMap<String, u32>,
5351        ) {
5352            for s in stacks {
5353                if GameState::stack_is_farm_seed(s) {
5354                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5355                }
5356                walk(&s.contents, counts);
5357            }
5358        }
5359        walk(&self.inventory_stacks, &mut counts);
5360        for worn in self.worn.values() {
5361            walk(std::slice::from_ref(worn), &mut counts);
5362        }
5363        let mut out: Vec<_> = counts
5364            .into_iter()
5365            .map(|(template_id, quantity)| {
5366                let label = self
5367                    .inventory_hints
5368                    .get(&template_id)
5369                    .map(|h| h.display_name.clone())
5370                    .filter(|n| !n.trim().is_empty())
5371                    .unwrap_or_else(|| humanize_template_id(&template_id));
5372                (template_id, quantity, label)
5373            })
5374            .collect();
5375        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5376        out
5377    }
5378
5379    /// First farm seed template in inventory (root + nested bags).
5380    pub fn first_farm_seed_template(&self) -> Option<String> {
5381        self.farm_seed_entries()
5382            .into_iter()
5383            .next()
5384            .map(|(id, _, _)| id)
5385    }
5386
5387    pub fn clamp_plant_menu(&mut self) {
5388        let n = self.farm_seed_entries().len();
5389        if n == 0 {
5390            self.plant_menu_index = 0;
5391            self.plant_quantity = 1;
5392            return;
5393        }
5394        self.plant_menu_index = self.plant_menu_index.min(n - 1);
5395        let max_qty = self
5396            .farm_seed_entries()
5397            .get(self.plant_menu_index)
5398            .map(|(_, q, _)| *q)
5399            .unwrap_or(1)
5400            .max(1);
5401        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5402    }
5403
5404    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5405        let entries = self.farm_seed_entries();
5406        let (id, max, label) = entries.get(self.plant_menu_index)?;
5407        let qty = self.plant_quantity.min(*max).max(1);
5408        Some((id.clone(), qty, label.clone()))
5409    }
5410
5411    /// Terrain, interactables, and map objects near the player for the HUD location panel.
5412    pub fn location_context_lines(&self) -> Vec<ContextLine> {
5413        let (px, py) = self.player_position();
5414        let inside = self.effective_inside_building();
5415        let mut lines = Vec::new();
5416
5417        if let Some(kind) = self.terrain_at(px, py) {
5418            lines.push(ContextLine {
5419                on_top: true,
5420                text: format!("Terrain: {}", terrain_kind_label(kind)),
5421            });
5422        }
5423
5424        if let Some(id) = inside.as_ref() {
5425            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5426                lines.push(ContextLine {
5427                    on_top: true,
5428                    text: format!("Inside: {}", b.label),
5429                });
5430            }
5431        }
5432
5433        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5434
5435        for node in &self.resource_nodes {
5436            if node.id.starts_with("preview:") {
5437                continue;
5438            }
5439            let dist = distance(px, py, node.x, node.y);
5440            if dist > NEARBY_SCAN_M {
5441                continue;
5442            }
5443            let on_top = dist <= ON_TOP_RADIUS_M;
5444            let prefix = if on_top { "On" } else { "Near" };
5445            let name = resource_node_near_display_label(&node.label);
5446            let action = resource_node_near_action_suffix(node);
5447            nearby.push((
5448                dist,
5449                ContextLine {
5450                    on_top,
5451                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5452                },
5453            ));
5454        }
5455
5456        for drop in &self.ground_drops {
5457            let dist = distance(px, py, drop.x, drop.y);
5458            if dist > INTERACTION_RADIUS_M {
5459                continue;
5460            }
5461            let on_top = dist <= ON_TOP_RADIUS_M;
5462            let name = self.template_display_name(&drop.template_id);
5463            let prefix = if on_top { "On" } else { "Near" };
5464            let qty = if drop.quantity > 1 {
5465                format!(" ×{}", drop.quantity)
5466            } else {
5467                String::new()
5468            };
5469            nearby.push((
5470                dist,
5471                ContextLine {
5472                    on_top,
5473                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5474                },
5475            ));
5476        }
5477
5478        for c in &self.placed_containers {
5479            let dist = distance(px, py, c.x, c.y);
5480            if dist > CONTAINER_RANGE_M {
5481                continue;
5482            }
5483            let on_top = dist <= ON_TOP_RADIUS_M;
5484            let name = self.placed_container_public_label(c);
5485            let lock = if c.locked { " [locked]" } else { "" };
5486            let prefix = if on_top { "On" } else { "Near" };
5487            nearby.push((
5488                dist,
5489                ContextLine {
5490                    on_top,
5491                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5492                },
5493            ));
5494        }
5495
5496        for npc in &self.npcs {
5497            let dist = distance(px, py, npc.x, npc.y);
5498            if dist > NEARBY_SCAN_M {
5499                continue;
5500            }
5501            let on_top = dist <= ON_TOP_RADIUS_M;
5502            let prefix = if on_top { "On" } else { "Near" };
5503            nearby.push((
5504                dist,
5505                ContextLine {
5506                    on_top,
5507                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5508                },
5509            ));
5510        }
5511
5512        for door in &self.doors {
5513            let dist = distance(px, py, door.x, door.y);
5514            if dist > DOOR_INTERACTION_RADIUS_M {
5515                continue;
5516            }
5517            let building = self
5518                .buildings
5519                .iter()
5520                .find(|b| b.id == door.building_id)
5521                .map(|b| b.label.as_str())
5522                .unwrap_or(door.building_id.as_str());
5523            let action = if inside.is_some() && door.portal.is_some() {
5524                "exit"
5525            } else {
5526                "enter"
5527            };
5528            nearby.push((
5529                dist,
5530                ContextLine {
5531                    on_top: dist <= ON_TOP_RADIUS_M,
5532                    text: format!("{building} door ({dist:.1}m) — f {action}"),
5533                },
5534            ));
5535        }
5536
5537        if inside.is_none() {
5538            for inter in &self.interactables {
5539                if inter.kind != "quest_board" {
5540                    continue;
5541                }
5542                let dist = distance(px, py, inter.x, inter.y);
5543                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5544                    continue;
5545                }
5546                let on_top = dist <= ON_TOP_RADIUS_M;
5547                let prefix = if on_top { "On" } else { "Near" };
5548                let label = if inter.label.is_empty() {
5549                    "Quest board".to_string()
5550                } else {
5551                    inter.label.clone()
5552                };
5553                nearby.push((
5554                    dist,
5555                    ContextLine {
5556                        on_top,
5557                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5558                    },
5559                ));
5560            }
5561        }
5562
5563        if self.in_shallow_water() {
5564            let already = self
5565                .terrain_at(px, py)
5566                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5567            if !already {
5568                nearby.push((
5569                    0.0,
5570                    ContextLine {
5571                        on_top: true,
5572                        text: "Shallow water — f fill bottle".into(),
5573                    },
5574                ));
5575            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5576                line.text.push_str(" — f fill bottle");
5577            }
5578        }
5579
5580        if self.claim_mode.is_some() {
5581            nearby.push((
5582                0.0,
5583                ContextLine {
5584                    on_top: true,
5585                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5586                        .into(),
5587                },
5588            ));
5589        } else if let Some(plot) = self.my_plot_under_player() {
5590            let zone = plot
5591                .zone_label
5592                .as_deref()
5593                .filter(|s| !s.trim().is_empty())
5594                .or_else(|| {
5595                    self.property_zones
5596                        .iter()
5597                        .find(|z| z.id == plot.property_zone_id)
5598                        .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5599                })
5600                .unwrap_or(plot.property_zone_id.as_str());
5601            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5602                format!("Your plot ({zone}) — f again to sell to crown")
5603            } else {
5604                format!(
5605                    "Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
5606                )
5607            };
5608            nearby.push((
5609                0.0,
5610                ContextLine {
5611                    on_top: true,
5612                    text: prompt,
5613                },
5614            ));
5615        } else if let Some(plot) = self.farmable_plot_under_player() {
5616            let owner = plot
5617                .owner_label
5618                .as_deref()
5619                .filter(|s| !s.trim().is_empty())
5620                .unwrap_or("owner");
5621            let disc = if plot.farm_public {
5622                plot.public_tax_discount_bps / 100
5623            } else {
5624                plot.farm_allow
5625                    .iter()
5626                    .find(|g| Some(g.character_id) == self.character_id)
5627                    .map(|g| g.tax_discount_bps / 100)
5628                    .unwrap_or(0)
5629            };
5630            nearby.push((
5631                0.0,
5632                ContextLine {
5633                    on_top: true,
5634                    text: format!(
5635                        "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5636                    ),
5637                },
5638            ));
5639        } else if let Some(zone) = self.free_property_zone_under_player() {
5640            let label = zone
5641                .label
5642                .as_deref()
5643                .filter(|s| !s.trim().is_empty())
5644                .unwrap_or(zone.id.as_str());
5645            nearby.push((
5646                0.0,
5647                ContextLine {
5648                    on_top: true,
5649                    text: format!("Claimable land: {label} — k buy plot"),
5650                },
5651            ));
5652        }
5653
5654        for entity in &self.entities {
5655            if entity.id == self.entity_id {
5656                continue;
5657            }
5658            let dist = distance(
5659                px,
5660                py,
5661                entity.transform.position.x,
5662                entity.transform.position.y,
5663            );
5664            if dist > NEARBY_SCAN_M {
5665                continue;
5666            }
5667            let label = if entity.label.is_empty() {
5668                format!("entity {}", entity.id)
5669            } else {
5670                entity.label.clone()
5671            };
5672            nearby.push((
5673                dist,
5674                ContextLine {
5675                    on_top: dist <= ON_TOP_RADIUS_M,
5676                    text: format!("Near: {label} ({dist:.1}m)"),
5677                },
5678            ));
5679        }
5680
5681        nearby.sort_by(|a, b| {
5682            a.0.partial_cmp(&b.0)
5683                .unwrap_or(std::cmp::Ordering::Equal)
5684                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
5685        });
5686        lines.extend(nearby.into_iter().map(|(_, l)| l));
5687
5688        if lines.is_empty() {
5689            lines.push(ContextLine {
5690                on_top: false,
5691                text: "(nothing notable nearby)".into(),
5692            });
5693        }
5694
5695        lines
5696    }
5697}
5698
5699/// HUD line for the location / nearby panel.
5700#[derive(Debug, Clone)]
5701pub struct ContextLine {
5702    pub on_top: bool,
5703    pub text: String,
5704}
5705
5706const ON_TOP_RADIUS_M: f32 = 0.65;
5707const NEARBY_SCAN_M: f32 = 5.0;
5708
5709/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
5710pub fn resource_node_near_display_label(label: &str) -> String {
5711    label
5712        .strip_suffix(" (growing)")
5713        .unwrap_or(label)
5714        .to_string()
5715}
5716
5717/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
5718pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
5719    use flatland_protocol::ResourceNodeState;
5720    if let Some(p) = node.growth_progress {
5721        if p < 1.0 - f32::EPSILON {
5722            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
5723            return format!(" (growing, {pct}%)");
5724        }
5725        return " — f harvest".to_string();
5726    }
5727    match node.state {
5728        ResourceNodeState::Available => " — f harvest".to_string(),
5729        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
5730        ResourceNodeState::Cooldown => " (depleted)".to_string(),
5731    }
5732}
5733
5734fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
5735    use flatland_protocol::TerrainKindView;
5736    match kind {
5737        TerrainKindView::Grass => "Grass",
5738        TerrainKindView::Dirt => "Dirt",
5739        TerrainKindView::Tilled => "Tilled",
5740        TerrainKindView::Desert => "Desert",
5741        TerrainKindView::Hill => "Hills",
5742        TerrainKindView::Bog => "Bog",
5743        TerrainKindView::Beach => "Beach",
5744        TerrainKindView::ShallowWater => "Shallow water",
5745        TerrainKindView::DeepWater => "Deep water",
5746        TerrainKindView::Trail => "Trail",
5747        TerrainKindView::Road => "Road",
5748        TerrainKindView::Rock => "Rock",
5749    }
5750}
5751
5752fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
5753    crate::world_zones::zone_rects_contain(rects, x, y)
5754}
5755
5756fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
5757    zone.rects
5758        .iter()
5759        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
5760        .sum()
5761}
5762
5763fn claim_rect_fully_inside_zone(
5764    zone: &flatland_protocol::PropertyZoneView,
5765    x0: f32,
5766    y0: f32,
5767    x1: f32,
5768    y1: f32,
5769) -> bool {
5770    let mut y = y0 + 0.5;
5771    while y < y1 {
5772        let mut x = x0 + 0.5;
5773        while x < x1 {
5774            if !zone_rects_contain(&zone.rects, x, y) {
5775                return false;
5776            }
5777            x += 1.0;
5778        }
5779        y += 1.0;
5780    }
5781    true
5782}
5783
5784fn rects_overlap_half_open(
5785    ax0: f32,
5786    ay0: f32,
5787    ax1: f32,
5788    ay1: f32,
5789    bx0: f32,
5790    by0: f32,
5791    bx1: f32,
5792    by1: f32,
5793) -> bool {
5794    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
5795}
5796
5797fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
5798    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
5799}
5800
5801fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
5802    p.zone_label
5803        .as_deref()
5804        .filter(|s| !s.trim().is_empty())
5805        .map(|s| s.to_string())
5806        .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
5807}
5808
5809/// Match `flatland_sim::economy_zones::snap_claim_rect`.
5810fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
5811    let a = x0.min(x1).floor();
5812    let b = y0.min(y1).floor();
5813    let mut c = x0.max(x1).ceil();
5814    let mut d = y0.max(y1).ceil();
5815    if (c - a) < 1.0 {
5816        c = a + 1.0;
5817    }
5818    if (d - b) < 1.0 {
5819        d = b + 1.0;
5820    }
5821    (a, b, c, d)
5822}
5823
5824fn humanize_template_id(template_id: &str) -> String {
5825    template_id
5826        .split('_')
5827        .map(|word| {
5828            let mut chars = word.chars();
5829            match chars.next() {
5830                None => String::new(),
5831                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5832            }
5833        })
5834        .collect::<Vec<_>>()
5835        .join(" ")
5836}
5837
5838/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
5839const HARVEST_RANGE_M: f32 = 1.5;
5840
5841pub struct GameClient<S: PlayConnection> {
5842    session: S,
5843    seq: Seq,
5844    pub state: GameState,
5845    last_move_forward: f32,
5846    last_move_strafe: f32,
5847}
5848
5849impl<S: PlayConnection> GameClient<S> {
5850    pub fn new(session: S) -> Self {
5851        let session_id = session.session_id();
5852        let entity_id = session.entity_id();
5853        let mut client = Self {
5854            session,
5855            seq: 0,
5856            last_move_forward: 0.0,
5857            last_move_strafe: 0.0,
5858            state: GameState {
5859                session_id,
5860                entity_id,
5861                character_id: None,
5862                tick: 0,
5863                chunk_rev: 0,
5864                content_rev: 0,
5865                publish_rev: 0,
5866                entities: Vec::new(),
5867                player: None,
5868                resource_nodes: Vec::new(),
5869                ground_drops: Vec::new(),
5870                placed_containers: Vec::new(),
5871                buildings: Vec::new(),
5872                doors: Vec::new(),
5873                interior_map: None,
5874                npcs: Vec::new(),
5875                blueprints: Vec::new(),
5876                world_x0: 0.0,
5877                world_y0: 0.0,
5878                world_width_m: 0.0,
5879                world_height_m: 0.0,
5880                terrain_zones: Vec::new(),
5881                z_platforms: Vec::new(),
5882                z_transitions: Vec::new(),
5883                z_bands_outdoor_backup: None,
5884                world_clock: flatland_protocol::WorldClock::default(),
5885                inventory: std::collections::HashMap::new(),
5886                inventory_hints: std::collections::HashMap::new(),
5887                logs: VecDeque::new(),
5888                intents_sent: 0,
5889                ticks_received: 0,
5890                connected: false,
5891                disconnect_reason: None,
5892                show_stats: false,
5893            hud_log_hidden: false,
5894                show_equip_menu: false,
5895                equip_menu_index: 0,
5896                show_craft_menu: false,
5897                craft_menu_index: 0,
5898                craft_batch_quantity: 1,
5899                show_shop_menu: false,
5900                shop_catalog: None,
5901                bank_panel: None,
5902                bank_menu_index: 0,
5903                bank_ui_mode: BankUiMode::Menu,
5904                storage_panel: None,
5905                market_panel: None,
5906                market_menu_index: 0,
5907                market_filter: String::new(),
5908                market_filter_focused: false,
5909                market_category_filter: None,
5910                market_buy_confirm: None,
5911                market_ui_mode: MarketUiMode::Browse,
5912                storage_menu_index: 0,
5913                storage_ui_mode: StorageUiMode::Menu,
5914                shop_tab: ShopTab::default(),
5915                shop_menu_index: 0,
5916                shop_quantity: 1,
5917                shop_trade_log: VecDeque::new(),
5918                show_npc_verb_menu: false,
5919                npc_verb_target: None,
5920                npc_verb_index: 0,
5921                player_verbs: crate::social::PlayerVerbState::default(),
5922                social_chat: crate::social::SocialChatState::default(),
5923                trade_ui: crate::social::TradeUiState::default(),
5924                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
5925                show_npc_chat: false,
5926                npc_chat: None,
5927                show_inventory_menu: false,
5928                inventory_menu_index: 0,
5929                inventory_tab: InventoryTab::OnPerson,
5930                inventory_filter: String::new(),
5931                inventory_filter_focused: false,
5932                show_move_picker: false,
5933                show_rename_prompt: false,
5934                show_worker_rename: false,
5935                rename_buffer: String::new(),
5936                move_picker_index: 0,
5937                move_picker: None,
5938                show_grant_picker: false,
5939                grant_picker_index: 0,
5940                grant_picker: None,
5941                show_destroy_picker: false,
5942                destroy_confirm_pending: false,
5943                destroy_picker: None,
5944                combat_target: None,
5945                combat_target_label: None,
5946                ground_target: None,
5947                combat_fx: Vec::new(),
5948                property_zones: Vec::new(),
5949                tax_zones: Vec::new(),
5950                growth_zones: Vec::new(),
5951                biome_zones: Vec::new(),
5952                property_plots: Vec::new(),
5953                property_plot_settings: None,
5954                claim_mode: None,
5955                relocate_mode: None,
5956                sell_plot_confirm: None,
5957                sell_plot_armed_at: None,
5958                show_plant_menu: false,
5959                plant_menu_index: 0,
5960                show_farm_access: false,
5961                farm_access_name_draft: String::new(),
5962                farm_access_discount_bps: 0,
5963                farm_access_index: 0,
5964                plant_quantity: 1,
5965                in_combat: false,
5966                auto_attack: true,
5967                combat_has_los: false,
5968                attack_cd_ticks: 0,
5969                gcd_ticks: 0,
5970                weapon_ability_id: "unarmed".into(),
5971                mainhand_template_id: None,
5972                mainhand_label: None,
5973                offhand_template_id: None,
5974                offhand_label: None,
5975                mainhand_hand_slots: 1,
5976                defense: None,
5977                worn: BTreeMap::new(),
5978                carry_mass: 0.0,
5979                carry_mass_max: 0.0,
5980                encumbrance: flatland_protocol::EncumbranceState::Light,
5981                inventory_stacks: Vec::new(),
5982                keychain_stacks: Vec::new(),
5983            whisper_pouch_stacks: Vec::new(),
5984                combat_target_detail: None,
5985                statuses: Vec::new(),
5986                cast_progress: None,
5987                timed_channel: None,
5988                ability_cooldowns: Vec::new(),
5989                blocking_active: false,
5990                max_target_slots: 1,
5991                combat_slots: Vec::new(),
5992                rotation_presets: Vec::new(),
5993                known_abilities: Vec::new(),
5994                ability_meta: std::collections::HashMap::new(),
5995                hotbar: vec![None; 9],
5996                max_abilities_per_rotation: 0,
5997                show_loadout_menu: false,
5998                show_keychain_menu: false,
5999                keychain_menu_index: 0,
6000                show_rotation_editor: false,
6001                loadout_menu_index: 0,
6002                loadout_hotbar_slot: 1,
6003                loadout_ability_index: 0,
6004                loadout_focus_presets: false,
6005                rotation_editor: RotationEditorState::default(),
6006                harvest_in_progress: false,
6007                harvest_started_at: None,
6008                pending_craft_ack: None,
6009                pending_worker_job_ack: None,
6010                attending_worker_instance_id: None,
6011                quest_log: Vec::new(),
6012                interactables: Vec::new(),
6013                ledger: None,
6014                career: None,
6015                character_sheet_tab: CharacterSheetTab::Character,
6016                ledger_period: LedgerPeriod::Day,
6017                show_quest_offer: false,
6018                pending_quest_offer: None,
6019                show_quest_menu: false,
6020                quest_menu_index: 0,
6021                quest_withdraw_confirm: false,
6022                hired_workers: Vec::new(),
6023                show_workers_menu: false,
6024                workers_menu_index: 0,
6025                workers_menu_compact: false,
6026                worker_step_display: BTreeMap::new(),
6027                worker_error_display: BTreeMap::new(),
6028                show_worker_give_picker: false,
6029                worker_give_picker_index: 0,
6030                worker_give_picker: None,
6031                show_worker_give_target_picker: false,
6032                worker_give_target_picker_index: 0,
6033                worker_give_target_picker: None,
6034                show_worker_take_picker: false,
6035                worker_take_picker_index: 0,
6036                worker_take_picker: None,
6037                show_worker_teach_picker: false,
6038                worker_teach_picker_index: 0,
6039                worker_teach_picker: None,
6040                worker_route_editor: None,
6041                progression_curve: None,
6042            },
6043        };
6044        client.state.apply_client_ui_prefs();
6045        client
6046    }
6047
6048    pub fn entity_id(&self) -> EntityId {
6049        self.state.entity_id
6050    }
6051
6052    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6053        if self.state.connected {
6054            return Ok(());
6055        }
6056
6057        loop {
6058            match self.session.next_event().await {
6059                Some(SessionEvent::Welcome {
6060                    session_id,
6061                    entity_id,
6062                    snapshot,
6063                }) => {
6064                    self.state
6065                        .restore_from_welcome(session_id, entity_id, &snapshot);
6066                    self.state.apply_client_ui_prefs();
6067                    self.state.push_log(format!(
6068                        "Connected — session {session_id}, entity {entity_id}"
6069                    ));
6070                    return Ok(());
6071                }
6072                Some(SessionEvent::Disconnected { .. }) => {
6073                    anyhow::bail!("disconnected before welcome");
6074                }
6075                Some(_) => continue,
6076                None => anyhow::bail!("session closed before welcome"),
6077            }
6078        }
6079    }
6080
6081    /// Drain all pending server events (non-blocking).
6082    pub fn drain_events(&mut self) {
6083        while let Some(event) = self.session.try_next_event() {
6084            if self.handle_event_sync(event).is_err() {
6085                break;
6086            }
6087        }
6088    }
6089
6090    /// Wait for the next server event.
6091    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6092        self.session.next_event().await
6093    }
6094
6095    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6096        self.handle_event_sync(event)
6097    }
6098
6099    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6100        match event {
6101            SessionEvent::Welcome {
6102                session_id,
6103                entity_id,
6104                snapshot,
6105            } => {
6106                let resumed = self.state.connected;
6107                self.state
6108                    .restore_from_welcome(session_id, entity_id, &snapshot);
6109                if resumed {
6110                    self.state.push_log(format!(
6111                        "Session restored — session {session_id}, entity {entity_id}"
6112                    ));
6113                }
6114            }
6115            SessionEvent::ContentUpdated { snapshot } => {
6116                self.state
6117                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6118                self.state.push_log(format!(
6119                    "World updated (content rev {})",
6120                    snapshot.content_rev
6121                ));
6122            }
6123            SessionEvent::Tick(delta) => {
6124                self.state.apply_tick_fields(&delta, self.state.entity_id);
6125                self.state.ticks_received += 1;
6126            }
6127            SessionEvent::IntentAck {
6128                entity_id,
6129                seq,
6130                tick,
6131            } => {
6132                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6133                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6134                    if *craft_seq == seq {
6135                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6136                        if batches > 1 {
6137                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6138                        } else {
6139                            self.state.push_log(format!("Crafting {label}…"));
6140                        }
6141                    }
6142                }
6143                if self
6144                    .state
6145                    .pending_worker_job_ack
6146                    .as_ref()
6147                    .is_some_and(|p| p.seq == seq)
6148                {
6149                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6150                    if pending.idle {
6151                        self.state.push_log(format!(
6152                            "Route cleared for {} — worker idle",
6153                            pending.worker_label
6154                        ));
6155                    } else {
6156                        self.state.push_log(format!(
6157                            "Route saved for {} — {} stop(s), job loop active",
6158                            pending.worker_label, pending.stop_count
6159                        ));
6160                    }
6161                    if self
6162                        .state
6163                        .worker_route_editor
6164                        .as_ref()
6165                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6166                    {
6167                        self.close_worker_route_editor();
6168                    }
6169                }
6170            }
6171            SessionEvent::Chat(msg) => {
6172                let label = match msg.channel {
6173                    flatland_protocol::ChatChannel::Nearby => "nearby",
6174                    flatland_protocol::ChatChannel::Direct => "speak",
6175                    flatland_protocol::ChatChannel::Whisper => "whisper",
6176                    flatland_protocol::ChatChannel::WhisperStone => "stone",
6177                };
6178                let clarity = match msg.clarity {
6179                    flatland_protocol::ChatClarity::Clear => "",
6180                    flatland_protocol::ChatClarity::Partial => "~",
6181                    flatland_protocol::ChatClarity::Heavy => "…",
6182                };
6183                self.state.push_log(format!(
6184                    "[{label}{clarity}] {}: {}",
6185                    msg.from_name, msg.text
6186                ));
6187                let now_ms = std::time::SystemTime::now()
6188                    .duration_since(std::time::UNIX_EPOCH)
6189                    .map(|d| d.as_millis() as u64)
6190                    .unwrap_or(0);
6191                self.state
6192                    .social_chat
6193                    .note_speech(&msg, self.state.entity_id, now_ms);
6194                self.state
6195                    .social_chat
6196                    .push(crate::social::ChatLogEntry::from_message(
6197                        msg,
6198                        self.state.entity_id,
6199                    ));
6200            }
6201            SessionEvent::TradeOpened(panel) => {
6202                self.state.social_chat.pending_trade = None;
6203                let peer = panel.peer_name.clone();
6204                self.state.trade_ui.open(panel);
6205                self.state
6206                    .social_chat
6207                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6208                self.state
6209                    .social_chat
6210                    .push_cue(crate::social::AudioCue::TradeOpened);
6211            }
6212            SessionEvent::TradeClosed { reason } => {
6213                self.state.push_log(reason.clone());
6214                self.state.social_chat.push_system(reason);
6215                self.state.trade_ui.close();
6216            }
6217            SessionEvent::HarvestResult(result) => {
6218                self.state.clear_harvest_state();
6219                crate::harvest_trace!(
6220                    entity_id = self.state.entity_id,
6221                    node_id = %result.node_id,
6222                    template = %result.item_template,
6223                    quantity = result.quantity,
6224                    client_tick = self.state.tick,
6225                    "client applied harvest result"
6226                );
6227                let msg = if result.quantity == 0 {
6228                    format!(
6229                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6230                        result.item_template
6231                    )
6232                } else {
6233                    format!(
6234                        "Harvested {} x{} (on the ground — press P to pick up)",
6235                        result.item_template, result.quantity
6236                    )
6237                };
6238                self.state.push_log(msg);
6239            }
6240            SessionEvent::CraftResult(result) => {
6241                for stack in &result.consumed {
6242                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6243                        *qty = qty.saturating_sub(stack.quantity);
6244                        if *qty == 0 {
6245                            self.state.inventory.remove(&stack.template_id);
6246                        }
6247                    }
6248                }
6249                for stack in &result.outputs {
6250                    *self
6251                        .state
6252                        .inventory
6253                        .entry(stack.template_id.clone())
6254                        .or_insert(0) += stack.quantity;
6255                }
6256                if let Some(output) = result.outputs.first() {
6257                    if result.batch_total > 1 {
6258                        self.state.push_log(format!(
6259                            "Crafted {} x{} ({}/{})",
6260                            output.template_id,
6261                            output.quantity,
6262                            result.batch_index,
6263                            result.batch_total
6264                        ));
6265                    } else {
6266                        self.state.push_log(format!(
6267                            "Crafted {} x{}",
6268                            output.template_id, output.quantity
6269                        ));
6270                    }
6271                } else {
6272                    self.state
6273                        .push_log(format!("Craft finished: {}", result.blueprint_id));
6274                }
6275            }
6276            SessionEvent::Death(notice) => {
6277                self.state.clear_harvest_state();
6278                self.state.push_log(notice.message.clone());
6279                self.state.push_log(format!(
6280                    "Respawned at ({:.1}, {:.1})",
6281                    notice.respawn_x, notice.respawn_y
6282                ));
6283            }
6284            SessionEvent::Interaction(notice) => {
6285                if notice.message.starts_with("Harvest failed:") {
6286                    self.state.clear_harvest_state();
6287                }
6288                if notice.message.starts_with("Can't do that:") {
6289                    self.state.pending_craft_ack = None;
6290                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
6291                        if let Some(w) = self
6292                            .state
6293                            .hired_workers
6294                            .iter_mut()
6295                            .find(|w| w.instance_id == pending.worker_instance_id)
6296                        {
6297                            w.route = pending.prev_route;
6298                            w.mode = pending.prev_mode;
6299                            w.step_label = pending.prev_step_label;
6300                            w.last_error = pending.prev_last_error;
6301                        }
6302                        let reason = notice
6303                            .message
6304                            .strip_prefix("Can't do that:")
6305                            .unwrap_or(&notice.message)
6306                            .trim();
6307                        self.state.push_log(format!(
6308                            "Route save failed for {}: {reason}",
6309                            pending.worker_label
6310                        ));
6311                    }
6312                    let reason = notice
6313                        .message
6314                        .strip_prefix("Can't do that:")
6315                        .unwrap_or(&notice.message)
6316                        .trim();
6317                    if reason.contains("already tilled") {
6318                        if let Some(plot) = self.state.my_plot_under_player() {
6319                            self.state.sell_plot_confirm = Some(plot.plot_id);
6320                            self.state.sell_plot_armed_at = Some(Instant::now());
6321                        }
6322                    }
6323                }
6324                if notice.message.starts_with("Cast failed:") {
6325                    self.state.cast_progress = None;
6326                }
6327                if notice.message.contains("slain the") {
6328                    self.state.combat_target = None;
6329                    self.state.combat_target_label = None;
6330                }
6331                // Inbound trade request → inline Y/N in the CHAT column (no popup).
6332                if notice.message.contains("wants to trade") {
6333                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6334                        let from_name = notice
6335                            .message
6336                            .split(" wants to trade")
6337                            .next()
6338                            .unwrap_or("Player")
6339                            .to_string();
6340                        self.state.social_chat.pending_trade =
6341                            Some(crate::social::PendingTradeRequest {
6342                                from_entity,
6343                                from_name: from_name.clone(),
6344                            });
6345                        self.state.social_chat.push_system(format!(
6346                            "{from_name} wants to trade — [Y] accept · [N] decline"
6347                        ));
6348                        self.state
6349                            .social_chat
6350                            .push_cue(crate::social::AudioCue::TradeOffer);
6351                    }
6352                }
6353                if notice.message.starts_with("trade request declined") {
6354                    self.state
6355                        .social_chat
6356                        .push_system(notice.message.clone());
6357                    self.state
6358                        .social_chat
6359                        .push_cue(crate::social::AudioCue::TradeDeclined);
6360                }
6361                self.state.apply_interaction_notice(&notice);
6362                self.state.push_log(notice.message.clone());
6363            }
6364            SessionEvent::ShopOpened(catalog) => {
6365                self.state.apply_shop_catalog(catalog);
6366            }
6367            SessionEvent::BankOpened(panel) => {
6368                self.state.apply_bank_panel(panel);
6369            }
6370            SessionEvent::StorageOpened(panel) => {
6371                self.state.apply_storage_panel(panel);
6372            }
6373            SessionEvent::MarketOpened(panel) => {
6374                self.state.apply_market_panel(panel);
6375            }
6376            SessionEvent::NpcTalkOpened(opened) => {
6377                self.state.show_npc_verb_menu = false;
6378                if self.state.npc_verb_target.is_none() {
6379                    self.state.npc_verb_target = Some(opened.npc_id.clone());
6380                }
6381                let label = opened.npc_label.clone();
6382                let banner = if !opened.trade_allowed {
6383                    Some("Trade is unavailable right now.".to_string())
6384                } else {
6385                    None
6386                };
6387                self.state.show_npc_chat = true;
6388                self.state.npc_chat = Some(NpcChatState {
6389                    npc_id: opened.npc_id,
6390                    npc_label: opened.npc_label,
6391                    lines: if opened.greeting.is_empty() {
6392                        vec![]
6393                    } else {
6394                        vec![format!("{label}: {}", opened.greeting)]
6395                    },
6396                    input: String::new(),
6397                    pending: opened.greeting.is_empty(),
6398                    talk_depth: opened.talk_depth,
6399                    trade_allowed: opened.trade_allowed,
6400                    banner,
6401                });
6402            }
6403            SessionEvent::NpcTalkPending(_) => {
6404                if let Some(chat) = self.state.npc_chat.as_mut() {
6405                    chat.pending = true;
6406                }
6407            }
6408            SessionEvent::NpcTalkReply(reply) => {
6409                if let Some(chat) = self.state.npc_chat.as_mut() {
6410                    if chat.npc_id == reply.npc_id {
6411                        chat.pending = false;
6412                        if reply.trade_disabled {
6413                            chat.trade_allowed = false;
6414                            chat.banner = Some("Trade is unavailable right now.".to_string());
6415                        }
6416                        if reply.wind_down {
6417                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6418                            if chat.banner.is_none() {
6419                                chat.banner =
6420                                    Some("They're wrapping up — keep it brief.".to_string());
6421                            }
6422                        }
6423                        chat.lines
6424                            .push(format!("{}: {}", chat.npc_label, reply.line));
6425                    }
6426                }
6427            }
6428            SessionEvent::NpcTalkClosed(closed) => {
6429                if self
6430                    .state
6431                    .npc_chat
6432                    .as_ref()
6433                    .is_some_and(|c| c.npc_id == closed.npc_id)
6434                {
6435                    self.state.show_npc_chat = false;
6436                    self.state.npc_chat = None;
6437                }
6438            }
6439            SessionEvent::NpcTalkError(err) => {
6440                self.state.push_log(format!("Talk failed: {}", err.reason));
6441                if let Some(chat) = self.state.npc_chat.as_mut() {
6442                    chat.pending = false;
6443                }
6444            }
6445            SessionEvent::UseResult(result) => {
6446                // Inventory count hint only — the Interaction notice already logs
6447                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
6448                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6449                    *qty = qty.saturating_sub(1);
6450                    if *qty == 0 {
6451                        self.state.inventory.remove(&result.template_id);
6452                    }
6453                }
6454            }
6455            SessionEvent::QuestOffer(offer) => {
6456                self.state.pending_quest_offer = Some(offer.clone());
6457                self.state.show_quest_offer = true;
6458                self.state
6459                    .push_log(format!("Quest offered: {}", offer.title));
6460            }
6461            SessionEvent::QuestAccepted(notice) => {
6462                self.state.show_quest_offer = false;
6463                self.state.pending_quest_offer = None;
6464                self.state.push_log(notice.message);
6465            }
6466            SessionEvent::QuestWithdrawn(notice) => {
6467                self.state.show_quest_menu = false;
6468                self.state.quest_withdraw_confirm = false;
6469                self.state.push_log(notice.message);
6470            }
6471            SessionEvent::QuestStepCompleted(notice) => {
6472                self.state.push_log(notice.message);
6473            }
6474            SessionEvent::QuestCompleted(notice) => {
6475                self.state.push_log(notice.message);
6476            }
6477            SessionEvent::Disconnected { reason } => {
6478                self.state.clear_harvest_state();
6479                self.state.connected = false;
6480                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6481                if let Some(r) = &self.state.disconnect_reason {
6482                    self.state.push_log(format!("Disconnected: {r}"));
6483                } else {
6484                    self.state.push_log("Disconnected from server");
6485                }
6486            }
6487        }
6488        Ok(())
6489    }
6490
6491    pub fn is_connected(&self) -> bool {
6492        self.state.connected
6493    }
6494
6495    pub fn close_overlays(&mut self) {
6496        self.state.show_stats = false;
6497        self.state.show_craft_menu = false;
6498        self.state.show_shop_menu = false;
6499        self.state.shop_catalog = None;
6500        self.state.show_npc_verb_menu = false;
6501        self.state.npc_verb_target = None;
6502        self.state.show_npc_chat = false;
6503        self.state.npc_chat = None;
6504        self.state.show_inventory_menu = false;
6505        self.state.show_loadout_menu = false;
6506        self.state.show_rotation_editor = false;
6507        self.state.rotation_editor.reset();
6508        self.state.show_rename_prompt = false;
6509        self.state.show_worker_rename = false;
6510        self.state.rename_buffer.clear();
6511        self.state.show_move_picker = false;
6512        self.state.move_picker = None;
6513        self.state.show_destroy_picker = false;
6514        self.state.destroy_confirm_pending = false;
6515        self.state.destroy_picker = None;
6516        self.state.show_quest_offer = false;
6517        self.state.pending_quest_offer = None;
6518        self.state.show_quest_menu = false;
6519        self.state.quest_withdraw_confirm = false;
6520        self.state.show_workers_menu = false;
6521        self.close_worker_give_picker();
6522        self.close_worker_give_target_picker();
6523        self.close_worker_take_picker();
6524        self.close_worker_teach_picker();
6525        self.state.worker_route_editor = None;
6526        self.state.claim_mode = None;
6527        self.state.relocate_mode = None;
6528        self.state.sell_plot_confirm = None;
6529        self.state.sell_plot_armed_at = None;
6530        self.close_farm_access_panel();
6531        if self.state.show_plant_menu {
6532            self.close_plant_menu();
6533        }
6534    }
6535
6536    /// Esc / back — pop one UI layer instead of closing every overlay at once.
6537    pub fn back_on_esc(&mut self) -> bool {
6538        if self.state.social_chat.composer_open() {
6539            self.state.social_chat.close_composer();
6540            return true;
6541        }
6542        if self.state.player_verbs.open {
6543            self.state.player_verbs.close();
6544            return true;
6545        }
6546        if self.state.whisper_pouch_ui.open {
6547            self.state.whisper_pouch_ui.open = false;
6548            return true;
6549        }
6550        if self.state.trade_ui.panel.is_some() {
6551            // async cancel — caller should prefer trade_cancel; close UI optimistically
6552            self.state.trade_ui.close();
6553            return true;
6554        }
6555        if self.state.show_rename_prompt {
6556            self.cancel_rename_prompt();
6557            return true;
6558        }
6559        if self.state.show_worker_rename {
6560            self.cancel_worker_rename();
6561            return true;
6562        }
6563        if self.state.show_destroy_picker {
6564            if self.state.destroy_confirm_pending {
6565                self.cancel_destroy_confirm();
6566            } else {
6567                self.close_destroy_picker();
6568            }
6569            return true;
6570        }
6571        if self.state.claim_mode.is_some() {
6572            self.cancel_claim_mode();
6573            return true;
6574        }
6575        if self.state.relocate_mode.is_some() {
6576            self.cancel_relocate_mode();
6577            return true;
6578        }
6579        if self.state.show_plant_menu {
6580            self.close_plant_menu();
6581            return true;
6582        }
6583        if self.state.show_farm_access {
6584            self.close_farm_access_panel();
6585            return true;
6586        }
6587        if self.state.sell_plot_confirm.is_some() {
6588            self.state.sell_plot_confirm = None;
6589            self.state.sell_plot_armed_at = None;
6590            self.state.push_log("Sell cancelled");
6591            return true;
6592        }
6593        if self.state.show_move_picker {
6594            self.close_move_picker();
6595            return true;
6596        }
6597        if self.state.show_rotation_editor {
6598            match self.state.rotation_editor.mode {
6599                RotationEditorMode::List => {
6600                    self.state.show_rotation_editor = false;
6601                    self.state.rotation_editor.reset();
6602                }
6603                RotationEditorMode::EditLabel => {
6604                    self.state.rotation_editor.label_buffer.clear();
6605                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6606                }
6607                RotationEditorMode::PickAbility => {
6608                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6609                }
6610                RotationEditorMode::EditSequence => {
6611                    self.state.rotation_editor.draft = None;
6612                    self.state.rotation_editor.mode = RotationEditorMode::List;
6613                }
6614            }
6615            return true;
6616        }
6617        if self.state.show_inventory_menu {
6618            self.close_inventory_menu();
6619            return true;
6620        }
6621        if self.state.show_craft_menu {
6622            self.close_craft_menu();
6623            return true;
6624        }
6625        if self.state.show_keychain_menu {
6626            self.close_keychain_menu();
6627            return true;
6628        }
6629        if self.state.show_quest_offer {
6630            self.quest_offer_decline();
6631            return true;
6632        }
6633        if self.state.show_shop_menu {
6634            // Caller must await shop close (npc_interaction_back / play-loop).
6635            return false;
6636        }
6637        if self.state.bank_panel.is_some() {
6638            return false;
6639        }
6640        if self.state.storage_panel.is_some() {
6641            return false;
6642        }
6643        if self.state.market_panel.is_some() {
6644            return false;
6645        }
6646        if self.state.show_npc_chat {
6647            // play.rs calls npc_interaction_back().await on Esc
6648            return false;
6649        }
6650        if self.state.show_npc_verb_menu {
6651            self.state.show_npc_verb_menu = false;
6652            self.state.npc_verb_target = None;
6653            return true;
6654        }
6655        if self.state.show_quest_menu {
6656            if self.state.quest_withdraw_confirm {
6657                self.state.quest_withdraw_confirm = false;
6658            } else {
6659                self.state.show_quest_menu = false;
6660            }
6661            return true;
6662        }
6663        if self.state.worker_route_editor.is_some() {
6664            // Pop one sheet level; only close the editor at the root stop list.
6665            if self.re_at_root_sheet() {
6666                let reopen = self.state.attending_worker_instance_id.clone();
6667                self.close_worker_route_editor();
6668                if let Some(id) = reopen {
6669                    if let Some(idx) = self
6670                        .state
6671                        .hired_workers
6672                        .iter()
6673                        .position(|w| w.instance_id == id)
6674                    {
6675                        self.state.workers_menu_index = idx;
6676                        self.state.show_workers_menu = true;
6677                    }
6678                }
6679            } else {
6680                self.re_sheet_back();
6681            }
6682            return true;
6683        }
6684        if self.state.show_worker_give_picker {
6685            self.close_worker_give_picker();
6686            return true;
6687        }
6688        if self.state.show_worker_give_target_picker {
6689            self.close_worker_give_target_picker();
6690            return true;
6691        }
6692        if self.state.show_worker_take_picker {
6693            self.close_worker_take_picker();
6694            return true;
6695        }
6696        if self.state.show_worker_teach_picker {
6697            self.close_worker_teach_picker();
6698            return true;
6699        }
6700        if self.state.show_workers_menu {
6701            self.close_workers_menu_ui();
6702            return true;
6703        }
6704        if self.state.show_loadout_menu {
6705            self.state.show_loadout_menu = false;
6706            return true;
6707        }
6708        if self.state.show_stats {
6709            self.state.show_stats = false;
6710            return true;
6711        }
6712        if self.state.show_equip_menu {
6713            self.state.show_equip_menu = false;
6714            return true;
6715        }
6716        false
6717    }
6718
6719    pub fn toggle_stats(&mut self) {
6720        self.state.show_stats = !self.state.show_stats;
6721        if self.state.show_stats {
6722            self.state.character_sheet_tab = CharacterSheetTab::Character;
6723            self.state.show_craft_menu = false;
6724            self.state.show_shop_menu = false;
6725            self.state.shop_catalog = None;
6726            self.state.show_inventory_menu = false;
6727            self.state.show_equip_menu = false;
6728        }
6729    }
6730
6731    pub fn toggle_equip_menu(&mut self) {
6732        self.state.show_equip_menu = !self.state.show_equip_menu;
6733        if self.state.show_equip_menu {
6734            self.state.show_stats = false;
6735            self.state.show_craft_menu = false;
6736            self.state.show_shop_menu = false;
6737            self.state.shop_catalog = None;
6738            self.state.show_inventory_menu = false;
6739            self.state.show_loadout_menu = false;
6740        }
6741    }
6742
6743    pub fn cycle_character_sheet_tab(&mut self) {
6744        if self.state.show_stats {
6745            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
6746        }
6747    }
6748
6749    pub fn set_ledger_period_digit(&mut self, c: char) {
6750        if self.state.show_stats {
6751            if let Some(p) = LedgerPeriod::from_digit(c) {
6752                self.state.ledger_period = p;
6753                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
6754            }
6755        }
6756    }
6757
6758    pub fn cycle_ledger_period(&mut self) {
6759        if self.state.show_stats
6760            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
6761        {
6762            self.state.ledger_period = self.state.ledger_period.cycle();
6763        }
6764    }
6765
6766    pub fn open_inventory_menu(&mut self) {
6767        self.state.show_inventory_menu = true;
6768        self.state.show_craft_menu = false;
6769        self.state.show_shop_menu = false;
6770        self.state.shop_catalog = None;
6771        self.state.show_stats = false;
6772        self.state.show_move_picker = false;
6773        self.state.move_picker = None;
6774        self.state.show_destroy_picker = false;
6775        self.state.destroy_confirm_pending = false;
6776        self.state.destroy_picker = None;
6777        self.state.show_rename_prompt = false;
6778        self.state.rename_buffer.clear();
6779        self.state.inventory_filter_focused = false;
6780        self.state.clamp_inventory_indices();
6781    }
6782
6783    pub fn close_inventory_menu(&mut self) {
6784        self.state.show_inventory_menu = false;
6785        self.state.show_move_picker = false;
6786        self.state.move_picker = None;
6787        self.close_grant_picker();
6788        self.state.show_destroy_picker = false;
6789        self.state.destroy_confirm_pending = false;
6790        self.state.destroy_picker = None;
6791        self.state.show_rename_prompt = false;
6792        self.state.rename_buffer.clear();
6793        self.state.inventory_filter_focused = false;
6794    }
6795
6796    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
6797        let Some(row) = self.state.inventory_selected_row() else {
6798            anyhow::bail!("inventory empty");
6799        };
6800        if !self.state.row_is_renameable_container(&row) {
6801            anyhow::bail!("only storage containers can be renamed");
6802        }
6803        let current = row
6804            .stack
6805            .display_name
6806            .clone()
6807            .unwrap_or_else(|| row.stack.template_id.clone());
6808        self.state.rename_buffer = current;
6809        self.state.show_rename_prompt = true;
6810        self.state.show_worker_rename = false;
6811        self.state.show_move_picker = false;
6812        self.state.show_destroy_picker = false;
6813        self.state.destroy_confirm_pending = false;
6814        Ok(())
6815    }
6816
6817    pub fn cancel_rename_prompt(&mut self) {
6818        self.state.show_rename_prompt = false;
6819        self.state.rename_buffer.clear();
6820    }
6821
6822    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
6823        let name = self.state.rename_buffer.trim().to_string();
6824        if name.is_empty() {
6825            anyhow::bail!("name cannot be empty");
6826        }
6827        let Some(row) = self.state.inventory_selected_row() else {
6828            anyhow::bail!("inventory empty");
6829        };
6830        let Some(instance_id) = row.stack.item_instance_id else {
6831            anyhow::bail!("item has no instance id");
6832        };
6833        self.seq += 1;
6834        self.session
6835            .submit_intent(Intent::RenameContainer {
6836                entity_id: self.state.entity_id,
6837                item_instance_id: instance_id,
6838                location: row.from.clone(),
6839                name,
6840                seq: self.seq,
6841            })
6842            .await?;
6843        self.state.intents_sent += 1;
6844        self.state.show_rename_prompt = false;
6845        self.state.rename_buffer.clear();
6846        Ok(())
6847    }
6848
6849    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
6850        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6851            anyhow::bail!("no worker selected");
6852        };
6853        self.state.rename_buffer = worker.label.clone();
6854        self.state.show_worker_rename = true;
6855        self.state.show_rename_prompt = false;
6856        Ok(())
6857    }
6858
6859    pub fn cancel_worker_rename(&mut self) {
6860        self.state.show_worker_rename = false;
6861        self.state.rename_buffer.clear();
6862    }
6863
6864    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
6865        let name = self.state.rename_buffer.trim().to_string();
6866        if name.is_empty() {
6867            anyhow::bail!("name cannot be empty");
6868        }
6869        if name.chars().count() > 32 {
6870            anyhow::bail!("name must be 1–32 characters");
6871        }
6872        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6873            anyhow::bail!("no worker selected");
6874        };
6875        let worker_instance_id = worker.instance_id.clone();
6876        self.seq += 1;
6877        self.session
6878            .submit_intent(Intent::RenameHiredWorker {
6879                entity_id: self.state.entity_id,
6880                worker_instance_id: worker_instance_id.clone(),
6881                name: name.clone(),
6882                seq: self.seq,
6883            })
6884            .await?;
6885        self.state.intents_sent += 1;
6886        if let Some(w) = self
6887            .state
6888            .hired_workers
6889            .iter_mut()
6890            .find(|w| w.instance_id == worker_instance_id)
6891        {
6892            w.label = name.clone();
6893        }
6894        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6895            if ed.worker_instance_id == worker_instance_id {
6896                ed.worker_label = name.clone();
6897            }
6898        }
6899        self.state.show_worker_rename = false;
6900        self.state.rename_buffer.clear();
6901        self.state.push_log(format!("Renamed worker to \"{name}\""));
6902        Ok(())
6903    }
6904
6905    pub fn toggle_inventory_menu(&mut self) {
6906        if self.state.show_inventory_menu {
6907            self.close_inventory_menu();
6908        } else {
6909            self.open_inventory_menu();
6910        }
6911    }
6912
6913    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
6914    pub fn inventory_menu_move(&mut self, delta: i32) {
6915        if self.state.show_grant_picker {
6916            let Some(picker) = self.state.grant_picker.as_ref() else {
6917                return;
6918            };
6919            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6920            let filter = picker.filter.clone();
6921            let n = labels.len();
6922            if n == 0 {
6923                return;
6924            }
6925            self.state.grant_picker_index = step_filtered_index(
6926                self.state.grant_picker_index,
6927                delta,
6928                n,
6929                |i| list_label_matches(&labels[i], &filter),
6930            );
6931            return;
6932        }
6933        if self.state.show_move_picker {
6934            let Some(picker) = self.state.move_picker.as_ref() else {
6935                return;
6936            };
6937            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6938            let filter = picker.filter.clone();
6939            let n = labels.len();
6940            if n == 0 {
6941                return;
6942            }
6943            self.state.move_picker_index = step_filtered_index(
6944                self.state.move_picker_index,
6945                delta,
6946                n,
6947                |i| list_label_matches(&labels[i], &filter),
6948            );
6949            self.state.clamp_move_picker_quantity();
6950            return;
6951        }
6952        let n = self.state.inventory_selectable_rows().len();
6953        if n == 0 {
6954            return;
6955        }
6956        let idx = self.state.inventory_menu_index as i32;
6957        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6958    }
6959
6960    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
6961    pub fn inventory_menu_page(&mut self, pages: i32) {
6962        if self.state.show_grant_picker {
6963            let Some(picker) = self.state.grant_picker.as_ref() else {
6964                return;
6965            };
6966            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6967            let filter = picker.filter.clone();
6968            let n = labels.len();
6969            self.state.grant_picker_index = page_filtered_index(
6970                self.state.grant_picker_index,
6971                pages,
6972                n,
6973                |i| list_label_matches(&labels[i], &filter),
6974            );
6975            return;
6976        }
6977        if self.state.show_move_picker {
6978            let Some(picker) = self.state.move_picker.as_ref() else {
6979                return;
6980            };
6981            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6982            let filter = picker.filter.clone();
6983            let n = labels.len();
6984            self.state.move_picker_index = page_filtered_index(
6985                self.state.move_picker_index,
6986                pages,
6987                n,
6988                |i| list_label_matches(&labels[i], &filter),
6989            );
6990            self.state.clamp_move_picker_quantity();
6991            return;
6992        }
6993        let n = self.state.inventory_selectable_rows().len();
6994        self.state.inventory_menu_index =
6995            page_list_index(self.state.inventory_menu_index, pages, n);
6996    }
6997
6998    pub fn cycle_inventory_tab(&mut self, forward: bool) {
6999        if self.state.show_move_picker
7000            || self.state.show_grant_picker
7001            || self.state.show_destroy_picker
7002            || self.state.show_rename_prompt
7003            || self.state.inventory_filter_focused
7004        {
7005            return;
7006        }
7007        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7008        self.state.inventory_menu_index = 0;
7009        self.state.clamp_inventory_indices();
7010    }
7011
7012    pub fn focus_inventory_filter(&mut self) {
7013        if self.state.show_grant_picker {
7014            if let Some(p) = self.state.grant_picker.as_mut() {
7015                p.filter_focused = true;
7016            }
7017            return;
7018        }
7019        if self.state.show_move_picker {
7020            if let Some(p) = self.state.move_picker.as_mut() {
7021                p.filter_focused = true;
7022            }
7023            return;
7024        }
7025        self.state.inventory_filter_focused = true;
7026    }
7027
7028    pub fn set_inventory_filter(&mut self, filter: String) {
7029        self.state.inventory_filter = filter;
7030        self.state.inventory_menu_index = 0;
7031        self.state.clamp_inventory_indices();
7032    }
7033
7034    pub fn append_inventory_filter_char(&mut self, ch: char) {
7035        if ch.is_control() {
7036            return;
7037        }
7038        if self.state.show_grant_picker {
7039            if let Some(p) = self.state.grant_picker.as_mut() {
7040                if p.filter_focused {
7041                    p.filter.push(ch);
7042                    self.state.grant_picker_index = 0;
7043                }
7044            }
7045            return;
7046        }
7047        if self.state.show_move_picker {
7048            if let Some(p) = self.state.move_picker.as_mut() {
7049                if p.filter_focused {
7050                    p.filter.push(ch);
7051                    self.state.move_picker_index = 0;
7052                    self.state.clamp_move_picker_quantity();
7053                }
7054            }
7055            return;
7056        }
7057        if !self.state.inventory_filter_focused {
7058            return;
7059        }
7060        self.state.inventory_filter.push(ch);
7061        self.state.inventory_menu_index = 0;
7062        self.state.clamp_inventory_indices();
7063    }
7064
7065    pub fn inventory_filter_backspace(&mut self) {
7066        if self.state.show_grant_picker {
7067            if let Some(p) = self.state.grant_picker.as_mut() {
7068                if p.filter_focused {
7069                    p.filter.pop();
7070                    self.state.grant_picker_index = 0;
7071                }
7072            }
7073            return;
7074        }
7075        if self.state.show_move_picker {
7076            if let Some(p) = self.state.move_picker.as_mut() {
7077                if p.filter_focused {
7078                    p.filter.pop();
7079                    self.state.move_picker_index = 0;
7080                    self.state.clamp_move_picker_quantity();
7081                }
7082            }
7083            return;
7084        }
7085        if !self.state.inventory_filter_focused {
7086            return;
7087        }
7088        self.state.inventory_filter.pop();
7089        self.state.inventory_menu_index = 0;
7090        self.state.clamp_inventory_indices();
7091    }
7092
7093    /// Esc while filter focused: clear filter then blur. Returns true if handled.
7094    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7095        if self.state.show_grant_picker {
7096            if let Some(p) = self.state.grant_picker.as_mut() {
7097                if p.filter_focused {
7098                    if !p.filter.is_empty() {
7099                        p.filter.clear();
7100                        self.state.grant_picker_index = 0;
7101                    } else {
7102                        p.filter_focused = false;
7103                    }
7104                    return true;
7105                }
7106                if !p.filter.is_empty() {
7107                    p.filter.clear();
7108                    self.state.grant_picker_index = 0;
7109                    return true;
7110                }
7111            }
7112            return false;
7113        }
7114        if self.state.show_move_picker {
7115            if let Some(p) = self.state.move_picker.as_mut() {
7116                if p.filter_focused {
7117                    if !p.filter.is_empty() {
7118                        p.filter.clear();
7119                        self.state.move_picker_index = 0;
7120                        self.state.clamp_move_picker_quantity();
7121                    } else {
7122                        p.filter_focused = false;
7123                    }
7124                    return true;
7125                }
7126                if !p.filter.is_empty() {
7127                    p.filter.clear();
7128                    self.state.move_picker_index = 0;
7129                    self.state.clamp_move_picker_quantity();
7130                    return true;
7131                }
7132            }
7133            return false;
7134        }
7135        if self.state.inventory_filter_focused {
7136            if !self.state.inventory_filter.is_empty() {
7137                self.state.inventory_filter.clear();
7138                self.state.inventory_menu_index = 0;
7139                self.state.clamp_inventory_indices();
7140            } else {
7141                self.state.inventory_filter_focused = false;
7142            }
7143            return true;
7144        }
7145        if !self.state.inventory_filter.is_empty() {
7146            self.state.inventory_filter.clear();
7147            self.state.inventory_menu_index = 0;
7148            self.state.clamp_inventory_indices();
7149            return true;
7150        }
7151        false
7152    }
7153
7154    pub fn craft_menu_page(&mut self, pages: i32) {
7155        let n = self.state.blueprints.len();
7156        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7157        self.state.clamp_craft_batch_quantity();
7158    }
7159
7160    pub fn shop_menu_page(&mut self, pages: i32) {
7161        let n = self.state.shop_list_len();
7162        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7163        self.state.clamp_shop_quantity();
7164    }
7165
7166    pub fn workers_menu_page(&mut self, pages: i32) {
7167        let n = self.state.hired_workers.len();
7168        self.state.workers_menu_index =
7169            page_list_index(self.state.workers_menu_index, pages, n);
7170    }
7171
7172    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
7173    /// chest — or fall back to the "move to…" destination picker (including
7174    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
7175    /// Placed chest shells open a pick-up destination picker (`l` still locks).
7176    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7177        if self.state.show_destroy_picker {
7178            if self.state.destroy_confirm_pending {
7179                return self.confirm_destroy_item().await;
7180            }
7181            return self.request_destroy_confirm();
7182        }
7183        if self.state.show_grant_picker {
7184            return self.confirm_grant_picker().await;
7185        }
7186        if self.state.show_move_picker {
7187            return self.confirm_move_picker().await;
7188        }
7189        let Some(row) = self.state.inventory_selected_row() else {
7190            anyhow::bail!("inventory empty");
7191        };
7192        if row.is_equip_shell {
7193            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7194                anyhow::bail!("not a worn item");
7195            };
7196            return self.equip_worn(slot, None).await;
7197        }
7198        if row.is_chest_shell {
7199            return self.open_chest_pickup_picker();
7200        }
7201        let template_id = row.stack.template_id.clone();
7202        let instance_id = row.stack.item_instance_id;
7203        let category = self.state.inventory_item_category(&template_id);
7204        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7205
7206        if category == Some("weapon") {
7207            return self.equip_mainhand(Some(template_id)).await;
7208        }
7209        if category == Some("lodging") && on_person {
7210            if let Some(inst) = instance_id {
7211                return self.place_container(inst).await;
7212            }
7213        }
7214        if (category == Some("container") || category == Some("armor")) && on_person {
7215            if let Some(inst) = instance_id {
7216                let world_placeable = row.stack.world_placeable == Some(true)
7217                    || template_id.contains("chest");
7218                if world_placeable {
7219                    return self.place_container(inst).await;
7220                }
7221                // Pouches no longer equip directly — they clip onto a worn belt's loops
7222                // instead, so fall through to the move picker (offers "belt loop" when a
7223                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
7224                if let Some(slot) = guess_body_slot(&template_id) {
7225                    return self.equip_worn(slot, Some(inst)).await;
7226                }
7227            }
7228        }
7229        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
7230        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
7231        // instead of guessing.
7232        self.open_move_picker()
7233    }
7234
7235    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
7236    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7237        let Some(row) = self.state.inventory_selected_row() else {
7238            anyhow::bail!("inventory empty");
7239        };
7240        if row.from != flatland_protocol::InventoryLocation::Root {
7241            anyhow::bail!("select a consumable on your person");
7242        }
7243        if GameState::stack_is_item_grant(&row.stack) {
7244            return self.open_grant_target_picker();
7245        }
7246        if GameState::is_property_deed_template(&row.stack.template_id) {
7247            return self.open_move_picker();
7248        }
7249        let category = self
7250            .state
7251            .inventory_item_category(&row.stack.template_id);
7252        if category != Some("consumable") {
7253            anyhow::bail!("selected item is not consumable");
7254        }
7255        self.use_item(&row.stack.template_id).await
7256    }
7257
7258    /// Open picker: apply selected grant item onto inventory / worn gear.
7259    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7260        let Some(row) = self.state.inventory_selected_row() else {
7261            anyhow::bail!("inventory empty");
7262        };
7263        if row.from != flatland_protocol::InventoryLocation::Root {
7264            anyhow::bail!("select a grant item on your person");
7265        }
7266        if !GameState::stack_is_item_grant(&row.stack) {
7267            anyhow::bail!("selected item does not grant onto gear");
7268        }
7269        let Some(grant_instance_id) = row.stack.item_instance_id else {
7270            anyhow::bail!("grant has no instance id");
7271        };
7272        let effect_id = GameState::grant_effect_id(&row.stack)
7273            .unwrap_or("?")
7274            .to_string();
7275        let mode = GameState::grant_mode(&row.stack).to_string();
7276        let options = self.state.grant_target_options(&row.stack);
7277        if options.is_empty() {
7278            anyhow::bail!("no valid gear to apply {effect_id} to");
7279        }
7280        let grant_label = row
7281            .stack
7282            .display_name
7283            .clone()
7284            .unwrap_or_else(|| row.stack.template_id.clone());
7285        self.state.show_grant_picker = true;
7286        self.state.grant_picker_index = 0;
7287        self.state.grant_picker = Some(GrantTargetPicker {
7288            grant_instance_id,
7289            grant_label,
7290            effect_id,
7291            mode,
7292            options,
7293            filter: String::new(),
7294            filter_focused: false,
7295        });
7296        Ok(())
7297    }
7298
7299    pub fn close_grant_picker(&mut self) {
7300        self.state.show_grant_picker = false;
7301        self.state.grant_picker = None;
7302        self.state.grant_picker_index = 0;
7303    }
7304
7305    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7306        let Some(picker) = self.state.grant_picker.clone() else {
7307            self.close_grant_picker();
7308            return Ok(());
7309        };
7310        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7311            self.close_grant_picker();
7312            return Ok(());
7313        };
7314        self.close_grant_picker();
7315        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7316            .await?;
7317        self.state.push_log(format!(
7318            "Applying {} onto {}…",
7319            picker.effect_id, opt.label
7320        ));
7321        Ok(())
7322    }
7323
7324    /// `m`: always open the "move to…" picker for the selected item, even for
7325    /// weapons/wearables that Enter would otherwise equip/wear directly.
7326    /// Placed chests open the pick-up destination picker instead.
7327    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7328        let Some(row) = self.state.inventory_selected_row() else {
7329            anyhow::bail!("inventory empty");
7330        };
7331        if row.is_equip_shell {
7332            anyhow::bail!("this is a worn bag — press Enter to unequip it");
7333        }
7334        if row.is_chest_shell {
7335            return self.open_chest_pickup_picker();
7336        }
7337        let Some(instance_id) = row.stack.item_instance_id else {
7338            anyhow::bail!("item has no instance id");
7339        };
7340        let mut options = self.state.move_destinations_for(
7341            &row.from,
7342            row.from_parent_instance_id,
7343            row.stack.item_instance_id,
7344            &row.stack.template_id,
7345        );
7346        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7347        let category = self.state.inventory_item_category(&row.stack.template_id);
7348        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7349            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7350                options.insert(
7351                    0,
7352                    MoveOption {
7353                        label: "Sell plot to crown…".into(),
7354                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
7355                    },
7356                );
7357            }
7358        }
7359        if on_person && category == Some("consumable") {
7360            if GameState::stack_is_item_grant(&row.stack) {
7361                options.insert(
7362                    0,
7363                    MoveOption {
7364                        label: "Apply onto gear…".into(),
7365                        kind: MoveOptionKind::GrantApply,
7366                    },
7367                );
7368            } else {
7369                options.insert(
7370                    0,
7371                    MoveOption {
7372                        label: "Use (eat / drink)".into(),
7373                        kind: MoveOptionKind::Use,
7374                    },
7375                );
7376            }
7377        }
7378        let item_label = row
7379            .stack
7380            .display_name
7381            .clone()
7382            .unwrap_or_else(|| row.stack.template_id.clone());
7383        // Default to 1 so withdrawing from storage is partial unless the player
7384        // presses `a` for max fit (full stack when the destination allows it).
7385        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7386        self.state.move_picker = Some(MovePicker {
7387            item_instance_id: instance_id,
7388            from: row.from,
7389            item_label,
7390            template_id: row.stack.template_id.clone(),
7391            stack_quantity: row.stack.quantity,
7392            quantity: initial_qty.max(1),
7393            options,
7394            filter: String::new(),
7395            filter_focused: false,
7396        });
7397        self.state.move_picker_index = 0;
7398        self.state.show_move_picker = true;
7399        self.state.show_destroy_picker = false;
7400        self.state.destroy_confirm_pending = false;
7401        self.state.destroy_picker = None;
7402        self.state.clamp_move_picker_quantity();
7403        Ok(())
7404    }
7405
7406    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
7407    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7408        let Some(row) = self.state.inventory_selected_row() else {
7409            anyhow::bail!("inventory empty");
7410        };
7411        if !row.is_chest_shell {
7412            anyhow::bail!("not a placed chest");
7413        }
7414        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7415            anyhow::bail!("not a placed chest");
7416        };
7417        let Some(instance_id) = row.stack.item_instance_id else {
7418            anyhow::bail!("chest has no instance id");
7419        };
7420        let chest = self
7421            .state
7422            .placed_containers
7423            .iter()
7424            .find(|c| c.id == *container_id)
7425            .cloned()
7426            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7427        let (px, py) = self.state.player_position();
7428        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7429            anyhow::bail!("too far from {}", chest.display_name);
7430        }
7431        if chest.locked && !chest.accessible {
7432            anyhow::bail!(
7433                "need the matching key for {} before picking it up",
7434                chest.display_name
7435            );
7436        }
7437        let options = self.state.chest_pickup_destinations(container_id);
7438        let item_label = row
7439            .stack
7440            .display_name
7441            .clone()
7442            .unwrap_or_else(|| row.stack.template_id.clone());
7443        self.state.move_picker = Some(MovePicker {
7444            item_instance_id: instance_id,
7445            from: row.from.clone(),
7446            item_label,
7447            template_id: row.stack.template_id.clone(),
7448            stack_quantity: 1,
7449            quantity: 1,
7450            options,
7451            filter: String::new(),
7452            filter_focused: false,
7453        });
7454        self.state.move_picker_index = 0;
7455        self.state.show_move_picker = true;
7456        self.state.show_destroy_picker = false;
7457        self.state.destroy_confirm_pending = false;
7458        self.state.destroy_picker = None;
7459        Ok(())
7460    }
7461
7462    pub fn close_move_picker(&mut self) {
7463        self.state.show_move_picker = false;
7464        self.state.move_picker = None;
7465    }
7466
7467    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7468        self.state.move_picker_adjust_quantity(delta);
7469    }
7470
7471    pub fn move_picker_set_quantity_max(&mut self) {
7472        self.state.move_picker_set_quantity_max();
7473    }
7474
7475    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7476        self.state.destroy_picker_adjust_quantity(delta);
7477    }
7478
7479    pub fn destroy_picker_set_quantity_max(&mut self) {
7480        self.state.destroy_picker_set_quantity_max();
7481    }
7482
7483    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7484        let Some(picker) = self.state.move_picker.clone() else {
7485            self.close_move_picker();
7486            return Ok(());
7487        };
7488        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7489            self.close_move_picker();
7490            return Ok(());
7491        };
7492        match option.kind {
7493            MoveOptionKind::Cancel => {
7494                self.close_move_picker();
7495            }
7496            MoveOptionKind::Use => {
7497                self.close_move_picker();
7498                self.use_item(&picker.template_id).await?;
7499            }
7500            MoveOptionKind::GrantApply => {
7501                self.close_move_picker();
7502                self.open_grant_target_picker()?;
7503            }
7504            MoveOptionKind::SellPlotToCrown { plot_id } => {
7505                self.close_move_picker();
7506                self.confirm_sell_plot_to_crown(plot_id).await?;
7507            }
7508            MoveOptionKind::RelocatePlaced { container_id } => {
7509                self.close_move_picker();
7510                self.state.show_inventory_menu = false;
7511                self.begin_relocate_container(&container_id)?;
7512            }
7513            MoveOptionKind::Drop => {
7514                self.close_move_picker();
7515                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7516                    if self.state.deed_bound(&stack) {
7517                        anyhow::bail!(
7518                            "cannot drop a property deed — store it or trade it to another player"
7519                        );
7520                    }
7521                    if self.state.key_drop_blocked(&stack) {
7522                        anyhow::bail!("cannot drop the key while its chest is locked");
7523                    }
7524                }
7525                self.drop_item(picker.item_instance_id, picker.from).await?;
7526                self.state
7527                    .push_log(format!("Dropped {}", picker.item_label));
7528            }
7529            MoveOptionKind::PickupPlaced {
7530                container_id,
7531                nest_location,
7532                nest_parent_instance_id,
7533            } => {
7534                self.close_move_picker();
7535                self.pickup_container(container_id.clone()).await?;
7536                let nest_into_bag = nest_parent_instance_id.is_some()
7537                    || !matches!(
7538                        nest_location,
7539                        flatland_protocol::InventoryLocation::Root
7540                    );
7541                if nest_into_bag {
7542                    self.move_item(
7543                        picker.item_instance_id,
7544                        flatland_protocol::InventoryLocation::Root,
7545                        nest_location,
7546                        nest_parent_instance_id,
7547                        None,
7548                    )
7549                    .await?;
7550                    self.state
7551                        .push_log(format!("Picked up {} into bag", picker.item_label));
7552                } else {
7553                    self.state
7554                        .push_log(format!("Picked up {}", picker.item_label));
7555                }
7556            }
7557            MoveOptionKind::Move {
7558                location,
7559                parent_instance_id,
7560            } => {
7561                self.close_move_picker();
7562                let qty = if picker.quantity >= picker.stack_quantity {
7563                    None
7564                } else {
7565                    Some(picker.quantity)
7566                };
7567                self.move_item(
7568                    picker.item_instance_id,
7569                    picker.from,
7570                    location,
7571                    parent_instance_id,
7572                    qty,
7573                )
7574                .await?;
7575                let moved = qty.unwrap_or(picker.stack_quantity);
7576                if moved >= picker.stack_quantity {
7577                    self.state.push_log(format!("Moved {}", picker.item_label));
7578                } else {
7579                    self.state.push_log(format!(
7580                        "Moved {} ×{} of {}",
7581                        picker.item_label, moved, picker.stack_quantity
7582                    ));
7583                }
7584            }
7585        }
7586        Ok(())
7587    }
7588
7589    /// `d`: drop the selected item on the ground immediately (no picker).
7590    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7591        let Some(row) = self.state.inventory_selected_row() else {
7592            anyhow::bail!("inventory empty");
7593        };
7594        if row.is_equip_shell {
7595            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7596        }
7597        if row.is_chest_shell {
7598            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
7599        }
7600        let Some(inst) = row.stack.item_instance_id else {
7601            anyhow::bail!("item has no instance id");
7602        };
7603        if self.state.deed_bound(&row.stack) {
7604            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
7605        }
7606        if self.state.key_drop_blocked(&row.stack) {
7607            anyhow::bail!("cannot drop the key while its chest is locked");
7608        }
7609        let label = row
7610            .stack
7611            .display_name
7612            .clone()
7613            .unwrap_or_else(|| row.stack.template_id.clone());
7614        self.drop_item(inst, row.from).await?;
7615        self.state.push_log(format!("Dropped {label}"));
7616        Ok(())
7617    }
7618
7619    pub async fn drop_item(
7620        &mut self,
7621        item_instance_id: uuid::Uuid,
7622        from: flatland_protocol::InventoryLocation,
7623    ) -> anyhow::Result<()> {
7624        self.seq += 1;
7625        self.session
7626            .submit_intent(Intent::DropItem {
7627                entity_id: self.state.entity_id,
7628                item_instance_id,
7629                from,
7630                seq: self.seq,
7631            })
7632            .await?;
7633        self.state.intents_sent += 1;
7634        Ok(())
7635    }
7636
7637    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
7638    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
7639        let Some(row) = self.state.inventory_selected_row() else {
7640            anyhow::bail!("inventory empty");
7641        };
7642        if row.is_equip_shell {
7643            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
7644        }
7645        if row.is_chest_shell {
7646            anyhow::bail!("can't destroy a placed chest from the inventory list");
7647        }
7648        let Some(instance_id) = row.stack.item_instance_id else {
7649            anyhow::bail!("item has no instance id");
7650        };
7651        if self.state.deed_bound(&row.stack) {
7652            anyhow::bail!(
7653                "cannot destroy a property deed — store it or trade it to another player"
7654            );
7655        }
7656        if self.state.key_drop_blocked(&row.stack) {
7657            anyhow::bail!("cannot destroy the key while its chest is locked");
7658        }
7659        let item_label = row
7660            .stack
7661            .display_name
7662            .clone()
7663            .unwrap_or_else(|| row.stack.template_id.clone());
7664        self.state.destroy_picker = Some(DestroyPicker {
7665            item_instance_id: instance_id,
7666            from: row.from,
7667            item_label,
7668            stack_quantity: row.stack.quantity,
7669            quantity: row.stack.quantity,
7670        });
7671        self.state.destroy_confirm_pending = false;
7672        self.state.show_destroy_picker = true;
7673        self.state.show_move_picker = false;
7674        self.state.move_picker = None;
7675        Ok(())
7676    }
7677
7678    pub fn close_destroy_picker(&mut self) {
7679        self.state.show_destroy_picker = false;
7680        self.state.destroy_confirm_pending = false;
7681        self.state.destroy_picker = None;
7682    }
7683
7684    pub fn cancel_destroy_confirm(&mut self) {
7685        self.state.destroy_confirm_pending = false;
7686    }
7687
7688    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
7689        if self.state.destroy_picker.is_none() {
7690            self.close_destroy_picker();
7691            return Ok(());
7692        }
7693        self.state.destroy_confirm_pending = true;
7694        Ok(())
7695    }
7696
7697    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
7698        let Some(picker) = self.state.destroy_picker.clone() else {
7699            self.close_destroy_picker();
7700            return Ok(());
7701        };
7702        let qty = if picker.quantity >= picker.stack_quantity {
7703            None
7704        } else {
7705            Some(picker.quantity)
7706        };
7707        self.destroy_item(picker.item_instance_id, picker.from, qty)
7708            .await?;
7709        let destroyed = qty.unwrap_or(picker.stack_quantity);
7710        if destroyed >= picker.stack_quantity {
7711            self.state
7712                .push_log(format!("Destroyed {}", picker.item_label));
7713        } else {
7714            self.state.push_log(format!(
7715                "Destroyed {} ×{} of {}",
7716                picker.item_label, destroyed, picker.stack_quantity
7717            ));
7718        }
7719        self.close_destroy_picker();
7720        Ok(())
7721    }
7722
7723    pub async fn destroy_item(
7724        &mut self,
7725        item_instance_id: uuid::Uuid,
7726        from: flatland_protocol::InventoryLocation,
7727        quantity: Option<u32>,
7728    ) -> anyhow::Result<()> {
7729        self.seq += 1;
7730        self.session
7731            .submit_intent(Intent::DestroyItem {
7732                entity_id: self.state.entity_id,
7733                item_instance_id,
7734                from,
7735                quantity,
7736                seq: self.seq,
7737            })
7738            .await?;
7739        self.state.intents_sent += 1;
7740        Ok(())
7741    }
7742
7743    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
7744    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
7745        if let Some(row) = self.state.inventory_selected_row() {
7746            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
7747                return self.toggle_placed_chest_lock(container_id).await;
7748            }
7749        }
7750        self.toggle_nearby_chest_lock().await
7751    }
7752
7753    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
7754        let chest = self
7755            .state
7756            .placed_containers
7757            .iter()
7758            .find(|c| c.id == container_id)
7759            .cloned()
7760            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7761        let (px, py) = self.state.player_position();
7762        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7763            anyhow::bail!("too far from {}", chest.display_name);
7764        }
7765        if !chest.accessible && chest.locked {
7766            anyhow::bail!(
7767                "need the matching key for {} (each crafted chest has its own key)",
7768                chest.display_name
7769            );
7770        }
7771        let lock = !chest.locked;
7772        self.set_container_locked(
7773            flatland_protocol::InventoryLocation::Placed {
7774                container_id: chest.id.clone(),
7775            },
7776            lock,
7777        )
7778        .await?;
7779        self.state.push_log(if lock {
7780            format!("Locked {}", chest.display_name)
7781        } else {
7782            format!("Unlocked {}", chest.display_name)
7783        });
7784        Ok(())
7785    }
7786
7787    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
7788    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
7789        let chest = self
7790            .state
7791            .nearest_placed_container(CONTAINER_RANGE_M)
7792            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
7793        self.toggle_placed_chest_lock(&chest.id).await
7794    }
7795
7796    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
7797        self.equip_mainhand(None).await
7798    }
7799
7800    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7801        if !self.state.is_alive() {
7802            anyhow::bail!("you are dead");
7803        }
7804        self.seq += 1;
7805        self.session
7806            .submit_intent(Intent::EquipOffhand {
7807                entity_id: self.state.entity_id,
7808                template_id,
7809                instance_id: None,
7810                seq: self.seq,
7811            })
7812            .await?;
7813        self.state.intents_sent += 1;
7814        Ok(())
7815    }
7816
7817    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
7818        self.equip_offhand(None).await
7819    }
7820
7821    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
7822        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
7823        for slot in slots {
7824            self.equip_worn(slot, None).await?;
7825        }
7826        Ok(())
7827    }
7828
7829    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
7830        let (px, py) = self.state.player_position();
7831        let nearest = self
7832            .state
7833            .placed_containers
7834            .iter()
7835            .min_by(|a, b| {
7836                let da = (a.x - px).hypot(a.y - py);
7837                let db = (b.x - px).hypot(b.y - py);
7838                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7839            })
7840            .cloned();
7841        let Some(chest) = nearest else {
7842            anyhow::bail!("no chest nearby");
7843        };
7844        if (chest.x - px).hypot(chest.y - py) > 2.0 {
7845            anyhow::bail!("too far from chest");
7846        }
7847        self.pickup_container(chest.id).await
7848    }
7849
7850    pub async fn equip_worn(
7851        &mut self,
7852        slot: BodySlot,
7853        instance_id: Option<uuid::Uuid>,
7854    ) -> anyhow::Result<()> {
7855        self.seq += 1;
7856        self.session
7857            .submit_intent(Intent::EquipWorn {
7858                entity_id: self.state.entity_id,
7859                slot,
7860                instance_id,
7861                seq: self.seq,
7862            })
7863            .await?;
7864        self.state.intents_sent += 1;
7865        Ok(())
7866    }
7867
7868    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
7869        self.seq += 1;
7870        self.session
7871            .submit_intent(Intent::PlaceContainer {
7872                entity_id: self.state.entity_id,
7873                item_instance_id,
7874                seq: self.seq,
7875            })
7876            .await?;
7877        self.state.intents_sent += 1;
7878        Ok(())
7879    }
7880
7881    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
7882        self.seq += 1;
7883        self.session
7884            .submit_intent(Intent::PickupContainer {
7885                entity_id: self.state.entity_id,
7886                container_id,
7887                seq: self.seq,
7888            })
7889            .await?;
7890        self.state.intents_sent += 1;
7891        Ok(())
7892    }
7893
7894    pub async fn move_item(
7895        &mut self,
7896        item_instance_id: uuid::Uuid,
7897        from: flatland_protocol::InventoryLocation,
7898        to: flatland_protocol::InventoryLocation,
7899        to_parent_instance_id: Option<uuid::Uuid>,
7900        quantity: Option<u32>,
7901    ) -> anyhow::Result<()> {
7902        self.seq += 1;
7903        self.session
7904            .submit_intent(Intent::MoveItem {
7905                entity_id: self.state.entity_id,
7906                item_instance_id,
7907                from,
7908                to,
7909                to_parent_instance_id,
7910                quantity,
7911                seq: self.seq,
7912            })
7913            .await?;
7914        self.state.intents_sent += 1;
7915        Ok(())
7916    }
7917
7918    pub async fn set_container_locked(
7919        &mut self,
7920        location: flatland_protocol::InventoryLocation,
7921        locked: bool,
7922    ) -> anyhow::Result<()> {
7923        self.seq += 1;
7924        self.session
7925            .submit_intent(Intent::SetContainerLocked {
7926                entity_id: self.state.entity_id,
7927                location,
7928                locked,
7929                seq: self.seq,
7930            })
7931            .await?;
7932        self.state.intents_sent += 1;
7933        Ok(())
7934    }
7935
7936    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
7937        if !self.state.is_alive() {
7938            anyhow::bail!("you are dead");
7939        }
7940        self.seq += 1;
7941        self.session
7942            .submit_intent(Intent::Use {
7943                entity_id: self.state.entity_id,
7944                template_id: template_id.to_string(),
7945                seq: self.seq,
7946            })
7947            .await?;
7948        self.state.intents_sent += 1;
7949        Ok(())
7950    }
7951
7952    /// Bind a grant consumable onto a specific item instance (unique gear status).
7953    pub async fn use_grant(
7954        &mut self,
7955        grant_instance_id: uuid::Uuid,
7956        target_instance_id: uuid::Uuid,
7957    ) -> anyhow::Result<()> {
7958        if !self.state.is_alive() {
7959            anyhow::bail!("you are dead");
7960        }
7961        self.seq += 1;
7962        self.session
7963            .submit_intent(Intent::UseGrant {
7964                entity_id: self.state.entity_id,
7965                grant_instance_id,
7966                target_instance_id,
7967                seq: self.seq,
7968            })
7969            .await?;
7970        self.state.intents_sent += 1;
7971        Ok(())
7972    }
7973
7974    pub fn open_craft_menu(&mut self) {
7975        self.state.show_craft_menu = true;
7976        self.state.show_shop_menu = false;
7977        self.state.shop_catalog = None;
7978        self.state.show_stats = false;
7979        self.state.show_inventory_menu = false;
7980        if self.state.blueprints.is_empty() {
7981            self.state.craft_menu_index = 0;
7982            self.state.craft_batch_quantity = 1;
7983            return;
7984        }
7985        self.state.craft_menu_index = self
7986            .state
7987            .craft_menu_index
7988            .min(self.state.blueprints.len() - 1);
7989        if let Some(idx) = self
7990            .state
7991            .blueprints
7992            .iter()
7993            .position(|bp| self.state.can_craft_blueprint(bp))
7994        {
7995            self.state.craft_menu_index = idx;
7996        }
7997        self.state.clamp_craft_batch_quantity();
7998    }
7999
8000    pub fn close_craft_menu(&mut self) {
8001        self.state.show_craft_menu = false;
8002    }
8003
8004    pub fn toggle_keychain_menu(&mut self) {
8005        if self.state.show_keychain_menu {
8006            self.close_keychain_menu();
8007        } else {
8008            self.state.show_keychain_menu = true;
8009            self.state.show_craft_menu = false;
8010            self.state.show_shop_menu = false;
8011            self.state.show_inventory_menu = false;
8012            let n = self.state.keychain_entries().len();
8013            if n == 0 {
8014                self.state.keychain_menu_index = 0;
8015            } else {
8016                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8017            }
8018        }
8019    }
8020
8021    pub fn close_keychain_menu(&mut self) {
8022        self.state.show_keychain_menu = false;
8023    }
8024
8025    pub fn keychain_menu_move(&mut self, delta: i32) {
8026        let n = self.state.keychain_entries().len();
8027        if n == 0 {
8028            self.state.keychain_menu_index = 0;
8029            return;
8030        }
8031        let idx = self.state.keychain_menu_index as i32 + delta;
8032        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8033    }
8034
8035    pub fn keychain_menu_page(&mut self, pages: i32) {
8036        let n = self.state.keychain_entries().len();
8037        self.state.keychain_menu_index =
8038            page_list_index(self.state.keychain_menu_index, pages, n);
8039    }
8040
8041    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8042        if !self.state.is_alive() {
8043            anyhow::bail!("you are dead");
8044        }
8045        let entries = self.state.keychain_entries();
8046        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8047            anyhow::bail!("nothing selected");
8048        };
8049        let Some(instance_id) = entry.stack.item_instance_id else {
8050            anyhow::bail!("key has no instance id");
8051        };
8052        if entry.stowed {
8053            self.move_item(
8054                instance_id,
8055                flatland_protocol::InventoryLocation::Keychain,
8056                flatland_protocol::InventoryLocation::Root,
8057                None,
8058                Some(1),
8059            )
8060            .await
8061        } else {
8062            self.move_item(
8063                instance_id,
8064                flatland_protocol::InventoryLocation::Root,
8065                flatland_protocol::InventoryLocation::Keychain,
8066                None,
8067                Some(1),
8068            )
8069            .await
8070        }
8071    }
8072
8073    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8074        let npc_id = self
8075            .state
8076            .shop_catalog
8077            .as_ref()
8078            .map(|c| c.npc_id.clone());
8079        self.state.show_shop_menu = false;
8080        self.state.shop_catalog = None;
8081        self.state.clear_shop_trade_log();
8082        if let Some(npc_id) = npc_id {
8083            self.seq += 1;
8084            self.session
8085                .submit_intent(Intent::ShopClose {
8086                    entity_id: self.state.entity_id,
8087                    npc_id,
8088                    seq: self.seq,
8089                })
8090                .await?;
8091            self.state.intents_sent += 1;
8092        }
8093        Ok(())
8094    }
8095
8096    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8097        let Some(panel) = self.state.bank_panel.clone() else {
8098            return Ok(());
8099        };
8100        self.seq += 1;
8101        self.session
8102            .submit_intent(Intent::BankDeposit {
8103                entity_id: self.state.entity_id,
8104                npc_id: panel.npc_id,
8105                amount_copper,
8106                seq: self.seq,
8107            })
8108            .await?;
8109        self.state.intents_sent += 1;
8110        Ok(())
8111    }
8112
8113    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8114        let Some(panel) = self.state.bank_panel.clone() else {
8115            return Ok(());
8116        };
8117        self.seq += 1;
8118        self.session
8119            .submit_intent(Intent::BankWithdraw {
8120                entity_id: self.state.entity_id,
8121                npc_id: panel.npc_id,
8122                amount_copper,
8123                seq: self.seq,
8124            })
8125            .await?;
8126        self.state.intents_sent += 1;
8127        Ok(())
8128    }
8129
8130    pub async fn bank_transfer(
8131        &mut self,
8132        to_character_id: Option<uuid::Uuid>,
8133        to_name: String,
8134        amount_copper: u64,
8135    ) -> anyhow::Result<()> {
8136        let Some(panel) = self.state.bank_panel.clone() else {
8137            return Ok(());
8138        };
8139        self.seq += 1;
8140        self.session
8141            .submit_intent(Intent::BankTransfer {
8142                entity_id: self.state.entity_id,
8143                npc_id: panel.npc_id,
8144                to_character_id,
8145                to_name,
8146                amount_copper,
8147                seq: self.seq,
8148            })
8149            .await?;
8150        self.state.intents_sent += 1;
8151        Ok(())
8152    }
8153
8154    pub fn bank_menu_move(&mut self, delta: i32) {
8155        let n = self.state.bank_menu_options().len();
8156        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8157            return;
8158        }
8159        let idx = self.state.bank_menu_index as i32;
8160        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8161    }
8162
8163    pub fn storage_menu_move(&mut self, delta: i32) {
8164        let n = self.state.storage_menu_options().len();
8165        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8166            return;
8167        }
8168        let idx = self.state.storage_menu_index as i32;
8169        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8170    }
8171
8172    pub fn storage_pick_move(&mut self, delta: i32) {
8173        let n = match &self.state.storage_ui_mode {
8174            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8175            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8176                self.state.storage_vault_options().len()
8177            }
8178            StorageUiMode::Menu
8179            | StorageUiMode::StoreAmount { .. }
8180            | StorageUiMode::TakeAmount { .. }
8181            | StorageUiMode::ShipAmount { .. } => 0,
8182        };
8183        if n == 0 {
8184            return;
8185        }
8186        match &mut self.state.storage_ui_mode {
8187            StorageUiMode::StorePick { index }
8188            | StorageUiMode::TakePick { index }
8189            | StorageUiMode::ShipPick { index, .. } => {
8190                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8191            }
8192            StorageUiMode::Menu
8193            | StorageUiMode::StoreAmount { .. }
8194            | StorageUiMode::TakeAmount { .. }
8195            | StorageUiMode::ShipAmount { .. } => {}
8196        }
8197    }
8198
8199    pub fn storage_ui_back(&mut self) {
8200        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8201            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8202                index: *pick_index,
8203            },
8204            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8205                index: *pick_index,
8206            },
8207            StorageUiMode::ShipAmount {
8208                dest_building_id,
8209                dest_label,
8210                pick_index,
8211                ..
8212            } => StorageUiMode::ShipPick {
8213                dest_building_id: dest_building_id.clone(),
8214                dest_label: dest_label.clone(),
8215                index: *pick_index,
8216            },
8217            StorageUiMode::StorePick { .. }
8218            | StorageUiMode::TakePick { .. }
8219            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8220            StorageUiMode::Menu => StorageUiMode::Menu,
8221        };
8222    }
8223
8224    pub fn storage_amount_append_char(&mut self, c: char) {
8225        match &mut self.state.storage_ui_mode {
8226            StorageUiMode::StoreAmount { input, .. }
8227            | StorageUiMode::TakeAmount { input, .. }
8228            | StorageUiMode::ShipAmount { input, .. } => {
8229                if c.is_ascii_digit() && input.len() < 8 {
8230                    input.push(c);
8231                }
8232            }
8233            _ => {}
8234        }
8235    }
8236
8237    pub fn storage_amount_backspace(&mut self) {
8238        match &mut self.state.storage_ui_mode {
8239            StorageUiMode::StoreAmount { input, .. }
8240            | StorageUiMode::TakeAmount { input, .. }
8241            | StorageUiMode::ShipAmount { input, .. } => {
8242                input.pop();
8243            }
8244            _ => {}
8245        }
8246    }
8247
8248    pub fn storage_ui_typing(&self) -> bool {
8249        matches!(
8250            self.state.storage_ui_mode,
8251            StorageUiMode::StoreAmount { .. }
8252                | StorageUiMode::TakeAmount { .. }
8253                | StorageUiMode::ShipAmount { .. }
8254        )
8255    }
8256
8257    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8258        match self.state.storage_ui_mode.clone() {
8259            StorageUiMode::Menu => {
8260                let index = self.state.storage_menu_index;
8261                match index {
8262                    0 => {
8263                        let opts = self.state.storage_store_options();
8264                        if opts.is_empty() {
8265                            self.state.push_log("Nothing loose to store.");
8266                            return Ok(());
8267                        }
8268                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8269                    }
8270                    1 => {
8271                        let opts = self.state.storage_vault_options();
8272                        if opts.is_empty() {
8273                            self.state.push_log("Vault is empty.");
8274                            return Ok(());
8275                        }
8276                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8277                    }
8278                    n => {
8279                        let dest = self
8280                            .state
8281                            .storage_panel
8282                            .as_ref()
8283                            .and_then(|p| p.ship_destinations.get(n - 2))
8284                            .cloned();
8285                        let Some(dest) = dest else {
8286                            return Ok(());
8287                        };
8288                        let opts = self.state.storage_vault_options();
8289                        if opts.is_empty() {
8290                            self.state
8291                                .push_log("Vault is empty — nothing to ship.");
8292                            return Ok(());
8293                        }
8294                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
8295                            dest_building_id: dest.building_id,
8296                            dest_label: dest.label,
8297                            index: 0,
8298                        };
8299                    }
8300                }
8301            }
8302            StorageUiMode::StorePick { index } => {
8303                let opts = self.state.storage_store_options();
8304                let Some(opt) = opts.get(index) else {
8305                    self.state.push_log("Nothing loose to store.");
8306                    self.state.storage_ui_mode = StorageUiMode::Menu;
8307                    return Ok(());
8308                };
8309                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8310                    pick_index: index,
8311                    item_instance_id: opt.item_instance_id,
8312                    label: opt.label.clone(),
8313                    max_qty: opt.quantity.max(1),
8314                    input: String::new(),
8315                };
8316            }
8317            StorageUiMode::TakePick { index } => {
8318                let opts = self.state.storage_vault_options();
8319                let Some(opt) = opts.get(index) else {
8320                    self.state.push_log("Vault is empty.");
8321                    self.state.storage_ui_mode = StorageUiMode::Menu;
8322                    return Ok(());
8323                };
8324                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8325                    pick_index: index,
8326                    item_instance_id: opt.item_instance_id,
8327                    label: opt.label.clone(),
8328                    max_qty: opt.quantity.max(1),
8329                    input: String::new(),
8330                };
8331            }
8332            StorageUiMode::ShipPick {
8333                dest_building_id,
8334                dest_label,
8335                index,
8336            } => {
8337                let opts = self.state.storage_vault_options();
8338                let Some(opt) = opts.get(index) else {
8339                    self.state
8340                        .push_log("Vault is empty — nothing to ship.");
8341                    self.state.storage_ui_mode = StorageUiMode::Menu;
8342                    return Ok(());
8343                };
8344                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8345                    dest_building_id,
8346                    dest_label,
8347                    pick_index: index,
8348                    item_instance_id: opt.item_instance_id,
8349                    label: opt.label.clone(),
8350                    max_qty: opt.quantity.max(1),
8351                    input: String::new(),
8352                };
8353            }
8354            StorageUiMode::StoreAmount {
8355                item_instance_id,
8356                max_qty,
8357                input,
8358                ..
8359            } => {
8360                let Some(qty) = parse_storage_quantity(&input) else {
8361                    self.state
8362                        .push_log("Enter a quantity (blank or 0 = all).");
8363                    return Ok(());
8364                };
8365                let qty = qty.map(|n| n.min(max_qty).max(1));
8366                self.storage_store(item_instance_id, qty).await?;
8367                self.state.storage_ui_mode = StorageUiMode::Menu;
8368            }
8369            StorageUiMode::TakeAmount {
8370                item_instance_id,
8371                max_qty,
8372                input,
8373                ..
8374            } => {
8375                let Some(qty) = parse_storage_quantity(&input) else {
8376                    self.state
8377                        .push_log("Enter a quantity (blank or 0 = all).");
8378                    return Ok(());
8379                };
8380                let qty = qty.map(|n| n.min(max_qty).max(1));
8381                self.storage_take(item_instance_id, qty).await?;
8382                self.state.storage_ui_mode = StorageUiMode::Menu;
8383            }
8384            StorageUiMode::ShipAmount {
8385                dest_building_id,
8386                item_instance_id,
8387                max_qty,
8388                input,
8389                ..
8390            } => {
8391                let Some(qty) = parse_storage_quantity(&input) else {
8392                    self.state
8393                        .push_log("Enter a quantity (blank or 0 = all).");
8394                    return Ok(());
8395                };
8396                let qty = qty.map(|n| n.min(max_qty).max(1));
8397                self.storage_ship(dest_building_id, item_instance_id, qty)
8398                    .await?;
8399                self.state.storage_ui_mode = StorageUiMode::Menu;
8400            }
8401        }
8402        Ok(())
8403    }
8404
8405    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8406        match self.state.bank_ui_mode.clone() {
8407            BankUiMode::Menu => {
8408                let choice = self
8409                    .state
8410                    .bank_menu_options()
8411                    .get(self.state.bank_menu_index)
8412                    .copied()
8413                    .unwrap_or("Deposit…");
8414                match choice {
8415                    "Withdraw…" => {
8416                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8417                            input: String::new(),
8418                        };
8419                    }
8420                    "Deposit all" => self.bank_deposit(0).await?,
8421                    "Withdraw all" => self.bank_withdraw(0).await?,
8422                    "Transfer…" => {
8423                        self.state.bank_ui_mode = BankUiMode::TransferName {
8424                            input: String::new(),
8425                        };
8426                    }
8427                    _ => {
8428                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
8429                            input: String::new(),
8430                        };
8431                    }
8432                }
8433            }
8434            BankUiMode::DepositAmount { input } => {
8435                let Some(amount) = parse_bank_copper_amount(&input) else {
8436                    self.state
8437                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
8438                    return Ok(());
8439                };
8440                self.bank_deposit(amount).await?;
8441                self.state.bank_ui_mode = BankUiMode::Menu;
8442            }
8443            BankUiMode::WithdrawAmount { input } => {
8444                let Some(amount) = parse_bank_copper_amount(&input) else {
8445                    self.state
8446                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
8447                    return Ok(());
8448                };
8449                self.bank_withdraw(amount).await?;
8450                self.state.bank_ui_mode = BankUiMode::Menu;
8451            }
8452            BankUiMode::TransferName { input } => {
8453                let name = input.trim().to_string();
8454                if name.is_empty() {
8455                    self.state.push_log("Enter the recipient character name.");
8456                    return Ok(());
8457                }
8458                self.state.bank_ui_mode = BankUiMode::TransferAmount {
8459                    to_name: name,
8460                    input: String::new(),
8461                };
8462            }
8463            BankUiMode::TransferAmount { to_name, input } => {
8464                let amount: u64 = match input.trim().parse() {
8465                    Ok(v) if v > 0 => v,
8466                    _ => {
8467                        self.state
8468                            .push_log("Enter a positive copper amount to transfer.");
8469                        return Ok(());
8470                    }
8471                };
8472                self.bank_transfer(None, to_name, amount).await?;
8473                self.state.bank_ui_mode = BankUiMode::Menu;
8474            }
8475        }
8476        Ok(())
8477    }
8478
8479    pub fn bank_transfer_back(&mut self) {
8480        match &self.state.bank_ui_mode {
8481            BankUiMode::TransferAmount { to_name, .. } => {
8482                self.state.bank_ui_mode = BankUiMode::TransferName {
8483                    input: to_name.clone(),
8484                };
8485            }
8486            BankUiMode::TransferName { .. }
8487            | BankUiMode::DepositAmount { .. }
8488            | BankUiMode::WithdrawAmount { .. } => {
8489                self.state.bank_ui_mode = BankUiMode::Menu;
8490            }
8491            BankUiMode::Menu => {}
8492        }
8493    }
8494
8495    pub fn bank_transfer_append_char(&mut self, c: char) {
8496        match &mut self.state.bank_ui_mode {
8497            BankUiMode::TransferName { input } => {
8498                if input.len() < 32 && !c.is_control() {
8499                    input.push(c);
8500                }
8501            }
8502            BankUiMode::DepositAmount { input }
8503            | BankUiMode::WithdrawAmount { input }
8504            | BankUiMode::TransferAmount { input, .. } => {
8505                if c.is_ascii_digit() && input.len() < 12 {
8506                    input.push(c);
8507                }
8508            }
8509            BankUiMode::Menu => {}
8510        }
8511    }
8512
8513    pub fn bank_transfer_backspace(&mut self) {
8514        match &mut self.state.bank_ui_mode {
8515            BankUiMode::TransferName { input }
8516            | BankUiMode::DepositAmount { input }
8517            | BankUiMode::WithdrawAmount { input }
8518            | BankUiMode::TransferAmount { input, .. } => {
8519                input.pop();
8520            }
8521            BankUiMode::Menu => {}
8522        }
8523    }
8524
8525    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8526        let npc_id = self
8527            .state
8528            .bank_panel
8529            .as_ref()
8530            .map(|p| p.npc_id.clone());
8531        self.state.clear_bank_panel();
8532        if let Some(npc_id) = npc_id {
8533            self.seq += 1;
8534            self.session
8535                .submit_intent(Intent::BankClose {
8536                    entity_id: self.state.entity_id,
8537                    npc_id,
8538                    seq: self.seq,
8539                })
8540                .await?;
8541            self.state.intents_sent += 1;
8542        }
8543        Ok(())
8544    }
8545
8546    pub async fn storage_store(
8547        &mut self,
8548        item_instance_id: uuid::Uuid,
8549        quantity: Option<u32>,
8550    ) -> anyhow::Result<()> {
8551        let Some(panel) = self.state.storage_panel.clone() else {
8552            return Ok(());
8553        };
8554        self.seq += 1;
8555        self.session
8556            .submit_intent(Intent::StorageStore {
8557                entity_id: self.state.entity_id,
8558                npc_id: panel.npc_id,
8559                item_instance_id,
8560                quantity,
8561                seq: self.seq,
8562            })
8563            .await?;
8564        self.state.intents_sent += 1;
8565        Ok(())
8566    }
8567
8568    pub async fn storage_take(
8569        &mut self,
8570        item_instance_id: uuid::Uuid,
8571        quantity: Option<u32>,
8572    ) -> anyhow::Result<()> {
8573        let Some(panel) = self.state.storage_panel.clone() else {
8574            return Ok(());
8575        };
8576        self.seq += 1;
8577        self.session
8578            .submit_intent(Intent::StorageTake {
8579                entity_id: self.state.entity_id,
8580                npc_id: panel.npc_id,
8581                item_instance_id,
8582                quantity,
8583                seq: self.seq,
8584            })
8585            .await?;
8586        self.state.intents_sent += 1;
8587        Ok(())
8588    }
8589
8590    pub async fn storage_ship(
8591        &mut self,
8592        dest_building_id: String,
8593        item_instance_id: uuid::Uuid,
8594        quantity: Option<u32>,
8595    ) -> anyhow::Result<()> {
8596        let Some(panel) = self.state.storage_panel.clone() else {
8597            return Ok(());
8598        };
8599        self.seq += 1;
8600        self.session
8601            .submit_intent(Intent::StorageShip {
8602                entity_id: self.state.entity_id,
8603                npc_id: panel.npc_id,
8604                dest_building_id,
8605                item_instance_id,
8606                quantity,
8607                seq: self.seq,
8608            })
8609            .await?;
8610        self.state.intents_sent += 1;
8611        Ok(())
8612    }
8613
8614    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
8615        let npc_id = self
8616            .state
8617            .storage_panel
8618            .as_ref()
8619            .map(|p| p.npc_id.clone());
8620        self.state.clear_storage_panel();
8621        if let Some(npc_id) = npc_id {
8622            self.seq += 1;
8623            self.session
8624                .submit_intent(Intent::StorageClose {
8625                    entity_id: self.state.entity_id,
8626                    npc_id,
8627                    seq: self.seq,
8628                })
8629                .await?;
8630            self.state.intents_sent += 1;
8631        }
8632        Ok(())
8633    }
8634
8635    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
8636        let npc_id = self
8637            .state
8638            .market_panel
8639            .as_ref()
8640            .map(|p| p.npc_id.clone());
8641        self.state.clear_market_panel();
8642        if let Some(npc_id) = npc_id {
8643            self.seq += 1;
8644            self.session
8645                .submit_intent(Intent::MarketClose {
8646                    entity_id: self.state.entity_id,
8647                    npc_id,
8648                    seq: self.seq,
8649                })
8650                .await?;
8651            self.state.intents_sent += 1;
8652        }
8653        Ok(())
8654    }
8655
8656    pub fn market_move_selection(&mut self, delta: i32) {
8657        let indices = self.state.market_filtered_listing_indices();
8658        let n = indices.len();
8659        if n == 0 {
8660            self.state.market_menu_index = 0;
8661            return;
8662        }
8663        let cur = self.state.market_menu_index as i32;
8664        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
8665    }
8666
8667    pub fn market_page_selection(&mut self, pages: i32) {
8668        let indices = self.state.market_filtered_listing_indices();
8669        let n = indices.len();
8670        if n == 0 {
8671            self.state.market_menu_index = 0;
8672            return;
8673        }
8674        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
8675    }
8676
8677    pub fn market_list_page(&mut self, pages: i32) {
8678        match &self.state.market_ui_mode {
8679            MarketUiMode::ListSource { index } => {
8680                let n = self.state.market_list_source_options().len();
8681                if n == 0 {
8682                    return;
8683                }
8684                let next = page_list_index(*index, pages, n);
8685                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8686            }
8687            MarketUiMode::ListPick { source, index } => {
8688                let opts = self.state.market_list_item_options(source);
8689                let n = opts.len();
8690                if n == 0 {
8691                    return;
8692                }
8693                let next = page_list_index(*index, pages, n);
8694                self.state.market_ui_mode = MarketUiMode::ListPick {
8695                    source: source.clone(),
8696                    index: next,
8697                };
8698            }
8699            _ => {}
8700        }
8701    }
8702
8703    pub fn market_cycle_category(&mut self, delta: i32) {
8704        let groups = self.state.market_available_category_groups();
8705        // All + groups
8706        let mut labels: Vec<Option<&'static str>> = vec![None];
8707        labels.extend(groups.into_iter().map(Some));
8708        let n = labels.len() as i32;
8709        let cur = labels
8710            .iter()
8711            .position(|g| *g == self.state.market_category_filter)
8712            .unwrap_or(0) as i32;
8713        let next = (cur + delta).rem_euclid(n) as usize;
8714        self.state.market_category_filter = labels[next];
8715        self.state.market_menu_index = 0;
8716        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8717            let source = source.clone();
8718            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8719        }
8720    }
8721
8722    pub fn focus_market_filter(&mut self) {
8723        self.state.market_filter_focused = true;
8724    }
8725
8726    pub fn append_market_filter_char(&mut self, ch: char) {
8727        if !self.state.market_filter_focused {
8728            return;
8729        }
8730        if ch.is_control() {
8731            return;
8732        }
8733        if self.state.market_filter.len() < 48 {
8734            self.state.market_filter.push(ch);
8735            self.state.market_menu_index = 0;
8736            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8737                let source = source.clone();
8738                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8739            }
8740        }
8741    }
8742
8743    pub fn market_filter_backspace(&mut self) {
8744        if !self.state.market_filter_focused {
8745            return;
8746        }
8747        self.state.market_filter.pop();
8748        self.state.market_menu_index = 0;
8749        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8750            let source = source.clone();
8751            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8752        }
8753    }
8754
8755    /// Clears filter text, blurs search, or returns false if already idle.
8756    pub fn clear_or_blur_market_filter(&mut self) -> bool {
8757        if self.state.market_filter_focused {
8758            if !self.state.market_filter.is_empty() {
8759                self.state.market_filter.clear();
8760                self.state.market_menu_index = 0;
8761                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8762                    let source = source.clone();
8763                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8764                }
8765                return true;
8766            }
8767            self.state.market_filter_focused = false;
8768            return true;
8769        }
8770        if !self.state.market_filter.is_empty() {
8771            self.state.market_filter.clear();
8772            self.state.market_menu_index = 0;
8773            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8774                let source = source.clone();
8775                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8776            }
8777            return true;
8778        }
8779        false
8780    }
8781
8782    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
8783        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
8784            return self.market_confirm_buy(listing_id, qty).await;
8785        }
8786        let Some(panel) = self.state.market_panel.clone() else {
8787            return Ok(());
8788        };
8789        let indices = self.state.market_filtered_listing_indices();
8790        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
8791            return Ok(());
8792        };
8793        let Some(listing) = panel.listings.get(raw_idx) else {
8794            return Ok(());
8795        };
8796        if listing.mine {
8797            self.seq += 1;
8798            self.session
8799                .submit_intent(Intent::MarketDelist {
8800                    entity_id: self.state.entity_id,
8801                    npc_id: panel.npc_id.clone(),
8802                    listing_id: listing.listing_id,
8803                    dest: flatland_protocol::GoodsLocation::Person,
8804                    seq: self.seq,
8805                })
8806                .await?;
8807            self.state.intents_sent += 1;
8808            return Ok(());
8809        }
8810        let qty = 1u32.min(listing.quantity).max(1);
8811        let line = listing.unit_price_copper.saturating_mul(qty as u64);
8812        self.state.market_buy_confirm = Some((
8813            listing.listing_id,
8814            qty,
8815            listing.unit_price_copper,
8816            line,
8817            listing.display_name.clone(),
8818        ));
8819        Ok(())
8820    }
8821
8822    pub async fn market_confirm_buy(
8823        &mut self,
8824        listing_id: uuid::Uuid,
8825        quantity: u32,
8826    ) -> anyhow::Result<()> {
8827        let Some(panel) = self.state.market_panel.clone() else {
8828            self.state.market_buy_confirm = None;
8829            return Ok(());
8830        };
8831        self.state.market_buy_confirm = None;
8832        self.seq += 1;
8833        self.session
8834            .submit_intent(Intent::MarketBuy {
8835                entity_id: self.state.entity_id,
8836                npc_id: panel.npc_id,
8837                listing_id,
8838                quantity,
8839                dest: flatland_protocol::GoodsLocation::Person,
8840                seq: self.seq,
8841            })
8842            .await?;
8843        self.state.intents_sent += 1;
8844        Ok(())
8845    }
8846
8847    /// Begin the list wizard (`l` in market browse).
8848    pub fn market_begin_list(&mut self) {
8849        if self.state.market_panel.is_none() {
8850            return;
8851        }
8852        let sources = self.state.market_list_source_options();
8853        if sources.is_empty() {
8854            self.state.push_log("Nothing to list from.");
8855            return;
8856        }
8857        // One source (person only) → skip straight to item pick when it has stacks.
8858        if sources.len() == 1 {
8859            let (source, _) = sources[0].clone();
8860            let opts = self.state.market_list_item_options(&source);
8861            if opts.is_empty() {
8862                self.state.push_log("Nothing loose to list.");
8863                return;
8864            }
8865            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8866            self.state.market_buy_confirm = None;
8867            return;
8868        }
8869        self.state.market_buy_confirm = None;
8870        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
8871    }
8872
8873    pub fn market_ui_back(&mut self) {
8874        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
8875            MarketUiMode::Browse => MarketUiMode::Browse,
8876            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
8877            MarketUiMode::ListPick { .. } => {
8878                if self.state.market_list_source_options().len() <= 1 {
8879                    MarketUiMode::Browse
8880                } else {
8881                    MarketUiMode::ListSource { index: 0 }
8882                }
8883            }
8884            MarketUiMode::ListAmount {
8885                source,
8886                pick_index,
8887                ..
8888            } => MarketUiMode::ListPick {
8889                source,
8890                index: pick_index,
8891            },
8892            MarketUiMode::ListPrice {
8893                source,
8894                item_instance_id,
8895                label,
8896                max_qty,
8897                quantity,
8898                ..
8899            } => {
8900                let input = quantity
8901                    .map(|q| q.to_string())
8902                    .unwrap_or_default();
8903                MarketUiMode::ListAmount {
8904                    source,
8905                    pick_index: 0,
8906                    item_instance_id,
8907                    label,
8908                    max_qty,
8909                    input,
8910                }
8911            }
8912        };
8913    }
8914
8915    pub fn market_list_move(&mut self, delta: i32) {
8916        match &self.state.market_ui_mode {
8917            MarketUiMode::ListSource { index } => {
8918                let n = self.state.market_list_source_options().len();
8919                if n == 0 {
8920                    return;
8921                }
8922                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8923                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8924            }
8925            MarketUiMode::ListPick { source, index } => {
8926                let opts = self.state.market_list_item_options(source);
8927                let n = opts.len();
8928                if n == 0 {
8929                    return;
8930                }
8931                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8932                self.state.market_ui_mode = MarketUiMode::ListPick {
8933                    source: source.clone(),
8934                    index: next,
8935                };
8936            }
8937            _ => {}
8938        }
8939    }
8940
8941    pub fn market_list_amount_append_char(&mut self, c: char) {
8942        if !c.is_ascii_digit() {
8943            return;
8944        }
8945        match &mut self.state.market_ui_mode {
8946            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8947                if input.len() < 12 {
8948                    input.push(c);
8949                }
8950            }
8951            _ => {}
8952        }
8953    }
8954
8955    pub fn market_list_amount_backspace(&mut self) {
8956        match &mut self.state.market_ui_mode {
8957            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8958                input.pop();
8959            }
8960            _ => {}
8961        }
8962    }
8963
8964    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
8965        match self.state.market_ui_mode.clone() {
8966            MarketUiMode::Browse => Ok(()),
8967            MarketUiMode::ListSource { index } => {
8968                let sources = self.state.market_list_source_options();
8969                let Some((source, _)) = sources.get(index).cloned() else {
8970                    return Ok(());
8971                };
8972                let opts = self.state.market_list_item_options(&source);
8973                if opts.is_empty() {
8974                    self.state.push_log("Nothing to list from that source.");
8975                    return Ok(());
8976                }
8977                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8978                Ok(())
8979            }
8980            MarketUiMode::ListPick { source, index } => {
8981                let opts = self.state.market_list_item_options(&source);
8982                let Some(opt) = opts.get(index) else {
8983                    self.state.push_log("Nothing to list.");
8984                    self.state.market_ui_mode = MarketUiMode::Browse;
8985                    return Ok(());
8986                };
8987                self.state.market_ui_mode = MarketUiMode::ListAmount {
8988                    source,
8989                    pick_index: index,
8990                    item_instance_id: opt.item_instance_id,
8991                    label: opt.label.clone(),
8992                    max_qty: opt.quantity.max(1),
8993                    input: String::new(),
8994                };
8995                Ok(())
8996            }
8997            MarketUiMode::ListAmount {
8998                source,
8999                item_instance_id,
9000                label,
9001                max_qty,
9002                input,
9003                ..
9004            } => {
9005                let Some(qty_opt) = parse_storage_quantity(&input) else {
9006                    self.state.push_log("Enter a quantity (blank = all).");
9007                    return Ok(());
9008                };
9009                if let Some(q) = qty_opt {
9010                    if q > max_qty {
9011                        self.state
9012                            .push_log(format!("Only {max_qty} available."));
9013                        return Ok(());
9014                    }
9015                }
9016                self.state.market_ui_mode = MarketUiMode::ListPrice {
9017                    source,
9018                    item_instance_id,
9019                    label,
9020                    quantity: qty_opt,
9021                    max_qty,
9022                    input: String::new(),
9023                };
9024                Ok(())
9025            }
9026            MarketUiMode::ListPrice {
9027                source,
9028                item_instance_id,
9029                label,
9030                quantity,
9031                input,
9032                ..
9033            } => {
9034                let price = input.trim().parse::<u64>().unwrap_or(0);
9035                if price == 0 {
9036                    self.state.push_log("Enter a unit price of at least 1 copper.");
9037                    return Ok(());
9038                }
9039                let Some(panel) = self.state.market_panel.clone() else {
9040                    self.state.market_ui_mode = MarketUiMode::Browse;
9041                    return Ok(());
9042                };
9043                let goods = match source {
9044                    MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9045                    MarketListSourceKind::TownStorage { building_id } => {
9046                        flatland_protocol::GoodsLocation::TownStorage { building_id }
9047                    }
9048                };
9049                self.seq += 1;
9050                self.session
9051                    .submit_intent(Intent::MarketList {
9052                        entity_id: self.state.entity_id,
9053                        npc_id: panel.npc_id,
9054                        source: goods,
9055                        item_instance_id,
9056                        quantity,
9057                        unit_price_copper: price,
9058                        seq: self.seq,
9059                    })
9060                    .await?;
9061                self.state.intents_sent += 1;
9062                self.state
9063                    .push_log(format!("Listing {label} @ {price} cp…"));
9064                self.state.market_ui_mode = MarketUiMode::Browse;
9065                Ok(())
9066            }
9067        }
9068    }
9069
9070    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
9071    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9072        let return_to_verbs = self.state.npc_verb_target.is_some();
9073        self.close_shop_menu().await?;
9074        if return_to_verbs {
9075            self.state.show_npc_verb_menu = true;
9076        }
9077        Ok(())
9078    }
9079
9080    pub fn shop_tab_toggle(&mut self) {
9081        self.state.shop_tab = match self.state.shop_tab {
9082            ShopTab::Buy => ShopTab::Sell,
9083            ShopTab::Sell => ShopTab::Buy,
9084        };
9085        self.state.shop_menu_index = 0;
9086        if self.state.shop_tab == ShopTab::Sell {
9087            self.state.shop_quantity_set_max();
9088        }
9089        self.state.clamp_shop_selection();
9090    }
9091
9092    pub fn shop_menu_move(&mut self, delta: i32) {
9093        self.state.shop_menu_move(delta);
9094    }
9095
9096    pub fn shop_quantity_adjust(&mut self, delta: i32) {
9097        self.state.shop_quantity_adjust(delta);
9098    }
9099
9100    pub fn shop_quantity_set_max(&mut self) {
9101        self.state.shop_quantity_set_max();
9102    }
9103
9104    pub fn toggle_quest_menu(&mut self) {
9105        self.state.show_quest_menu = !self.state.show_quest_menu;
9106        if self.state.show_quest_menu {
9107            self.state.quest_menu_index = 0;
9108            self.state.quest_withdraw_confirm = false;
9109            self.state.show_workers_menu = false;
9110        }
9111    }
9112
9113    pub fn toggle_workers_menu(&mut self) {
9114        if self.state.show_workers_menu {
9115            self.close_workers_menu_ui();
9116        } else {
9117            self.state.show_workers_menu = true;
9118            self.state.workers_menu_index = 0;
9119            self.state.show_quest_menu = false;
9120            self.close_worker_give_picker();
9121            self.close_worker_give_target_picker();
9122            self.close_worker_take_picker();
9123            self.close_worker_teach_picker();
9124            self.cancel_worker_rename();
9125        }
9126    }
9127
9128    /// Close workers UI layers without network (caller should release attend).
9129    pub fn close_workers_menu_ui(&mut self) {
9130        self.state.show_workers_menu = false;
9131        self.close_worker_give_picker();
9132        self.close_worker_give_target_picker();
9133        self.close_worker_take_picker();
9134        self.close_worker_teach_picker();
9135        self.cancel_worker_rename();
9136    }
9137
9138    /// Open the workers menu focused on `instance_id` and pause that worker's job.
9139    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9140        let Some(idx) = self
9141            .state
9142            .hired_workers
9143            .iter()
9144            .position(|w| w.instance_id == instance_id)
9145        else {
9146            anyhow::bail!("worker not found");
9147        };
9148        let label = self.state.hired_workers[idx].label.clone();
9149        self.state.show_workers_menu = true;
9150        self.state.workers_menu_index = idx;
9151        self.state.show_quest_menu = false;
9152        self.close_worker_give_picker();
9153        self.close_worker_give_target_picker();
9154        self.close_worker_take_picker();
9155        self.close_worker_teach_picker();
9156        self.cancel_worker_rename();
9157        self.set_worker_attending(instance_id, true).await?;
9158        self.state
9159            .push_log(format!("Managing {label} — job paused while menu is open"));
9160        Ok(())
9161    }
9162
9163    /// Close workers menu and release any attend pause.
9164    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9165        self.close_workers_menu_ui();
9166        self.release_worker_attend().await
9167    }
9168
9169    async fn set_worker_attending(
9170        &mut self,
9171        instance_id: &str,
9172        attending: bool,
9173    ) -> anyhow::Result<()> {
9174        if attending {
9175            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9176                return Ok(());
9177            }
9178            // Switch target: release previous first.
9179            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9180                if prev != instance_id {
9181                    self.send_attend_hired_worker(&prev, false).await?;
9182                }
9183            }
9184            self.send_attend_hired_worker(instance_id, true).await?;
9185            self.state.attending_worker_instance_id = Some(instance_id.to_string());
9186        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9187            self.send_attend_hired_worker(instance_id, false).await?;
9188            self.state.attending_worker_instance_id = None;
9189        }
9190        Ok(())
9191    }
9192
9193    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9194        let Some(id) = self.state.attending_worker_instance_id.take() else {
9195            return Ok(());
9196        };
9197        self.send_attend_hired_worker(&id, false).await
9198    }
9199
9200    async fn send_attend_hired_worker(
9201        &mut self,
9202        worker_instance_id: &str,
9203        attending: bool,
9204    ) -> anyhow::Result<()> {
9205        self.seq += 1;
9206        self.session
9207            .submit_intent(Intent::AttendHiredWorker {
9208                entity_id: self.state.entity_id,
9209                worker_instance_id: worker_instance_id.to_string(),
9210                attending,
9211                seq: self.seq,
9212            })
9213            .await?;
9214        self.state.intents_sent += 1;
9215        Ok(())
9216    }
9217
9218    pub fn workers_menu_move(&mut self, delta: i32) {
9219        let n = self.state.hired_workers.len();
9220        if n == 0 {
9221            return;
9222        }
9223        let idx = self.state.workers_menu_index as i32;
9224        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9225    }
9226
9227    pub fn toggle_workers_menu_compact(&mut self) {
9228        self.state.workers_menu_compact = !self.state.workers_menu_compact;
9229        let mut cfg = crate::client_config::ClientConfig::load();
9230        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9231    }
9232
9233    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9234        let Some(worker) = self
9235            .state
9236            .hired_workers
9237            .get(self.state.workers_menu_index)
9238            .cloned()
9239        else {
9240            anyhow::bail!("no worker selected");
9241        };
9242        self.seq += 1;
9243        self.session
9244            .submit_intent(Intent::DismissWorker {
9245                entity_id: self.state.entity_id,
9246                worker_instance_id: worker.instance_id.clone(),
9247                seq: self.seq,
9248            })
9249            .await?;
9250        self.state.intents_sent += 1;
9251        self.state
9252            .hired_workers
9253            .retain(|w| w.instance_id != worker.instance_id);
9254        if self.state.workers_menu_index >= self.state.hired_workers.len() {
9255            self.state.workers_menu_index = self
9256                .state
9257                .hired_workers
9258                .len()
9259                .saturating_sub(1);
9260        }
9261        self.state.push_log(format!("Dismissed {}", worker.label));
9262        Ok(())
9263    }
9264
9265    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9266        let Some(worker) = self
9267            .state
9268            .hired_workers
9269            .get(self.state.workers_menu_index)
9270            .cloned()
9271        else {
9272            anyhow::bail!("no worker selected");
9273        };
9274        let mode = match worker.mode {
9275            flatland_protocol::WorkerModeView::Companion => "job_loop",
9276            flatland_protocol::WorkerModeView::JobLoop => "idle",
9277            flatland_protocol::WorkerModeView::Idle => "companion",
9278        };
9279        self.seq += 1;
9280        self.session
9281            .submit_intent(Intent::SetWorkerMode {
9282                entity_id: self.state.entity_id,
9283                worker_instance_id: worker.instance_id,
9284                mode: mode.into(),
9285                seq: self.seq,
9286            })
9287            .await?;
9288        self.state.intents_sent += 1;
9289        Ok(())
9290    }
9291
9292    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9293        if self.state.hired_workers.is_empty() {
9294            return self.hire_worker_laborer().await;
9295        }
9296        self.workers_toggle_mode_selected().await
9297    }
9298
9299    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
9300    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
9301    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9302        let row = self
9303            .state
9304            .inventory_selected_row()
9305            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9306            .clone();
9307        if row.from != flatland_protocol::InventoryLocation::Root {
9308            anyhow::bail!("select a carried item to give");
9309        }
9310        let Some(instance_id) = row.stack.item_instance_id else {
9311            anyhow::bail!("that stack can't be given");
9312        };
9313        let options = self.nearby_worker_give_targets();
9314        if options.is_empty() {
9315            anyhow::bail!(
9316                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9317            );
9318        }
9319        let item_label = row
9320            .stack
9321            .display_name
9322            .as_deref()
9323            .unwrap_or(&row.stack.template_id)
9324            .to_string();
9325        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9326            item_instance_id: instance_id,
9327            item_label,
9328            quantity: None,
9329            options,
9330        });
9331        self.state.worker_give_target_picker_index = 0;
9332        self.state.show_worker_give_target_picker = true;
9333        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
9334        self.state.show_inventory_menu = false;
9335        Ok(())
9336    }
9337
9338    /// Hired workers within give/take range, nearest first.
9339    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9340        let (px, py, _) = self.state.player_position_with_z();
9341        let mut options: Vec<WorkerGiveTargetOption> = self
9342            .state
9343            .hired_workers
9344            .iter()
9345            .filter_map(|w| {
9346                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9347                if dist > WORKER_GIVE_RANGE_M {
9348                    return None;
9349                }
9350                Some(WorkerGiveTargetOption {
9351                    instance_id: w.instance_id.clone(),
9352                    label: w.label.clone(),
9353                    distance_m: dist,
9354                })
9355            })
9356            .collect();
9357        options.sort_by(|a, b| {
9358            a.distance_m
9359                .partial_cmp(&b.distance_m)
9360                .unwrap_or(std::cmp::Ordering::Equal)
9361        });
9362        options
9363    }
9364
9365    pub fn close_worker_give_target_picker(&mut self) {
9366        self.state.show_worker_give_target_picker = false;
9367        self.state.worker_give_target_picker = None;
9368        self.state.worker_give_target_picker_index = 0;
9369    }
9370
9371    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9372        let Some(picker) = &self.state.worker_give_target_picker else {
9373            return;
9374        };
9375        let n = picker.options.len();
9376        if n == 0 {
9377            return;
9378        }
9379        let idx = self.state.worker_give_target_picker_index as i32;
9380        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9381    }
9382
9383    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9384        let Some(picker) = self.state.worker_give_target_picker.clone() else {
9385            anyhow::bail!("give target picker not open");
9386        };
9387        let Some(opt) = picker
9388            .options
9389            .get(self.state.worker_give_target_picker_index)
9390            .cloned()
9391        else {
9392            anyhow::bail!("no worker selected");
9393        };
9394        let Some(worker) = self
9395            .state
9396            .hired_workers
9397            .iter()
9398            .find(|w| w.instance_id == opt.instance_id)
9399            .cloned()
9400        else {
9401            self.close_worker_give_target_picker();
9402            anyhow::bail!("worker no longer hired");
9403        };
9404        self.give_item_to_worker(
9405            &worker.instance_id,
9406            &worker.label,
9407            worker.x,
9408            worker.y,
9409            picker.item_instance_id,
9410            &picker.item_label,
9411            picker.quantity,
9412        )
9413        .await?;
9414        self.close_worker_give_target_picker();
9415        Ok(())
9416    }
9417
9418    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
9419    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9420        self.open_worker_give_target_picker()
9421    }
9422
9423    /// Open the workers-menu give picker for the selected worker (must be in range).
9424    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9425        let Some(worker) = self
9426            .state
9427            .hired_workers
9428            .get(self.state.workers_menu_index)
9429            .cloned()
9430        else {
9431            anyhow::bail!("select a hired worker first");
9432        };
9433        let (px, py, _) = self.state.player_position_with_z();
9434        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9435        if dist > WORKER_GIVE_RANGE_M {
9436            anyhow::bail!(
9437                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9438                worker.label
9439            );
9440        }
9441        let options = self.state.giveable_inventory_options();
9442        if options.is_empty() {
9443            anyhow::bail!("nothing in inventory to give");
9444        }
9445        self.state.worker_give_picker = Some(WorkerGivePicker {
9446            worker_instance_id: worker.instance_id,
9447            worker_label: worker.label,
9448            options,
9449        });
9450        self.state.worker_give_picker_index = 0;
9451        self.state.show_worker_give_picker = true;
9452        Ok(())
9453    }
9454
9455    pub fn close_worker_give_picker(&mut self) {
9456        self.state.show_worker_give_picker = false;
9457        self.state.worker_give_picker = None;
9458        self.state.worker_give_picker_index = 0;
9459    }
9460
9461    pub fn worker_give_picker_move(&mut self, delta: i32) {
9462        let Some(picker) = &self.state.worker_give_picker else {
9463            return;
9464        };
9465        let n = picker.options.len();
9466        if n == 0 {
9467            return;
9468        }
9469        let idx = self.state.worker_give_picker_index as i32;
9470        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9471    }
9472
9473    /// Confirm the selected row in the workers-menu give picker.
9474    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9475        let Some(picker) = self.state.worker_give_picker.clone() else {
9476            anyhow::bail!("give picker not open");
9477        };
9478        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9479            anyhow::bail!("no item selected");
9480        };
9481        let Some(worker) = self
9482            .state
9483            .hired_workers
9484            .iter()
9485            .find(|w| w.instance_id == picker.worker_instance_id)
9486            .cloned()
9487        else {
9488            self.close_worker_give_picker();
9489            anyhow::bail!("worker no longer hired");
9490        };
9491        self.give_item_to_worker(
9492            &worker.instance_id,
9493            &worker.label,
9494            worker.x,
9495            worker.y,
9496            opt.item_instance_id,
9497            &opt.label,
9498            None,
9499        )
9500        .await?;
9501        // Refresh options (stack may be gone / reduced) or close when empty.
9502        let options = self.state.giveable_inventory_options();
9503        if options.is_empty() {
9504            self.close_worker_give_picker();
9505        } else {
9506            self.state.worker_give_picker = Some(WorkerGivePicker {
9507                worker_instance_id: picker.worker_instance_id,
9508                worker_label: picker.worker_label,
9509                options,
9510            });
9511            if self.state.worker_give_picker_index
9512                >= self
9513                    .state
9514                    .worker_give_picker
9515                    .as_ref()
9516                    .map(|p| p.options.len())
9517                    .unwrap_or(0)
9518            {
9519                self.state.worker_give_picker_index = self
9520                    .state
9521                    .worker_give_picker
9522                    .as_ref()
9523                    .map(|p| p.options.len().saturating_sub(1))
9524                    .unwrap_or(0);
9525            }
9526        }
9527        Ok(())
9528    }
9529
9530    /// Open the workers-menu teach picker for the selected worker (must be in range).
9531    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9532        let Some(worker) = self
9533            .state
9534            .hired_workers
9535            .get(self.state.workers_menu_index)
9536            .cloned()
9537        else {
9538            anyhow::bail!("select a hired worker first");
9539        };
9540        let (px, py, _) = self.state.player_position_with_z();
9541        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9542        if dist > WORKER_GIVE_RANGE_M {
9543            anyhow::bail!(
9544                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
9545                worker.label
9546            );
9547        }
9548        let options = self.state.teachable_blueprint_options(&worker);
9549        if options.is_empty() {
9550            anyhow::bail!("no recipes you know that {} still needs", worker.label);
9551        }
9552        self.state.worker_teach_picker = Some(WorkerTeachPicker {
9553            worker_instance_id: worker.instance_id,
9554            worker_label: worker.label,
9555            worker_level: worker.level,
9556            options,
9557        });
9558        self.state.worker_teach_picker_index = 0;
9559        self.state.show_worker_teach_picker = true;
9560        Ok(())
9561    }
9562
9563    pub fn close_worker_teach_picker(&mut self) {
9564        self.state.show_worker_teach_picker = false;
9565        self.state.worker_teach_picker = None;
9566        self.state.worker_teach_picker_index = 0;
9567    }
9568
9569    pub fn worker_teach_picker_move(&mut self, delta: i32) {
9570        let Some(picker) = &self.state.worker_teach_picker else {
9571            return;
9572        };
9573        let n = picker.options.len();
9574        if n == 0 {
9575            return;
9576        }
9577        let idx = self.state.worker_teach_picker_index as i32;
9578        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9579    }
9580
9581    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9582        let Some(picker) = self.state.worker_teach_picker.clone() else {
9583            anyhow::bail!("teach picker not open");
9584        };
9585        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
9586            anyhow::bail!("nothing selected");
9587        };
9588        if !opt.level_ok {
9589            anyhow::bail!(
9590                "{} needs level {} (is level {})",
9591                picker.worker_label,
9592                opt.min_level,
9593                opt.worker_level
9594            );
9595        }
9596        if !opt.can_afford {
9597            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
9598        }
9599        let Some(worker) = self
9600            .state
9601            .hired_workers
9602            .iter()
9603            .find(|w| w.instance_id == picker.worker_instance_id)
9604            .cloned()
9605        else {
9606            anyhow::bail!("worker gone");
9607        };
9608        let (px, py, _) = self.state.player_position_with_z();
9609        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9610        if dist > WORKER_GIVE_RANGE_M {
9611            anyhow::bail!("worker {} too far — stand next to them", worker.label);
9612        }
9613        self.seq += 1;
9614        self.session
9615            .submit_intent(Intent::TeachWorkerBlueprint {
9616                entity_id: self.state.entity_id,
9617                worker_instance_id: picker.worker_instance_id.clone(),
9618                blueprint_id: opt.blueprint_id.clone(),
9619                seq: self.seq,
9620            })
9621            .await?;
9622        self.state.intents_sent += 1;
9623        self.state.push_log(format!(
9624            "Teaching {} to {} ({} cp)",
9625            opt.label, picker.worker_label, opt.cost_copper
9626        ));
9627        self.close_worker_teach_picker();
9628        Ok(())
9629    }
9630
9631    async fn give_item_to_worker(
9632        &mut self,
9633        worker_instance_id: &str,
9634        worker_label: &str,
9635        worker_x: f32,
9636        worker_y: f32,
9637        item_instance_id: uuid::Uuid,
9638        item_label: &str,
9639        quantity: Option<u32>,
9640    ) -> anyhow::Result<()> {
9641        let (px, py, _) = self.state.player_position_with_z();
9642        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9643        if dist > WORKER_GIVE_RANGE_M {
9644            anyhow::bail!("worker {worker_label} too far — stand next to them");
9645        }
9646        self.seq += 1;
9647        self.session
9648            .submit_intent(Intent::GiveWorkerItem {
9649                entity_id: self.state.entity_id,
9650                worker_instance_id: worker_instance_id.to_string(),
9651                item_instance_id,
9652                quantity,
9653                seq: self.seq,
9654            })
9655            .await?;
9656        self.state.intents_sent += 1;
9657        self.state
9658            .push_log(format!("Gave {item_label} to {worker_label}"));
9659        Ok(())
9660    }
9661
9662    /// Open the workers-menu take picker for the selected worker (must be in range).
9663    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
9664        let Some(worker) = self
9665            .state
9666            .hired_workers
9667            .get(self.state.workers_menu_index)
9668            .cloned()
9669        else {
9670            anyhow::bail!("select a hired worker first");
9671        };
9672        let (px, py, _) = self.state.player_position_with_z();
9673        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9674        if dist > WORKER_GIVE_RANGE_M {
9675            anyhow::bail!(
9676                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
9677                worker.label
9678            );
9679        }
9680        let options = Self::worker_inventory_options(&worker);
9681        if options.is_empty() {
9682            anyhow::bail!("{} isn't carrying anything", worker.label);
9683        }
9684        let initial_qty = options
9685            .first()
9686            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
9687            .unwrap_or(1);
9688        self.state.worker_take_picker = Some(WorkerTakePicker {
9689            worker_instance_id: worker.instance_id,
9690            worker_label: worker.label,
9691            options,
9692            quantity: initial_qty,
9693        });
9694        self.state.worker_take_picker_index = 0;
9695        self.state.show_worker_take_picker = true;
9696        Ok(())
9697    }
9698
9699    fn worker_inventory_options(
9700        worker: &flatland_protocol::HiredWorkerView,
9701    ) -> Vec<WorkerGiveOption> {
9702        worker
9703            .inventory
9704            .iter()
9705            .filter_map(|stack| {
9706                let item_instance_id = stack.item_instance_id?;
9707                let label = stack
9708                    .display_name
9709                    .clone()
9710                    .unwrap_or_else(|| stack.template_id.clone());
9711                let label = if stack.quantity > 1 {
9712                    format!("{label} ×{}", stack.quantity)
9713                } else {
9714                    label
9715                };
9716                Some(WorkerGiveOption {
9717                    item_instance_id,
9718                    label,
9719                    quantity: stack.quantity,
9720                    template_id: stack.template_id.clone(),
9721                })
9722            })
9723            .collect()
9724    }
9725
9726    pub fn close_worker_take_picker(&mut self) {
9727        self.state.show_worker_take_picker = false;
9728        self.state.worker_take_picker = None;
9729        self.state.worker_take_picker_index = 0;
9730    }
9731
9732    pub fn worker_take_picker_move(&mut self, delta: i32) {
9733        let Some(picker) = &self.state.worker_take_picker else {
9734            return;
9735        };
9736        let n = picker.options.len();
9737        if n == 0 {
9738            return;
9739        }
9740        let idx = self.state.worker_take_picker_index as i32;
9741        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9742        self.clamp_worker_take_quantity();
9743    }
9744
9745    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
9746        let Some(picker) = &mut self.state.worker_take_picker else {
9747            return;
9748        };
9749        let max = picker
9750            .options
9751            .get(self.state.worker_take_picker_index)
9752            .map(|o| o.quantity.max(1))
9753            .unwrap_or(1);
9754        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
9755        picker.quantity = next as u32;
9756    }
9757
9758    pub fn worker_take_picker_set_quantity_max(&mut self) {
9759        let Some(picker) = &mut self.state.worker_take_picker else {
9760            return;
9761        };
9762        let max = picker
9763            .options
9764            .get(self.state.worker_take_picker_index)
9765            .map(|o| o.quantity.max(1))
9766            .unwrap_or(1);
9767        picker.quantity = max;
9768    }
9769
9770    fn clamp_worker_take_quantity(&mut self) {
9771        let Some(picker) = &mut self.state.worker_take_picker else {
9772            return;
9773        };
9774        let max = picker
9775            .options
9776            .get(self.state.worker_take_picker_index)
9777            .map(|o| o.quantity.max(1))
9778            .unwrap_or(1);
9779        if picker.quantity == 0 || picker.quantity > max {
9780            picker.quantity = if max > 1 { 1 } else { max };
9781        }
9782    }
9783
9784    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
9785        let Some(picker) = self.state.worker_take_picker.clone() else {
9786            anyhow::bail!("take picker not open");
9787        };
9788        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
9789            anyhow::bail!("no item selected");
9790        };
9791        let Some(worker) = self
9792            .state
9793            .hired_workers
9794            .iter()
9795            .find(|w| w.instance_id == picker.worker_instance_id)
9796            .cloned()
9797        else {
9798            self.close_worker_take_picker();
9799            anyhow::bail!("worker no longer hired");
9800        };
9801        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
9802        let intent_qty = if qty >= opt.quantity {
9803            None
9804        } else {
9805            Some(qty)
9806        };
9807        self.take_item_from_worker(
9808            &worker.instance_id,
9809            &worker.label,
9810            worker.x,
9811            worker.y,
9812            opt.item_instance_id,
9813            &opt.label,
9814            intent_qty,
9815        )
9816        .await?;
9817        // Leave the picker open — server Interaction / hired-workers sync refresh
9818        // options. Do not optimistic-strip stacks (rejects used to look like success).
9819        Ok(())
9820    }
9821
9822    async fn take_item_from_worker(
9823        &mut self,
9824        worker_instance_id: &str,
9825        worker_label: &str,
9826        worker_x: f32,
9827        worker_y: f32,
9828        item_instance_id: uuid::Uuid,
9829        item_label: &str,
9830        quantity: Option<u32>,
9831    ) -> anyhow::Result<()> {
9832        let (px, py, _) = self.state.player_position_with_z();
9833        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9834        if dist > WORKER_GIVE_RANGE_M {
9835            anyhow::bail!("worker {worker_label} too far — stand next to them");
9836        }
9837        self.seq += 1;
9838        self.session
9839            .submit_intent(Intent::TakeWorkerItem {
9840                entity_id: self.state.entity_id,
9841                worker_instance_id: worker_instance_id.to_string(),
9842                item_instance_id,
9843                quantity,
9844                seq: self.seq,
9845            })
9846            .await?;
9847        self.state.intents_sent += 1;
9848        let qty_note = quantity
9849            .map(|q| format!(" ×{q}"))
9850            .unwrap_or_default();
9851        self.state
9852            .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
9853        Ok(())
9854    }
9855
9856    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
9857        if !self.state.has_worker_lodging() {
9858            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
9859        }
9860        self.seq += 1;
9861        self.session
9862            .submit_intent(Intent::HireWorker {
9863                entity_id: self.state.entity_id,
9864                def_id: "worker_laborer".into(),
9865                wage_copper_per_interval: 8,
9866                lodging_container_id: None,
9867                job_yaml: None,
9868                seq: self.seq,
9869            })
9870            .await?;
9871        self.state.intents_sent += 1;
9872        Ok(())
9873    }
9874
9875    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
9876        let Some(worker) = self
9877            .state
9878            .hired_workers
9879            .get(self.state.workers_menu_index)
9880            .cloned()
9881        else {
9882            anyhow::bail!("select a hired worker first");
9883        };
9884        let lodging = worker.lodging_container_id.clone().or_else(|| {
9885            crate::worker_route_editor::owned_lodging_container_ids(
9886                &self.state.placed_containers,
9887                self.state.character_id,
9888            )
9889            .into_iter()
9890            .next()
9891            .map(|(id, _)| id)
9892        });
9893        let label = worker.label.clone();
9894        let editor = if let Some(route) = &worker.route {
9895            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
9896                worker.instance_id,
9897                worker.label,
9898                route,
9899                lodging,
9900            )
9901        } else {
9902            crate::worker_route_editor::WorkerRouteEditorState::new(
9903                worker.instance_id,
9904                worker.label,
9905                lodging,
9906            )
9907        };
9908        self.state.worker_route_editor = Some(editor);
9909        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9910            if let Some(collapsed) =
9911                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
9912            {
9913                ed.panel_collapsed = collapsed;
9914            }
9915        }
9916        self.state.show_workers_menu = false;
9917        self.state.push_log(format!(
9918            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
9919        ));
9920        Ok(())
9921    }
9922
9923    pub fn close_worker_route_editor(&mut self) {
9924        self.state.worker_route_editor = None;
9925    }
9926
9927    pub fn worker_route_editor_toggle_panel(&mut self) {
9928        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9929            ed.toggle_panel_collapsed();
9930            let collapsed = ed.panel_collapsed;
9931            let mut cfg = crate::client_config::ClientConfig::load();
9932            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
9933        }
9934    }
9935
9936    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
9937        let n = {
9938            let Some(ed) = self.state.worker_route_editor.as_mut() else {
9939                return;
9940            };
9941            ed.append_waypoint(x, y, z);
9942            ed.stop_count()
9943        };
9944        self.state
9945            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
9946    }
9947
9948    // ---- picker candidate snapshots ----------------------------------
9949
9950    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
9951        let (px, py, _) = self.state.player_position_with_z();
9952        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
9953            &self.state.placed_containers,
9954            &self.state.buildings,
9955            self.state.character_id,
9956            px,
9957            py,
9958            &self.state.hired_workers,
9959        )
9960    }
9961
9962    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
9963        self.state.route_editor_node_candidates()
9964    }
9965
9966    fn re_open_harvest_picker(
9967        &mut self,
9968        index: usize,
9969        picked: std::collections::BTreeSet<String>,
9970    ) {
9971        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
9972        let nodes = self.state.route_editor_node_candidates();
9973        let index = if nodes.is_empty() {
9974            ROUTE_PICKER_DONE_ROW
9975        } else {
9976            index.max(1).min(nodes.len())
9977        };
9978        self.re_open_sheet(S::HarvestPicker {
9979            index,
9980            picked,
9981            nodes,
9982        });
9983    }
9984
9985    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
9986        let (px, py, _) = self.state.player_position_with_z();
9987        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
9988    }
9989
9990    fn re_template_candidates(&self) -> Vec<String> {
9991        let mut extra = Vec::new();
9992        if let Some(ed) = self.state.worker_route_editor.as_ref() {
9993            for stop in &ed.stops {
9994                match stop {
9995                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
9996                        filter: Some(filter),
9997                        ..
9998                    } => extra.extend(filter.iter().cloned()),
9999                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10000                        extra.push(template.clone());
10001                    }
10002                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10003                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10004                            extra.push(bp.output.clone());
10005                            for input in &bp.inputs {
10006                                extra.push(input.template_id.clone());
10007                            }
10008                        }
10009                    }
10010                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10011                        for it in items {
10012                            extra.push(it.template.clone());
10013                        }
10014                    }
10015                    _ => {}
10016                }
10017            }
10018            // Worker-known recipe outputs even if the player hasn't learned them yet.
10019            if let Some(worker) = self
10020                .state
10021                .hired_workers
10022                .iter()
10023                .find(|w| w.instance_id == ed.worker_instance_id)
10024            {
10025                for recipe in &worker.known_blueprint_ids {
10026                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10027                        extra.push(bp.output.clone());
10028                    }
10029                }
10030            }
10031        }
10032        crate::worker_route_editor::route_item_template_candidates(
10033            &self.state.placed_containers,
10034            self.state.character_id,
10035            &self.state.inventory,
10036            &self.state.blueprints,
10037            &self.state.resource_nodes,
10038            &extra,
10039        )
10040    }
10041
10042    fn re_blueprint_ids(&self) -> Vec<String> {
10043        let worker_known: Option<&[String]> = self
10044            .state
10045            .worker_route_editor
10046            .as_ref()
10047            .and_then(|ed| {
10048                self.state
10049                    .hired_workers
10050                    .iter()
10051                    .find(|w| w.instance_id == ed.worker_instance_id)
10052            })
10053            .map(|w| w.known_blueprint_ids.as_slice());
10054        crate::worker_route_editor::worker_craft_blueprint_ids(
10055            &self.state.blueprints,
10056            worker_known,
10057        )
10058    }
10059
10060    fn re_bed_candidates(&self) -> Vec<(String, String)> {
10061        crate::worker_route_editor::owned_lodging_container_ids(
10062            &self.state.placed_containers,
10063            self.state.character_id,
10064        )
10065    }
10066
10067    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10068        self.state
10069            .placed_containers
10070            .iter()
10071            .find(|c| c.id == container_id)
10072            .map(|c| c.contents.clone())
10073            .unwrap_or_default()
10074    }
10075
10076    // ---- sheet navigation (`plans/33`) --------------------------------
10077
10078    fn re_sheet_supports_filter(&self) -> bool {
10079        use crate::worker_route_editor::RouteEditorSheet as S;
10080        self.state
10081            .worker_route_editor
10082            .as_ref()
10083            .is_some_and(|ed| {
10084                matches!(
10085                    ed.sheet,
10086                    S::HarvestPicker { .. }
10087                        | S::SellItem { .. }
10088                        | S::DepositFilter { .. }
10089                        | S::WithdrawItems { .. }
10090                        | S::WithdrawContainers { .. }
10091                        | S::DepositContainers { .. }
10092                        | S::SellNpcs { .. }
10093                        | S::CraftBlueprint { .. }
10094                        | S::BedPicker { .. }
10095                )
10096            })
10097    }
10098
10099    /// Whether a sheet row is shown under the current `/` filter.
10100    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10101        use crate::worker_route_editor::{
10102            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10103            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10104        };
10105        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10106            return false;
10107        };
10108        let filter = &ed.sheet_filter;
10109        match &ed.sheet {
10110            S::HarvestPicker { nodes, .. } => {
10111                harvest_picker_row_matches(nodes, row, filter)
10112            }
10113            S::SellItem { templates, .. } => {
10114                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10115                    return true;
10116                }
10117                let slot = row.saturating_sub(2);
10118                templates.get(slot).is_some_and(|t| {
10119                    let label = self.state.template_display_name(t);
10120                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10121                })
10122            }
10123            S::DepositFilter { rows, .. } => {
10124                if row >= rows.len() {
10125                    return true;
10126                }
10127                rows.get(row).is_some_and(|(t, _)| {
10128                    let label = self.state.template_display_name(t);
10129                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10130                })
10131            }
10132            S::WithdrawItems { lines, .. } => {
10133                if row >= lines.len() {
10134                    return true;
10135                }
10136                lines.get(row).is_some_and(|l| {
10137                    let label = self.state.template_display_name(&l.template);
10138                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10139                })
10140            }
10141            S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10142                .re_container_candidates()
10143                .get(row)
10144                .is_some_and(|c| {
10145                    list_filter_row_matches(
10146                        filter,
10147                        Some(c.dist),
10148                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10149                    )
10150                }),
10151            S::SellNpcs { .. } => {
10152                if row == 0 {
10153                    return true;
10154                }
10155                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10156                    list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10157                })
10158            }
10159            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10160                let label = self
10161                    .state
10162                    .blueprints
10163                    .iter()
10164                    .find(|b| &b.id == id)
10165                    .map(|b| {
10166                        if b.label.is_empty() {
10167                            id.as_str()
10168                        } else {
10169                            b.label.as_str()
10170                        }
10171                    })
10172                    .unwrap_or(id.as_str());
10173                list_filter_row_matches(filter, None, &[id.as_str(), label])
10174            }),
10175            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10176                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10177            }),
10178            _ => true,
10179        }
10180    }
10181
10182    fn re_sheet_clamp_index(&mut self) {
10183        let count = self.re_sheet_row_count();
10184        if count == 0 {
10185            return;
10186        }
10187        let cur = self.re_sheet_index();
10188        if self.re_sheet_row_visible(cur) {
10189            return;
10190        }
10191        for offset in 1..count {
10192            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10193                self.re_sheet_set_index(cur + offset);
10194                return;
10195            }
10196            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10197                self.re_sheet_set_index(cur - offset);
10198                return;
10199            }
10200        }
10201    }
10202
10203    fn re_sheet_set_index(&mut self, index: usize) {
10204        use crate::worker_route_editor::RouteEditorSheet as S;
10205        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10206            return;
10207        };
10208        match &mut ed.sheet {
10209            S::AddMenu { index: slot }
10210            | S::WaypointMenu { index: slot }
10211            | S::HarvestPicker { index: slot, .. }
10212            | S::WithdrawContainers { index: slot }
10213            | S::DepositContainers { index: slot }
10214            | S::SellNpcs { index: slot }
10215            | S::CraftBlueprint { index: slot }
10216            | S::BedPicker { index: slot }
10217            | S::FarmPlotPicker { index: slot, .. }
10218            | S::FarmPlantSeed { index: slot, .. }
10219            | S::WithdrawItems { index: slot, .. }
10220            | S::DepositFilter { index: slot, .. }
10221            | S::SellItem { index: slot, .. } => *slot = index,
10222            _ => {}
10223        }
10224    }
10225
10226    pub fn re_focus_sheet_filter(&mut self) {
10227        if !self.re_sheet_supports_filter() {
10228            return;
10229        }
10230        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10231            ed.sheet_filter_focused = true;
10232        }
10233    }
10234
10235    pub fn re_blur_sheet_filter_keep_text(&mut self) {
10236        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10237            return;
10238        };
10239        if !ed.sheet_filter_focused {
10240            return;
10241        }
10242        ed.sheet_filter_focused = false;
10243        self.re_sheet_clamp_index();
10244    }
10245
10246    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10247        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10248            return false;
10249        };
10250        if ed.sheet_filter_focused {
10251            ed.sheet_filter_focused = false;
10252            self.re_sheet_clamp_index();
10253            return true;
10254        }
10255        if !ed.sheet_filter.is_empty() {
10256            ed.sheet_filter.clear();
10257            self.re_sheet_clamp_index();
10258            return true;
10259        }
10260        false
10261    }
10262
10263    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10264        if ch.is_control() {
10265            return;
10266        }
10267        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10268            return;
10269        };
10270        if !ed.sheet_filter_focused {
10271            return;
10272        }
10273        ed.sheet_filter.push(ch);
10274        self.re_sheet_set_index(0);
10275        self.re_sheet_clamp_index();
10276    }
10277
10278    pub fn re_sheet_filter_backspace(&mut self) {
10279        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10280            return;
10281        };
10282        if !ed.sheet_filter_focused {
10283            return;
10284        }
10285        ed.sheet_filter.pop();
10286        self.re_sheet_set_index(0);
10287        self.re_sheet_clamp_index();
10288    }
10289
10290    /// Row count of the current sheet (for cursor wrapping).
10291    pub fn re_sheet_row_count(&self) -> usize {
10292        use crate::worker_route_editor::{
10293            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10294        };
10295        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10296            return 0;
10297        };
10298        match &ed.sheet {
10299            S::Stops => ed.stops.len(),
10300            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10301            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10302            S::WaypointMapPick => 0,
10303            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10304            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10305                self.re_container_candidates().len()
10306            }
10307            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
10308            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
10309            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
10310            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
10311            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10312            S::WaitEntry { .. } => 1,
10313            S::BedPicker { .. } => self.re_bed_candidates().len(),
10314            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10315            S::FarmPlantSeed { seeds, .. } => seeds.len(),
10316        }
10317    }
10318
10319    /// Current sheet cursor index (0 for sheets without one).
10320    pub fn re_sheet_index(&self) -> usize {
10321        use crate::worker_route_editor::RouteEditorSheet as S;
10322        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10323            return 0;
10324        };
10325        match &ed.sheet {
10326            S::AddMenu { index }
10327            | S::WaypointMenu { index }
10328            | S::HarvestPicker { index, .. }
10329            | S::WithdrawContainers { index }
10330            | S::DepositContainers { index }
10331            | S::SellNpcs { index }
10332            | S::CraftBlueprint { index }
10333            | S::BedPicker { index }
10334            | S::FarmPlotPicker { index, .. }
10335            | S::FarmPlantSeed { index, .. }
10336            | S::WithdrawItems { index, .. }
10337            | S::DepositFilter { index, .. }
10338            | S::SellItem { index, .. } => *index,
10339            _ => 0,
10340        }
10341    }
10342
10343    /// Move the current sheet's cursor, wrapping within its rows.
10344    pub fn re_sheet_move(&mut self, delta: i32) {
10345        let count = self.re_sheet_row_count();
10346        if count == 0 {
10347            return;
10348        }
10349        let cur = self.re_sheet_index();
10350        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10351        self.re_sheet_set_index(next);
10352    }
10353
10354    pub fn re_sheet_page(&mut self, pages: i32) {
10355        let count = self.re_sheet_row_count();
10356        if count == 0 {
10357            return;
10358        }
10359        let cur = self.re_sheet_index();
10360        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10361        self.re_sheet_set_index(next);
10362    }
10363
10364    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
10365    pub fn re_sheet_adjust(&mut self, delta: i32) {
10366        use crate::worker_route_editor::RouteEditorSheet as S;
10367        let index = self.re_sheet_index();
10368        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10369            return;
10370        };
10371        match &mut ed.sheet {
10372            S::WithdrawItems { lines, .. } => {
10373                if let Some(line) = lines.get_mut(index) {
10374                    line.adjust_qty(delta);
10375                }
10376            }
10377            S::WaitEntry { ticks } => {
10378                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10379            }
10380            _ => {}
10381        }
10382    }
10383
10384    pub fn re_sheet_back(&mut self) {
10385        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10386            return;
10387        };
10388        use crate::worker_route_editor::RouteEditorSheet as S;
10389        let was_editing = ed.editing_index.is_some();
10390        let from_top_picker = matches!(
10391            ed.sheet,
10392            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10393        );
10394        ed.sheet_back();
10395        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10396            // Chest retarget commits on pick; Esc here just ends the edit sheet.
10397            self.state
10398                .push_log("Route: left edit sheet — press s to save current stops".to_string());
10399        }
10400    }
10401
10402    /// True when the editor is at its root sheet (Esc should close it).
10403    pub fn re_at_root_sheet(&self) -> bool {
10404        self.state
10405            .worker_route_editor
10406            .as_ref()
10407            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10408    }
10409
10410    pub fn re_open_add_menu(&mut self) {
10411        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10412            ed.open_add_menu();
10413        }
10414    }
10415
10416    pub fn re_open_bed_picker(&mut self) {
10417        let beds = self.re_bed_candidates();
10418        if beds.is_empty() {
10419            self.state
10420                .push_log("Route: place a camp bed first".to_string());
10421            return;
10422        }
10423        let current = self
10424            .state
10425            .worker_route_editor
10426            .as_ref()
10427            .and_then(|ed| ed.lodging_container_id.clone());
10428        let index = current
10429            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10430            .unwrap_or(0);
10431        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10432    }
10433
10434    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10435        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10436            ed.open_sheet(sheet);
10437        }
10438    }
10439
10440    /// Confirm a sheet's stop and log the outcome.
10441    fn re_confirm_stop(
10442        &mut self,
10443        stop: crate::worker_route_editor::WorkerRouteStop,
10444        what: String,
10445    ) {
10446        let appended = self
10447            .state
10448            .worker_route_editor
10449            .as_mut()
10450            .is_some_and(|ed| ed.confirm_stop(stop));
10451        if appended {
10452            self.state.push_log(format!("Route: + {what}"));
10453        } else {
10454            self.state
10455                .push_log(format!("Route: {what} already in route — selected it"));
10456        }
10457    }
10458
10459    fn re_open_withdraw_items(&mut self, container_id: String) {
10460        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10461        let contents = self.re_container_contents(&container_id);
10462        // When editing, keep the stop's item picks even if the player retargets
10463        // to a different (possibly empty) chest — otherwise Done has nothing to
10464        // confirm and the edit appears to "revert".
10465        let existing = self
10466            .state
10467            .worker_route_editor
10468            .as_ref()
10469            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10470            .and_then(|stop| match stop {
10471                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10472                _ => None,
10473            })
10474            .unwrap_or_default();
10475        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10476        // Commit the new chest immediately while editing so Esc-before-Done still
10477        // keeps the retarget (items update only when Done is pressed).
10478        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10479            let _ = ed.retarget_withdraw_container(container_id.clone());
10480        }
10481        self.re_open_sheet(S::WithdrawItems {
10482            container_id,
10483            lines,
10484            index: 0,
10485        });
10486    }
10487
10488    fn re_withdraw_items_activate(&mut self, index: usize) {
10489        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10490        enum Outcome {
10491            Cycled,
10492            Confirmed(String),
10493            Empty,
10494        }
10495        let outcome = {
10496            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10497                return;
10498            };
10499            let S::WithdrawItems {
10500                container_id,
10501                lines,
10502                index: sheet_index,
10503            } = &mut ed.sheet
10504            else {
10505                return;
10506            };
10507            *sheet_index = index;
10508            if index < lines.len() {
10509                lines[index].cycle();
10510                Outcome::Cycled
10511            } else {
10512                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
10513                if items.is_empty() {
10514                    Outcome::Empty
10515                } else {
10516                    let stop = WorkerRouteStop::WithdrawFrom {
10517                        container_id: container_id.clone(),
10518                        items,
10519                    };
10520                    let summary = stop.summary();
10521                    ed.confirm_stop(stop);
10522                    Outcome::Confirmed(summary)
10523                }
10524            }
10525        };
10526        match outcome {
10527            Outcome::Cycled => {}
10528            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
10529            Outcome::Empty => self
10530                .state
10531                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
10532        }
10533    }
10534
10535    fn re_open_deposit_filter(&mut self, container_id: String) {
10536        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10537        // Preserve filter when retargeting the deposit chest while editing.
10538        let existing_filter = self
10539            .state
10540            .worker_route_editor
10541            .as_ref()
10542            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10543            .and_then(|stop| match stop {
10544                WorkerRouteStop::DepositAt { filter, .. } => {
10545                    Some(filter.clone().unwrap_or_default())
10546                }
10547                _ => None,
10548            });
10549        let mut candidates = self.re_template_candidates();
10550        if let Some(ref chosen) = existing_filter {
10551            for t in chosen {
10552                if !candidates.iter().any(|c| c == t) {
10553                    candidates.push(t.clone());
10554                }
10555            }
10556            candidates.sort();
10557            candidates.dedup();
10558        }
10559        let rows: Vec<(String, bool)> = match existing_filter {
10560            Some(chosen) => candidates
10561                .iter()
10562                .map(|t| (t.clone(), chosen.contains(t)))
10563                .collect(),
10564            None => candidates.into_iter().map(|t| (t, false)).collect(),
10565        };
10566        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10567            let _ = ed.retarget_deposit_container(container_id.clone());
10568        }
10569        self.re_open_sheet(S::DepositFilter {
10570            container_id,
10571            rows,
10572            index: 0,
10573        });
10574    }
10575
10576    fn re_deposit_filter_activate(&mut self, index: usize) {
10577        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10578        let mut confirmed: Option<String> = None;
10579        {
10580            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10581                return;
10582            };
10583            let S::DepositFilter {
10584                container_id,
10585                rows,
10586                index: sheet_index,
10587            } = &mut ed.sheet
10588            else {
10589                return;
10590            };
10591            *sheet_index = index;
10592            if index < rows.len() {
10593                rows[index].1 = !rows[index].1;
10594            } else {
10595                // "Done" row.
10596                let chosen: Vec<String> = rows
10597                    .iter()
10598                    .filter(|(_, on)| *on)
10599                    .map(|(t, _)| t.clone())
10600                    .collect();
10601                let filter = if chosen.is_empty() { None } else { Some(chosen) };
10602                let stop = WorkerRouteStop::DepositAt {
10603                    container_id: container_id.clone(),
10604                    filter,
10605                };
10606                confirmed = Some(stop.summary());
10607                ed.confirm_stop(stop);
10608            }
10609        }
10610        if let Some(what) = confirmed {
10611            self.state.push_log(format!("Route: + {what}"));
10612        }
10613    }
10614
10615    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
10616        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10617        let templates = self.re_template_candidates();
10618        if templates.is_empty() {
10619            self.state.push_log(
10620                "Route: no item templates available — learn a craft recipe or place a harvest node first"
10621                    .to_string(),
10622            );
10623            return;
10624        }
10625        // Prefill when editing an existing sell stop.
10626        let (pre_npc, pre_template, pre_all) = self
10627            .state
10628            .worker_route_editor
10629            .as_ref()
10630            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10631            .and_then(|stop| match stop {
10632                WorkerRouteStop::TradeWith {
10633                    npc_id,
10634                    template,
10635                    sell_all,
10636                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
10637                _ => None,
10638            })
10639            .unwrap_or((None, None, true));
10640        let npc_id = npc_id.or(pre_npc);
10641        let mut picked = std::collections::BTreeSet::new();
10642        if let Some(t) = pre_template {
10643            picked.insert(t);
10644        }
10645        self.re_open_sheet(S::SellItem {
10646            npc_id,
10647            templates,
10648            index: if picked.is_empty() {
10649                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
10650            } else {
10651                2
10652            },
10653            sell_all: pre_all,
10654            picked,
10655        });
10656    }
10657
10658    fn re_sell_item_activate(&mut self, index: usize) {
10659        use crate::worker_route_editor::{
10660            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10661        };
10662        let mut batch_log: Option<String> = None;
10663        {
10664            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10665                return;
10666            };
10667            let S::SellItem {
10668                npc_id,
10669                templates,
10670                index: sheet_index,
10671                sell_all,
10672                picked,
10673            } = &mut ed.sheet
10674            else {
10675                return;
10676            };
10677            *sheet_index = index;
10678            if index == ROUTE_PICKER_DONE_ROW {
10679                if picked.is_empty() {
10680                    batch_log = Some(
10681                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
10682                    );
10683                } else {
10684                    let picks: Vec<String> = picked.iter().cloned().collect();
10685                    let npc = npc_id.clone();
10686                    let all = *sell_all;
10687                    let added = ed.confirm_trade_picks(npc, &picks, all);
10688                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
10689                }
10690            } else if index == SELL_ITEM_TOGGLE_ROW {
10691                *sell_all = !*sell_all;
10692            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
10693                if picked.contains(template) {
10694                    picked.remove(template);
10695                } else {
10696                    picked.insert(template.clone());
10697                }
10698            }
10699        }
10700        if let Some(msg) = batch_log {
10701            self.state.push_log(msg);
10702        }
10703    }
10704
10705    /// Enter on the stop list: open the selected stop's sheet, prefilled.
10706    pub fn re_edit_selected_stop(&mut self) {
10707        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10708        let Some(stop) = self
10709            .state
10710            .worker_route_editor
10711            .as_ref()
10712            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
10713        else {
10714            self.state
10715                .push_log("Route: no stop selected — press a to add one".to_string());
10716            return;
10717        };
10718        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10719            ed.begin_edit_selected();
10720        }
10721        match stop {
10722            WorkerRouteStop::Waypoint { .. } => {
10723                self.re_open_sheet(S::WaypointMenu { index: 0 });
10724            }
10725            WorkerRouteStop::HarvestNode { node_id } => {
10726                let nodes = self.state.route_editor_node_candidates();
10727                if nodes.is_empty() {
10728                    self.re_cancel_edit();
10729                    self.state
10730                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
10731                } else {
10732                    let mut picked = std::collections::BTreeSet::new();
10733                    picked.insert(node_id.clone());
10734                    let index = nodes
10735                        .iter()
10736                        .position(|n| n.id == node_id)
10737                        .map(|i| i + 1)
10738                        .unwrap_or(1);
10739                    self.re_open_harvest_picker(index, picked);
10740                }
10741            }
10742            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
10743                // Open the container picker so the player can retarget storage
10744                // (previously jumped straight to items on the same chest).
10745                let containers = self.re_container_candidates();
10746                if containers.is_empty() {
10747                    self.re_cancel_edit();
10748                    self.state
10749                        .push_log("Route: place a storage chest first".to_string());
10750                } else {
10751                    let index = containers
10752                        .iter()
10753                        .position(|c| c.id == container_id)
10754                        .unwrap_or(0);
10755                    self.re_open_sheet(S::WithdrawContainers { index });
10756                }
10757            }
10758            WorkerRouteStop::DepositAt { container_id, .. } => {
10759                let containers = self.re_container_candidates();
10760                if containers.is_empty() {
10761                    self.re_cancel_edit();
10762                    self.state
10763                        .push_log("Route: place a storage chest first".to_string());
10764                } else {
10765                    let index = containers
10766                        .iter()
10767                        .position(|c| c.id == container_id)
10768                        .unwrap_or(0);
10769                    self.re_open_sheet(S::DepositContainers { index });
10770                }
10771            }
10772            WorkerRouteStop::TradeWith { npc_id, .. } => {
10773                let npcs = self.re_npc_candidates();
10774                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
10775                let index = npc_id
10776                    .as_ref()
10777                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
10778                    .unwrap_or(0);
10779                self.re_open_sheet(S::SellNpcs { index });
10780            }
10781            WorkerRouteStop::CraftAt { blueprint, .. } => {
10782                let bps = self.re_blueprint_ids();
10783                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
10784                if bps.is_empty() {
10785                    self.re_cancel_edit();
10786                    self.state
10787                        .push_log("Route: no known blueprints to retarget".to_string());
10788                } else {
10789                    self.re_open_sheet(S::CraftBlueprint { index });
10790                }
10791            }
10792            WorkerRouteStop::CultivatePlot { .. } => {
10793                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
10794            }
10795            WorkerRouteStop::PlantPlot { .. } => {
10796                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
10797            }
10798            WorkerRouteStop::HarvestPlot { .. } => {
10799                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
10800            }
10801            WorkerRouteStop::RestIfNeeded => {
10802                self.re_cancel_edit();
10803                self.state
10804                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
10805            }
10806            WorkerRouteStop::Wait { wait_ticks } => {
10807                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
10808            }
10809        }
10810    }
10811
10812    fn re_cancel_edit(&mut self) {
10813        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10814            ed.editing_index = None;
10815        }
10816    }
10817
10818    // ---- mouse clicks inside the overlay ------------------------------
10819
10820    pub fn worker_route_editor_ui_click(
10821        &mut self,
10822        click: crate::worker_route_editor::RouteEditorClick,
10823    ) {
10824        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
10825        match click {
10826            RouteEditorClick::SelectStop(i) => {
10827                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10828                    ed.sheet = S::Stops;
10829                    ed.select_stop(i);
10830                }
10831            }
10832            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
10833            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
10834            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
10835        }
10836    }
10837
10838    /// Activate (Enter / click) a row of the current sheet.
10839    pub fn re_sheet_row_activate(&mut self, row: usize) {
10840        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10841        let Some(sheet) = self
10842            .state
10843            .worker_route_editor
10844            .as_ref()
10845            .map(|ed| ed.sheet.clone())
10846        else {
10847            return;
10848        };
10849        match sheet {
10850            S::Stops => {
10851                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10852                    ed.select_stop(row);
10853                }
10854            }
10855            S::AddMenu { .. } => match row {
10856                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
10857                1 => {
10858                    if self.re_node_candidates().is_empty() {
10859                        self.state
10860                            .push_log("Route: no harvestable nodes visible in this region".to_string());
10861                    } else {
10862                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
10863                    }
10864                }
10865                2 | 3 => {
10866                    if self.re_container_candidates().is_empty() {
10867                        self.state
10868                            .push_log("Route: place a storage chest first".to_string());
10869                    } else if row == 2 {
10870                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
10871                    } else {
10872                        self.re_open_sheet(S::DepositContainers { index: 0 });
10873                    }
10874                }
10875                4 => {
10876                    if self.re_template_candidates().is_empty() {
10877                        self.state.push_log(
10878                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
10879                                .to_string(),
10880                        );
10881                    } else {
10882                        self.re_open_sheet(S::SellNpcs { index: 0 });
10883                    }
10884                }
10885                5 => {
10886                    if self.re_blueprint_ids().is_empty() {
10887                        self.state.push_log(
10888                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
10889                                .to_string(),
10890                        );
10891                    } else {
10892                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
10893                    }
10894                }
10895                6 => self.re_confirm_stop(
10896                    WorkerRouteStop::RestIfNeeded,
10897                    "rest at lodging (if needed)".into(),
10898                ),
10899                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
10900                8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
10901                9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10902                10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
10903                _ => {}
10904            },
10905            S::WaypointMenu { .. } => match row {
10906                0 => {
10907                    let (x, y, z) = self.state.player_position_with_z();
10908                    let stop = WorkerRouteStop::Waypoint { x, y, z };
10909                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
10910                }
10911                1 => {
10912                    self.re_open_sheet(S::WaypointMapPick);
10913                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
10914                }
10915                _ => {}
10916            },
10917            S::HarvestPicker { .. } => {
10918                let mut log: Option<String> = None;
10919                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10920                    let S::HarvestPicker {
10921                        index: sheet_index,
10922                        picked,
10923                        nodes,
10924                    } = &mut ed.sheet
10925                    else {
10926                        return;
10927                    };
10928                    *sheet_index = row;
10929                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
10930                        if picked.is_empty() {
10931                            log = Some(
10932                                "Route: pick at least one node (Space toggles, Done confirms)"
10933                                    .into(),
10934                            );
10935                        } else {
10936                            let ids: Vec<String> = picked.iter().cloned().collect();
10937                            let added = ed.confirm_harvest_picks(&ids);
10938                            log = Some(format!("Route: + {added} harvest stop(s)"));
10939                        }
10940                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
10941                        if picked.contains(&n.id) {
10942                            picked.remove(&n.id);
10943                        } else {
10944                            picked.insert(n.id.clone());
10945                        }
10946                    }
10947                }
10948                if let Some(msg) = log {
10949                    self.state.push_log(msg);
10950                }
10951            }
10952            S::WithdrawContainers { .. } => {
10953                let containers = self.re_container_candidates();
10954                if let Some(c) = containers.get(row) {
10955                    let id = c.id.clone();
10956                    self.re_open_withdraw_items(id);
10957                }
10958            }
10959            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
10960            S::DepositContainers { .. } => {
10961                let containers = self.re_container_candidates();
10962                if let Some(c) = containers.get(row) {
10963                    let id = c.id.clone();
10964                    self.re_open_deposit_filter(id);
10965                }
10966            }
10967            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
10968            S::SellNpcs { .. } => {
10969                let npcs = self.re_npc_candidates();
10970                let npc_id = if row == 0 {
10971                    None
10972                } else {
10973                    npcs.get(row - 1).map(|n| n.id.clone())
10974                };
10975                if row == 0 || npc_id.is_some() {
10976                    self.re_open_sell_item(npc_id);
10977                }
10978            }
10979            S::SellItem { .. } => self.re_sell_item_activate(row),
10980            S::CraftBlueprint { .. } => {
10981                let bps = self.re_blueprint_ids();
10982                if let Some(bp) = bps.get(row) {
10983                    let stop = WorkerRouteStop::CraftAt {
10984                        device: "hand".into(),
10985                        blueprint: bp.clone(),
10986                        qty: None,
10987                    };
10988                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
10989                }
10990            }
10991            S::WaitEntry { ticks } => {
10992                let stop = WorkerRouteStop::Wait {
10993                    wait_ticks: ticks,
10994                };
10995                self.re_confirm_stop(stop, format!("wait {ticks}t"));
10996            }
10997            S::BedPicker { .. } => {
10998                let beds = self.re_bed_candidates();
10999                if let Some((id, name)) = beds.get(row) {
11000                    let (id, name) = (id.clone(), name.clone());
11001                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11002                        ed.lodging_container_id = Some(id.clone());
11003                        ed.sheet = S::Stops;
11004                    }
11005                    self.state
11006                        .push_log(format!("Route: rest bed set to {name}"));
11007                }
11008            }
11009            S::FarmPlotPicker { action, .. } => {
11010                let plots = self.re_farm_plot_candidates();
11011                let Some(plot) = plots.get(row).cloned() else {
11012                    return;
11013                };
11014                match action {
11015                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
11016                        let label = plot_route_label(&plot);
11017                        self.re_confirm_stop(
11018                            WorkerRouteStop::CultivatePlot {
11019                                plot_id: plot.plot_id,
11020                            },
11021                            format!("cultivate {label}"),
11022                        );
11023                    }
11024                    crate::worker_route_editor::FarmPlotAction::Harvest => {
11025                        let label = plot_route_label(&plot);
11026                        self.re_confirm_stop(
11027                            WorkerRouteStop::HarvestPlot {
11028                                plot_id: plot.plot_id,
11029                            },
11030                            format!("harvest {label}"),
11031                        );
11032                    }
11033                    crate::worker_route_editor::FarmPlotAction::Plant => {
11034                        let seeds = self.re_farm_seed_candidates();
11035                        if seeds.is_empty() {
11036                            self.state.push_log(
11037                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11038                            );
11039                            return;
11040                        }
11041                        self.re_open_sheet(S::FarmPlantSeed {
11042                            plot_id: plot.plot_id,
11043                            seeds,
11044                            index: 0,
11045                        });
11046                    }
11047                }
11048            }
11049            S::FarmPlantSeed { plot_id, seeds, .. } => {
11050                if let Some(seed) = seeds.get(row).cloned() {
11051                    self.re_confirm_stop(
11052                        WorkerRouteStop::PlantPlot {
11053                            plot_id,
11054                            seed_template: seed.clone(),
11055                        },
11056                        format!("plant {seed}"),
11057                    );
11058                }
11059            }
11060            S::WaypointMapPick => {}
11061        }
11062    }
11063
11064    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11065        use crate::worker_route_editor::RouteEditorSheet as S;
11066        if self.re_farm_plot_candidates().is_empty() {
11067            self.state.push_log(
11068                "Route: no farmable plots visible — claim land or get farm access first",
11069            );
11070            return;
11071        }
11072        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11073    }
11074
11075    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11076        self.state
11077            .property_plots
11078            .iter()
11079            .filter(|p| p.is_mine || p.may_farm)
11080            .cloned()
11081            .collect()
11082    }
11083
11084    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
11085    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
11086    /// and known crop seeds so routes can withdraw-then-plant.
11087    fn re_farm_seed_candidates(&self) -> Vec<String> {
11088        let mut set = std::collections::BTreeSet::new();
11089        let looks_like_seed = |id: &str| {
11090            id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11091        };
11092        for (id, _, _) in self.state.farm_seed_entries() {
11093            set.insert(id);
11094        }
11095        for c in &self.state.placed_containers {
11096            let mine = match (self.state.character_id, c.owner_character_id) {
11097                (Some(a), Some(b)) => a == b,
11098                _ => false,
11099            };
11100            if !mine {
11101                continue;
11102            }
11103            for s in &c.contents {
11104                if s.quantity > 0
11105                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11106                {
11107                    set.insert(s.template_id.clone());
11108                }
11109            }
11110        }
11111        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11112            for stop in &ed.stops {
11113                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11114                {
11115                    for it in items {
11116                        if looks_like_seed(&it.template) {
11117                            set.insert(it.template.clone());
11118                        }
11119                    }
11120                }
11121                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11122                    seed_template, ..
11123                } = stop
11124                {
11125                    if !seed_template.is_empty() {
11126                        set.insert(seed_template.clone());
11127                    }
11128                }
11129            }
11130        }
11131        for id in self.state.inventory_hints.keys() {
11132            if looks_like_seed(id) {
11133                set.insert(id.clone());
11134            }
11135        }
11136        // Authored crop seeds (always offer so withdraw-then-plant routes work).
11137        for id in ["potato_seed", "carrot_seed"] {
11138            set.insert(id.to_string());
11139        }
11140        set.into_iter().collect()
11141    }
11142
11143    // ---- map clicks (sheet-scoped accelerators) ------------------------
11144
11145    /// Map click while the route editor is open. When a picker sheet is open
11146    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
11147    /// container, NPC clicks the merchant, node clicks the harvest target);
11148    /// otherwise the click quick-adds the nearest target (context-aware).
11149    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11150        use crate::worker_route_editor as wre;
11151        use wre::RouteEditorSheet as S;
11152        if self.state.worker_route_editor.is_none() {
11153            return;
11154        }
11155        let sheet = self
11156            .state
11157            .worker_route_editor
11158            .as_ref()
11159            .map(|ed| ed.sheet.clone())
11160            .unwrap_or(S::Stops);
11161        match sheet {
11162            S::WaypointMapPick => {
11163                let (_, _, z) = self.state.player_position_with_z();
11164                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11165                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11166                // Stay in map-pick mode when adding (not editing) for fast pathing.
11167                let editing = self
11168                    .state
11169                    .worker_route_editor
11170                    .as_ref()
11171                    .is_some_and(|ed| ed.editing_index.is_some());
11172                if !editing {
11173                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11174                        ed.sheet = S::WaypointMapPick;
11175                    }
11176                }
11177            }
11178            S::HarvestPicker { .. } => {
11179                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11180                    let mut log: Option<String> = None;
11181                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11182                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11183                            return;
11184                        };
11185                        let selected = if picked.contains(&node.id) {
11186                            picked.remove(&node.id);
11187                            false
11188                        } else {
11189                            picked.insert(node.id.clone());
11190                            true
11191                        };
11192                        log = Some(format!(
11193                            "Route: {} {}",
11194                            if selected { "selected" } else { "deselected" },
11195                            node.label
11196                        ));
11197                    }
11198                    if let Some(msg) = log {
11199                        self.state.push_log(msg);
11200                    }
11201                }
11202            }
11203            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11204                // Chest click picks (or switches) the withdraw source.
11205                if let Some(cid) = wre::pick_storage_container_at(
11206                    &self.state.placed_containers,
11207                    self.state.character_id,
11208                    x,
11209                    y,
11210                ) {
11211                    self.re_open_withdraw_items(cid);
11212                }
11213            }
11214            S::DepositContainers { .. } | S::DepositFilter { .. } => {
11215                if let Some(cid) = wre::pick_storage_container_at(
11216                    &self.state.placed_containers,
11217                    self.state.character_id,
11218                    x,
11219                    y,
11220                ) {
11221                    self.re_open_deposit_filter(cid);
11222                }
11223            }
11224            S::SellNpcs { .. } => {
11225                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11226                    self.re_open_sell_item(Some(npc_id));
11227                }
11228            }
11229            S::SellItem { .. } => {
11230                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11231                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11232                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11233                            *slot = Some(npc_id.clone());
11234                        }
11235                    }
11236                    self.state
11237                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11238                }
11239            }
11240            // Stop list / other sheets: context-aware quick add.
11241            _ => self.worker_route_editor_quick_add_click(x, y),
11242        }
11243    }
11244
11245    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
11246    /// node wins; duplicates select the existing stop; a click on a merchant
11247    /// while a sell stop is selected pins it.
11248    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11249        use crate::worker_route_editor as wre;
11250        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11251            let dx = ax - bx;
11252            let dy = ay - by;
11253            (dx * dx + dy * dy).sqrt()
11254        };
11255
11256        // 1. Retarget the selected stop when the click names its target:
11257        //    sell stop → pin the merchant; withdraw stop → set the source chest.
11258        let selected_stop_kind = self
11259            .state
11260            .worker_route_editor
11261            .as_ref()
11262            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11263            .map(|s| match s {
11264                wre::WorkerRouteStop::TradeWith { .. } => 1,
11265                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11266                _ => 0,
11267            })
11268            .unwrap_or(0);
11269        if selected_stop_kind == 1 {
11270            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11271                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11272                    ed.set_selected_trade_npc(npc_id.clone());
11273                }
11274                self.state
11275                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11276                return;
11277            }
11278        }
11279        if selected_stop_kind == 2 {
11280            if let Some(cid) = wre::pick_storage_container_at(
11281                &self.state.placed_containers,
11282                self.state.character_id,
11283                x,
11284                y,
11285            ) {
11286                let name = self
11287                    .state
11288                    .placed_containers
11289                    .iter()
11290                    .find(|c| c.id == cid)
11291                    .map(|c| c.display_name.clone())
11292                    .unwrap_or_else(|| "container".into());
11293                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11294                    ed.set_selected_withdraw_container(cid.clone());
11295                }
11296                self.state
11297                    .push_log(format!("Route: withdraw source → {name}"));
11298                return;
11299            }
11300        }
11301
11302        // 2. Nearest candidate of any kind wins. Category order is the
11303        //    tie-break for exact overlaps (bed over chest over merchant over node).
11304        enum Target {
11305            Bed(String),
11306            Container(String),
11307            Npc(String, String),
11308            Node(String, String),
11309        }
11310        let mut best: Option<(f32, u8, Target)> = None;
11311        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11312            let better = match best {
11313                None => true,
11314                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11315            };
11316            if better {
11317                *best = Some((d, rank, t));
11318            }
11319        };
11320        if let Some(bed_id) = wre::pick_lodging_container_at(
11321            &self.state.placed_containers,
11322            self.state.character_id,
11323            x,
11324            y,
11325        ) {
11326            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11327                // A bed that's already the rest bed doubles as a storage chest —
11328                // a second click on it means "deposit here", not "set bed again".
11329                let already_bed = self
11330                    .state
11331                    .worker_route_editor
11332                    .as_ref()
11333                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11334                if already_bed {
11335                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11336                } else {
11337                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11338                }
11339            }
11340        }
11341        if let Some(cid) = wre::pick_storage_container_at(
11342            &self.state.placed_containers,
11343            self.state.character_id,
11344            x,
11345            y,
11346        ) {
11347            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11348                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11349            }
11350        }
11351        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11352            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11353                consider(
11354                    dist(x, y, n.x, n.y),
11355                    2,
11356                    Target::Npc(npc_id, label),
11357                    &mut best,
11358                );
11359            }
11360        }
11361        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11362            let d = dist(x, y, node.x, node.y);
11363            consider(
11364                d,
11365                3,
11366                Target::Node(node.id.clone(), node.label.clone()),
11367                &mut best,
11368            );
11369        }
11370
11371        match best.map(|(_, _, t)| t) {
11372            Some(Target::Bed(bed_id)) => {
11373                let name = self
11374                    .state
11375                    .placed_containers
11376                    .iter()
11377                    .find(|c| c.id == bed_id)
11378                    .map(|c| c.display_name.clone())
11379                    .unwrap_or_else(|| "camp bed".into());
11380                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11381                    ed.lodging_container_id = Some(bed_id.clone());
11382                }
11383                self.state
11384                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11385            }
11386            Some(Target::Container(cid)) => {
11387                let name = self
11388                    .state
11389                    .placed_containers
11390                    .iter()
11391                    .find(|c| c.id == cid)
11392                    .map(|c| c.display_name.clone())
11393                    .unwrap_or_else(|| "container".into());
11394                let added = self
11395                    .state
11396                    .worker_route_editor
11397                    .as_mut()
11398                    .is_some_and(|ed| ed.append_deposit_at(&cid));
11399                if added {
11400                    self.state
11401                        .push_log(format!("Route: + deposit at {name} ({cid})"));
11402                } else {
11403                    self.state.push_log(format!(
11404                        "Route: {name} already in route — selected it (d to remove)"
11405                    ));
11406                }
11407            }
11408            Some(Target::Npc(npc_id, label)) => {
11409                // Quick-add sell: first storage template (the full sell flow
11410                // lives in the a → Sell sheet).
11411                let template = self.re_template_candidates().into_iter().next();
11412                let Some(template) = template else {
11413                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11414                    return;
11415                };
11416                let added = self
11417                    .state
11418                    .worker_route_editor
11419                    .as_mut()
11420                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11421                if added {
11422                    self.state
11423                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11424                } else {
11425                    self.state.push_log(format!(
11426                        "Route: {label} already sells {template} — selected it (d to remove)"
11427                    ));
11428                }
11429            }
11430            Some(Target::Node(id, label)) => {
11431                let added = self
11432                    .state
11433                    .worker_route_editor
11434                    .as_mut()
11435                    .is_some_and(|ed| ed.append_harvest_node(&id));
11436                if added {
11437                    self.state
11438                        .push_log(format!("Route: + harvest node {label} ({id})"));
11439                } else {
11440                    self.state.push_log(format!(
11441                        "Route: {label} already in route — selected it (d to remove)"
11442                    ));
11443                }
11444            }
11445            None => {}
11446        }
11447    }
11448
11449    pub fn worker_route_editor_select(&mut self, delta: i32) {
11450        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11451            return;
11452        };
11453        if ed.stops.is_empty() {
11454            return;
11455        }
11456        let n = ed.stops.len() as i32;
11457        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11458        ed.selected_stop_index = next;
11459    }
11460
11461    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11462        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11463            return;
11464        };
11465        if delta < 0 {
11466            ed.move_selected_up();
11467        } else if delta > 0 {
11468            ed.move_selected_down();
11469        }
11470    }
11471
11472    pub fn worker_route_editor_delete_selected(&mut self) {
11473        let removed = self
11474            .state
11475            .worker_route_editor
11476            .as_mut()
11477            .is_some_and(|ed| {
11478                let before = ed.stop_count();
11479                ed.remove_selected_stop();
11480                ed.stop_count() < before
11481            });
11482        if removed {
11483            self.state.push_log("Route: removed selected stop");
11484        }
11485    }
11486
11487    /// Clear all stops. Saving afterwards parks the worker in idle mode
11488    /// instead of leaving it in a broken job loop.
11489    pub fn worker_route_editor_clear_stops(&mut self) {
11490        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11491            return;
11492        };
11493        if ed.stops.is_empty() {
11494            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
11495            return;
11496        }
11497        ed.stops.clear();
11498        ed.selected_stop_index = 0;
11499        self.state
11500            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
11501    }
11502
11503    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
11504        if self.state.pending_worker_job_ack.is_some() {
11505            anyhow::bail!("route save still pending — wait for server ack");
11506        }
11507        let Some(ed) = self.state.worker_route_editor.clone() else {
11508            anyhow::bail!("route editor not open");
11509        };
11510        // Empty route = stand down: park the worker in idle mode rather than
11511        // erroring out or leaving it marching a ghost loop.
11512        let (job_yaml, idle) = if ed.stops.is_empty() {
11513            (ed.build_idle_job_yaml(), true)
11514        } else {
11515            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
11516        };
11517        let worker_id = ed.worker_instance_id.clone();
11518        let route_view = if idle {
11519            None
11520        } else {
11521            Some(ed.to_route_view())
11522        };
11523        let mode = if idle {
11524            flatland_protocol::WorkerModeView::Idle
11525        } else {
11526            flatland_protocol::WorkerModeView::JobLoop
11527        };
11528        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
11529            .state
11530            .hired_workers
11531            .iter()
11532            .find(|w| w.instance_id == worker_id)
11533            .map(|w| {
11534                (
11535                    w.route.clone(),
11536                    w.mode,
11537                    w.step_label.clone(),
11538                    w.last_error.clone(),
11539                )
11540            })
11541            .unwrap_or((
11542                None,
11543                flatland_protocol::WorkerModeView::Idle,
11544                String::new(),
11545                None,
11546            ));
11547        self.seq += 1;
11548        let seq = self.seq;
11549        self.session
11550            .submit_intent(Intent::SetWorkerJob {
11551                entity_id: self.state.entity_id,
11552                worker_instance_id: worker_id.clone(),
11553                job_yaml,
11554                seq,
11555            })
11556            .await?;
11557        self.state.intents_sent += 1;
11558        if let Some(w) = self
11559            .state
11560            .hired_workers
11561            .iter_mut()
11562            .find(|w| w.instance_id == worker_id)
11563        {
11564            w.route = route_view;
11565            w.mode = mode;
11566            w.last_error = None;
11567            if idle {
11568                w.step_label.clear();
11569                w.route_stop_index = None;
11570            }
11571        }
11572        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
11573            seq,
11574            worker_instance_id: worker_id,
11575            worker_label: ed.worker_label.clone(),
11576            idle,
11577            stop_count: ed.stops.len(),
11578            prev_route,
11579            prev_mode,
11580            prev_step_label,
11581            prev_last_error,
11582        });
11583        self.state.push_log(format!(
11584            "Route: saving for {}… (waiting for server)",
11585            ed.worker_label
11586        ));
11587        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
11588        Ok(())
11589    }
11590    pub fn quest_menu_move(&mut self, delta: i32) {
11591        let n = self.state.active_quest_entries().len();
11592        if n == 0 {
11593            return;
11594        }
11595        let idx = self.state.quest_menu_index as i32;
11596        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11597    }
11598
11599    pub fn quest_menu_page(&mut self, pages: i32) {
11600        let n = self.state.active_quest_entries().len();
11601        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
11602    }
11603
11604    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
11605        let Some(offer) = self.state.pending_quest_offer.clone() else {
11606            anyhow::bail!("no quest offer");
11607        };
11608        self.seq += 1;
11609        let seq = self.seq;
11610        self.session
11611            .submit_intent(Intent::AcceptQuest {
11612                entity_id: self.state.entity_id,
11613                quest_id: offer.quest_id,
11614                seq,
11615            })
11616            .await?;
11617        self.state.intents_sent += 1;
11618        Ok(())
11619    }
11620
11621    pub fn quest_offer_decline(&mut self) {
11622        self.state.show_quest_offer = false;
11623        self.state.pending_quest_offer = None;
11624        if !self.state.show_npc_chat
11625            && !self.state.show_shop_menu
11626            && self.state.npc_verb_target.is_some()
11627        {
11628            self.state.show_npc_verb_menu = true;
11629        }
11630    }
11631
11632    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
11633        if !self.state.show_quest_menu {
11634            return Ok(());
11635        }
11636        let active: Vec<_> = self
11637            .state
11638            .active_quest_entries()
11639            .into_iter()
11640            .cloned()
11641            .collect();
11642        let Some(entry) = active.get(self.state.quest_menu_index) else {
11643            return Ok(());
11644        };
11645        if self.state.quest_withdraw_confirm {
11646            if !entry.can_withdraw {
11647                anyhow::bail!("quest cannot be withdrawn");
11648            }
11649            self.seq += 1;
11650            let seq = self.seq;
11651            self.session
11652                .submit_intent(Intent::WithdrawQuest {
11653                    entity_id: self.state.entity_id,
11654                    quest_id: entry.quest_id.clone(),
11655                    seq,
11656                })
11657                .await?;
11658            self.state.intents_sent += 1;
11659            self.state.quest_withdraw_confirm = false;
11660            return Ok(());
11661        }
11662        self.seq += 1;
11663        let seq = self.seq;
11664        self.session
11665            .submit_intent(Intent::TrackQuest {
11666                entity_id: self.state.entity_id,
11667                quest_id: entry.quest_id.clone(),
11668                seq,
11669            })
11670            .await?;
11671        self.state.intents_sent += 1;
11672        Ok(())
11673    }
11674
11675    pub fn quest_request_withdraw(&mut self) {
11676        if self.state.show_quest_menu {
11677            self.state.quest_withdraw_confirm = true;
11678        }
11679    }
11680
11681    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
11682        if !self.state.is_alive() {
11683            anyhow::bail!("you are dead");
11684        }
11685        let Some(catalog) = self.state.shop_catalog.clone() else {
11686            anyhow::bail!("no shop open");
11687        };
11688        self.seq += 1;
11689        let seq = self.seq;
11690        match self.state.shop_tab {
11691            ShopTab::Buy => {
11692                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
11693                    anyhow::bail!("nothing selected");
11694                };
11695                if offer.already_owned {
11696                    anyhow::bail!("already owned");
11697                }
11698                self.session
11699                    .submit_intent(Intent::ShopBuy {
11700                        entity_id: self.state.entity_id,
11701                        npc_id: catalog.npc_id.clone(),
11702                        offer_id: offer.offer_id.clone(),
11703                        quantity: self.state.shop_quantity,
11704                        seq,
11705                    })
11706                    .await?;
11707            }
11708            ShopTab::Sell => {
11709                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
11710                    anyhow::bail!("nothing to sell");
11711                };
11712                if line.quantity == 0 {
11713                    anyhow::bail!("you have no {}", line.label);
11714                }
11715                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
11716                self.session
11717                    .submit_intent(Intent::ShopSell {
11718                        entity_id: self.state.entity_id,
11719                        npc_id: catalog.npc_id.clone(),
11720                        template_id: line.template_id.clone(),
11721                        quantity,
11722                        seq,
11723                    })
11724                    .await?;
11725            }
11726        }
11727        self.state.intents_sent += 1;
11728        Ok(())
11729    }
11730
11731    pub fn craft_menu_move(&mut self, delta: i32) {
11732        let n = self.state.blueprints.len();
11733        if n == 0 {
11734            return;
11735        }
11736        let idx = self.state.craft_menu_index as i32;
11737        let next = (idx + delta).rem_euclid(n as i32);
11738        self.state.craft_menu_index = next as usize;
11739        self.state.clamp_craft_batch_quantity();
11740    }
11741
11742    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
11743        self.state.craft_batch_adjust_quantity(delta);
11744    }
11745
11746    pub fn craft_batch_set_max(&mut self) {
11747        self.state.craft_batch_set_max();
11748    }
11749
11750    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
11751        let Some(blueprint) = self
11752            .state
11753            .blueprints
11754            .get(self.state.craft_menu_index)
11755            .cloned()
11756        else {
11757            anyhow::bail!("no blueprints known");
11758        };
11759        if !self.state.can_craft_blueprint(&blueprint) {
11760            let hint = self
11761                .state
11762                .craft_missing_hint(&blueprint)
11763                .unwrap_or_else(|| "missing materials".into());
11764            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
11765        }
11766        let count = self.state.craft_batch_quantity;
11767        let max = self.state.max_craft_batches(&blueprint);
11768        if max == 0 {
11769            anyhow::bail!("cannot craft {}", blueprint.label);
11770        }
11771        let batches = count.min(max);
11772        self.craft(&blueprint.id, Some(batches)).await?;
11773        self.state.show_craft_menu = false;
11774        Ok(())
11775    }
11776
11777    pub async fn move_by(
11778        &mut self,
11779        forward: f32,
11780        strafe: f32,
11781        vertical: f32,
11782        sprint: bool,
11783    ) -> anyhow::Result<()> {
11784        if !self.state.is_alive() {
11785            anyhow::bail!("you are dead");
11786        }
11787        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
11788            self.last_move_forward = forward;
11789            self.last_move_strafe = strafe;
11790        }
11791        self.seq += 1;
11792        self.session
11793            .submit_intent(Intent::Move {
11794                entity_id: self.state.entity_id,
11795                forward,
11796                strafe,
11797                vertical,
11798                sprint,
11799                seq: self.seq,
11800            })
11801            .await?;
11802        self.state.intents_sent += 1;
11803        Ok(())
11804    }
11805
11806    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
11807        if !self.state.connected {
11808            crate::harvest_trace!("harvest_nearest rejected: not connected");
11809            anyhow::bail!("not connected");
11810        }
11811        if !self.state.is_alive() {
11812            crate::harvest_trace!("harvest_nearest rejected: player dead");
11813            anyhow::bail!("you are dead");
11814        }
11815        if self.state.harvest_in_progress {
11816            if self.state.harvest_state_stale() {
11817                self.state.clear_harvest_state();
11818            } else {
11819                anyhow::bail!("already harvesting");
11820            }
11821        }
11822        let (px, py) = self
11823            .state
11824            .player
11825            .as_ref()
11826            .map(|p| (p.transform.position.x, p.transform.position.y))
11827            .unwrap_or((0.0, 0.0));
11828
11829        let available = self
11830            .state
11831            .resource_nodes
11832            .iter()
11833            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11834            .count();
11835        let node_id = self
11836            .state
11837            .resource_nodes
11838            .iter()
11839            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11840            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
11841            .min_by(|a, b| {
11842                let da = distance(px, py, a.x, a.y);
11843                let db = distance(px, py, b.x, b.y);
11844                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
11845            })
11846            .map(|n| n.id.clone());
11847
11848        let Some(node_id) = node_id else {
11849            let has_loot = self
11850                .state
11851                .ground_drops
11852                .iter()
11853                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11854            if has_loot {
11855                return self.pickup_nearest().await;
11856            }
11857            anyhow::bail!(
11858                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
11859            );
11860        };
11861
11862        self.seq += 1;
11863        let seq = self.seq;
11864        crate::harvest_trace!(
11865            entity_id = self.state.entity_id,
11866            node_id = %node_id,
11867            seq,
11868            px,
11869            py,
11870            available_nodes = available,
11871            "submitting harvest intent"
11872        );
11873        self.session
11874            .submit_intent(Intent::Harvest {
11875                entity_id: self.state.entity_id,
11876                node_id,
11877                seq,
11878            })
11879            .await?;
11880        self.state.intents_sent += 1;
11881        self.state.harvest_in_progress = true;
11882        self.state.harvest_started_at = Some(Instant::now());
11883        self.state.push_log("Harvesting…");
11884        crate::harvest_trace!(
11885            entity_id = self.state.entity_id,
11886            seq,
11887            "harvest intent queued to session"
11888        );
11889        Ok(())
11890    }
11891
11892    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
11893        if !self.state.is_alive() {
11894            anyhow::bail!("you are dead");
11895        }
11896        let blueprint_id = self
11897            .state
11898            .blueprints
11899            .iter()
11900            .find(|bp| self.state.can_craft_blueprint(bp))
11901            .map(|bp| bp.id.clone())
11902            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
11903        self.craft(&blueprint_id, None).await
11904    }
11905
11906    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
11907        if !self.state.is_alive() {
11908            anyhow::bail!("you are dead");
11909        }
11910        self.seq += 1;
11911        self.session
11912            .submit_intent(Intent::Craft {
11913                entity_id: self.state.entity_id,
11914                blueprint_id: blueprint_id.to_string(),
11915                count,
11916                seq: self.seq,
11917            })
11918            .await?;
11919        self.state.intents_sent += 1;
11920        let (label, batches) = self
11921            .state
11922            .blueprints
11923            .iter()
11924            .find(|b| b.id == blueprint_id)
11925            .map(|b| {
11926                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
11927                (b.label.as_str(), n)
11928            })
11929            .unwrap_or((blueprint_id, count.unwrap_or(1)));
11930        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
11931        Ok(())
11932    }
11933
11934    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
11935        if !self.state.is_alive() {
11936            anyhow::bail!("you are dead");
11937        }
11938        let target_id = match self.state.nearest_interact_target() {
11939            Some(id) => id,
11940            None => {
11941                anyhow::bail!("nothing to interact with nearby");
11942            }
11943        };
11944        if self.state.npcs.iter().any(|n| n.id == target_id) {
11945            self.state.show_npc_verb_menu = true;
11946            self.state.npc_verb_target = Some(target_id);
11947            self.state.npc_verb_index = 0;
11948            return Ok(());
11949        }
11950        if self
11951            .state
11952            .hired_workers
11953            .iter()
11954            .any(|w| w.instance_id == target_id)
11955        {
11956            return self.open_workers_menu_for(&target_id).await;
11957        }
11958        if let Ok(peer_id) = target_id.parse::<EntityId>() {
11959            if self
11960                .state
11961                .hired_workers
11962                .iter()
11963                .any(|w| w.entity_id == peer_id)
11964            {
11965                if let Some(w) = self
11966                    .state
11967                    .hired_workers
11968                    .iter()
11969                    .find(|w| w.entity_id == peer_id)
11970                {
11971                    let id = w.instance_id.clone();
11972                    return self.open_workers_menu_for(&id).await;
11973                }
11974            }
11975            if let Some(entity) = self
11976                .state
11977                .entities
11978                .iter()
11979                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
11980            {
11981                self.state
11982                    .player_verbs
11983                    .open_for(peer_id, &entity.label);
11984                return Ok(());
11985            }
11986        }
11987        self.seq += 1;
11988        self.session
11989            .submit_intent(Intent::Interact {
11990                entity_id: self.state.entity_id,
11991                target_id: target_id.clone(),
11992                seq: self.seq,
11993            })
11994            .await?;
11995        self.state.intents_sent += 1;
11996        Ok(())
11997    }
11998
11999    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
12000    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12001        if !self.state.is_alive() {
12002            anyhow::bail!("you are dead");
12003        }
12004        let (px, py) = self.state.player_position();
12005        let has_loot = self
12006            .state
12007            .ground_drops
12008            .iter()
12009            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12010        if has_loot {
12011            return self.pickup_nearest().await;
12012        }
12013        if self
12014            .state
12015            .placed_containers
12016            .iter()
12017            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12018        {
12019            return self.pickup_nearest_container().await;
12020        }
12021
12022        if let Some(plot) = self.state.my_plot_under_player().cloned() {
12023            // Double-tap f within 1.2s sells; otherwise farm the plot.
12024            const SELL_WINDOW: Duration = Duration::from_millis(1200);
12025            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12026                && self
12027                    .state
12028                    .sell_plot_armed_at
12029                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12030            if sell_armed {
12031                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12032            }
12033            self.state.sell_plot_confirm = None;
12034            self.state.sell_plot_armed_at = None;
12035
12036            // Prefer farm actions on your own land unless an NPC/player/door/board
12037            // is in interact range (those still win).
12038            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12039                self.state.npcs.iter().any(|n| n.id == id)
12040                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12041                    || self.state.doors.iter().any(|d| d.id == id)
12042                    || self.state.interactables.iter().any(|i| {
12043                        i.id == id
12044                            && matches!(
12045                                i.kind.as_str(),
12046                                "quest_board" | "well" | "exit" | "enter"
12047                            )
12048                    })
12049                    || id.parse::<EntityId>().is_ok_and(|eid| {
12050                        self.state
12051                            .entities
12052                            .iter()
12053                            .any(|e| e.id == eid && e.id != self.state.entity_id)
12054                    })
12055            });
12056            if !blocking_interact {
12057                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
12058                match self.harvest_nearest().await {
12059                    Ok(()) => return Ok(()),
12060                    Err(err) => {
12061                        let msg = err.to_string();
12062                        if !(msg.contains("no harvestable")
12063                            || msg.contains("press p")
12064                            || msg.contains("press f")
12065                            || msg.contains("nothing"))
12066                        {
12067                            return Err(err);
12068                        }
12069                    }
12070                }
12071                return Ok(());
12072            }
12073        }
12074        if self.state.nearest_interact_target().is_some() {
12075            return self.interact_nearest().await;
12076        }
12077        // Prefer a clear "move closer" when a board is visible but out of reach,
12078        // instead of silently falling through to harvest.
12079        if let Some((label, dist)) = self.state.nearest_quest_board() {
12080            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12081                anyhow::bail!(
12082                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12083                );
12084            }
12085        }
12086
12087        match self.harvest_nearest().await {
12088            Ok(()) => Ok(()),
12089            Err(err) => {
12090                let msg = err.to_string();
12091                if msg.contains("no harvestable")
12092                    || msg.contains("press p")
12093                    || msg.contains("press f")
12094                {
12095                    anyhow::bail!(
12096                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12097                    );
12098                }
12099                Err(err)
12100            }
12101        }
12102    }
12103
12104    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
12105    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12106        if !self.state.is_alive() {
12107            anyhow::bail!("you are dead");
12108        }
12109        if self.state.claim_mode.is_some() {
12110            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12111        }
12112        let zone = self
12113            .state
12114            .free_property_zone_under_player()
12115            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12116        let zone_id = zone.id.clone();
12117        let label = zone
12118            .label
12119            .as_deref()
12120            .filter(|s| !s.trim().is_empty())
12121            .unwrap_or(zone.id.as_str())
12122            .to_string();
12123        self.enter_claim_mode(&zone_id);
12124        self.state
12125            .push_log(format!(
12126                "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12127            ));
12128        Ok(())
12129    }
12130
12131    /// Enter claim mode over a property zone (default 4×4 or min size).
12132    pub fn enter_claim_mode(&mut self, zone_id: &str) {
12133        let Some(zone) = self
12134            .state
12135            .property_zones
12136            .iter()
12137            .find(|z| z.id == zone_id)
12138            .cloned()
12139        else {
12140            self.state.push_log("unknown property zone");
12141            return;
12142        };
12143        self.state.sell_plot_confirm = None;
12144        self.state.sell_plot_armed_at = None;
12145        let min_area = self
12146            .state
12147            .property_plot_settings
12148            .as_ref()
12149            .map(|s| s.min_plot_area_m2)
12150            .unwrap_or(4.0)
12151            .max(1.0);
12152        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12153        let side = 4u32.max(min_side);
12154        let (px, py) = self.state.player_position();
12155        let anchor_x = px.floor();
12156        let anchor_y = py.floor();
12157        self.state.claim_mode = Some(ClaimModeState {
12158            zone_id: zone.id.clone(),
12159            width_m: side,
12160            height_m: side,
12161            anchor_x,
12162            anchor_y,
12163        });
12164        let label = zone
12165            .label
12166            .as_deref()
12167            .filter(|s| !s.trim().is_empty())
12168            .unwrap_or(zone.id.as_str());
12169        self.state.push_log(format!(
12170            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12171        ));
12172    }
12173
12174    pub fn cancel_claim_mode(&mut self) {
12175        if self.state.claim_mode.take().is_some() {
12176            self.state.push_log("Claim cancelled");
12177        }
12178    }
12179
12180    /// Enter relocate mode for a placed container (1×1 ghost).
12181    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12182        if !self.state.is_alive() {
12183            anyhow::bail!("you are dead");
12184        }
12185        if self.state.relocate_mode.is_some() {
12186            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12187        }
12188        if self.state.claim_mode.is_some() {
12189            anyhow::bail!("finish or cancel claim mode first");
12190        }
12191        let chest = self
12192            .state
12193            .placed_containers
12194            .iter()
12195            .find(|c| c.id == container_id)
12196            .cloned()
12197            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12198        let (px, py) = self.state.player_position();
12199        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12200            anyhow::bail!("too far from {}", chest.display_name);
12201        }
12202        if chest.locked && !chest.accessible {
12203            anyhow::bail!(
12204                "need the matching key for {} before moving it",
12205                chest.display_name
12206            );
12207        }
12208        let label = if chest.display_name.trim().is_empty() {
12209            chest.template_id.clone()
12210        } else {
12211            chest.display_name.clone()
12212        };
12213        self.state.relocate_mode = Some(RelocateModeState {
12214            container_id: chest.id.clone(),
12215            label: label.clone(),
12216            cursor_x: chest.x.floor() + 0.5,
12217            cursor_y: chest.y.floor() + 0.5,
12218        });
12219        self.state.push_log(format!(
12220            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12221        ));
12222        Ok(())
12223    }
12224
12225    /// World `Shift+m`: relocate nearest owned/accessible placed container.
12226    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12227        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12228            anyhow::bail!("no chest nearby to relocate");
12229        };
12230        if chest.locked && !chest.accessible {
12231            anyhow::bail!(
12232                "need the matching key for {} before moving it",
12233                chest.display_name
12234            );
12235        }
12236        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
12237        // Server enforces ownership; client starts on nearest accessible chest in range.
12238        self.begin_relocate_container(&chest.id)
12239    }
12240
12241    pub fn cancel_relocate_mode(&mut self) {
12242        if self.state.relocate_mode.take().is_some() {
12243            self.state.push_log("Relocate cancelled");
12244        }
12245    }
12246
12247    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12248        let Some(mode) = self.state.relocate_mode.as_mut() else {
12249            return;
12250        };
12251        let max_x = self.state.world_width_m.max(1.0);
12252        let max_y = self.state.world_height_m.max(1.0);
12253        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12254        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12255        mode.cursor_x = nx.floor() + 0.5;
12256        mode.cursor_y = ny.floor() + 0.5;
12257    }
12258
12259    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12260        let Some(mode) = self.state.relocate_mode.as_mut() else {
12261            return;
12262        };
12263        let max_x = self.state.world_width_m.max(1.0);
12264        let max_y = self.state.world_height_m.max(1.0);
12265        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12266        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12267    }
12268
12269    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12270        if !self.state.is_alive() {
12271            anyhow::bail!("you are dead");
12272        }
12273        let Some(mode) = self.state.relocate_mode.clone() else {
12274            anyhow::bail!("not relocating");
12275        };
12276        let (px, py) = self.state.player_position();
12277        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12278        if dist > 8.0 {
12279            anyhow::bail!("destination too far (max 8 m)");
12280        }
12281        self.seq += 1;
12282        self.session
12283            .submit_intent(Intent::MovePlacedContainer {
12284                entity_id: self.state.entity_id,
12285                container_id: mode.container_id.clone(),
12286                x: mode.cursor_x,
12287                y: mode.cursor_y,
12288                seq: self.seq,
12289            })
12290            .await?;
12291        self.state.intents_sent += 1;
12292        self.state.relocate_mode = None;
12293        self.state
12294            .push_log(format!("Moving {}…", mode.label));
12295        Ok(())
12296    }
12297
12298    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12299        let Some(mode) = self.state.claim_mode.as_mut() else {
12300            return;
12301        };
12302        mode.width_m = w.max(1);
12303        mode.height_m = h.max(1);
12304    }
12305
12306    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12307        let Some(mode) = self.state.claim_mode.as_mut() else {
12308            return;
12309        };
12310        let w = (mode.width_m as i32 + dw).max(1) as u32;
12311        let h = (mode.height_m as i32 + dh).max(1) as u32;
12312        mode.width_m = w;
12313        mode.height_m = h;
12314    }
12315
12316    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
12317    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12318        let Some(mode) = self.state.claim_mode.as_mut() else {
12319            return;
12320        };
12321        let max_x = self.state.world_width_m.max(1.0);
12322        let max_y = self.state.world_height_m.max(1.0);
12323        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12324        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12325        mode.anchor_x = nx.floor();
12326        mode.anchor_y = ny.floor();
12327    }
12328
12329    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12330        if !self.state.is_alive() {
12331            anyhow::bail!("you are dead");
12332        }
12333        let Some(mode) = self.state.claim_mode.clone() else {
12334            anyhow::bail!("not in claim mode");
12335        };
12336        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12337            self.state.claim_quote()
12338        else {
12339            anyhow::bail!("cannot quote claim");
12340        };
12341        if !valid {
12342            anyhow::bail!(reason);
12343        }
12344        if !can_afford {
12345            anyhow::bail!(
12346                "not enough copper (need {})",
12347                crate::currency::format_copper(purchase)
12348            );
12349        }
12350        let (x0, y0, x1, y1) = self
12351            .state
12352            .claim_footprint_rect()
12353            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12354        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12355        self.seq += 1;
12356        self.session
12357            .submit_intent(Intent::BuyPlot {
12358                entity_id: self.state.entity_id,
12359                zone_id: mode.zone_id,
12360                x0,
12361                y0,
12362                x1,
12363                y1,
12364                seq: self.seq,
12365            })
12366            .await?;
12367        self.state.intents_sent += 1;
12368        self.state.claim_mode = None;
12369        self.state
12370            .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12371        Ok(())
12372    }
12373
12374    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12375        if !self.state.is_alive() {
12376            anyhow::bail!("you are dead");
12377        }
12378        let zone_id = self
12379            .state
12380            .claim_mode
12381            .as_ref()
12382            .map(|m| m.zone_id.clone())
12383            .or_else(|| {
12384                self.state
12385                    .free_property_zone_under_player()
12386                    .map(|z| z.id.clone())
12387            })
12388            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12389        self.seq += 1;
12390        self.session
12391            .submit_intent(Intent::BuyPlotAllFree {
12392                entity_id: self.state.entity_id,
12393                zone_id,
12394                seq: self.seq,
12395            })
12396            .await?;
12397        self.state.intents_sent += 1;
12398        self.state.claim_mode = None;
12399        self.state.push_log("Claiming largest free plot…");
12400        Ok(())
12401    }
12402
12403    pub async fn confirm_sell_plot_to_crown(
12404        &mut self,
12405        plot_id: uuid::Uuid,
12406    ) -> anyhow::Result<()> {
12407        if !self.state.is_alive() {
12408            anyhow::bail!("you are dead");
12409        }
12410        self.seq += 1;
12411        self.session
12412            .submit_intent(Intent::SellPlotToCrown {
12413                entity_id: self.state.entity_id,
12414                plot_id,
12415                seq: self.seq,
12416            })
12417            .await?;
12418        self.state.intents_sent += 1;
12419        self.state.sell_plot_confirm = None;
12420        self.state.sell_plot_armed_at = None;
12421        self.state.push_log("Selling plot to the crown…");
12422        Ok(())
12423    }
12424
12425    pub async fn set_plot_farm_public(
12426        &mut self,
12427        plot_id: uuid::Uuid,
12428        public: bool,
12429        public_tax_discount_bps: u32,
12430    ) -> anyhow::Result<()> {
12431        self.seq += 1;
12432        self.session
12433            .submit_intent(Intent::SetPlotFarmPublic {
12434                entity_id: self.state.entity_id,
12435                plot_id,
12436                public,
12437                public_tax_discount_bps,
12438                seq: self.seq,
12439            })
12440            .await?;
12441        self.state.intents_sent += 1;
12442        Ok(())
12443    }
12444
12445    pub async fn plot_farm_allow_upsert(
12446        &mut self,
12447        plot_id: uuid::Uuid,
12448        character_id: Option<uuid::Uuid>,
12449        character_name: String,
12450        tax_discount_bps: u32,
12451    ) -> anyhow::Result<()> {
12452        self.seq += 1;
12453        self.session
12454            .submit_intent(Intent::PlotFarmAllowUpsert {
12455                entity_id: self.state.entity_id,
12456                plot_id,
12457                character_id,
12458                character_name,
12459                tax_discount_bps,
12460                seq: self.seq,
12461            })
12462            .await?;
12463        self.state.intents_sent += 1;
12464        Ok(())
12465    }
12466
12467    pub async fn plot_farm_allow_remove(
12468        &mut self,
12469        plot_id: uuid::Uuid,
12470        character_id: uuid::Uuid,
12471    ) -> anyhow::Result<()> {
12472        self.seq += 1;
12473        self.session
12474            .submit_intent(Intent::PlotFarmAllowRemove {
12475                entity_id: self.state.entity_id,
12476                plot_id,
12477                character_id,
12478                seq: self.seq,
12479            })
12480            .await?;
12481        self.state.intents_sent += 1;
12482        Ok(())
12483    }
12484
12485    pub fn open_farm_access_panel(&mut self) {
12486        let Some(plot) = self.state.my_plot_under_player() else {
12487            self.state
12488                .push_log("Stand on your deed plot to manage farm access");
12489            return;
12490        };
12491        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
12492        self.state.farm_access_index = 0;
12493        self.state.show_farm_access = true;
12494    }
12495
12496    pub fn close_farm_access_panel(&mut self) {
12497        self.state.show_farm_access = false;
12498        self.state.farm_access_name_draft.clear();
12499        self.state.farm_access_index = 0;
12500    }
12501
12502    pub fn farm_access_move(&mut self, delta: i32) {
12503        let n = self.farm_access_row_count().max(1);
12504        let idx = self.state.farm_access_index as i32 + delta;
12505        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
12506    }
12507
12508    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
12509        let Some(plot) = self.state.my_plot_under_player() else {
12510            return vec![FarmAccessRow::PublicToggle];
12511        };
12512        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
12513        for g in &plot.farm_allow {
12514            rows.push(FarmAccessRow::AllowRemove {
12515                character_id: g.character_id,
12516                label: if g.character_label.trim().is_empty() {
12517                    g.character_id.to_string()[..8].to_string()
12518                } else {
12519                    g.character_label.clone()
12520                },
12521                tax_discount_bps: g.tax_discount_bps,
12522            });
12523        }
12524        for e in &self.state.entities {
12525            if e.id == self.state.entity_id || e.label.trim().is_empty() {
12526                continue;
12527            }
12528            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
12529                continue;
12530            }
12531            if self
12532                .state
12533                .npcs
12534                .iter()
12535                .any(|n| n.id == e.label || n.label == e.label)
12536            {
12537                continue;
12538            }
12539            if plot
12540                .farm_allow
12541                .iter()
12542                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
12543            {
12544                continue;
12545            }
12546            rows.push(FarmAccessRow::NearbyAdd {
12547                name: e.label.clone(),
12548            });
12549        }
12550        rows
12551    }
12552
12553    pub fn farm_access_row_count(&self) -> usize {
12554        self.farm_access_rows().len().max(1)
12555    }
12556
12557    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
12558        let Some(plot) = self.state.my_plot_under_player().cloned() else {
12559            self.close_farm_access_panel();
12560            return Ok(());
12561        };
12562        let rows = self.farm_access_rows();
12563        let Some(row) = rows.get(self.state.farm_access_index) else {
12564            return Ok(());
12565        };
12566        match row {
12567            FarmAccessRow::PublicToggle => {
12568                self.set_plot_farm_public(
12569                    plot.plot_id,
12570                    !plot.farm_public,
12571                    plot.public_tax_discount_bps,
12572                )
12573                .await
12574            }
12575            FarmAccessRow::PublicDiscount => Ok(()),
12576            FarmAccessRow::AllowRemove { character_id, .. } => {
12577                self.plot_farm_allow_remove(plot.plot_id, *character_id)
12578                    .await
12579            }
12580            FarmAccessRow::NearbyAdd { name } => {
12581                let disc = self
12582                    .state
12583                    .farm_access_discount_bps
12584                    .max(plot.public_tax_discount_bps);
12585                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
12586                    .await
12587            }
12588        }
12589    }
12590
12591    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
12592        let Some(plot) = self.state.my_plot_under_player().cloned() else {
12593            return Ok(());
12594        };
12595        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
12596        self.state.farm_access_discount_bps = next;
12597        self.state.farm_access_index = 1;
12598        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
12599            .await
12600    }
12601
12602    /// Till the cell under you on a farmable plot (`c`).
12603    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
12604        if self.state.farmable_plot_under_player().is_none() {
12605            anyhow::bail!("stand on a farmable plot to cultivate");
12606        }
12607        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
12608            let (px, py) = self.state.player_position();
12609            if self
12610                .state
12611                .terrain_at(px, py)
12612                .is_some_and(|k| k == TerrainKindView::Tilled)
12613            {
12614                anyhow::bail!("already tilled — stand on bare soil and press c");
12615            }
12616            anyhow::bail!("cannot till this cell — move onto soil on your plot");
12617        };
12618        self.cultivate_at(tx, ty).await
12619    }
12620
12621    /// Plant seeds on empty tilled soil under you (`p`).
12622    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
12623        if self.state.farmable_plot_under_player().is_none() {
12624            anyhow::bail!("stand on a farmable plot to plant");
12625        }
12626        if !self.state.underfoot_free_tilled_plant_slot() {
12627            anyhow::bail!("stand on empty tilled soil and press p");
12628        }
12629        let seeds = self.state.farm_seed_entries();
12630        if seeds.is_empty() {
12631            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
12632        }
12633        if seeds.len() == 1 {
12634            return self.plant_seeds(seeds[0].0.clone(), 1).await;
12635        }
12636        self.open_plant_menu();
12637        Ok(())
12638    }
12639
12640    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
12641        if !self.state.is_alive() {
12642            anyhow::bail!("you are dead");
12643        }
12644        self.seq += 1;
12645        self.session
12646            .submit_intent(Intent::Cultivate {
12647                entity_id: self.state.entity_id,
12648                x,
12649                y,
12650                seq: self.seq,
12651            })
12652            .await?;
12653        self.state.intents_sent += 1;
12654        Ok(())
12655    }
12656
12657    pub async fn plant_seeds(
12658        &mut self,
12659        seed_template_id: String,
12660        quantity: u32,
12661    ) -> anyhow::Result<()> {
12662        if !self.state.is_alive() {
12663            anyhow::bail!("you are dead");
12664        }
12665        self.seq += 1;
12666        self.session
12667            .submit_intent(Intent::PlantSeeds {
12668                entity_id: self.state.entity_id,
12669                seed_template_id: seed_template_id.clone(),
12670                quantity,
12671                seq: self.seq,
12672            })
12673            .await?;
12674        self.state.intents_sent += 1;
12675        self.state
12676            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
12677        Ok(())
12678    }
12679
12680    pub fn open_plant_menu(&mut self) {
12681        if self.state.farm_seed_entries().is_empty() {
12682            self.state.push_log("No seeds in inventory to plant");
12683            return;
12684        }
12685        self.state.show_plant_menu = true;
12686        self.state.plant_menu_index = 0;
12687        self.state.plant_quantity = 1;
12688        self.state.clamp_plant_menu();
12689    }
12690
12691    pub fn close_plant_menu(&mut self) {
12692        self.state.show_plant_menu = false;
12693    }
12694
12695    pub fn plant_menu_move(&mut self, delta: i32) {
12696        let n = self.state.farm_seed_entries().len();
12697        if n == 0 {
12698            return;
12699        }
12700        let idx = self.state.plant_menu_index as i32 + delta;
12701        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
12702        self.state.clamp_plant_menu();
12703    }
12704
12705    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
12706        let next = self.state.plant_quantity as i32 + delta;
12707        self.state.plant_quantity = next.max(1) as u32;
12708        self.state.clamp_plant_menu();
12709    }
12710
12711    pub fn plant_menu_set_quantity_max(&mut self) {
12712        if let Some((_, max, _)) = self.state.plant_menu_selection() {
12713            self.state.plant_quantity = max;
12714        }
12715        self.state.clamp_plant_menu();
12716    }
12717
12718    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
12719        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
12720            self.close_plant_menu();
12721            anyhow::bail!("no seeds to plant");
12722        };
12723        self.close_plant_menu();
12724        self.plant_seeds(seed, qty).await?;
12725        self.state.push_log(format!("Planted {qty}× {label}"));
12726        Ok(())
12727    }
12728
12729    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
12730    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
12731    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
12732        if !self.state.is_alive() {
12733            anyhow::bail!("you are dead");
12734        }
12735        let binding = self
12736            .state
12737            .hotbar_ability(slot)
12738            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
12739            .to_string();
12740        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
12741            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
12742            if qty == 0 {
12743                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
12744            }
12745            return self.use_item(template_id).await;
12746        }
12747        let ability_id = binding;
12748        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
12749            return self
12750                .cast_ability(&ability_id, Some(self.state.entity_id))
12751                .await;
12752        }
12753        let is_heal = ability_id == "heal_touch"
12754            || self
12755                .state
12756                .ability_meta
12757                .get(&ability_id)
12758                .map(|meta| meta.is_heal)
12759                .unwrap_or(false);
12760        let target = if is_heal {
12761            Some(
12762                self.state
12763                    .target_for_slot(2)
12764                    .unwrap_or(self.state.entity_id),
12765            )
12766        } else {
12767            self.state
12768                .target_for_slot(1)
12769                .or_else(|| self.state.target_for_slot(2))
12770        };
12771        let Some(target_id) = target else {
12772            anyhow::bail!("no target — Tab to select, then press the hotbar key");
12773        };
12774        self.cast_ability(&ability_id, Some(target_id)).await
12775    }
12776
12777    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
12778    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
12779    pub async fn set_hotbar_slot(
12780        &mut self,
12781        slot: u8,
12782        ability_id: Option<&str>,
12783    ) -> anyhow::Result<()> {
12784        if !self.state.is_alive() {
12785            anyhow::bail!("you are dead");
12786        }
12787        if !(1..=9).contains(&slot) {
12788            anyhow::bail!("hotbar slot must be 1–9");
12789        }
12790        let ability_id = ability_id
12791            .map(str::trim)
12792            .filter(|id| !id.is_empty())
12793            .map(str::to_string);
12794        self.seq += 1;
12795        self.session
12796            .submit_intent(Intent::SetHotbarSlot {
12797                entity_id: self.state.entity_id,
12798                slot,
12799                ability_id: ability_id.clone(),
12800                seq: self.seq,
12801            })
12802            .await?;
12803        self.state.intents_sent += 1;
12804        let idx = (slot - 1) as usize;
12805        if self.state.hotbar.len() < 9 {
12806            self.state.hotbar.resize(9, None);
12807        }
12808        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
12809            *slot_mut = ability_id.clone();
12810        }
12811        match ability_id {
12812            Some(id) => {
12813                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
12814                    format!("use {tid}")
12815                } else {
12816                    id
12817                };
12818                self.state.push_log(format!("Hotbar {slot} → {label}"))
12819            }
12820            None => self.state.push_log(format!("Hotbar {slot} cleared")),
12821        }
12822        Ok(())
12823    }
12824
12825    pub fn npc_verb_options(&self) -> Vec<&'static str> {
12826        self.state.npc_verb_options()
12827    }
12828
12829    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
12830        let Some(npc_id) = self.state.npc_verb_target.clone() else {
12831            return Ok(());
12832        };
12833        let options = self.npc_verb_options();
12834        let choice = options
12835            .get(self.state.npc_verb_index)
12836            .copied()
12837            .unwrap_or("Talk");
12838        self.seq += 1;
12839        match choice {
12840            "Trade" | "Bank" | "Storage" | "Market" => {
12841                self.session
12842                    .submit_intent(Intent::Interact {
12843                        entity_id: self.state.entity_id,
12844                        target_id: npc_id,
12845                        seq: self.seq,
12846                    })
12847                    .await?;
12848            }
12849            _ => {
12850                self.session
12851                    .submit_intent(Intent::NpcTalkOpen {
12852                        entity_id: self.state.entity_id,
12853                        npc_id,
12854                        seq: self.seq,
12855                    })
12856                    .await?;
12857            }
12858        }
12859        self.state.intents_sent += 1;
12860        Ok(())
12861    }
12862
12863    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
12864        let Some(chat) = self.state.npc_chat.clone() else {
12865            return Ok(());
12866        };
12867        let message = chat.input.trim().to_string();
12868        if message.is_empty() || chat.pending {
12869            return Ok(());
12870        }
12871        if let Some(c) = self.state.npc_chat.as_mut() {
12872            c.lines.push(format!("You: {message}"));
12873            c.input.clear();
12874            c.pending = true;
12875        }
12876        self.seq += 1;
12877        self.session
12878            .submit_intent(Intent::NpcTalkSay {
12879                entity_id: self.state.entity_id,
12880                npc_id: chat.npc_id,
12881                message,
12882                seq: self.seq,
12883            })
12884            .await?;
12885        self.state.intents_sent += 1;
12886        Ok(())
12887    }
12888
12889    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
12890        let return_to_verbs = self.state.npc_verb_target.is_some();
12891        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
12892            self.state.show_npc_chat = false;
12893            if return_to_verbs {
12894                self.state.show_npc_verb_menu = true;
12895            }
12896            return Ok(());
12897        };
12898        self.seq += 1;
12899        self.session
12900            .submit_intent(Intent::NpcTalkClose {
12901                entity_id: self.state.entity_id,
12902                npc_id,
12903                seq: self.seq,
12904            })
12905            .await?;
12906        self.state.intents_sent += 1;
12907        self.state.show_npc_chat = false;
12908        self.state.npc_chat = None;
12909        if return_to_verbs {
12910            self.state.show_npc_verb_menu = true;
12911        }
12912        Ok(())
12913    }
12914
12915    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
12916    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
12917        if self.state.show_quest_offer
12918            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
12919        {
12920            self.quest_offer_decline();
12921            return Ok(());
12922        }
12923        if self.state.show_npc_chat {
12924            return self.npc_talk_close().await;
12925        }
12926        if self.state.show_shop_menu {
12927            return self.back_from_shop_menu().await;
12928        }
12929        if self.state.bank_panel.is_some() {
12930            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
12931                self.bank_transfer_back();
12932                return Ok(());
12933            }
12934            return self.close_bank_panel().await;
12935        }
12936        if self.state.storage_panel.is_some() {
12937            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
12938                self.storage_ui_back();
12939                return Ok(());
12940            }
12941            return self.close_storage_panel().await;
12942        }
12943        if self.state.market_panel.is_some() {
12944            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
12945                self.market_ui_back();
12946                return Ok(());
12947            }
12948            if self.state.market_buy_confirm.is_some() {
12949                self.state.market_buy_confirm = None;
12950                return Ok(());
12951            }
12952            return self.close_market_panel().await;
12953        }
12954        if self.state.show_npc_verb_menu {
12955            self.state.show_npc_verb_menu = false;
12956            self.state.npc_verb_target = None;
12957        }
12958        Ok(())
12959    }
12960
12961    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
12962        self.seq += 1;
12963        self.session
12964            .submit_intent(Intent::TestDamage {
12965                entity_id: self.state.entity_id,
12966                amount,
12967                seq: self.seq,
12968            })
12969            .await?;
12970        self.state.intents_sent += 1;
12971        Ok(())
12972    }
12973
12974    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
12975        self.cycle_combat_target_slot(1, reverse).await
12976    }
12977
12978    pub async fn cycle_combat_target_slot(
12979        &mut self,
12980        slot_index: u8,
12981        reverse: bool,
12982    ) -> anyhow::Result<()> {
12983        if !self.state.is_alive() {
12984            anyhow::bail!("you are dead");
12985        }
12986        let candidates = self.state.candidates_for_slot(slot_index);
12987        if candidates.is_empty() {
12988            anyhow::bail!("no targets nearby");
12989        }
12990        let current = self.state.target_for_slot(slot_index);
12991        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
12992        let next_idx = match idx {
12993            None => 0,
12994            Some(i) if reverse => {
12995                if i == 0 {
12996                    candidates.len() - 1
12997                } else {
12998                    i - 1
12999                }
13000            }
13001            Some(i) => (i + 1) % candidates.len(),
13002        };
13003        if idx == Some(next_idx) && candidates.len() == 1 {
13004            self.clear_combat_target_slot(slot_index).await?;
13005            return Ok(());
13006        }
13007        let (target_id, label) = candidates[next_idx].clone();
13008        self.set_combat_target_slot(slot_index, target_id, &label)
13009            .await
13010    }
13011
13012    pub async fn set_combat_target_slot(
13013        &mut self,
13014        slot_index: u8,
13015        target_id: EntityId,
13016        label: &str,
13017    ) -> anyhow::Result<()> {
13018        if !self.state.is_alive() {
13019            anyhow::bail!("you are dead");
13020        }
13021        self.seq += 1;
13022        self.session
13023            .submit_intent(Intent::SetTargetSlot {
13024                entity_id: self.state.entity_id,
13025                slot_index,
13026                target_id,
13027                seq: self.seq,
13028            })
13029            .await?;
13030        self.state.intents_sent += 1;
13031        if slot_index == 1 {
13032            self.state.combat_target = Some(target_id);
13033            self.state.combat_target_label = Some(label.to_string());
13034        }
13035        self.state
13036            .push_log(format!("Slot {slot_index} target: {label}"));
13037        Ok(())
13038    }
13039
13040    pub async fn set_combat_target(
13041        &mut self,
13042        target_id: EntityId,
13043        label: &str,
13044    ) -> anyhow::Result<()> {
13045        self.set_combat_target_slot(1, target_id, label).await
13046    }
13047
13048    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13049        if slot_index == 1 && self.state.combat_target.is_none() {
13050            return Ok(());
13051        }
13052        self.seq += 1;
13053        self.session
13054            .submit_intent(Intent::ClearTargetSlot {
13055                entity_id: self.state.entity_id,
13056                slot_index,
13057                seq: self.seq,
13058            })
13059            .await?;
13060        if slot_index == 1 {
13061            self.state.combat_target = None;
13062            self.state.combat_target_label = None;
13063        }
13064        self.state.intents_sent += 1;
13065        self.state
13066            .push_log(format!("Slot {slot_index} target cleared"));
13067        Ok(())
13068    }
13069
13070    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
13071        self.clear_combat_target_slot(1).await
13072    }
13073
13074    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
13075        if !self.state.is_alive() {
13076            anyhow::bail!("you are dead");
13077        }
13078        self.seq += 1;
13079        self.session
13080            .submit_intent(Intent::AdvanceRotation {
13081                entity_id: self.state.entity_id,
13082                slot_index,
13083                seq: self.seq,
13084            })
13085            .await?;
13086        self.state.intents_sent += 1;
13087        Ok(())
13088    }
13089
13090    pub async fn assign_slot_preset(
13091        &mut self,
13092        slot_index: u8,
13093        preset_id: &str,
13094    ) -> anyhow::Result<()> {
13095        if !self.state.is_alive() {
13096            anyhow::bail!("you are dead");
13097        }
13098        self.seq += 1;
13099        self.session
13100            .submit_intent(Intent::AssignSlotPreset {
13101                entity_id: self.state.entity_id,
13102                slot_index,
13103                preset_id: preset_id.to_string(),
13104                seq: self.seq,
13105            })
13106            .await?;
13107        self.state.intents_sent += 1;
13108        if let Some(slot) = self
13109            .state
13110            .combat_slots
13111            .iter_mut()
13112            .find(|s| s.slot_index == slot_index)
13113        {
13114            slot.preset_id = Some(preset_id.to_string());
13115            if let Some(preset) = self
13116                .state
13117                .rotation_presets
13118                .iter()
13119                .find(|p| p.id == preset_id)
13120            {
13121                slot.preset_label = Some(preset.label.clone());
13122                slot.rotation = preset.abilities.clone();
13123                slot.rotation_index = 0;
13124            }
13125        }
13126        self.state
13127            .push_log(format!("T{slot_index} loadout → {preset_id}"));
13128        Ok(())
13129    }
13130
13131    pub async fn cast_ability(
13132        &mut self,
13133        ability_id: &str,
13134        target_id: Option<EntityId>,
13135    ) -> anyhow::Result<()> {
13136        if !self.state.is_alive() {
13137            anyhow::bail!("you are dead");
13138        }
13139        let allows_ground = self.state.ability_allows_ground(ability_id);
13140        let requires_ground = self.state.ability_requires_ground(ability_id);
13141        if requires_ground && self.state.ground_target.is_none() {
13142            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
13143        }
13144        let (resolved_target_id, target_point) = if allows_ground {
13145            if let Some((x, y, z)) = self.state.ground_target {
13146                (
13147                    target_id.unwrap_or(self.state.entity_id),
13148                    Some(flatland_protocol::AimPoint { x, y, z }),
13149                )
13150            } else {
13151                (
13152                    target_id
13153                        .or_else(|| self.state.target_for_slot(2))
13154                        .or_else(|| self.state.target_for_slot(1))
13155                        .unwrap_or(self.state.entity_id),
13156                    None,
13157                )
13158            }
13159        } else {
13160            (
13161                target_id
13162                    .or_else(|| self.state.target_for_slot(2))
13163                    .or_else(|| self.state.target_for_slot(1))
13164                    .unwrap_or(self.state.entity_id),
13165                None,
13166            )
13167        };
13168        self.seq += 1;
13169        self.session
13170            .submit_intent(Intent::Cast {
13171                entity_id: self.state.entity_id,
13172                ability_id: ability_id.to_string(),
13173                target_id: resolved_target_id,
13174                target_point,
13175                seq: self.seq,
13176            })
13177            .await?;
13178        self.state.intents_sent += 1;
13179        match target_point {
13180            Some(point) => self.state.push_log(format!(
13181                "Cast {ability_id} → ({:.1}, {:.1})",
13182                point.x, point.y
13183            )),
13184            None => self
13185                .state
13186                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
13187        }
13188        Ok(())
13189    }
13190
13191    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13192        self.seq += 1;
13193        self.session
13194            .submit_intent(Intent::UpsertRotationPreset {
13195                entity_id: self.state.entity_id,
13196                preset: preset.clone(),
13197                seq: self.seq,
13198            })
13199            .await?;
13200        self.state.intents_sent += 1;
13201        if let Some(existing) = self
13202            .state
13203            .rotation_presets
13204            .iter_mut()
13205            .find(|p| p.id == preset.id)
13206        {
13207            *existing = preset.clone();
13208        } else {
13209            self.state.rotation_presets.push(preset.clone());
13210        }
13211        for slot in &mut self.state.combat_slots {
13212            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
13213                slot.preset_label = Some(preset.label.clone());
13214                slot.rotation = preset.abilities.clone();
13215            }
13216        }
13217        self.state
13218            .push_log(format!("Saved rotation: {}", preset.label));
13219        Ok(())
13220    }
13221
13222    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
13223        self.seq += 1;
13224        self.session
13225            .submit_intent(Intent::DeleteRotationPreset {
13226                entity_id: self.state.entity_id,
13227                preset_id: preset_id.to_string(),
13228                seq: self.seq,
13229            })
13230            .await?;
13231        self.state.intents_sent += 1;
13232        self.state.rotation_presets.retain(|p| p.id != preset_id);
13233        for slot in &mut self.state.combat_slots {
13234            if slot.preset_id.as_deref() == Some(preset_id) {
13235                slot.preset_id = None;
13236                slot.preset_label = None;
13237                slot.rotation.clear();
13238                slot.rotation_index = 0;
13239            }
13240        }
13241        self.state
13242            .push_log(format!("Deleted rotation: {preset_id}"));
13243        Ok(())
13244    }
13245
13246    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13247        if !self.state.is_alive() {
13248            anyhow::bail!("you are dead");
13249        }
13250        let enabled = !self
13251            .state
13252            .combat_slots
13253            .iter()
13254            .find(|s| s.slot_index == slot_index)
13255            .map(|s| s.auto_enabled)
13256            .unwrap_or(false);
13257        self.seq += 1;
13258        self.session
13259            .submit_intent(Intent::SetAutoAttack {
13260                entity_id: self.state.entity_id,
13261                slot_index,
13262                enabled,
13263                seq: self.seq,
13264            })
13265            .await?;
13266        if slot_index == 1 {
13267            self.state.auto_attack = enabled;
13268        }
13269        self.state.intents_sent += 1;
13270        self.state.push_log(format!(
13271            "T{slot_index} auto {}",
13272            if enabled { "ON" } else { "OFF" }
13273        ));
13274        Ok(())
13275    }
13276
13277    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
13278        if !self.state.connected {
13279            anyhow::bail!("not connected");
13280        }
13281        if !self.state.is_alive() {
13282            anyhow::bail!("you are dead");
13283        }
13284        let (px, py) = self.state.player_position();
13285        if self
13286            .state
13287            .ground_drops
13288            .iter()
13289            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
13290        {
13291            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
13292        }
13293        self.seq += 1;
13294        self.session
13295            .submit_intent(Intent::Pickup {
13296                entity_id: self.state.entity_id,
13297                drop_id: None,
13298                seq: self.seq,
13299            })
13300            .await?;
13301        self.state.intents_sent += 1;
13302        Ok(())
13303    }
13304
13305    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
13306        self.toggle_auto_attack_slot(1).await
13307    }
13308
13309    pub async fn dodge(&mut self) -> anyhow::Result<()> {
13310        if !self.state.is_alive() {
13311            anyhow::bail!("you are dead");
13312        }
13313        self.seq += 1;
13314        self.session
13315            .submit_intent(Intent::Dodge {
13316                entity_id: self.state.entity_id,
13317                seq: self.seq,
13318            })
13319            .await?;
13320        self.state.intents_sent += 1;
13321        self.state.push_log("Dodge!");
13322        Ok(())
13323    }
13324
13325    pub async fn lunge(&mut self) -> anyhow::Result<()> {
13326        if !self.state.is_alive() {
13327            anyhow::bail!("you are dead");
13328        }
13329        let (forward, strafe) = self.last_move_axes();
13330        self.seq += 1;
13331        self.session
13332            .submit_intent(Intent::Lunge {
13333                entity_id: self.state.entity_id,
13334                forward,
13335                strafe,
13336                seq: self.seq,
13337            })
13338            .await?;
13339        self.state.intents_sent += 1;
13340        self.state.push_log("Lunge!");
13341        Ok(())
13342    }
13343
13344    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
13345        if !self.state.is_alive() {
13346            anyhow::bail!("you are dead");
13347        }
13348        self.seq += 1;
13349        self.session
13350            .submit_intent(Intent::DirectionalJump {
13351                entity_id: self.state.entity_id,
13352                forward,
13353                strafe,
13354                seq: self.seq,
13355            })
13356            .await?;
13357        self.state.intents_sent += 1;
13358        self.state.push_log("Jump!");
13359        Ok(())
13360    }
13361
13362    /// Remembered WASD axes for lunge when not currently moving.
13363    pub fn last_move_axes(&self) -> (f32, f32) {
13364        (self.last_move_forward, self.last_move_strafe)
13365    }
13366
13367    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
13368        if !self.state.is_alive() {
13369            anyhow::bail!("you are dead");
13370        }
13371        self.seq += 1;
13372        self.session
13373            .submit_intent(Intent::Block {
13374                entity_id: self.state.entity_id,
13375                enabled,
13376                seq: self.seq,
13377            })
13378            .await?;
13379        self.state.intents_sent += 1;
13380        if enabled {
13381            self.state.push_log("Blocking");
13382        }
13383        Ok(())
13384    }
13385
13386    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
13387        if !self.state.is_alive() {
13388            anyhow::bail!("you are dead");
13389        }
13390        self.seq += 1;
13391        self.session
13392            .submit_intent(Intent::EquipMainhand {
13393                entity_id: self.state.entity_id,
13394                template_id,
13395                instance_id: None,
13396                seq: self.seq,
13397            })
13398            .await?;
13399        self.state.intents_sent += 1;
13400        Ok(())
13401    }
13402
13403    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
13404    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
13405        let idx = self.state.equip_menu_index;
13406        let slots = equip_paperdoll_rows(&self.state);
13407        let Some(row) = slots.get(idx) else {
13408            return Ok(());
13409        };
13410        match row {
13411            EquipPaperdollRow::Body { slot, filled } => {
13412                if *filled {
13413                    self.equip_worn(*slot, None).await
13414                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
13415                    self.equip_worn(*slot, Some(inst)).await
13416                } else {
13417                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
13418                    Ok(())
13419                }
13420            }
13421            EquipPaperdollRow::Mainhand { filled } => {
13422                if *filled {
13423                    self.unequip_mainhand().await
13424                } else if let Some(tid) = first_inventory_weapon(&self.state) {
13425                    self.equip_mainhand(Some(tid)).await
13426                } else {
13427                    self.state.push_log("No weapon in inventory".to_string());
13428                    Ok(())
13429                }
13430            }
13431            EquipPaperdollRow::Offhand { filled, locked } => {
13432                if *locked {
13433                    self.state
13434                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
13435                    Ok(())
13436                } else if *filled {
13437                    self.unequip_offhand().await
13438                } else if let Some(tid) = first_inventory_offhand(&self.state) {
13439                    self.equip_offhand(Some(tid)).await
13440                } else {
13441                    self.state
13442                        .push_log("No offhand item in inventory".to_string());
13443                    Ok(())
13444                }
13445            }
13446        }
13447    }
13448
13449    pub async fn say(
13450        &mut self,
13451        channel: flatland_protocol::ChatChannel,
13452        text: &str,
13453    ) -> anyhow::Result<()> {
13454        self.say_to(channel, text, None).await
13455    }
13456
13457    pub async fn say_to(
13458        &mut self,
13459        channel: flatland_protocol::ChatChannel,
13460        text: &str,
13461        to_entity: Option<EntityId>,
13462    ) -> anyhow::Result<()> {
13463        self.seq += 1;
13464        self.session
13465            .submit_intent(Intent::Say {
13466                entity_id: self.state.entity_id,
13467                channel,
13468                text: text.to_string(),
13469                to_entity,
13470                seq: self.seq,
13471            })
13472            .await?;
13473        self.state.intents_sent += 1;
13474        Ok(())
13475    }
13476
13477    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
13478        let Some(peer) = self.state.player_verbs.target_entity else {
13479            return Ok(());
13480        };
13481        let label = self.state.player_verbs.target_label.clone();
13482        let choice = crate::social::PlayerVerbState::options()
13483            .get(self.state.player_verbs.index)
13484            .copied()
13485            .unwrap_or("Whisper");
13486        self.state.player_verbs.close();
13487        match choice {
13488            "Trade" => {
13489                // Only request — peer must accept (Y in chat). Do not auto-respond;
13490                // TradeRespond requires a pending inbound request from the peer.
13491                self.seq += 1;
13492                self.session
13493                    .submit_intent(Intent::TradeRequest {
13494                        entity_id: self.state.entity_id,
13495                        peer_entity_id: peer,
13496                        seq: self.seq,
13497                    })
13498                    .await?;
13499                self.state.intents_sent += 1;
13500                self.state
13501                    .social_chat
13502                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
13503            }
13504            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
13505            _ => self.state.social_chat.focus_nearby(),
13506        }
13507        Ok(())
13508    }
13509
13510    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
13511        let Some(pending) = self.state.social_chat.pending_trade.take() else {
13512            return Ok(());
13513        };
13514        self.seq += 1;
13515        self.session
13516            .submit_intent(Intent::TradeRespond {
13517                entity_id: self.state.entity_id,
13518                peer_entity_id: pending.from_entity,
13519                accept,
13520                seq: self.seq,
13521            })
13522            .await?;
13523        self.state.intents_sent += 1;
13524        if accept {
13525            self.state
13526                .social_chat
13527                .push_system(format!("Accepted trade with {}", pending.from_name));
13528        } else {
13529            self.state
13530                .social_chat
13531                .push_system(format!("Declined trade with {}", pending.from_name));
13532        }
13533        Ok(())
13534    }
13535
13536    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
13537        let text = self.state.social_chat.buffer.trim().to_string();
13538        if text.is_empty() {
13539            return Ok(());
13540        }
13541        self.state.social_chat.buffer.clear();
13542        if crate::social::is_chat_slash_line(&text) {
13543            match crate::social::parse_chat_slash(&text) {
13544                Some(cmd) => return self.apply_chat_slash(cmd).await,
13545                None => {
13546                    self.state.social_chat.push_system(format!(
13547                        "Unknown command — {}",
13548                        crate::social::chat_slash_help_text()
13549                    ));
13550                    return Ok(());
13551                }
13552            }
13553        }
13554        let thread = self.state.social_chat.thread;
13555        let channel = thread.channel();
13556        let to = thread.to_entity();
13557        if let Some(peer) = to {
13558            let label = self.state.social_chat.peer_label.clone();
13559            self.state
13560                .social_chat
13561                .remember_whisper_peer(peer, &label, channel);
13562        }
13563        self.say_to(channel, &text, to).await
13564    }
13565
13566    async fn apply_chat_slash(
13567        &mut self,
13568        cmd: crate::social::ChatSlashCommand,
13569    ) -> anyhow::Result<()> {
13570        use crate::social::{chat_slash_help_text, ChatSlashCommand};
13571        match cmd {
13572            ChatSlashCommand::Help => {
13573                self.state
13574                    .social_chat
13575                    .push_system(chat_slash_help_text().to_string());
13576                Ok(())
13577            }
13578            ChatSlashCommand::Nearby { message } => {
13579                self.state.social_chat.focus_nearby();
13580                self.state
13581                    .social_chat
13582                    .push_system("Nearby speech — everyone close can hear");
13583                if let Some(msg) = message {
13584                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
13585                        .await
13586                } else {
13587                    Ok(())
13588                }
13589            }
13590            ChatSlashCommand::Reply { message } => {
13591                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13592                    self.state.social_chat.push_system(
13593                        "No one to reply to — wait for a whisper, or /whisper Name",
13594                    );
13595                    return Ok(());
13596                };
13597                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
13598                self.state
13599                    .social_chat
13600                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
13601                self.state.social_chat.push_system(format!(
13602                    "Replying to {} — type and Enter · /nearby",
13603                    peer.label
13604                ));
13605                if let Some(msg) = message {
13606                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
13607                } else {
13608                    Ok(())
13609                }
13610            }
13611            ChatSlashCommand::Whisper { name, message } => {
13612                let (peer_id, label, stone) = if let Some(name) = name {
13613                    match self.resolve_whisper_target(&name) {
13614                        Ok(t) => t,
13615                        Err(err) => {
13616                            self.state.social_chat.push_system(err);
13617                            return Ok(());
13618                        }
13619                    }
13620                } else {
13621                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13622                        self.state.social_chat.push_system(
13623                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
13624                        );
13625                        return Ok(());
13626                    };
13627                    (
13628                        peer.entity_id,
13629                        peer.label,
13630                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
13631                    )
13632                };
13633                self.state
13634                    .social_chat
13635                    .set_whisper_thread(peer_id, &label, stone);
13636                let channel = if stone {
13637                    flatland_protocol::ChatChannel::WhisperStone
13638                } else {
13639                    flatland_protocol::ChatChannel::Whisper
13640                };
13641                if let Some(msg) = message {
13642                    self.state.social_chat.push_system(format!(
13643                        "Whisper → {label}"
13644                    ));
13645                    self.say_to(channel, &msg, Some(peer_id)).await
13646                } else {
13647                    self.state.social_chat.push_system(format!(
13648                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
13649                    ));
13650                    Ok(())
13651                }
13652            }
13653        }
13654    }
13655
13656    /// Resolve `/whisper Name` against AOI players (label match).
13657    fn resolve_whisper_target(
13658        &self,
13659        name: &str,
13660    ) -> Result<(EntityId, String, bool), String> {
13661        let needle = name.trim().to_ascii_lowercase();
13662        if needle.is_empty() {
13663            return Err("Usage: /whisper Name [message]".into());
13664        }
13665        let mut candidates: Vec<(EntityId, String)> = self
13666            .state
13667            .entities
13668            .iter()
13669            .filter(|e| e.id != self.state.entity_id)
13670            .filter(|e| !e.label.trim().is_empty())
13671            .filter(|e| e.vitals.is_some())
13672            .filter(|e| {
13673                !self
13674                    .state
13675                    .npcs
13676                    .iter()
13677                    .any(|n| n.id == e.id.to_string())
13678            })
13679            .filter(|e| {
13680                !self
13681                    .state
13682                    .hired_workers
13683                    .iter()
13684                    .any(|w| w.entity_id == e.id)
13685            })
13686            .map(|e| (e.id, e.label.clone()))
13687            .collect();
13688
13689        // Also allow matching the last whisper peer by name even if they left AOI briefly.
13690        if let Some(last) = &self.state.social_chat.last_whisper_peer {
13691            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
13692                candidates.push((last.entity_id, last.label.clone()));
13693            }
13694        }
13695
13696        let exact: Vec<_> = candidates
13697            .iter()
13698            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
13699            .cloned()
13700            .collect();
13701        let pool = if exact.len() == 1 {
13702            exact
13703        } else if exact.len() > 1 {
13704            return Err(format!(
13705                "Several players named '{name}' nearby — move closer and try again"
13706            ));
13707        } else {
13708            let starts: Vec<_> = candidates
13709                .iter()
13710                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
13711                .cloned()
13712                .collect();
13713            if starts.len() == 1 {
13714                starts
13715            } else if starts.len() > 1 {
13716                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
13717                return Err(format!(
13718                    "Ambiguous name '{name}' — matches: {}",
13719                    names.join(", ")
13720                ));
13721            } else {
13722                let contains: Vec<_> = candidates
13723                    .iter()
13724                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
13725                    .cloned()
13726                    .collect();
13727                if contains.len() == 1 {
13728                    contains
13729                } else if contains.is_empty() {
13730                    return Err(format!(
13731                        "No player matching '{name}' in range — get closer or check the spelling"
13732                    ));
13733                } else {
13734                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
13735                    return Err(format!(
13736                        "Ambiguous name '{name}' — matches: {}",
13737                        names.join(", ")
13738                    ));
13739                }
13740            }
13741        };
13742
13743        let (id, label) = pool.into_iter().next().unwrap();
13744        let stone = self
13745            .state
13746            .social_chat
13747            .last_whisper_peer
13748            .as_ref()
13749            .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
13750        Ok((id, label, stone))
13751    }
13752
13753    pub async fn trade_present_selected(
13754        &mut self,
13755        item_instance_id: uuid::Uuid,
13756    ) -> anyhow::Result<()> {
13757        self.trade_present_quantity(item_instance_id, None).await
13758    }
13759
13760    pub async fn trade_present_quantity(
13761        &mut self,
13762        item_instance_id: uuid::Uuid,
13763        quantity: Option<u32>,
13764    ) -> anyhow::Result<()> {
13765        self.seq += 1;
13766        self.session
13767            .submit_intent(Intent::TradePresent {
13768                entity_id: self.state.entity_id,
13769                item_instance_id,
13770                quantity,
13771                seq: self.seq,
13772            })
13773            .await?;
13774        self.state.intents_sent += 1;
13775        self.state.trade_ui.qty_entry = None;
13776        self.state.trade_ui.picking_inventory = false;
13777        Ok(())
13778    }
13779
13780    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
13781    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
13782        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
13783            let qty = self.state.trade_ui.present_quantity();
13784            return self
13785                .trade_present_quantity(entry.item_instance_id, qty)
13786                .await;
13787        }
13788        if !self.state.trade_ui.picking_inventory {
13789            return Ok(());
13790        }
13791        let Some(stack) = self
13792            .state
13793            .inventory_stacks
13794            .get(self.state.trade_ui.inventory_index)
13795            .cloned()
13796        else {
13797            return Ok(());
13798        };
13799        let Some(id) = stack.item_instance_id else {
13800            return Ok(());
13801        };
13802        let label = stack
13803            .display_name
13804            .clone()
13805            .unwrap_or_else(|| stack.template_id.clone());
13806        if stack.quantity <= 1 {
13807            self.trade_present_quantity(id, Some(1)).await
13808        } else {
13809            self.state
13810                .trade_ui
13811                .begin_qty_entry(id, label, stack.quantity);
13812            Ok(())
13813        }
13814    }
13815
13816    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
13817        self.seq += 1;
13818        self.session
13819            .submit_intent(Intent::TradeSetReady {
13820                entity_id: self.state.entity_id,
13821                ready,
13822                seq: self.seq,
13823            })
13824            .await?;
13825        self.state.intents_sent += 1;
13826        Ok(())
13827    }
13828
13829    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
13830        self.seq += 1;
13831        self.session
13832            .submit_intent(Intent::TradeCancel {
13833                entity_id: self.state.entity_id,
13834                seq: self.seq,
13835            })
13836            .await?;
13837        self.state.intents_sent += 1;
13838        self.state.trade_ui.close();
13839        Ok(())
13840    }
13841
13842    pub async fn destroy_whisper_stone(
13843        &mut self,
13844        item_instance_id: uuid::Uuid,
13845    ) -> anyhow::Result<()> {
13846        self.seq += 1;
13847        self.session
13848            .submit_intent(Intent::DestroyWhisperStone {
13849                entity_id: self.state.entity_id,
13850                item_instance_id,
13851                seq: self.seq,
13852            })
13853            .await?;
13854        self.state.intents_sent += 1;
13855        Ok(())
13856    }
13857
13858    pub async fn stop(&mut self) -> anyhow::Result<()> {
13859        self.seq += 1;
13860        self.session
13861            .submit_intent(Intent::Stop {
13862                entity_id: self.state.entity_id,
13863                seq: self.seq,
13864            })
13865            .await?;
13866        self.state.intents_sent += 1;
13867        Ok(())
13868    }
13869
13870    pub fn disconnect(&self) {
13871        self.session.disconnect();
13872    }
13873}
13874
13875fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
13876    let dx = ax - bx;
13877    let dy = ay - by;
13878    (dx * dx + dy * dy).sqrt()
13879}
13880
13881#[cfg(test)]
13882mod tests {
13883    use std::collections::BTreeMap;
13884
13885    use super::*;
13886    use flatland_protocol::{
13887        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
13888    };
13889
13890    fn sample_state() -> GameState {
13891        let mut state = GameState {
13892            session_id: 1,
13893            entity_id: 1,
13894            character_id: None,
13895            tick: 0,
13896            chunk_rev: 0,
13897            content_rev: 0,
13898            publish_rev: 0,
13899            entities: vec![EntityState {
13900                id: 1,
13901                label: "You".into(),
13902                transform: Transform {
13903                    position: WorldCoord::surface(128.0, 128.0),
13904                    yaw: 0.0,
13905                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
13906                },
13907                vitals: None,
13908                attributes: None,
13909                skills: None,
13910                inside_building: None,
13911                tile_id: None,
13912                paperdoll_ref: None,
13913                presentation_state: None,
13914                sprite_mode: None,
13915                progression_xp: None,
13916                combat_cues: vec![],
13917            }],
13918            player: None,
13919            resource_nodes: vec![ResourceNodeView {
13920                id: "oak-1".into(),
13921                label: "Oak".into(),
13922                x: 130.0,
13923                y: 128.0,
13924                z: 0.0,
13925                item_template: "oak_log".into(),
13926                state: ResourceNodeState::Available,
13927                blocking: true,
13928                blocking_radius_m: 0.8,
13929                tile_id: None,
13930                                yaw: 0.0,
13931                pitch: 0.0,
13932                roll: 0.0,
13933                draw_scale: 1.0,
13934                sprite_mode: None,
13935                growth_progress: None,
13936                presentation_state: None,
13937                channel_start_tick: None,
13938                channel_end_tick: None,
13939                harvest_drop_templates: vec![],
13940            }],
13941            ground_drops: vec![],
13942            placed_containers: vec![],
13943            buildings: vec![BuildingView {
13944                id: "broker-hut".into(),
13945                label: "Broker".into(),
13946                x: 148.0,
13947                y: 118.0,
13948                width_m: 8.0,
13949                depth_m: 6.0,
13950                interior_blueprint: Some("broker_hut".into()),
13951                tags: vec![],
13952                market_boundary_zone_ids: vec![],
13953                market_max_volume: None,
13954                wall_set: None,
13955                roof_set: None,
13956            }],
13957            doors: vec![flatland_protocol::DoorView {
13958                id: "door-1".into(),
13959                building_id: "broker-hut".into(),
13960                x: 148.0,
13961                y: 118.0,
13962                open: false,
13963                portal: Some("front".into()),
13964            }],
13965            interior_map: None,
13966            npcs: vec![],
13967            blueprints: vec![],
13968            world_x0: 0.0,
13969            world_y0: 0.0,
13970            world_width_m: 256.0,
13971            world_height_m: 256.0,
13972            terrain_zones: Vec::new(),
13973            z_platforms: Vec::new(),
13974            z_transitions: Vec::new(),
13975            z_bands_outdoor_backup: None,
13976            world_clock: flatland_protocol::WorldClock::default(),
13977            inventory: std::collections::HashMap::new(),
13978            inventory_hints: std::collections::HashMap::new(),
13979            logs: VecDeque::new(),
13980            intents_sent: 0,
13981            ticks_received: 0,
13982            connected: true,
13983            disconnect_reason: None,
13984            show_stats: false,
13985            hud_log_hidden: false,
13986            show_equip_menu: false,
13987            equip_menu_index: 0,
13988            show_craft_menu: false,
13989            craft_menu_index: 0,
13990            craft_batch_quantity: 1,
13991            show_shop_menu: false,
13992            shop_catalog: None,
13993            bank_panel: None,
13994            bank_menu_index: 0,
13995            bank_ui_mode: BankUiMode::Menu,
13996            storage_panel: None,
13997                market_panel: None,
13998            market_menu_index: 0,
13999            market_filter: String::new(),
14000            market_filter_focused: false,
14001            market_category_filter: None,
14002            market_buy_confirm: None,
14003            market_ui_mode: MarketUiMode::Browse,
14004            storage_menu_index: 0,
14005            storage_ui_mode: StorageUiMode::Menu,
14006            shop_tab: ShopTab::default(),
14007            shop_menu_index: 0,
14008            shop_quantity: 1,
14009            shop_trade_log: VecDeque::new(),
14010            show_npc_verb_menu: false,
14011            npc_verb_target: None,
14012            npc_verb_index: 0,
14013            player_verbs: crate::social::PlayerVerbState::default(),
14014            social_chat: crate::social::SocialChatState::default(),
14015            trade_ui: crate::social::TradeUiState::default(),
14016            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14017            show_npc_chat: false,
14018            npc_chat: None,
14019            show_inventory_menu: false,
14020            inventory_menu_index: 0,
14021            inventory_tab: InventoryTab::OnPerson,
14022            inventory_filter: String::new(),
14023            inventory_filter_focused: false,
14024            show_move_picker: false,
14025            show_rename_prompt: false,
14026            show_worker_rename: false,
14027            rename_buffer: String::new(),
14028            move_picker_index: 0,
14029            move_picker: None,
14030            show_grant_picker: false,
14031            grant_picker_index: 0,
14032            grant_picker: None,
14033            show_destroy_picker: false,
14034            destroy_confirm_pending: false,
14035            destroy_picker: None,
14036            combat_target: None,
14037            combat_target_label: None,
14038            ground_target: None,
14039            combat_fx: Vec::new(),
14040            property_zones: Vec::new(),
14041            tax_zones: Vec::new(),
14042            growth_zones: Vec::new(),
14043            biome_zones: Vec::new(),
14044            property_plots: Vec::new(),
14045            property_plot_settings: None,
14046            claim_mode: None,
14047            relocate_mode: None,
14048            sell_plot_confirm: None,
14049            sell_plot_armed_at: None,
14050            show_plant_menu: false,
14051            plant_menu_index: 0,
14052            show_farm_access: false,
14053            farm_access_name_draft: String::new(),
14054            farm_access_discount_bps: 0,
14055            farm_access_index: 0,
14056            plant_quantity: 1,
14057            in_combat: false,
14058            auto_attack: true,
14059            combat_has_los: false,
14060            attack_cd_ticks: 0,
14061            gcd_ticks: 0,
14062            weapon_ability_id: "unarmed".into(),
14063            mainhand_template_id: None,
14064            mainhand_label: None,
14065            offhand_template_id: None,
14066            offhand_label: None,
14067            mainhand_hand_slots: 1,
14068            defense: None,
14069            worn: BTreeMap::new(),
14070            carry_mass: 0.0,
14071            carry_mass_max: 0.0,
14072            encumbrance: flatland_protocol::EncumbranceState::Light,
14073            inventory_stacks: Vec::new(),
14074            keychain_stacks: Vec::new(),
14075            whisper_pouch_stacks: Vec::new(),
14076            combat_target_detail: None,
14077            statuses: Vec::new(),
14078            cast_progress: None,
14079            timed_channel: None,
14080            ability_cooldowns: Vec::new(),
14081            blocking_active: false,
14082            max_target_slots: 1,
14083            combat_slots: Vec::new(),
14084            rotation_presets: Vec::new(),
14085            known_abilities: Vec::new(),
14086            ability_meta: std::collections::HashMap::new(),
14087            hotbar: vec![None; 9],
14088            max_abilities_per_rotation: 0,
14089            show_loadout_menu: false,
14090            show_keychain_menu: false,
14091            keychain_menu_index: 0,
14092            show_rotation_editor: false,
14093            loadout_menu_index: 0,
14094            loadout_hotbar_slot: 1,
14095            loadout_ability_index: 0,
14096            loadout_focus_presets: false,
14097            rotation_editor: RotationEditorState::default(),
14098            harvest_in_progress: false,
14099            harvest_started_at: None,
14100            pending_craft_ack: None,
14101            pending_worker_job_ack: None,
14102            attending_worker_instance_id: None,
14103            quest_log: Vec::new(),
14104            interactables: Vec::new(),
14105            ledger: None,
14106            career: None,
14107            character_sheet_tab: CharacterSheetTab::Character,
14108            ledger_period: LedgerPeriod::Day,
14109            show_quest_offer: false,
14110            pending_quest_offer: None,
14111            show_quest_menu: false,
14112            quest_menu_index: 0,
14113            quest_withdraw_confirm: false,
14114            hired_workers: Vec::new(),
14115            show_workers_menu: false,
14116            workers_menu_index: 0,
14117            workers_menu_compact: false,
14118            worker_step_display: BTreeMap::new(),
14119            worker_error_display: BTreeMap::new(),
14120            show_worker_give_picker: false,
14121            worker_give_picker_index: 0,
14122            worker_give_picker: None,
14123            show_worker_give_target_picker: false,
14124            worker_give_target_picker_index: 0,
14125            worker_give_target_picker: None,
14126            show_worker_take_picker: false,
14127            worker_take_picker_index: 0,
14128            worker_take_picker: None,
14129            show_worker_teach_picker: false,
14130            worker_teach_picker_index: 0,
14131            worker_teach_picker: None,
14132            worker_route_editor: None,
14133            progression_curve: None,
14134        };
14135        state.player = state.entities.first().cloned();
14136        state
14137    }
14138
14139    #[test]
14140    fn whisper_cancels_when_peer_walks_out_of_range() {
14141        let mut state = sample_state();
14142        state.player = state.entities.first().cloned();
14143        let mut peer = state.entities[0].clone();
14144        peer.id = 2;
14145        peer.label = "Ada".into();
14146        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
14147        state.entities.push(peer.clone());
14148        state.social_chat.focus_whisper(2, "Ada");
14149        state.refresh_whisper_range();
14150        assert!(matches!(
14151            state.social_chat.thread,
14152            crate::social::ChatThreadKind::Whisper { peer: 2 }
14153        ));
14154
14155        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
14156        state.entities[1] = peer;
14157        state.refresh_whisper_range();
14158        assert_eq!(
14159            state.social_chat.thread,
14160            crate::social::ChatThreadKind::Nearby
14161        );
14162        assert!(!state.social_chat.input_focused);
14163    }
14164
14165    #[test]
14166    fn probe_use_world_hired_worker_manage() {
14167        let mut state = sample_state();
14168        state.hired_workers.push(flatland_protocol::HiredWorkerView {
14169            instance_id: "worker-1".into(),
14170            entity_id: 42,
14171            def_id: "worker_laborer".into(),
14172            label: "Sam".into(),
14173            x: 129.0,
14174            y: 128.0,
14175            z: 0.0,
14176            mode: flatland_protocol::WorkerModeView::JobLoop,
14177            state: flatland_protocol::WorkerStateView::Working,
14178            step_label: "cultivate".into(),
14179            vitals: flatland_protocol::WorkerVitalsSummary {
14180                health_pct: 100.0,
14181                stamina_pct: 100.0,
14182            },
14183            carry_pct: 0.0,
14184            last_error: None,
14185            wage_copper_per_interval: 1,
14186            effective_wage_copper: 1,
14187            wage_meters_walked: 0.0,
14188            lodging_container_id: None,
14189            route: None,
14190            route_stop_index: None,
14191            known_blueprint_ids: Vec::new(),
14192            level: 1,
14193            worker_xp: 0.0,
14194            inventory: Vec::new(),
14195        });
14196        let probe = state.probe_use_world();
14197        let primary = probe.primary.expect("primary");
14198        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
14199        assert_eq!(primary.id, "worker-1");
14200        assert!(primary.hint_line().contains("Manage"));
14201        assert!(primary.hint_line().contains("Sam"));
14202        assert_eq!(
14203            state.nearest_interact_target().as_deref(),
14204            Some("worker-1")
14205        );
14206    }
14207
14208    #[test]
14209    fn market_clerk_verb_options_include_market() {
14210        let mut state = sample_state();
14211        state.npcs.push(flatland_protocol::NpcView {
14212            id: "mira_market".into(),
14213            label: "Mira".into(),
14214            role: "market_clerk".into(),
14215            x: 129.0,
14216            y: 128.0,
14217            building_id: Some("town_market".into()),
14218            entity_id: None,
14219            life_state: None,
14220            hp_pct: None,
14221            can_trade: false,
14222            tile_id: None,
14223            behavior_state: None,
14224            presentation_state: None,
14225            sprite_mode: None,
14226            paperdoll_ref: None,
14227        });
14228        state.npc_verb_target = Some("mira_market".into());
14229        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
14230    }
14231
14232    #[test]
14233    fn market_list_excludes_currency_stacks() {
14234        let mut state = sample_state();
14235        state.inventory_stacks = vec![
14236            flatland_protocol::ItemStack {
14237                template_id: "copper_coin".into(),
14238                quantity: 50,
14239                item_instance_id: Some(uuid::Uuid::from_u128(10)),
14240                display_name: Some("Copper Coin".into()),
14241                ..Default::default()
14242            },
14243            flatland_protocol::ItemStack {
14244                template_id: "oak_log".into(),
14245                quantity: 2,
14246                item_instance_id: Some(uuid::Uuid::from_u128(11)),
14247                display_name: Some("Oak Log".into()),
14248                ..Default::default()
14249            },
14250            flatland_protocol::ItemStack {
14251                template_id: "whisper_stone".into(),
14252                quantity: 1,
14253                item_instance_id: Some(uuid::Uuid::from_u128(12)),
14254                display_name: Some("Whisper Stone".into()),
14255                category: Some("quest".into()),
14256                listable: Some(false),
14257                ..Default::default()
14258            },
14259        ];
14260        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
14261        assert_eq!(opts.len(), 1);
14262        assert!(opts[0].label.contains("Oak"));
14263    }
14264
14265    #[test]
14266    fn market_browse_filters_by_category_and_search() {
14267        let mut state = sample_state();
14268        state.market_panel = Some(flatland_protocol::MarketPanel {
14269            npc_id: "mira_market".into(),
14270            npc_label: "Mira".into(),
14271            building_id: "town_market".into(),
14272            building_label: "Town Market".into(),
14273            used_volume: 0.0,
14274            max_volume: 100.0,
14275            listings: vec![
14276                flatland_protocol::MarketListingView {
14277                    listing_id: uuid::Uuid::from_u128(1),
14278                    seller_character_id: uuid::Uuid::from_u128(2),
14279                    seller_label: "Ada".into(),
14280                    hall_building_id: "town_market".into(),
14281                    hall_label: "Town Market".into(),
14282                    template_id: "oak_log".into(),
14283                    display_name: "Oak Log".into(),
14284                    category: "resource".into(),
14285                    quantity: 3,
14286                    unit_price_copper: 10,
14287                    line_total_copper: 30,
14288                    mine: false,
14289                },
14290                flatland_protocol::MarketListingView {
14291                    listing_id: uuid::Uuid::from_u128(3),
14292                    seller_character_id: uuid::Uuid::from_u128(2),
14293                    seller_label: "Ada".into(),
14294                    hall_building_id: "town_market".into(),
14295                    hall_label: "Town Market".into(),
14296                    template_id: "short_sword".into(),
14297                    display_name: "Short Sword".into(),
14298                    category: "weapon".into(),
14299                    quantity: 1,
14300                    unit_price_copper: 100,
14301                    line_total_copper: 100,
14302                    mine: false,
14303                },
14304            ],
14305            tax_bps: 0,
14306            tax_flat_copper: 0,
14307            list_vaults: vec![],
14308        });
14309        assert_eq!(state.market_filtered_listing_indices().len(), 2);
14310        state.market_category_filter = Some("Weapons");
14311        let weapons = state.market_filtered_listing_indices();
14312        assert_eq!(weapons.len(), 1);
14313        assert_eq!(
14314            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
14315            "Short Sword"
14316        );
14317        state.market_category_filter = None;
14318        state.market_filter = "oak".into();
14319        let oak = state.market_filtered_listing_indices();
14320        assert_eq!(oak.len(), 1);
14321        assert_eq!(
14322            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
14323            "Oak Log"
14324        );
14325    }
14326
14327    #[test]
14328    fn market_list_source_includes_person_and_vaults() {
14329        let mut state = sample_state();
14330        let item_id = uuid::Uuid::from_u128(1);
14331        state.inventory_stacks = vec![flatland_protocol::ItemStack {
14332            template_id: "oak_log".into(),
14333            quantity: 2,
14334            item_instance_id: Some(item_id),
14335            display_name: Some("Oak Log".into()),
14336            ..Default::default()
14337        }];
14338        state.market_panel = Some(flatland_protocol::MarketPanel {
14339            npc_id: "mira_market".into(),
14340            npc_label: "Mira".into(),
14341            building_id: "town_market".into(),
14342            building_label: "Town Market".into(),
14343            used_volume: 0.0,
14344            max_volume: 100.0,
14345            listings: vec![],
14346            tax_bps: 0,
14347            tax_flat_copper: 0,
14348            list_vaults: vec![flatland_protocol::MarketListVault {
14349                building_id: "town_storage".into(),
14350                building_label: "Town Storage".into(),
14351                contents: vec![flatland_protocol::ItemStack {
14352                    template_id: "lumber".into(),
14353                    quantity: 1,
14354                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
14355                    display_name: Some("Lumber".into()),
14356                    ..Default::default()
14357                }],
14358            }],
14359        });
14360        let sources = state.market_list_source_options();
14361        assert_eq!(sources.len(), 2);
14362        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
14363        assert!(matches!(
14364            sources[1].0,
14365            MarketListSourceKind::TownStorage { .. }
14366        ));
14367        assert!(sources[1].1.contains("Town Storage"));
14368    }
14369
14370    #[test]
14371    fn probe_use_world_npc_beats_nearby_loot() {
14372        let mut state = sample_state();
14373        state.npcs.push(flatland_protocol::NpcView {
14374            id: "ada".into(),
14375            label: "Ada".into(),
14376            role: "broker".into(),
14377            x: 129.0,
14378            y: 128.0,
14379            building_id: None,
14380            entity_id: None,
14381            life_state: None,
14382            hp_pct: None,
14383            can_trade: true,
14384            tile_id: None,
14385            behavior_state: None,
14386            presentation_state: None,
14387            sprite_mode: None,
14388            paperdoll_ref: None,
14389        });
14390        state.ground_drops.push(flatland_protocol::GroundDropView {
14391            id: "d1".into(),
14392            template_id: "lumber".into(),
14393            quantity: 1,
14394            x: 128.5,
14395            y: 128.0,
14396            z: 0.0,
14397            tile_id: None,
14398            display_name: None,
14399            yaw: 0.0,
14400            pitch: 0.0,
14401            roll: 0.0,
14402            draw_scale: 1.0,
14403        });
14404        let probe = state.probe_use_world();
14405        let primary = probe.primary.expect("primary");
14406        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
14407        assert_eq!(primary.id, "ada");
14408    }
14409
14410    #[test]
14411    fn probe_use_world_harvest_when_in_range() {
14412        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
14413        let probe = state.probe_use_world();
14414        assert!(
14415            probe.primary.is_none(),
14416            "oak is 2m away, out of harvest range"
14417        );
14418        assert!(probe
14419            .candidates
14420            .iter()
14421            .any(|c| c.kind == crate::UseWorldKind::Harvest));
14422
14423        let mut state = sample_state();
14424        state.resource_nodes[0].x = 129.0;
14425        let probe = state.probe_use_world();
14426        let primary = probe.primary.expect("primary");
14427        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
14428    }
14429
14430    #[test]
14431    fn probe_use_world_door_uses_building_label() {
14432        let mut state = sample_state();
14433        state.doors[0].x = 129.0;
14434        state.doors[0].y = 128.0;
14435        let probe = state.probe_use_world();
14436        let primary = probe.primary.expect("primary");
14437        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
14438        assert_eq!(primary.label, "Broker");
14439        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
14440    }
14441
14442    #[test]
14443    fn empty_entity_tick_preserves_welcome_snapshot() {
14444        let mut state = sample_state();
14445        state.inventory.insert("carrot".into(), 3);
14446        let delta = TickDelta {
14447            tick: 1,
14448            entities: vec![],
14449            resource_nodes: vec![],
14450            ground_drops: vec![],
14451            placed_containers: vec![],
14452            buildings: vec![],
14453            doors: vec![],
14454            interior_map: None,
14455            npcs: vec![],
14456            inventory: vec![],
14457            blueprints: vec![],
14458            world_clock: flatland_protocol::WorldClock::default(),
14459            combat: None,
14460            quest_log: vec![],
14461            hired_workers: Vec::new(),
14462            interactables: vec![],
14463            ledger: None,
14464            career: None,
14465            combat_fx: Vec::new(),
14466            property_plots: Vec::new(),
14467        terrain_overlays: Vec::new(),
14468        };
14469
14470        state.apply_tick_fields(&delta, 1);
14471
14472        assert_eq!(state.entities.len(), 1);
14473        assert!(state.player.is_some());
14474        assert_eq!(state.inventory.get("carrot"), Some(&3));
14475        assert_eq!(state.resource_nodes.len(), 1);
14476    }
14477
14478    #[test]
14479    fn tick_preserves_world_layers_when_delta_omits_them() {
14480        let mut state = sample_state();
14481        let delta = TickDelta {
14482            tick: 1,
14483            entities: state.entities.clone(),
14484            resource_nodes: vec![],
14485            ground_drops: vec![],
14486            placed_containers: vec![],
14487            buildings: vec![],
14488            doors: vec![],
14489            interior_map: None,
14490            npcs: vec![],
14491            inventory: vec![],
14492            blueprints: vec![],
14493            world_clock: flatland_protocol::WorldClock::default(),
14494            combat: None,
14495            quest_log: vec![],
14496            hired_workers: Vec::new(),
14497            interactables: vec![],
14498            ledger: None,
14499            career: None,
14500            combat_fx: Vec::new(),
14501            property_plots: Vec::new(),
14502        terrain_overlays: Vec::new(),
14503        };
14504
14505        state.apply_tick_fields(&delta, 1);
14506
14507        assert_eq!(state.resource_nodes.len(), 1);
14508        assert_eq!(state.buildings.len(), 1);
14509        assert_eq!(state.doors.len(), 1);
14510    }
14511
14512    #[test]
14513    fn tick_updates_resource_nodes_when_server_sends_them() {
14514        let mut state = sample_state();
14515        let delta = TickDelta {
14516            tick: 1,
14517            entities: state.entities.clone(),
14518            resource_nodes: vec![ResourceNodeView {
14519                id: "oak-1".into(),
14520                label: "Oak".into(),
14521                x: 130.0,
14522                y: 128.0,
14523                z: 0.0,
14524                item_template: "oak_log".into(),
14525                state: ResourceNodeState::Cooldown,
14526                blocking: true,
14527                blocking_radius_m: 0.8,
14528                tile_id: None,
14529                                yaw: 0.0,
14530                pitch: 0.0,
14531                roll: 0.0,
14532                draw_scale: 1.0,
14533                sprite_mode: None,
14534                growth_progress: None,
14535                presentation_state: None,
14536                channel_start_tick: None,
14537                channel_end_tick: None,
14538                harvest_drop_templates: vec![],
14539            }],
14540            buildings: vec![],
14541            doors: vec![],
14542            interior_map: None,
14543            npcs: vec![],
14544            inventory: vec![],
14545            blueprints: vec![],
14546            world_clock: flatland_protocol::WorldClock::default(),
14547            ground_drops: vec![],
14548            placed_containers: vec![],
14549            combat: None,
14550            quest_log: vec![],
14551            hired_workers: Vec::new(),
14552            interactables: vec![],
14553            ledger: None,
14554            career: None,
14555            combat_fx: Vec::new(),
14556            property_plots: Vec::new(),
14557        terrain_overlays: Vec::new(),
14558        };
14559
14560        state.apply_tick_fields(&delta, 1);
14561
14562        assert!(matches!(
14563            state.resource_nodes[0].state,
14564            ResourceNodeState::Cooldown
14565        ));
14566    }
14567
14568    #[test]
14569    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
14570        let mut state = GameState {
14571            session_id: 1,
14572            entity_id: 1,
14573            character_id: None,
14574            tick: 0,
14575            chunk_rev: 0,
14576            content_rev: 0,
14577            publish_rev: 0,
14578            entities: vec![EntityState {
14579                id: 1,
14580                label: "You".into(),
14581                transform: Transform {
14582                    position: WorldCoord::surface(4.5, 2.0),
14583                    yaw: 0.0,
14584                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14585                },
14586                vitals: None,
14587                attributes: None,
14588                skills: None,
14589                inside_building: Some("broker_hut".into()),
14590                tile_id: None,
14591                paperdoll_ref: None,
14592                presentation_state: None,
14593                sprite_mode: None,
14594                progression_xp: None,
14595                combat_cues: vec![],
14596            }],
14597            player: None,
14598            resource_nodes: vec![],
14599            ground_drops: vec![],
14600            placed_containers: vec![],
14601            buildings: vec![BuildingView {
14602                id: "broker_hut".into(),
14603                label: "Broker".into(),
14604                x: 158.0,
14605                y: 124.0,
14606                width_m: 8.0,
14607                depth_m: 6.0,
14608                interior_blueprint: Some("broker_hut".into()),
14609                tags: vec![],
14610                market_boundary_zone_ids: vec![],
14611                market_max_volume: None,
14612                wall_set: None,
14613                roof_set: None,
14614            }],
14615            doors: vec![flatland_protocol::DoorView {
14616                id: "broker_hut_exit".into(),
14617                building_id: "broker_hut".into(),
14618                x: 4.3,
14619                y: 0.9,
14620                open: true,
14621                portal: Some("front".into()),
14622            }],
14623            interior_map: None,
14624            npcs: vec![flatland_protocol::NpcView {
14625                id: "ada_broker".into(),
14626                label: "Ada".into(),
14627                x: 4.5,
14628                y: 2.0,
14629                building_id: Some("broker_hut".into()),
14630                role: "broker".into(),
14631                entity_id: None,
14632                life_state: None,
14633                hp_pct: None,
14634                can_trade: true,
14635                tile_id: None,
14636                behavior_state: None,
14637                presentation_state: None,
14638                sprite_mode: None,
14639                paperdoll_ref: None,
14640            }],
14641            blueprints: vec![],
14642            world_x0: 0.0,
14643            world_y0: 0.0,
14644            world_width_m: 256.0,
14645            world_height_m: 256.0,
14646            terrain_zones: Vec::new(),
14647            z_platforms: Vec::new(),
14648            z_transitions: Vec::new(),
14649            z_bands_outdoor_backup: None,
14650            world_clock: flatland_protocol::WorldClock::default(),
14651            inventory: std::collections::HashMap::new(),
14652            inventory_hints: std::collections::HashMap::new(),
14653            logs: VecDeque::new(),
14654            intents_sent: 0,
14655            ticks_received: 0,
14656            connected: true,
14657            disconnect_reason: None,
14658            show_stats: false,
14659            hud_log_hidden: false,
14660            show_equip_menu: false,
14661            equip_menu_index: 0,
14662            show_craft_menu: false,
14663            craft_menu_index: 0,
14664            craft_batch_quantity: 1,
14665            show_shop_menu: false,
14666            shop_catalog: None,
14667            bank_panel: None,
14668            bank_menu_index: 0,
14669            bank_ui_mode: BankUiMode::Menu,
14670            storage_panel: None,
14671                market_panel: None,
14672            market_menu_index: 0,
14673            market_filter: String::new(),
14674            market_filter_focused: false,
14675            market_category_filter: None,
14676            market_buy_confirm: None,
14677            market_ui_mode: MarketUiMode::Browse,
14678            storage_menu_index: 0,
14679            storage_ui_mode: StorageUiMode::Menu,
14680            shop_tab: ShopTab::default(),
14681            shop_menu_index: 0,
14682            shop_quantity: 1,
14683            shop_trade_log: VecDeque::new(),
14684            show_npc_verb_menu: false,
14685            npc_verb_target: None,
14686            npc_verb_index: 0,
14687            player_verbs: crate::social::PlayerVerbState::default(),
14688            social_chat: crate::social::SocialChatState::default(),
14689            trade_ui: crate::social::TradeUiState::default(),
14690            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14691            show_npc_chat: false,
14692            npc_chat: None,
14693            show_inventory_menu: false,
14694            inventory_menu_index: 0,
14695            inventory_tab: InventoryTab::OnPerson,
14696            inventory_filter: String::new(),
14697            inventory_filter_focused: false,
14698            show_move_picker: false,
14699            show_rename_prompt: false,
14700            show_worker_rename: false,
14701            rename_buffer: String::new(),
14702            move_picker_index: 0,
14703            move_picker: None,
14704            show_grant_picker: false,
14705            grant_picker_index: 0,
14706            grant_picker: None,
14707            show_destroy_picker: false,
14708            destroy_confirm_pending: false,
14709            destroy_picker: None,
14710            combat_target: None,
14711            combat_target_label: None,
14712            ground_target: None,
14713            combat_fx: Vec::new(),
14714            property_zones: Vec::new(),
14715            tax_zones: Vec::new(),
14716            growth_zones: Vec::new(),
14717            biome_zones: Vec::new(),
14718            property_plots: Vec::new(),
14719            property_plot_settings: None,
14720            claim_mode: None,
14721            relocate_mode: None,
14722            sell_plot_confirm: None,
14723            sell_plot_armed_at: None,
14724            show_plant_menu: false,
14725            plant_menu_index: 0,
14726            show_farm_access: false,
14727            farm_access_name_draft: String::new(),
14728            farm_access_discount_bps: 0,
14729            farm_access_index: 0,
14730            plant_quantity: 1,
14731            in_combat: false,
14732            auto_attack: true,
14733            combat_has_los: false,
14734            attack_cd_ticks: 0,
14735            gcd_ticks: 0,
14736            weapon_ability_id: "unarmed".into(),
14737            mainhand_template_id: None,
14738            mainhand_label: None,
14739            offhand_template_id: None,
14740            offhand_label: None,
14741            mainhand_hand_slots: 1,
14742            defense: None,
14743            worn: BTreeMap::new(),
14744            carry_mass: 0.0,
14745            carry_mass_max: 0.0,
14746            encumbrance: flatland_protocol::EncumbranceState::Light,
14747            inventory_stacks: Vec::new(),
14748            keychain_stacks: Vec::new(),
14749            whisper_pouch_stacks: Vec::new(),
14750            combat_target_detail: None,
14751            statuses: Vec::new(),
14752            cast_progress: None,
14753            timed_channel: None,
14754            ability_cooldowns: Vec::new(),
14755            blocking_active: false,
14756            max_target_slots: 1,
14757            combat_slots: Vec::new(),
14758            rotation_presets: Vec::new(),
14759            known_abilities: Vec::new(),
14760            ability_meta: std::collections::HashMap::new(),
14761            hotbar: vec![None; 9],
14762            max_abilities_per_rotation: 0,
14763            show_loadout_menu: false,
14764            show_keychain_menu: false,
14765            keychain_menu_index: 0,
14766            show_rotation_editor: false,
14767            loadout_menu_index: 0,
14768            loadout_hotbar_slot: 1,
14769            loadout_ability_index: 0,
14770            loadout_focus_presets: false,
14771            rotation_editor: RotationEditorState::default(),
14772            harvest_in_progress: false,
14773            harvest_started_at: None,
14774            pending_craft_ack: None,
14775            pending_worker_job_ack: None,
14776            attending_worker_instance_id: None,
14777            quest_log: Vec::new(),
14778            interactables: Vec::new(),
14779            ledger: None,
14780            career: None,
14781            character_sheet_tab: CharacterSheetTab::Character,
14782            ledger_period: LedgerPeriod::Day,
14783            show_quest_offer: false,
14784            pending_quest_offer: None,
14785            show_quest_menu: false,
14786            quest_menu_index: 0,
14787            quest_withdraw_confirm: false,
14788            hired_workers: Vec::new(),
14789            show_workers_menu: false,
14790            workers_menu_index: 0,
14791            workers_menu_compact: false,
14792            worker_step_display: BTreeMap::new(),
14793            worker_error_display: BTreeMap::new(),
14794            show_worker_give_picker: false,
14795            worker_give_picker_index: 0,
14796            worker_give_picker: None,
14797            show_worker_give_target_picker: false,
14798            worker_give_target_picker_index: 0,
14799            worker_give_target_picker: None,
14800            show_worker_take_picker: false,
14801            worker_take_picker_index: 0,
14802            worker_take_picker: None,
14803            show_worker_teach_picker: false,
14804            worker_teach_picker_index: 0,
14805            worker_teach_picker: None,
14806            worker_route_editor: None,
14807            progression_curve: None,
14808        };
14809        state.player = state.entities.first().cloned();
14810        assert_eq!(
14811            state.nearest_interact_target().as_deref(),
14812            Some("ada_broker")
14813        );
14814    }
14815
14816    #[test]
14817    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
14818        let mut state = sample_state();
14819        // Player is at (128, 128) per sample_state(). One chest just inside
14820        // CONTAINER_RANGE_M, one clearly beyond it.
14821        state.placed_containers = vec![
14822            flatland_protocol::PlacedContainerView {
14823                id: "near".into(),
14824                template_id: "wooden_chest_small".into(),
14825                display_name: "Wooden Chest".into(),
14826                x: 130.0,
14827                y: 128.0,
14828                z: 0.0,
14829                locked: true,
14830                accessible: true,
14831                owner_character_id: None,
14832                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
14833                lock_id: None,
14834                capacity_volume: None,
14835                item_instance_id: Some(uuid::Uuid::from_u128(1)),
14836                tile_id: None,
14837                worker_lodging_capacity: None,
14838                blocking: false,
14839                blocking_radius_m: 0.0,
14840            },
14841            flatland_protocol::PlacedContainerView {
14842                id: "far".into(),
14843                template_id: "wooden_chest_small".into(),
14844                display_name: "Distant Chest".into(),
14845                x: 128.0 + CONTAINER_RANGE_M + 5.0,
14846                y: 128.0,
14847                z: 0.0,
14848                locked: false,
14849                accessible: true,
14850                owner_character_id: None,
14851                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
14852                lock_id: None,
14853                capacity_volume: None,
14854                item_instance_id: Some(uuid::Uuid::from_u128(2)),
14855                tile_id: None,
14856                worker_lodging_capacity: None,
14857                blocking: false,
14858                blocking_radius_m: 0.0,
14859            },
14860        ];
14861
14862        let nearby = state.nearby_containers();
14863        assert_eq!(
14864            nearby.len(),
14865            1,
14866            "far chest must not appear once out of range"
14867        );
14868        assert_eq!(nearby[0].view.id, "near");
14869        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
14870        assert!(nearby[0].rows[0].is_chest_shell);
14871
14872        // The same chest, but locked and inaccessible (no key held), must hide
14873        // contents but still show the selectable chest shell row.
14874        state.placed_containers[0].accessible = false;
14875        let nearby = state.nearby_containers();
14876        assert_eq!(nearby.len(), 1);
14877        assert_eq!(nearby[0].rows.len(), 1);
14878        assert!(nearby[0].rows[0].is_chest_shell);
14879    }
14880
14881    #[test]
14882    fn chest_pickup_destinations_offer_person_and_worn_bag() {
14883        let mut state = sample_state();
14884        let back_id = uuid::Uuid::from_u128(42);
14885        state.worn.insert(
14886            BodySlot::Back,
14887            flatland_protocol::ItemStack {
14888                template_id: "travel_backpack".into(),
14889                quantity: 1,
14890                item_instance_id: Some(back_id),
14891                props: Default::default(),
14892                status_bindings: Vec::new(),
14893                contents: Vec::new(),
14894                display_name: Some("Travel Backpack".into()),
14895                category: Some("container".into()),
14896                base_mass: Some(2.5),
14897                base_volume: Some(12.0),
14898                capacity_volume: Some(80.0),
14899                stackable: Some(false),
14900                world_placeable: Some(false),
14901                worker_lodging_capacity: None,
14902                equip_slot: None,
14903                armor_physical: None,
14904                resists: vec![],
14905                hand_slots: None,
14906            listable: None,
14907        },
14908        );
14909        let opts = state.chest_pickup_destinations("chest-1");
14910        assert!(matches!(
14911            opts.first().map(|o| &o.kind),
14912            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
14913        ));
14914        assert!(opts.iter().any(|o| matches!(
14915            &o.kind,
14916            MoveOptionKind::PickupPlaced {
14917                nest_parent_instance_id: None,
14918                ..
14919            }
14920        )));
14921        assert!(opts.iter().any(|o| matches!(
14922            &o.kind,
14923            MoveOptionKind::PickupPlaced {
14924                nest_parent_instance_id: Some(id),
14925                ..
14926            } if *id == back_id
14927        )));
14928        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14929    }
14930
14931    #[test]
14932    fn placed_container_public_label_hides_owner_custom_name() {
14933        let owner = uuid::Uuid::from_u128(99);
14934        let mut state = sample_state();
14935        state.character_id = Some(uuid::Uuid::from_u128(1));
14936        state.inventory_hints.insert(
14937            "wooden_chest_medium".into(),
14938            InventoryHint {
14939                display_name: "Medium Wooden Chest".into(),
14940                category: "container".into(),
14941                base_mass: None,
14942                base_volume: None,
14943                capacity_volume: None,
14944                stackable: false,
14945                listable: true,
14946            },
14947        );
14948        let chest = flatland_protocol::PlacedContainerView {
14949            id: "c1".into(),
14950            template_id: "wooden_chest_medium".into(),
14951            display_name: "Barry's Loot #a3f2".into(),
14952            x: 128.0,
14953            y: 128.0,
14954            z: 0.0,
14955            locked: false,
14956            accessible: true,
14957            owner_character_id: Some(owner),
14958            contents: vec![],
14959            lock_id: None,
14960            capacity_volume: None,
14961            item_instance_id: None,
14962            tile_id: None,
14963            worker_lodging_capacity: None,
14964            blocking: false,
14965            blocking_radius_m: 0.0,
14966        };
14967        assert_eq!(
14968            state.placed_container_public_label(&chest),
14969            "Medium Wooden Chest"
14970        );
14971        state.character_id = Some(owner);
14972        assert_eq!(
14973            state.placed_container_public_label(&chest),
14974            "Barry's Loot #a3f2"
14975        );
14976    }
14977
14978    #[test]
14979    fn location_context_shows_crop_growth_percent_not_depleted() {
14980        let mut state = sample_state();
14981        state.player = state.entities.first().cloned();
14982        state.resource_nodes[0].label = "Carrot (growing)".into();
14983        state.resource_nodes[0].x = 128.2;
14984        state.resource_nodes[0].y = 128.0;
14985        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
14986        state.resource_nodes[0].growth_progress = Some(0.47);
14987        let lines = state.location_context_lines();
14988        let line = lines
14989            .iter()
14990            .find(|l| l.text.contains("Carrot"))
14991            .map(|l| l.text.as_str())
14992            .unwrap_or("");
14993        assert!(
14994            line.contains("(growing, 47%)"),
14995            "expected growth percent, got: {line}"
14996        );
14997        assert!(
14998            !line.contains("depleted"),
14999            "growing crop should not show depleted: {line}"
15000        );
15001    }
15002
15003    #[test]
15004    fn resource_node_near_action_suffix_prefers_growth() {
15005        let node = ResourceNodeView {
15006            id: "crop".into(),
15007            label: "Wheat".into(),
15008            x: 0.0,
15009            y: 0.0,
15010            z: 0.0,
15011            item_template: "wheat".into(),
15012            state: ResourceNodeState::Cooldown,
15013            blocking: false,
15014            blocking_radius_m: 0.0,
15015            tile_id: None,
15016                        yaw: 0.0,
15017            pitch: 0.0,
15018            roll: 0.0,
15019            draw_scale: 1.0,
15020            sprite_mode: None,
15021            growth_progress: Some(0.12),
15022            presentation_state: None,
15023            channel_start_tick: None,
15024            channel_end_tick: None,
15025            harvest_drop_templates: vec![],
15026        };
15027        assert_eq!(
15028            resource_node_near_action_suffix(&node),
15029            " (growing, 12%)"
15030        );
15031    }
15032
15033    #[test]
15034    fn location_context_lists_nearby_resource_node() {
15035        let mut state = sample_state();
15036        state.player = state.entities.first().cloned();
15037        state.resource_nodes[0].x = 128.2;
15038        state.resource_nodes[0].y = 128.0;
15039        let lines = state.location_context_lines();
15040        assert!(
15041            lines
15042                .iter()
15043                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
15044            "expected resource node in context: {:?}",
15045            lines
15046        );
15047    }
15048
15049    #[test]
15050    fn quest_board_usable_within_board_radius() {
15051        let mut state = sample_state();
15052        state.player = state.entities.first().cloned();
15053        state.interactables = vec![flatland_protocol::InteractableView {
15054            id: "board-1".into(),
15055            kind: "quest_board".into(),
15056            label: "Town Quest Board".into(),
15057            x: 130.5,
15058            y: 128.0,
15059            z: 0.0,
15060            board_id: Some("starter_town_board".into()),
15061        }];
15062        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
15063        assert_eq!(
15064            state.nearest_interact_target().as_deref(),
15065            Some("board-1"),
15066            "quest board should be selectable at ~2.5m"
15067        );
15068        let lines = state.location_context_lines();
15069        assert!(
15070            lines
15071                .iter()
15072                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
15073            "HUD should advertise f when board is in range: {:?}",
15074            lines
15075        );
15076    }
15077
15078    #[test]
15079    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
15080        let mut state = sample_state();
15081        state.worn.insert(
15082            BodySlot::Back,
15083            flatland_protocol::ItemStack {
15084                template_id: "travel_backpack".into(),
15085                quantity: 1,
15086                item_instance_id: Some(uuid::Uuid::from_u128(3)),
15087                props: Default::default(),
15088                status_bindings: Vec::new(),
15089                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
15090                display_name: None,
15091                category: None,
15092                base_mass: None,
15093                base_volume: None,
15094                capacity_volume: None,
15095                stackable: None,
15096                world_placeable: None,
15097                worker_lodging_capacity: None,
15098                equip_slot: None,
15099                armor_physical: None,
15100                resists: vec![],
15101                hand_slots: None,
15102            listable: None,
15103        },
15104        );
15105        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
15106        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15107            id: "chest-1".into(),
15108            template_id: "wooden_chest_small".into(),
15109            display_name: "Wooden Chest".into(),
15110            x: 129.0,
15111            y: 128.0,
15112            z: 0.0,
15113            locked: false,
15114            accessible: true,
15115            owner_character_id: None,
15116            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
15117            lock_id: None,
15118            capacity_volume: None,
15119            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15120            tile_id: None,
15121            worker_lodging_capacity: None,
15122            blocking: false,
15123            blocking_radius_m: 0.0,
15124        }];
15125
15126        state.inventory_tab = InventoryTab::OnPerson;
15127        let rows = state.inventory_selectable_rows();
15128        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15129        assert_eq!(
15130            sections,
15131            vec![
15132                InventorySection::Worn,   // backpack shell
15133                InventorySection::Worn,   // iron_ore nested in backpack
15134                InventorySection::Person, // lumber
15135            ]
15136        );
15137        assert_eq!(rows[0].stack.template_id, "travel_backpack");
15138        assert!(rows[0].is_equip_shell);
15139        assert_eq!(rows[1].stack.template_id, "iron_ore");
15140        assert_eq!(rows[1].depth, 1);
15141        assert_eq!(rows[2].stack.template_id, "lumber");
15142
15143        let lines = state.inventory_browser_lines();
15144        assert!(lines.iter().any(|l| matches!(
15145            l,
15146            InventoryBrowserLine::Section(s) if s.contains("Worn")
15147        )));
15148        assert!(lines.iter().any(|l| matches!(
15149            l,
15150            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15151                || text.contains("backpack")
15152        )));
15153        assert!(!lines.iter().any(|l| matches!(
15154            l,
15155            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15156        )));
15157
15158        state.inventory_tab = InventoryTab::Nearby;
15159        let nearby_rows = state.inventory_selectable_rows();
15160        assert_eq!(nearby_rows.len(), 2);
15161        assert!(nearby_rows[0].is_chest_shell);
15162        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15163        let nearby_lines = state.inventory_browser_lines();
15164        assert!(nearby_lines.iter().any(|l| matches!(
15165            l,
15166            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15167        )));
15168    }
15169
15170    #[test]
15171    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
15172        let mut state = sample_state();
15173        let back_id = uuid::Uuid::from_u128(5);
15174        state.worn.insert(
15175            BodySlot::Back,
15176            flatland_protocol::ItemStack {
15177                template_id: "travel_backpack".into(),
15178                quantity: 1,
15179                item_instance_id: Some(back_id),
15180                props: Default::default(),
15181                status_bindings: Vec::new(),
15182                contents: Vec::new(),
15183                display_name: None,
15184                category: Some("container".into()),
15185                base_mass: None,
15186                base_volume: None,
15187                capacity_volume: Some(80.0),
15188                stackable: None,
15189                world_placeable: None,
15190                worker_lodging_capacity: None,
15191                equip_slot: None,
15192                armor_physical: None,
15193                resists: vec![],
15194                hand_slots: None,
15195            listable: None,
15196        },
15197        );
15198        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15199            id: "chest-1".into(),
15200            template_id: "wooden_chest_small".into(),
15201            display_name: "Wooden Chest".into(),
15202            x: 129.0,
15203            y: 128.0,
15204            z: 0.0,
15205            locked: false,
15206            accessible: true,
15207            owner_character_id: None,
15208            contents: Vec::new(),
15209            lock_id: None,
15210            capacity_volume: None,
15211            item_instance_id: Some(uuid::Uuid::from_u128(6)),
15212            tile_id: None,
15213            worker_lodging_capacity: None,
15214            blocking: false,
15215            blocking_radius_m: 0.0,
15216        }];
15217
15218        // Item currently sitting loose on the person (Root): backpack + nearby
15219        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
15220        let opts = state.move_destinations_for(
15221            &flatland_protocol::InventoryLocation::Root,
15222            None,
15223            None,
15224            "lumber",
15225        );
15226        assert!(!opts.iter().any(|o| matches!(
15227            &o.kind,
15228            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15229        )));
15230        assert!(opts.iter().any(|o| matches!(
15231            &o.kind,
15232            MoveOptionKind::Move { location, parent_instance_id, .. }
15233                if *location == flatland_protocol::InventoryLocation::Worn {
15234                    slot: BodySlot::Back,
15235                } && *parent_instance_id == Some(back_id)
15236        )));
15237        assert!(opts.iter().any(|o| matches!(
15238            &o.kind,
15239            MoveOptionKind::Move { location, .. }
15240                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
15241        )));
15242        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15243        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
15244
15245        // Item currently inside the worn backpack: the backpack itself must be
15246        // excluded from its own destination list (can't move an item into the
15247        // container it's already in).
15248        let from_backpack = flatland_protocol::InventoryLocation::Worn {
15249            slot: BodySlot::Back,
15250        };
15251        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
15252        assert!(!opts.iter().any(|o| matches!(
15253            &o.kind,
15254            MoveOptionKind::Move { location, parent_instance_id, .. }
15255                if *location == from_backpack && *parent_instance_id == Some(back_id)
15256        )));
15257        assert!(opts.iter().any(|o| matches!(
15258            &o.kind,
15259            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15260        )));
15261    }
15262
15263    #[test]
15264    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
15265        let mut state = sample_state();
15266        // Insert out of display order — BTreeMap iteration must still yield the
15267        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
15268        state.worn.insert(
15269            BodySlot::Waist,
15270            flatland_protocol::ItemStack {
15271                template_id: "simple_belt".into(),
15272                quantity: 1,
15273                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15274                props: Default::default(),
15275                status_bindings: Vec::new(),
15276                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
15277                display_name: None,
15278                category: Some("container".into()),
15279                base_mass: None,
15280                base_volume: None,
15281                capacity_volume: None,
15282                stackable: None,
15283                world_placeable: None,
15284                worker_lodging_capacity: None,
15285                equip_slot: None,
15286                armor_physical: None,
15287                resists: vec![],
15288                hand_slots: None,
15289            listable: None,
15290        },
15291        );
15292        state.worn.insert(
15293            BodySlot::Head,
15294            flatland_protocol::ItemStack {
15295                template_id: "cloth_cap".into(),
15296                quantity: 1,
15297                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15298                props: Default::default(),
15299                status_bindings: Vec::new(),
15300                contents: Vec::new(),
15301                display_name: None,
15302                category: Some("armor".into()),
15303                base_mass: None,
15304                base_volume: None,
15305                capacity_volume: None,
15306                stackable: None,
15307                world_placeable: None,
15308                worker_lodging_capacity: None,
15309                equip_slot: None,
15310                armor_physical: None,
15311                resists: vec![],
15312                hand_slots: None,
15313            listable: None,
15314        },
15315        );
15316        state.worn.insert(
15317            BodySlot::Back,
15318            flatland_protocol::ItemStack {
15319                template_id: "travel_backpack".into(),
15320                quantity: 1,
15321                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15322                props: Default::default(),
15323                status_bindings: Vec::new(),
15324                contents: Vec::new(),
15325                display_name: None,
15326                category: Some("container".into()),
15327                base_mass: None,
15328                base_volume: None,
15329                capacity_volume: None,
15330                stackable: None,
15331                world_placeable: None,
15332                worker_lodging_capacity: None,
15333                equip_slot: None,
15334                armor_physical: None,
15335                resists: vec![],
15336                hand_slots: None,
15337            listable: None,
15338        },
15339        );
15340
15341        let rows = state.worn_rows();
15342        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
15343        assert_eq!(rows.len(), 4);
15344        assert_eq!(rows[0].stack.template_id, "cloth_cap");
15345        assert!(rows[0].is_equip_shell);
15346        assert_eq!(rows[1].stack.template_id, "travel_backpack");
15347        assert!(rows[1].is_equip_shell);
15348        assert_eq!(rows[2].stack.template_id, "simple_belt");
15349        assert!(rows[2].is_equip_shell);
15350        assert_eq!(rows[3].stack.template_id, "leather_pouch");
15351        assert_eq!(rows[3].depth, 1);
15352        assert!(!rows[3].is_equip_shell);
15353    }
15354
15355    #[test]
15356    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
15357        let mut state = sample_state();
15358        state.worn.insert(
15359            BodySlot::Waist,
15360            flatland_protocol::ItemStack {
15361                template_id: "simple_belt".into(),
15362                quantity: 1,
15363                item_instance_id: Some(uuid::Uuid::from_u128(20)),
15364                props: Default::default(),
15365                status_bindings: Vec::new(),
15366                contents: Vec::new(),
15367                display_name: Some("Simple Belt".into()),
15368                category: Some("container".into()),
15369                base_mass: None,
15370                base_volume: None,
15371                capacity_volume: None,
15372                stackable: None,
15373                world_placeable: None,
15374                worker_lodging_capacity: None,
15375                equip_slot: None,
15376                armor_physical: None,
15377                resists: vec![],
15378                hand_slots: None,
15379            listable: None,
15380        },
15381        );
15382        state.worn.insert(
15383            BodySlot::Head,
15384            flatland_protocol::ItemStack {
15385                template_id: "cloth_cap".into(),
15386                quantity: 1,
15387                item_instance_id: Some(uuid::Uuid::from_u128(21)),
15388                props: Default::default(),
15389                status_bindings: Vec::new(),
15390                contents: Vec::new(),
15391                display_name: Some("Cloth Cap".into()),
15392                category: Some("armor".into()),
15393                base_mass: None,
15394                base_volume: None,
15395                capacity_volume: None,
15396                stackable: None,
15397                world_placeable: None,
15398                worker_lodging_capacity: None,
15399                equip_slot: None,
15400                armor_physical: None,
15401                resists: vec![],
15402                hand_slots: None,
15403            listable: None,
15404        },
15405        );
15406
15407        let opts = state.move_destinations_for(
15408            &flatland_protocol::InventoryLocation::Root,
15409            None,
15410            None,
15411            "leather_pouch",
15412        );
15413        assert!(
15414            opts.iter().any(|o| matches!(
15415                &o.kind,
15416                MoveOptionKind::Move { location, .. }
15417                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15418            )),
15419            "belt loop must be offered when moving a pouch"
15420        );
15421        assert!(
15422            !opts.iter().any(|o| matches!(
15423                &o.kind,
15424                MoveOptionKind::Move { location, .. }
15425                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
15426            )),
15427            "armor slots can't hold other items and must not appear as move destinations"
15428        );
15429        let belt_opt = opts
15430            .iter()
15431            .find(|o| matches!(
15432                &o.kind,
15433                MoveOptionKind::Move { location, .. }
15434                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15435            ))
15436            .unwrap();
15437        assert!(belt_opt.label.contains("belt loop"));
15438
15439        let opts = state.move_destinations_for(
15440            &flatland_protocol::InventoryLocation::Root,
15441            None,
15442            None,
15443            "lumber",
15444        );
15445        assert!(
15446            !opts.iter().any(|o| o.label.contains("belt loop")),
15447            "loose materials must not target the belt shell — only nested pouches"
15448        );
15449    }
15450
15451    #[test]
15452    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
15453        let mut state = sample_state();
15454        let belt_id = uuid::Uuid::from_u128(30);
15455        let pouch_id = uuid::Uuid::from_u128(31);
15456        state.worn.insert(
15457            BodySlot::Waist,
15458            flatland_protocol::ItemStack {
15459                template_id: "simple_belt".into(),
15460                quantity: 1,
15461                item_instance_id: Some(belt_id),
15462                props: Default::default(),
15463                status_bindings: Vec::new(),
15464                world_placeable: None,
15465                worker_lodging_capacity: None,
15466                equip_slot: None,
15467                armor_physical: None,
15468                resists: vec![],
15469                hand_slots: None,
15470                contents: vec![flatland_protocol::ItemStack {
15471                    template_id: "dimensional_pouch".into(),
15472                    quantity: 1,
15473                    item_instance_id: Some(pouch_id),
15474                    props: Default::default(),
15475                    status_bindings: Vec::new(),
15476                    contents: Vec::new(),
15477                    display_name: Some("Dimensional Pouch".into()),
15478                    category: Some("container".into()),
15479                    base_mass: None,
15480                    base_volume: None,
15481                    capacity_volume: Some(200.0),
15482                    stackable: None,
15483                    world_placeable: None,
15484                    worker_lodging_capacity: None,
15485                    equip_slot: None,
15486                    armor_physical: None,
15487                    resists: vec![],
15488                    hand_slots: None,
15489            listable: None,
15490        }],
15491                display_name: Some("Simple Belt".into()),
15492                category: Some("container".into()),
15493                base_mass: None,
15494                base_volume: None,
15495                capacity_volume: None,
15496                stackable: None,
15497            listable: None,
15498        },
15499        );
15500
15501        let opts = state.move_destinations_for(
15502            &flatland_protocol::InventoryLocation::Root,
15503            None,
15504            None,
15505            "iron_ore",
15506        );
15507        assert!(
15508            opts.iter().any(|o| matches!(
15509                &o.kind,
15510                MoveOptionKind::Move {
15511                    location,
15512                    parent_instance_id,
15513                    ..
15514                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15515                    && *parent_instance_id == Some(pouch_id)
15516            )),
15517            "dimensional pouch clipped on belt must accept loose items"
15518        );
15519        assert!(
15520            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
15521            "destination label should name the pouch"
15522        );
15523    }
15524
15525    #[test]
15526    fn container_volume_label_on_placed_chest_shell() {
15527        let mut state = sample_state();
15528        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15529            id: "chest-1".into(),
15530            template_id: "wooden_chest_small".into(),
15531            display_name: "Camp Chest".into(),
15532            x: 129.0,
15533            y: 128.0,
15534            z: 0.0,
15535            locked: false,
15536            accessible: true,
15537            owner_character_id: None,
15538            contents: vec![flatland_protocol::ItemStack {
15539                template_id: "iron_ore".into(),
15540                quantity: 2,
15541                item_instance_id: None,
15542                props: Default::default(),
15543                status_bindings: Vec::new(),
15544                contents: Vec::new(),
15545                display_name: None,
15546                category: None,
15547                base_mass: None,
15548                base_volume: Some(2.0),
15549                capacity_volume: None,
15550                stackable: None,
15551                world_placeable: None,
15552                worker_lodging_capacity: None,
15553                equip_slot: None,
15554                armor_physical: None,
15555                resists: vec![],
15556                hand_slots: None,
15557            listable: None,
15558        }],
15559            lock_id: None,
15560            capacity_volume: Some(60.0),
15561            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15562            tile_id: None,
15563            worker_lodging_capacity: None,
15564            blocking: false,
15565            blocking_radius_m: 0.0,
15566        }];
15567        let nearby = state.nearby_containers();
15568        let label = state.container_volume_label(&nearby[0].rows[0]);
15569        assert!(
15570            label.contains("vol 4/60"),
15571            "expected used/cap in label, got {label}"
15572        );
15573        assert!(
15574            label.contains("56 free"),
15575            "expected free space, got {label}"
15576        );
15577    }
15578
15579    #[test]
15580    fn key_pair_chest_label_from_placed_lock_id() {
15581        let mut state = sample_state();
15582        let owner = uuid::Uuid::from_u128(77);
15583        state.character_id = Some(owner);
15584        let lock = uuid::Uuid::from_u128(99).to_string();
15585        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15586            id: "chest-1".into(),
15587            template_id: "wooden_chest_small".into(),
15588            display_name: "Barry's Loot #a3f2".into(),
15589            x: 129.0,
15590            y: 128.0,
15591            z: 0.0,
15592            locked: true,
15593            accessible: true,
15594            owner_character_id: Some(owner),
15595            contents: Vec::new(),
15596            lock_id: Some(lock.clone()),
15597            capacity_volume: None,
15598            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15599            tile_id: None,
15600            worker_lodging_capacity: None,
15601            blocking: false,
15602            blocking_radius_m: 0.0,
15603        }];
15604        let key_id = uuid::Uuid::from_u128(5);
15605        let key = flatland_protocol::ItemStack {
15606            template_id: KEY_TEMPLATE.into(),
15607            quantity: 1,
15608            item_instance_id: Some(key_id),
15609            props: BTreeMap::from([
15610                (PROP_OPENS_LOCK_ID.into(), lock),
15611                (
15612                    PROP_OPENS_CONTAINER_NAME.into(),
15613                    "Barry's Loot #a3f2".into(),
15614                ),
15615            ]),
15616            status_bindings: Vec::new(),
15617            contents: Vec::new(),
15618            display_name: Some("Container Key".into()),
15619            category: Some("key".into()),
15620            base_mass: None,
15621            base_volume: None,
15622            capacity_volume: None,
15623            stackable: None,
15624            world_placeable: None,
15625            worker_lodging_capacity: None,
15626            equip_slot: None,
15627            armor_physical: None,
15628            resists: vec![],
15629            hand_slots: None,
15630            listable: None,
15631        };
15632        state.inventory_stacks = vec![key.clone()];
15633        assert_eq!(
15634            state.key_pair_chest_label(&key).as_deref(),
15635            Some("Barry's Loot #a3f2")
15636        );
15637        assert!(state.key_drop_blocked(&key));
15638    }
15639
15640    #[test]
15641    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
15642        let mut state = sample_state();
15643        let lock = uuid::Uuid::from_u128(101).to_string();
15644        let key = flatland_protocol::ItemStack {
15645            template_id: KEY_TEMPLATE.into(),
15646            quantity: 1,
15647            item_instance_id: Some(uuid::Uuid::from_u128(7)),
15648            props: BTreeMap::from([
15649                (PROP_OPENS_LOCK_ID.into(), lock),
15650                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
15651            ]),
15652            status_bindings: Vec::new(),
15653            contents: Vec::new(),
15654            display_name: None,
15655            category: Some("key".into()),
15656            base_mass: None,
15657            base_volume: None,
15658            capacity_volume: None,
15659            stackable: None,
15660            world_placeable: None,
15661            worker_lodging_capacity: None,
15662            equip_slot: None,
15663            armor_physical: None,
15664            resists: vec![],
15665            hand_slots: None,
15666            listable: None,
15667        };
15668        state.placed_containers.clear();
15669        assert_eq!(
15670            state.key_pair_chest_label(&key).as_deref(),
15671            Some("Camp Stash")
15672        );
15673    }
15674
15675    #[test]
15676    fn key_drop_allowed_when_paired_chest_unlocked() {
15677        let mut state = sample_state();
15678        let lock = uuid::Uuid::from_u128(100).to_string();
15679        let key_id = uuid::Uuid::from_u128(6);
15680        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15681            id: "chest-1".into(),
15682            template_id: "wooden_chest_small".into(),
15683            display_name: "Camp Chest".into(),
15684            x: 129.0,
15685            y: 128.0,
15686            z: 0.0,
15687            locked: false,
15688            accessible: true,
15689            owner_character_id: None,
15690            contents: Vec::new(),
15691            lock_id: Some(lock.clone()),
15692            capacity_volume: None,
15693            item_instance_id: None,
15694            tile_id: None,
15695            worker_lodging_capacity: None,
15696            blocking: false,
15697            blocking_radius_m: 0.0,
15698        }];
15699        let key = flatland_protocol::ItemStack {
15700            template_id: KEY_TEMPLATE.into(),
15701            quantity: 1,
15702            item_instance_id: Some(key_id),
15703            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
15704            status_bindings: Vec::new(),
15705            contents: Vec::new(),
15706            display_name: None,
15707            category: Some("key".into()),
15708            base_mass: None,
15709            base_volume: None,
15710            capacity_volume: None,
15711            stackable: None,
15712            world_placeable: None,
15713            worker_lodging_capacity: None,
15714                equip_slot: None,
15715                armor_physical: None,
15716                resists: vec![],
15717                hand_slots: None,
15718            listable: None,
15719        };
15720        state.inventory_stacks = vec![key.clone()];
15721        assert!(!state.key_drop_blocked(&key));
15722        let opts = state.move_destinations_for(
15723            &flatland_protocol::InventoryLocation::Root,
15724            None,
15725            Some(key_id),
15726            KEY_TEMPLATE,
15727        );
15728        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
15729    }
15730
15731    #[test]
15732    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
15733        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
15734
15735        let mut state = sample_state();
15736        let curve = ProgressionCurve::default();
15737        let bootstrap = ProgressionXp::bootstrap_new(
15738            curve.baseline_display,
15739            curve.xp_base,
15740            curve.xp_growth,
15741        );
15742        let mut fresh = bootstrap.clone();
15743        fresh.strength += 0.08;
15744        if let Some(player) = state.player.as_mut() {
15745            player.progression_xp = Some(bootstrap);
15746        }
15747
15748        let combat = CombatHud {
15749            progression_xp: Some(fresh.clone()),
15750            progression_baseline: curve.baseline_display,
15751            progression_xp_base: curve.xp_base,
15752            progression_xp_growth: curve.xp_growth,
15753            attributes: state.player.as_ref().and_then(|p| p.attributes),
15754            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
15755            ..CombatHud::default()
15756        };
15757        state.apply_combat_hud(&combat);
15758
15759        let xp = state
15760            .player
15761            .as_ref()
15762            .and_then(|p| p.progression_xp.as_ref())
15763            .expect("xp");
15764        assert!((xp.strength - fresh.strength).abs() < 0.001);
15765        assert!(state.progression_curve.is_some());
15766    }
15767
15768    #[test]
15769    fn combat_hud_syncs_known_abilities_and_hotbar() {
15770        use flatland_protocol::CombatHud;
15771
15772        let mut state = sample_state();
15773        let combat = CombatHud {
15774            known_abilities: vec!["unarmed".into(), "fireball".into()],
15775            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
15776            max_abilities_per_rotation: 4,
15777            ability_id: "short_sword_slash".into(),
15778            ..CombatHud::default()
15779        };
15780        state.apply_combat_hud(&combat);
15781
15782        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
15783        assert_eq!(state.hotbar_ability(1), Some("fireball"));
15784        assert_eq!(state.hotbar_ability(2), None);
15785        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
15786        assert_eq!(state.max_abilities_per_rotation, 4);
15787        let choices = state.loadout_ability_choices();
15788        assert!(choices.iter().any(|a| a == "short_sword_slash"));
15789        assert!(choices.iter().any(|a| a == "fireball"));
15790    }
15791
15792    #[test]
15793    fn loadout_hotbar_choices_include_inventory_consumables() {
15794        let mut state = sample_state();
15795        state.known_abilities = vec!["unarmed".into()];
15796        state.weapon_ability_id = "unarmed".into();
15797        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15798            template_id: "bottle_of_water".into(),
15799            quantity: 3,
15800            item_instance_id: Some(uuid::Uuid::from_u128(9)),
15801            display_name: Some("Bottle of Water".into()),
15802            category: Some("consumable".into()),
15803            ..Default::default()
15804        }];
15805        state.inventory.insert("bottle_of_water".into(), 3);
15806        state.inventory_hints.insert(
15807            "bottle_of_water".into(),
15808            InventoryHint {
15809                display_name: "Bottle of Water".into(),
15810                category: "consumable".into(),
15811                ..Default::default()
15812            },
15813        );
15814
15815        let choices = state.loadout_hotbar_choices();
15816        assert!(choices.iter().any(|c| c.binding == "unarmed"));
15817        let water = choices
15818            .iter()
15819            .find(|c| c.binding == "item:bottle_of_water")
15820            .expect("water binding");
15821        assert_eq!(water.meta.as_deref(), Some("use"));
15822        assert!(water.label.contains("Water"));
15823        assert_eq!(
15824            state.hotbar_slot_label(1),
15825            None,
15826            "unbound until set"
15827        );
15828        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
15829        assert_eq!(
15830            state.hotbar_slot_label(5).as_deref(),
15831            Some("Bottle of Water×3")
15832        );
15833    }
15834
15835    #[test]
15836    fn loose_consumable_move_picker_offers_use_and_storage() {
15837        let mut state = sample_state();
15838        let inst = uuid::Uuid::from_u128(77);
15839        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15840            template_id: "carrot".into(),
15841            quantity: 2,
15842            item_instance_id: Some(inst),
15843            props: Default::default(),
15844            status_bindings: Vec::new(),
15845            contents: Vec::new(),
15846            display_name: Some("Wild Carrot".into()),
15847            category: Some("consumable".into()),
15848            base_mass: None,
15849            base_volume: None,
15850            capacity_volume: None,
15851            stackable: Some(true),
15852            world_placeable: None,
15853            worker_lodging_capacity: None,
15854                equip_slot: None,
15855                armor_physical: None,
15856                resists: vec![],
15857                hand_slots: None,
15858            listable: None,
15859        }];
15860        state.inventory_hints.insert(
15861            "carrot".into(),
15862            InventoryHint {
15863                display_name: "Wild Carrot".into(),
15864                category: "consumable".into(),
15865                base_mass: Some(0.15),
15866                base_volume: Some(0.3),
15867                capacity_volume: None,
15868                stackable: true,
15869                listable: true,
15870            },
15871        );
15872        state.show_inventory_menu = true;
15873        state.inventory_menu_index = 0;
15874
15875        let row = state.inventory_selected_row().expect("carrot row");
15876        let mut options = state.move_destinations_for(
15877            &row.from,
15878            row.from_parent_instance_id,
15879            row.stack.item_instance_id,
15880            &row.stack.template_id,
15881        );
15882        if row.from == flatland_protocol::InventoryLocation::Root
15883            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
15884        {
15885            options.insert(
15886                0,
15887                MoveOption {
15888                    label: "Use (eat / drink)".into(),
15889                    kind: MoveOptionKind::Use,
15890                },
15891            );
15892        }
15893
15894        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
15895        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
15896        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
15897    }
15898
15899    #[test]
15900    fn inventory_category_group_order_is_stable() {
15901        assert_eq!(inventory_category_group("weapon").0, "Weapons");
15902        assert_eq!(inventory_category_group("armor").0, "Armor");
15903        assert_eq!(inventory_category_group("consumable").0, "Consumables");
15904        assert_eq!(inventory_category_group("resource").0, "Resources");
15905        assert_eq!(inventory_category_group("container").0, "Containers");
15906        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
15907        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
15908    }
15909
15910    #[test]
15911    fn page_list_index_clamps_without_wrap() {
15912        assert_eq!(page_list_index(0, -1, 25), 0);
15913        assert_eq!(page_list_index(0, 1, 25), 10);
15914        assert_eq!(page_list_index(12, 1, 25), 22);
15915        assert_eq!(page_list_index(22, 1, 25), 24);
15916        assert_eq!(page_list_index(5, 1, 0), 0);
15917        assert_eq!(page_list_index(3, -1, 8), 0);
15918    }
15919
15920    #[test]
15921    fn inventory_filter_hides_non_matching_person_items() {
15922        let mut state = sample_state();
15923        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15924        sword.display_name = Some("Iron Sword".into());
15925        sword.category = Some("weapon".into());
15926        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
15927        herb.display_name = Some("Wild Herb".into());
15928        herb.category = Some("consumable".into());
15929        state.inventory_stacks = vec![sword, herb];
15930        state.inventory_tab = InventoryTab::OnPerson;
15931        state.inventory_filter = "sword".into();
15932
15933        let rows = state.inventory_selectable_rows();
15934        assert_eq!(rows.len(), 1);
15935        assert_eq!(rows[0].stack.template_id, "iron_sword");
15936
15937        let lines = state.inventory_browser_lines();
15938        assert!(lines.iter().any(|l| matches!(
15939            l,
15940            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
15941        )));
15942        assert!(!lines.iter().any(|l| matches!(
15943            l,
15944            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
15945        )));
15946    }
15947
15948    #[test]
15949    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
15950        let mut state = sample_state();
15951        let id_a = uuid::Uuid::from_u128(0xa1);
15952        let id_b = uuid::Uuid::from_u128(0xb2);
15953        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
15954        sword_a.display_name = Some("Iron Sword".into());
15955        sword_a.category = Some("weapon".into());
15956        sword_a.item_instance_id = Some(id_a);
15957        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
15958        sword_b.display_name = Some("Iron Sword".into());
15959        sword_b.category = Some("weapon".into());
15960        sword_b.item_instance_id = Some(id_b);
15961        state.inventory_stacks = vec![sword_a, sword_b];
15962        state.inventory_tab = InventoryTab::OnPerson;
15963
15964        let lines = state.inventory_browser_lines();
15965        let items: Vec<_> = lines
15966            .iter()
15967            .filter_map(|l| match l {
15968                InventoryBrowserLine::Item {
15969                    title,
15970                    instance_tooltip,
15971                    ..
15972                } => Some((title.clone(), instance_tooltip.clone())),
15973                _ => None,
15974            })
15975            .collect();
15976        assert_eq!(items.len(), 2);
15977        for (title, tip) in &items {
15978            assert!(
15979                !title.contains('#'),
15980                "title should not show instance suffix: {title}"
15981            );
15982            assert!(
15983                tip.is_some(),
15984                "two identical rows should expose instance on hover"
15985            );
15986        }
15987
15988        state.inventory_stacks.pop();
15989        let lines = state.inventory_browser_lines();
15990        let one = lines.iter().find_map(|l| match l {
15991            InventoryBrowserLine::Item {
15992                title,
15993                instance_tooltip,
15994                ..
15995            } => Some((title.clone(), instance_tooltip.clone())),
15996            _ => None,
15997        });
15998        let (title, tip) = one.expect("one sword row");
15999        assert!(!title.contains('#'));
16000        assert!(tip.is_none(), "single row should not need instance tooltip");
16001    }
16002
16003    #[test]
16004    fn inventory_person_rows_group_by_category() {
16005        let mut state = sample_state();
16006        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16007        sword.category = Some("weapon".into());
16008        sword.display_name = Some("Iron Sword".into());
16009        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
16010        ore.category = Some("resource".into());
16011        ore.display_name = Some("Iron Ore".into());
16012        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
16013        potion.category = Some("consumable".into());
16014        potion.display_name = Some("Health Potion".into());
16015        state.inventory_stacks = vec![ore, potion, sword];
16016        state.inventory_tab = InventoryTab::OnPerson;
16017
16018        let lines = state.inventory_browser_lines();
16019        let labels: Vec<&str> = lines
16020            .iter()
16021            .filter_map(|l| match l {
16022                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
16023                _ => None,
16024            })
16025            .collect();
16026        assert!(
16027            labels.iter().any(|s| s.contains("Weapons")),
16028            "expected Weapons group: {labels:?}"
16029        );
16030        assert!(labels.iter().any(|s| s.contains("Consumables")));
16031        assert!(labels.iter().any(|s| s.contains("Resources")));
16032
16033        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
16034        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
16035        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
16036        assert!(weapon_pos < consumable_pos);
16037        assert!(consumable_pos < resource_pos);
16038    }
16039
16040    #[test]
16041    fn inventory_tab_cycle_resets_selection() {
16042        let mut state = sample_state();
16043        state.inventory_tab = InventoryTab::OnPerson;
16044        state.inventory_menu_index = 3;
16045        state.inventory_tab = state.inventory_tab.cycle(true);
16046        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
16047        // Client method resets index; enum cycle alone does not — verify cycle labels.
16048        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
16049        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
16050        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
16051        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
16052    }
16053
16054    #[test]
16055    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
16056        assert_eq!(parse_bank_copper_amount(""), Some(0));
16057        assert_eq!(parse_bank_copper_amount("  "), Some(0));
16058        assert_eq!(parse_bank_copper_amount("0"), Some(0));
16059        assert_eq!(parse_bank_copper_amount("250"), Some(250));
16060        assert_eq!(parse_bank_copper_amount("nope"), None);
16061    }
16062
16063    #[test]
16064    fn parse_storage_quantity_blank_and_zero_mean_all() {
16065        assert_eq!(parse_storage_quantity(""), Some(None));
16066        assert_eq!(parse_storage_quantity("  "), Some(None));
16067        assert_eq!(parse_storage_quantity("0"), Some(None));
16068        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
16069        assert_eq!(parse_storage_quantity("nope"), None);
16070    }
16071
16072    #[test]
16073    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
16074        assert!(worker_error_is_hud_noise("path stuck — repathing"));
16075        assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
16076        assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
16077        // Real problem: player may need to place lodging / fix assignment.
16078        assert!(!worker_error_is_hud_noise(
16079            "path stuck — no lodging to reset to"
16080        ));
16081    }
16082
16083    #[test]
16084    fn leaving_building_restores_outdoor_z_bands() {
16085        use flatland_protocol::{InteriorMapView, ZPlatformView};
16086
16087        let mut state = sample_state();
16088        state.z_platforms.clear();
16089        state.z_transitions.clear();
16090        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
16091        state.interior_map = Some(InteriorMapView {
16092            building_id: "broker_hut".into(),
16093            blueprint_id: "broker_hut".into(),
16094            background_color: "#000".into(),
16095            default_floor_color: None,
16096            floor_height_m: 3.0,
16097            z_platforms: vec![ZPlatformView {
16098                id: "floor_0".into(),
16099                z: 0.0,
16100                x0: 0.0,
16101                y0: 0.0,
16102                x1: 8.0,
16103                y1: 8.0,
16104            }],
16105            z_transitions: vec![],
16106            rooms: vec![],
16107            room_doors: vec![],
16108        });
16109        state.sync_interior_map_context();
16110        assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
16111        assert!(state.z_bands_outdoor_backup.is_some());
16112
16113        state.player.as_mut().unwrap().inside_building = None;
16114        state.sync_interior_map_context();
16115        assert!(
16116            state.z_platforms.is_empty(),
16117            "leaving must restore outdoor bands (empty), not leave interior platforms"
16118        );
16119        assert!(state.z_bands_outdoor_backup.is_none());
16120        assert!(state.interior_map.is_none());
16121    }
16122}