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    /// Per-ability mastery from combat HUD (tier / XP).
1331    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1332    /// Server-persisted hotbar bindings (index 0 = key 1).
1333    pub hotbar: Vec<Option<String>>,
1334    /// Max abilities allowed in one rotation (mind score).
1335    pub max_abilities_per_rotation: u8,
1336    pub show_loadout_menu: bool,
1337    pub show_keychain_menu: bool,
1338    pub keychain_menu_index: usize,
1339    pub show_rotation_editor: bool,
1340    /// Selected rotation preset in the loadout menu.
1341    pub loadout_menu_index: usize,
1342    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1343    pub loadout_hotbar_slot: u8,
1344    /// Selected known-ability row in the loadout menu.
1345    pub loadout_ability_index: usize,
1346    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1347    pub loadout_focus_presets: bool,
1348    pub rotation_editor: RotationEditorState,
1349    /// True after a harvest intent is accepted until result/reject/disconnect.
1350    pub harvest_in_progress: bool,
1351    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1352    pub harvest_started_at: Option<Instant>,
1353    /// Craft log deferred until the server acks the craft intent.
1354    pub pending_craft_ack: Option<(u32, String, u32)>,
1355    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1356    pub interactables: Vec<flatland_protocol::InteractableView>,
1357    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1358    pub career: Option<flatland_protocol::PlayerCareerView>,
1359    pub character_sheet_tab: CharacterSheetTab,
1360    pub ledger_period: LedgerPeriod,
1361    pub show_quest_offer: bool,
1362    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1363    pub show_quest_menu: bool,
1364    pub quest_menu_index: usize,
1365    pub quest_withdraw_confirm: bool,
1366    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1367    pub show_workers_menu: bool,
1368    pub workers_menu_index: usize,
1369    /// Workers panel: one-line rows instead of full cards (more on screen).
1370    pub workers_menu_compact: bool,
1371    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1372    /// Public so out-of-crate tests can construct [`GameState`].
1373    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1374    /// Held worker route errors (readable when server clears `last_error` each tick).
1375    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1376    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1377    pub show_worker_give_picker: bool,
1378    pub worker_give_picker_index: usize,
1379    pub worker_give_picker: Option<WorkerGivePicker>,
1380    /// Inventory `g` — pick which nearby worker receives the selected stack.
1381    pub show_worker_give_target_picker: bool,
1382    pub worker_give_target_picker_index: usize,
1383    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1384    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1385    pub show_worker_take_picker: bool,
1386    pub worker_take_picker_index: usize,
1387    pub worker_take_picker: Option<WorkerTakePicker>,
1388    /// Teach-blueprint sheet opened from the workers menu (`t`).
1389    pub show_worker_teach_picker: bool,
1390    pub worker_teach_picker_index: usize,
1391    pub worker_teach_picker: Option<WorkerTeachPicker>,
1392    /// Active harvest-route editor (`h` → `e` on a worker).
1393    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1394    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1395    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1396    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1397    pub attending_worker_instance_id: Option<String>,
1398    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1399    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1400}
1401
1402impl GameState {
1403    pub fn push_log(&mut self, line: impl Into<String>) {
1404        self.logs.push_back(line.into());
1405        while self.logs.len() > MAX_LOG_LINES {
1406            self.logs.pop_front();
1407        }
1408    }
1409
1410    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1411        self.shop_trade_log.push_back(line.into());
1412        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1413            self.shop_trade_log.pop_front();
1414        }
1415    }
1416
1417    pub fn clear_shop_trade_log(&mut self) {
1418        self.shop_trade_log.clear();
1419    }
1420
1421    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1422        if !self.show_shop_menu {
1423            return;
1424        }
1425        let msg = notice.message.trim();
1426        if msg.is_empty() {
1427            return;
1428        }
1429        if notice.coins_delta != 0
1430            || msg.starts_with("Bought ")
1431            || msg.starts_with("Sold ")
1432            || msg.contains("taught you how to craft")
1433            || msg.starts_with("need ")
1434        {
1435            self.push_shop_trade_log(msg);
1436        }
1437    }
1438
1439    pub fn is_alive(&self) -> bool {
1440        self.player
1441            .as_ref()
1442            .and_then(|p| p.vitals)
1443            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1444            .unwrap_or(true)
1445    }
1446
1447    /// Verb menu entries for the current `npc_verb_target`.
1448    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1449        let Some(ref id) = self.npc_verb_target else {
1450            return vec![];
1451        };
1452        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1453            return vec!["Talk"];
1454        };
1455        let role = npc.role.as_str();
1456        if Self::npc_role_is_bank(role) {
1457            return vec!["Bank", "Talk"];
1458        }
1459        if Self::npc_role_is_storage(role) {
1460            return vec!["Storage", "Talk"];
1461        }
1462        if Self::npc_role_is_market(role) {
1463            return vec!["Market", "Talk"];
1464        }
1465        if npc.can_trade || Self::npc_role_can_trade(role) {
1466            vec!["Talk", "Trade"]
1467        } else {
1468            vec!["Talk"]
1469        }
1470    }
1471
1472    fn npc_role_can_trade(role: &str) -> bool {
1473        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1474    }
1475
1476    fn npc_role_is_bank(role: &str) -> bool {
1477        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1478    }
1479
1480    fn npc_role_is_storage(role: &str) -> bool {
1481        role.eq_ignore_ascii_case("storage_manager")
1482    }
1483
1484    fn npc_role_is_market(role: &str) -> bool {
1485        role.eq_ignore_ascii_case("market_clerk")
1486    }
1487
1488    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1489        vec![
1490            "Deposit…",
1491            "Withdraw…",
1492            "Deposit all",
1493            "Withdraw all",
1494            "Transfer…",
1495        ]
1496    }
1497
1498    pub fn storage_menu_options(&self) -> Vec<String> {
1499        let mut opts = vec!["Store…".into(), "Take…".into()];
1500        if let Some(panel) = &self.storage_panel {
1501            for dest in &panel.ship_destinations {
1502                opts.push(format!(
1503                    "Ship → {} ({} cp / {} ticks)",
1504                    dest.label, dest.fee_copper, dest.travel_ticks
1505                ));
1506            }
1507        }
1508        opts
1509    }
1510
1511    /// Loose on-person stacks eligible for town-storage store.
1512    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1513        self.person_rows()
1514            .into_iter()
1515            .filter(|r| r.depth == 0)
1516            .filter_map(|r| {
1517                let id = r.stack.item_instance_id?;
1518                Some(StoragePickOption {
1519                    item_instance_id: id,
1520                    label: storage_stack_label(&r.stack),
1521                    quantity: r.stack.quantity,
1522                    category: r.stack.category.clone().unwrap_or_default(),
1523                })
1524            })
1525            .collect()
1526    }
1527
1528    /// Vault stacks eligible for take / ship.
1529    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1530        let Some(panel) = &self.storage_panel else {
1531            return Vec::new();
1532        };
1533        panel
1534            .contents
1535            .iter()
1536            .filter_map(|s| {
1537                let id = s.item_instance_id?;
1538                Some(StoragePickOption {
1539                    item_instance_id: id,
1540                    label: storage_stack_label(s),
1541                    quantity: s.quantity,
1542                    category: s.category.clone().unwrap_or_default(),
1543                })
1544            })
1545            .collect()
1546    }
1547
1548    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1549    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1550        let mut opts = Vec::new();
1551        if !self
1552            .market_list_item_options(&MarketListSourceKind::Person)
1553            .is_empty()
1554        {
1555            opts.push((MarketListSourceKind::Person, "On person".into()));
1556        }
1557        if let Some(panel) = &self.market_panel {
1558            for vault in &panel.list_vaults {
1559                let source = MarketListSourceKind::TownStorage {
1560                    building_id: vault.building_id.clone(),
1561                };
1562                if self.market_list_item_options(&source).is_empty() {
1563                    continue;
1564                }
1565                let label = if vault.building_label.is_empty() {
1566                    format!("Town storage ({})", vault.building_id)
1567                } else {
1568                    format!("Town storage — {}", vault.building_label)
1569                };
1570                opts.push((source, label));
1571            }
1572        }
1573        opts
1574    }
1575
1576    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1577    pub fn market_list_item_options(
1578        &self,
1579        source: &MarketListSourceKind,
1580    ) -> Vec<StoragePickOption> {
1581        let filter = self.market_filter.as_str();
1582        let cat_filter = self.market_category_filter;
1583        let mut opts: Vec<StoragePickOption> = match source {
1584            MarketListSourceKind::Person => self
1585                .person_rows()
1586                .into_iter()
1587                .filter(|r| r.depth == 0)
1588                .filter(|r| self.stack_is_market_listable(&r.stack))
1589                .filter_map(|r| {
1590                    let id = r.stack.item_instance_id?;
1591                    Some(StoragePickOption {
1592                        item_instance_id: id,
1593                        label: storage_stack_label(&r.stack),
1594                        quantity: r.stack.quantity,
1595                        category: r
1596                            .stack
1597                            .category
1598                            .clone()
1599                            .or_else(|| {
1600                                self.inventory_item_category(&r.stack.template_id)
1601                                    .map(str::to_string)
1602                            })
1603                            .unwrap_or_default(),
1604                    })
1605                })
1606                .collect(),
1607            MarketListSourceKind::TownStorage { building_id } => {
1608                let Some(panel) = &self.market_panel else {
1609                    return Vec::new();
1610                };
1611                let Some(vault) = panel
1612                    .list_vaults
1613                    .iter()
1614                    .find(|v| &v.building_id == building_id)
1615                else {
1616                    return Vec::new();
1617                };
1618                vault
1619                    .contents
1620                    .iter()
1621                    .filter(|s| self.stack_is_market_listable(s))
1622                    .filter_map(|s| {
1623                        let id = s.item_instance_id?;
1624                        Some(StoragePickOption {
1625                            item_instance_id: id,
1626                            label: storage_stack_label(s),
1627                            quantity: s.quantity,
1628                            category: s
1629                                .category
1630                                .clone()
1631                                .or_else(|| {
1632                                    self.inventory_item_category(&s.template_id)
1633                                        .map(str::to_string)
1634                                })
1635                                .unwrap_or_default(),
1636                        })
1637                    })
1638                    .collect()
1639            }
1640        };
1641        opts.retain(|o| {
1642            if !list_label_matches(&o.label, filter) {
1643                return false;
1644            }
1645            if let Some(group) = cat_filter {
1646                inventory_category_group(&o.category).0 == group
1647            } else {
1648                true
1649            }
1650        });
1651        opts
1652    }
1653
1654    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1655        if crate::currency::is_currency(&stack.template_id) {
1656            return false;
1657        }
1658        if let Some(flag) = stack.listable {
1659            return flag;
1660        }
1661        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1662            return hint.listable;
1663        }
1664        let cat = stack
1665            .category
1666            .as_deref()
1667            .or_else(|| self.inventory_item_category(&stack.template_id))
1668            .unwrap_or("");
1669        category_default_listable(cat)
1670    }
1671
1672    /// Category group labels present in the current browse book or list-pick source.
1673    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1674        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1675        match &self.market_ui_mode {
1676            MarketUiMode::ListPick { source, .. } => {
1677                let raw: Vec<_> = match source {
1678                    MarketListSourceKind::Person => self
1679                        .person_rows()
1680                        .into_iter()
1681                        .filter(|r| r.depth == 0)
1682                        .filter(|r| self.stack_is_market_listable(&r.stack))
1683                        .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1684                        .map(|r| {
1685                            r.stack
1686                                .category
1687                                .clone()
1688                                .or_else(|| {
1689                                    self.inventory_item_category(&r.stack.template_id)
1690                                        .map(str::to_string)
1691                                })
1692                                .unwrap_or_default()
1693                        })
1694                        .collect(),
1695                    MarketListSourceKind::TownStorage { building_id } => self
1696                        .market_panel
1697                        .as_ref()
1698                        .and_then(|p| {
1699                            p.list_vaults
1700                                .iter()
1701                                .find(|v| &v.building_id == building_id)
1702                        })
1703                        .map(|vault| {
1704                            vault
1705                                .contents
1706                                .iter()
1707                                .filter(|s| self.stack_is_market_listable(s))
1708                                .filter(|s| {
1709                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
1710                                })
1711                                .map(|s| {
1712                                    s.category
1713                                        .clone()
1714                                        .or_else(|| {
1715                                            self.inventory_item_category(&s.template_id)
1716                                                .map(str::to_string)
1717                                        })
1718                                        .unwrap_or_default()
1719                                })
1720                                .collect::<Vec<_>>()
1721                        })
1722                        .unwrap_or_default(),
1723                };
1724                for category in raw {
1725                    let (label, ord) = inventory_category_group(&category);
1726                    seen.insert(ord, label);
1727                }
1728            }
1729            _ => {
1730                if let Some(panel) = &self.market_panel {
1731                    for listing in &panel.listings {
1732                        if !list_label_matches(&listing.display_name, &self.market_filter)
1733                            && !list_label_matches(&listing.seller_label, &self.market_filter)
1734                        {
1735                            continue;
1736                        }
1737                        let (label, ord) = inventory_category_group(&listing.category);
1738                        seen.insert(ord, label);
1739                    }
1740                }
1741            }
1742        }
1743        seen.into_values().collect()
1744    }
1745
1746    /// Indices into `market_panel.listings` after category + text filter.
1747    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1748        let Some(panel) = &self.market_panel else {
1749            return Vec::new();
1750        };
1751        let filter = self.market_filter.as_str();
1752        let cat_filter = self.market_category_filter;
1753        panel
1754            .listings
1755            .iter()
1756            .enumerate()
1757            .filter(|(_, listing)| {
1758                if !list_label_matches(&listing.display_name, filter)
1759                    && !list_label_matches(&listing.seller_label, filter)
1760                    && !list_label_matches(&listing.template_id, filter)
1761                {
1762                    return false;
1763                }
1764                if let Some(group) = cat_filter {
1765                    inventory_category_group(&listing.category).0 == group
1766                } else {
1767                    true
1768                }
1769            })
1770            .map(|(i, _)| i)
1771            .collect()
1772    }
1773
1774    pub fn clear_harvest_state(&mut self) {
1775        self.harvest_in_progress = false;
1776        self.harvest_started_at = None;
1777    }
1778
1779    fn harvest_state_stale(&self) -> bool {
1780        match self.harvest_started_at {
1781            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1782            None => self.harvest_in_progress,
1783        }
1784    }
1785
1786    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1787        self.player.as_ref().and_then(|p| p.vitals)
1788    }
1789
1790    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1791        let materials_ok = blueprint.inputs.iter().all(|input| {
1792            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1793        });
1794        let tools_ok = blueprint
1795            .required_tools
1796            .iter()
1797            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1798        let station_ok = match blueprint.station.as_deref() {
1799            None | Some("hand") => true,
1800            Some(tag) => self.player_at_station_tag(tag),
1801        };
1802        materials_ok && tools_ok && station_ok
1803    }
1804
1805    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1806        if !self.can_craft_blueprint(blueprint) {
1807            return 0;
1808        }
1809        let mut limit = u32::MAX;
1810        for input in &blueprint.inputs {
1811            if input.quantity == 0 {
1812                continue;
1813            }
1814            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1815            limit = limit.min(have / input.quantity);
1816        }
1817        for tool in &blueprint.required_tools {
1818            if tool.consumed {
1819                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1820                limit = limit.min(have);
1821            }
1822        }
1823        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1824        if CRAFT_STAMINA_COST > 0.0 {
1825            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1826        }
1827        limit
1828    }
1829
1830    pub fn clamp_craft_batch_quantity(&mut self) {
1831        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1832            self.craft_batch_quantity = 1;
1833            return;
1834        };
1835        let max = self.max_craft_batches(bp).max(1);
1836        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1837    }
1838
1839    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1840        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1841            return;
1842        };
1843        let max = self.max_craft_batches(&bp).max(1);
1844        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1845        self.craft_batch_quantity = next as u32;
1846    }
1847
1848    pub fn craft_batch_set_max(&mut self) {
1849        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1850            return;
1851        };
1852        let max = self.max_craft_batches(&bp);
1853        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1854    }
1855
1856    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1857        let preserve_ui = self.show_shop_menu;
1858        let tab = self.shop_tab;
1859        let index = self.shop_menu_index;
1860        let qty = self.shop_quantity;
1861
1862        self.show_shop_menu = true;
1863        self.bank_panel = None;
1864        self.show_craft_menu = false;
1865        self.show_inventory_menu = false;
1866        self.show_stats = false;
1867        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1868            self.npc_verb_target = Some(catalog.npc_id.clone());
1869        }
1870        self.shop_catalog = Some(catalog);
1871
1872        if preserve_ui {
1873            self.shop_tab = tab;
1874            self.shop_menu_index = index;
1875            self.shop_quantity = qty;
1876        } else {
1877            self.shop_tab = ShopTab::Buy;
1878            self.shop_menu_index = 0;
1879            self.shop_quantity = 1;
1880            self.clear_shop_trade_log();
1881        }
1882        self.show_npc_verb_menu = false;
1883        self.clamp_shop_selection();
1884    }
1885
1886    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1887        let same_teller = self
1888            .bank_panel
1889            .as_ref()
1890            .is_some_and(|p| p.npc_id == panel.npc_id);
1891        self.bank_panel = Some(panel);
1892        self.storage_panel = None;
1893        self.market_panel = None;
1894        self.shop_catalog = None;
1895        self.show_shop_menu = false;
1896        self.show_craft_menu = false;
1897        self.show_inventory_menu = false;
1898        self.show_stats = false;
1899        self.show_npc_verb_menu = false;
1900        self.show_npc_chat = false;
1901        self.npc_chat = None;
1902        if !same_teller {
1903            self.bank_menu_index = 0;
1904            self.bank_ui_mode = BankUiMode::Menu;
1905        }
1906        if let Some(panel) = &self.bank_panel {
1907            if self.npc_verb_target.is_none() {
1908                self.npc_verb_target = Some(panel.npc_id.clone());
1909            }
1910        }
1911    }
1912
1913    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1914        let same_manager = self
1915            .storage_panel
1916            .as_ref()
1917            .is_some_and(|p| p.npc_id == panel.npc_id);
1918        self.storage_panel = Some(panel);
1919        self.bank_panel = None;
1920        self.market_panel = None;
1921        self.bank_ui_mode = BankUiMode::Menu;
1922        self.shop_catalog = None;
1923        self.show_shop_menu = false;
1924        self.show_craft_menu = false;
1925        self.show_inventory_menu = false;
1926        self.show_stats = false;
1927        self.show_npc_verb_menu = false;
1928        self.show_npc_chat = false;
1929        self.npc_chat = None;
1930        if !same_manager {
1931            self.storage_menu_index = 0;
1932            self.storage_ui_mode = StorageUiMode::Menu;
1933        } else {
1934            self.clamp_storage_pick_index();
1935        }
1936        if let Some(panel) = &self.storage_panel {
1937            if self.npc_verb_target.is_none() {
1938                self.npc_verb_target = Some(panel.npc_id.clone());
1939            }
1940        }
1941    }
1942
1943    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
1944        self.market_panel = Some(panel);
1945        self.bank_panel = None;
1946        self.storage_panel = None;
1947        self.shop_catalog = None;
1948        self.show_shop_menu = false;
1949        self.show_craft_menu = false;
1950        self.show_inventory_menu = false;
1951        self.show_stats = false;
1952        self.show_npc_verb_menu = false;
1953        self.show_npc_chat = false;
1954        self.npc_chat = None;
1955        self.market_menu_index = 0;
1956        self.market_buy_confirm = None;
1957        self.market_ui_mode = MarketUiMode::Browse;
1958        self.market_filter.clear();
1959        self.market_filter_focused = false;
1960        self.market_category_filter = None;
1961        if let Some(panel) = &self.market_panel {
1962            if self.npc_verb_target.is_none() {
1963                self.npc_verb_target = Some(panel.npc_id.clone());
1964            }
1965        }
1966    }
1967
1968    pub fn clear_market_panel(&mut self) {
1969        self.market_panel = None;
1970        self.market_menu_index = 0;
1971        self.market_buy_confirm = None;
1972        self.market_ui_mode = MarketUiMode::Browse;
1973        self.market_filter.clear();
1974        self.market_filter_focused = false;
1975        self.market_category_filter = None;
1976    }
1977
1978    pub fn clear_bank_panel(&mut self) {
1979        self.bank_panel = None;
1980        self.bank_menu_index = 0;
1981        self.bank_ui_mode = BankUiMode::Menu;
1982    }
1983
1984    pub fn clear_storage_panel(&mut self) {
1985        self.storage_panel = None;
1986        self.storage_menu_index = 0;
1987        self.storage_ui_mode = StorageUiMode::Menu;
1988    }
1989
1990    fn clamp_storage_pick_index(&mut self) {
1991        match &self.storage_ui_mode {
1992            StorageUiMode::StorePick { index } => {
1993                let n = self.storage_store_options().len();
1994                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1995                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1996            }
1997            StorageUiMode::TakePick { index } => {
1998                let n = self.storage_vault_options().len();
1999                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2000                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2001            }
2002            StorageUiMode::ShipPick {
2003                dest_building_id,
2004                dest_label,
2005                index,
2006            } => {
2007                let n = self.storage_vault_options().len();
2008                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2009                self.storage_ui_mode = StorageUiMode::ShipPick {
2010                    dest_building_id: dest_building_id.clone(),
2011                    dest_label: dest_label.clone(),
2012                    index: next,
2013                };
2014            }
2015            StorageUiMode::Menu
2016            | StorageUiMode::StoreAmount { .. }
2017            | StorageUiMode::TakeAmount { .. }
2018            | StorageUiMode::ShipAmount { .. } => {}
2019        }
2020    }
2021
2022    pub fn shop_list_len(&self) -> usize {
2023        let Some(catalog) = &self.shop_catalog else {
2024            return 0;
2025        };
2026        match self.shop_tab {
2027            ShopTab::Buy => catalog.sells.len(),
2028            ShopTab::Sell => catalog.buys.len(),
2029        }
2030    }
2031
2032    pub fn shop_menu_move(&mut self, delta: i32) {
2033        let n = self.shop_list_len();
2034        if n == 0 {
2035            return;
2036        }
2037        let idx = self.shop_menu_index as i32;
2038        let next = (idx + delta).rem_euclid(n as i32);
2039        self.shop_menu_index = next as usize;
2040        self.clamp_shop_quantity();
2041    }
2042
2043    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2044        let max = self.shop_quantity_max();
2045        if max == 0 {
2046            self.shop_quantity = 0;
2047            return;
2048        }
2049        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2050        self.shop_quantity = next as u32;
2051    }
2052
2053    pub(crate) fn clamp_shop_selection(&mut self) {
2054        let n = self.shop_list_len();
2055        if n == 0 {
2056            self.shop_menu_index = 0;
2057        } else {
2058            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2059        }
2060        self.clamp_shop_quantity();
2061    }
2062
2063    fn shop_quantity_max(&self) -> u32 {
2064        let Some(catalog) = &self.shop_catalog else {
2065            return 1;
2066        };
2067        match self.shop_tab {
2068            ShopTab::Buy => {
2069                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2070                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2071                        return 1;
2072                    }
2073                }
2074                99
2075            }
2076            ShopTab::Sell => catalog
2077                .buys
2078                .get(self.shop_menu_index)
2079                .map(|l| l.quantity)
2080                .unwrap_or(0),
2081        }
2082    }
2083
2084    pub fn shop_quantity_set_max(&mut self) {
2085        self.shop_quantity = self.shop_quantity_max();
2086    }
2087
2088    fn clamp_shop_quantity(&mut self) {
2089        let max = self.shop_quantity_max();
2090        if max == 0 {
2091            self.shop_quantity = 0;
2092        } else {
2093            self.shop_quantity = self.shop_quantity.max(1).min(max);
2094        }
2095    }
2096
2097    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2098        let Some(id) = self.effective_inside_building() else {
2099            return false;
2100        };
2101        self.buildings
2102            .iter()
2103            .find(|b| b.id == id)
2104            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2105    }
2106
2107    /// Short hint for UI when a recipe cannot be started.
2108    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2109        if self.can_craft_blueprint(blueprint) {
2110            return None;
2111        }
2112        let mut missing = Vec::new();
2113        for input in &blueprint.inputs {
2114            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2115            if have < input.quantity {
2116                missing.push(format!(
2117                    "{}×{} (have {have})",
2118                    input.quantity, input.template_id
2119                ));
2120            }
2121        }
2122        for tool in &blueprint.required_tools {
2123            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2124            if have < 1 {
2125                missing.push(format!("tool: {}", tool.item));
2126            }
2127        }
2128        if let Some(station) = blueprint.station.as_deref() {
2129            if station != "hand" && !self.player_at_station_tag(station) {
2130                missing.push(format!("station: {station} (enter building)"));
2131            }
2132        }
2133        if missing.is_empty() {
2134            None
2135        } else {
2136            Some(missing.join(", "))
2137        }
2138    }
2139
2140    pub fn player_entity(&self) -> Option<&EntityState> {
2141        self.player
2142            .as_ref()
2143            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2144    }
2145
2146    /// Apply persisted HUD / workers UI prefs from `client.json`.
2147    pub fn apply_client_ui_prefs(&mut self) {
2148        let cfg = crate::client_config::ClientConfig::load();
2149        if let Some(hidden) = cfg.hud_log_hidden {
2150            self.hud_log_hidden = hidden;
2151        }
2152        if let Some(compact) = cfg.workers_menu_compact {
2153            self.workers_menu_compact = compact;
2154        }
2155    }
2156
2157    pub fn player_position(&self) -> (f32, f32) {
2158        let (x, y, _) = self.player_position_with_z();
2159        (x, y)
2160    }
2161
2162    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2163        if let Some(p) = self.player_entity() {
2164            (
2165                p.transform.position.x,
2166                p.transform.position.y,
2167                p.transform.position.z,
2168            )
2169        } else {
2170            (0.0, 0.0, 0.0)
2171        }
2172    }
2173
2174    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2175        let mut rows: Vec<(String, u32, String)> = self
2176            .inventory
2177            .iter()
2178            .filter(|(_, q)| **q > 0)
2179            .map(|(id, qty)| {
2180                let label = self
2181                    .inventory_hints
2182                    .get(id)
2183                    .map(|h| h.display_name.clone())
2184                    .unwrap_or_else(|| id.clone());
2185                (id.clone(), *qty, label)
2186            })
2187            .collect();
2188        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2189        rows
2190    }
2191
2192    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2193        self.inventory_hints
2194            .get(template_id)
2195            .map(|h| h.category.as_str())
2196            .filter(|c| !c.is_empty())
2197    }
2198
2199    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2200        stack
2201            .props
2202            .get("grants_item_status_effect")
2203            .map(|s| !s.is_empty())
2204            .unwrap_or(false)
2205    }
2206
2207    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2208        stack
2209            .props
2210            .get("grants_item_status_effect")
2211            .map(String::as_str)
2212            .filter(|s| !s.is_empty())
2213    }
2214
2215    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2216        stack
2217            .props
2218            .get("grants_item_status_mode")
2219            .map(String::as_str)
2220            .unwrap_or("on_hit")
2221    }
2222
2223    /// Candidate gear for a grant consumable (inventory + worn).
2224    pub fn grant_target_options(
2225        &self,
2226        grant: &flatland_protocol::ItemStack,
2227    ) -> Vec<GrantTargetOption> {
2228        let mode = Self::grant_mode(grant);
2229        let grant_tags: Vec<&str> = grant
2230            .props
2231            .get("grants_item_status_tags")
2232            .map(|s| {
2233                s.split(',')
2234                    .map(str::trim)
2235                    .filter(|t| !t.is_empty())
2236                    .collect()
2237            })
2238            .unwrap_or_default();
2239        let grant_id = grant.item_instance_id;
2240        let mut out = Vec::new();
2241        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2242            let Some(iid) = stack.item_instance_id else {
2243                return;
2244            };
2245            if Some(iid) == grant_id {
2246                return;
2247            }
2248            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2249                return;
2250            }
2251            if !grant_target_matches_mode(stack, mode) {
2252                return;
2253            }
2254            if !grant_tags_match(stack, &grant_tags) {
2255                return;
2256            }
2257            let name = stack
2258                .display_name
2259                .clone()
2260                .unwrap_or_else(|| stack.template_id.clone());
2261            let bindings = if stack.status_bindings.is_empty() {
2262                String::new()
2263            } else {
2264                format!(
2265                    " · {}",
2266                    stack
2267                        .status_bindings
2268                        .iter()
2269                        .map(|b| b.effect_id.as_str())
2270                        .collect::<Vec<_>>()
2271                        .join(", ")
2272                )
2273            };
2274            out.push(GrantTargetOption {
2275                label: format!("{where_label}: {name}{bindings}"),
2276                target_instance_id: iid,
2277            });
2278        };
2279        fn walk(
2280            stacks: &[flatland_protocol::ItemStack],
2281            where_label: &str,
2282            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2283        ) {
2284            for s in stacks {
2285                push(s, where_label);
2286                if !s.contents.is_empty() {
2287                    let nested = format!(
2288                        "{where_label}/{}",
2289                        s.display_name
2290                            .as_deref()
2291                            .unwrap_or(s.template_id.as_str())
2292                    );
2293                    walk(&s.contents, &nested, push);
2294                }
2295            }
2296        }
2297        walk(&self.inventory_stacks, "Bag", &mut push);
2298        for (slot, stack) in &self.worn {
2299            push(stack, body_slot_label(*slot));
2300            let nest = format!(
2301                "{}/{}",
2302                body_slot_label(*slot),
2303                stack
2304                    .display_name
2305                    .as_deref()
2306                    .unwrap_or(stack.template_id.as_str())
2307            );
2308            walk(&stack.contents, &nest, &mut push);
2309        }
2310        out
2311    }
2312
2313    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2314        self.inventory_hints
2315            .get(template_id)
2316            .and_then(|h| h.base_mass)
2317            .unwrap_or(0.5)
2318    }
2319
2320    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2321        self.inventory_hints
2322            .get(template_id)
2323            .and_then(|h| h.base_volume)
2324            .unwrap_or(1.0)
2325    }
2326
2327    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2328        let unit = stack
2329            .base_mass
2330            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2331        unit * stack.quantity as f32
2332    }
2333
2334    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2335        let unit = stack.base_volume.unwrap_or(1.0);
2336        unit * stack.quantity as f32
2337            + stack
2338                .contents
2339                .iter()
2340                .map(Self::stack_tree_volume)
2341                .sum::<f32>()
2342    }
2343
2344    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2345        contents.iter().map(Self::stack_tree_volume).sum()
2346    }
2347
2348    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2349        self.inventory_hints
2350            .get(template_id)
2351            .and_then(|h| h.capacity_volume)
2352            .filter(|c| *c > 0.0)
2353    }
2354
2355    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2356        stack
2357            .capacity_volume
2358            .filter(|c| *c > 0.0)
2359            .or_else(|| self.template_capacity_volume(&stack.template_id))
2360    }
2361
2362    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2363    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2364        let Some((used, cap)) = self.container_volume_stats(row) else {
2365            return String::new();
2366        };
2367        let free = (cap - used).max(0.0);
2368        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2369    }
2370
2371    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2372        if row.is_chest_shell {
2373            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2374                return None;
2375            };
2376            let chest = self
2377                .placed_containers
2378                .iter()
2379                .find(|c| c.id == *container_id)?;
2380            let cap = self
2381                .stack_capacity_volume(&row.stack)
2382                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2383            let used = if chest.accessible {
2384                Self::contents_used_volume(&chest.contents)
2385            } else {
2386                0.0
2387            };
2388            return Some((used, cap));
2389        }
2390
2391        let cap = self.stack_capacity_volume(&row.stack)?;
2392        let used = Self::contents_used_volume(&row.stack.contents);
2393        Some((used, cap))
2394    }
2395
2396    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2397        if row.is_chest_shell {
2398            return true;
2399        }
2400        if row.is_equip_shell {
2401            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2402        }
2403        self.inventory_item_category(&row.stack.template_id) == Some("container")
2404            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2405    }
2406
2407    fn container_stack_for(
2408        &self,
2409        location: &flatland_protocol::InventoryLocation,
2410        parent_instance_id: Option<uuid::Uuid>,
2411    ) -> Option<flatland_protocol::ItemStack> {
2412        match location {
2413            flatland_protocol::InventoryLocation::Root => {
2414                let pid = parent_instance_id?;
2415                self.find_stack_by_instance(&self.inventory_stacks, pid)
2416            }
2417            flatland_protocol::InventoryLocation::Worn { slot } => {
2418                let worn = self.worn.get(slot)?;
2419                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2420                    Some(worn.clone())
2421                } else {
2422                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2423                }
2424            }
2425            flatland_protocol::InventoryLocation::Placed { container_id } => {
2426                let chest = self
2427                    .placed_containers
2428                    .iter()
2429                    .find(|c| c.id == *container_id)?;
2430                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2431                    Some(flatland_protocol::ItemStack {
2432                        template_id: chest.template_id.clone(),
2433                        quantity: 1,
2434                        item_instance_id: chest.item_instance_id,
2435                        props: Default::default(),
2436                        status_bindings: Vec::new(),
2437                        contents: chest.contents.clone(),
2438                        display_name: Some(chest.display_name.clone()),
2439                        category: Some("container".into()),
2440                        capacity_volume: self
2441                            .inventory_hints
2442                            .get(&chest.template_id)
2443                            .and_then(|h| h.capacity_volume),
2444                        worker_lodging_capacity: chest.worker_lodging_capacity,
2445                        ..Default::default()
2446                    })
2447                } else {
2448                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2449                }
2450            }
2451            flatland_protocol::InventoryLocation::Keychain => None,
2452            flatland_protocol::InventoryLocation::WhisperPouch => None,
2453        }
2454    }
2455
2456    fn find_stack_by_instance(
2457        &self,
2458        stacks: &[flatland_protocol::ItemStack],
2459        instance_id: uuid::Uuid,
2460    ) -> Option<flatland_protocol::ItemStack> {
2461        for stack in stacks {
2462            if stack.item_instance_id == Some(instance_id) {
2463                return Some(stack.clone());
2464            }
2465            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2466                return Some(found);
2467            }
2468        }
2469        None
2470    }
2471
2472    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2473    pub fn max_movable_to(
2474        &self,
2475        template_id: &str,
2476        stack_qty: u32,
2477        from: &flatland_protocol::InventoryLocation,
2478        to: &flatland_protocol::InventoryLocation,
2479        parent_instance_id: Option<uuid::Uuid>,
2480    ) -> u32 {
2481        let unit_vol = self.item_base_volume(template_id);
2482        let unit_mass = self.item_base_mass(template_id);
2483        let mut limit = stack_qty;
2484
2485        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2486            let cap = parent
2487                .capacity_volume
2488                .or_else(|| {
2489                    self.inventory_hints
2490                        .get(&parent.template_id)
2491                        .and_then(|h| h.capacity_volume)
2492                })
2493                .unwrap_or(0.0);
2494            if cap > 0.0 && unit_vol > 0.0 {
2495                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2496                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2497            }
2498        }
2499
2500        let to_person = matches!(
2501            to,
2502            flatland_protocol::InventoryLocation::Root
2503                | flatland_protocol::InventoryLocation::Worn { .. }
2504        );
2505        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2506        if to_person && from_placed && unit_mass > 0.0 {
2507            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2508            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2509                limit = 0;
2510            } else {
2511                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2512            }
2513        }
2514
2515        limit.max(0).min(stack_qty)
2516    }
2517
2518    pub fn move_picker_max_at_selection(&self) -> u32 {
2519        let Some(picker) = &self.move_picker else {
2520            return 1;
2521        };
2522        let Some(opt) = picker.options.get(self.move_picker_index) else {
2523            return picker.stack_quantity;
2524        };
2525        match &opt.kind {
2526            MoveOptionKind::Cancel
2527            | MoveOptionKind::Drop
2528            | MoveOptionKind::Use
2529            | MoveOptionKind::GrantApply
2530            | MoveOptionKind::SellPlotToCrown { .. }
2531            | MoveOptionKind::PickupPlaced { .. }
2532            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2533            MoveOptionKind::Move {
2534                location,
2535                parent_instance_id,
2536            } => self.max_movable_to(
2537                &picker.template_id,
2538                picker.stack_quantity,
2539                &picker.from,
2540                location,
2541                *parent_instance_id,
2542            ),
2543        }
2544    }
2545
2546    pub fn clamp_move_picker_quantity(&mut self) {
2547        let max = self.move_picker_max_at_selection();
2548        if let Some(picker) = &mut self.move_picker {
2549            if max == 0 {
2550                picker.quantity = 1;
2551            } else {
2552                picker.quantity = picker.quantity.clamp(1, max);
2553            }
2554        }
2555    }
2556
2557    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2558        let max = self.move_picker_max_at_selection().max(1);
2559        if let Some(picker) = &mut self.move_picker {
2560            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2561            picker.quantity = next as u32;
2562        }
2563    }
2564
2565    pub fn move_picker_set_quantity_max(&mut self) {
2566        let max = self.move_picker_max_at_selection();
2567        if let Some(picker) = &mut self.move_picker {
2568            picker.quantity = if max == 0 {
2569                1
2570            } else {
2571                max.min(picker.stack_quantity)
2572            };
2573        }
2574    }
2575
2576    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2577        if let Some(picker) = &mut self.destroy_picker {
2578            let max = picker.stack_quantity.max(1);
2579            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2580            picker.quantity = next as u32;
2581        }
2582    }
2583
2584    pub fn destroy_picker_set_quantity_max(&mut self) {
2585        if let Some(picker) = &mut self.destroy_picker {
2586            picker.quantity = picker.stack_quantity.max(1);
2587        }
2588    }
2589
2590    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2591        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2592        (have, have >= need)
2593    }
2594
2595    pub fn currency_display(&self) -> String {
2596        crate::currency::currency_line(&self.inventory)
2597    }
2598
2599    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2600    pub fn in_shallow_water(&self) -> bool {
2601        let (px, py) = self.player_position();
2602        self.terrain_at(px, py)
2603            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2604    }
2605
2606    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2607        self.terrain_zone_at(x, y).map(|z| z.kind)
2608    }
2609
2610    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2611    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2612        use std::cell::RefCell;
2613
2614        const CHUNK: i32 = 8;
2615        thread_local! {
2616            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2617                RefCell::new(None);
2618        }
2619
2620        let zones = &self.terrain_zones;
2621        if zones.is_empty() {
2622            return None;
2623        }
2624        if zones.len() <= 48 {
2625            return zones
2626                .iter()
2627                .enumerate()
2628                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2629                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2630                .map(|(_, z)| z);
2631        }
2632
2633        let ptr = zones.as_ptr();
2634        let len = zones.len();
2635        INDEX.with(|cell| {
2636            let mut slot = cell.borrow_mut();
2637            let stale = match slot.as_ref() {
2638                Some((p, l, _)) => *p != ptr || *l != len,
2639                None => true,
2640            };
2641            if stale {
2642                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2643                    std::collections::HashMap::new();
2644                for (zi, z) in zones.iter().enumerate() {
2645                    let x0 = z.x0.min(z.x1).floor() as i32;
2646                    let y0 = z.y0.min(z.y1).floor() as i32;
2647                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2648                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2649                    let cx0 = x0.div_euclid(CHUNK);
2650                    let cy0 = y0.div_euclid(CHUNK);
2651                    let cx1 = x1.div_euclid(CHUNK);
2652                    let cy1 = y1.div_euclid(CHUNK);
2653                    for cy in cy0..=cy1 {
2654                        for cx in cx0..=cx1 {
2655                            chunks.entry((cx, cy)).or_default().push(zi);
2656                        }
2657                    }
2658                }
2659                *slot = Some((ptr, len, chunks));
2660            }
2661            let chunks = &slot.as_ref().expect("index").2;
2662            let cx = (x.floor() as i32).div_euclid(CHUNK);
2663            let cy = (y.floor() as i32).div_euclid(CHUNK);
2664            let mut best: Option<(usize, &TerrainZoneView)> = None;
2665            if let Some(list) = chunks.get(&(cx, cy)) {
2666                for &zi in list {
2667                    let Some(z) = zones.get(zi) else { continue };
2668                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2669                        continue;
2670                    }
2671                    best = match best {
2672                        None => Some((zi, z)),
2673                        Some((bi, bz)) => {
2674                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2675                                Some((zi, z))
2676                            } else {
2677                                Some((bi, bz))
2678                            }
2679                        }
2680                    };
2681                }
2682            }
2683            best.map(|(_, z)| z)
2684        })
2685    }
2686
2687    /// Ground elevation from terrain zones (m).
2688    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2689        self.terrain_zone_at(x, y)
2690            .map(|z| z.elevation)
2691            .unwrap_or(0.0)
2692    }
2693
2694    /// Walkable z levels at a map column (terrain + platforms).
2695    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2696        const TOL: f32 = 0.35;
2697        let mut levels = vec![self.elevation_at(x, y)];
2698        for p in &self.z_platforms {
2699            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2700                levels.push(p.z);
2701            }
2702        }
2703        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2704        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2705        levels
2706    }
2707
2708    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2709        const TOL: f32 = 0.35;
2710        self.walkable_levels_at(x, y)
2711            .iter()
2712            .any(|&l| (l - z).abs() <= TOL)
2713    }
2714
2715    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2716        let mut top = self.elevation_at(x, y);
2717        for p in &self.z_platforms {
2718            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2719                top = top.max(p.z);
2720            }
2721        }
2722        top
2723    }
2724
2725    /// Authoritative interior context from the server (`inside_building` flag).
2726    pub fn effective_inside_building(&self) -> Option<String> {
2727        self.player_entity().and_then(|p| p.inside_building.clone())
2728    }
2729
2730    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2731        self.inventory_stacks = stacks.to_vec();
2732        self.inventory.clear();
2733        self.inventory_hints.clear();
2734        fn walk(
2735            stacks: &[flatland_protocol::ItemStack],
2736            inventory: &mut std::collections::HashMap<String, u32>,
2737            hints: &mut std::collections::HashMap<String, InventoryHint>,
2738        ) {
2739            for stack in stacks {
2740                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2741                if stack.display_name.is_some()
2742                    || stack.category.is_some()
2743                    || stack.base_mass.is_some()
2744                    || stack.base_volume.is_some()
2745                {
2746                    hints.insert(
2747                        stack.template_id.clone(),
2748                        InventoryHint {
2749                            display_name: stack
2750                                .display_name
2751                                .clone()
2752                                .unwrap_or_else(|| stack.template_id.clone()),
2753                            category: stack.category.clone().unwrap_or_default(),
2754                            base_mass: stack.base_mass,
2755                            base_volume: stack.base_volume,
2756                            capacity_volume: stack.capacity_volume,
2757                            stackable: stack.stackable.unwrap_or(true),
2758                            listable: stack.listable.unwrap_or_else(|| {
2759                                category_default_listable(
2760                                    stack.category.as_deref().unwrap_or(""),
2761                                )
2762                            }),
2763                        },
2764                    );
2765                }
2766                walk(&stack.contents, inventory, hints);
2767            }
2768        }
2769        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2770        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
2771        for item in self.worn.values() {
2772            walk(
2773                std::slice::from_ref(item),
2774                &mut self.inventory,
2775                &mut self.inventory_hints,
2776            );
2777        }
2778    }
2779
2780    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
2781    /// inventory UI updates before the next tick snapshot arrives.
2782    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2783        let subtract_items =
2784            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2785        for stack in &notice.inventory_delta {
2786            if stack.quantity == 0 {
2787                continue;
2788            }
2789            if subtract_items {
2790                crate::currency::drain_template_stacks(
2791                    &mut self.inventory_stacks,
2792                    &stack.template_id,
2793                    stack.quantity,
2794                );
2795                continue;
2796            }
2797            let stackable = self
2798                .inventory_hints
2799                .get(&stack.template_id)
2800                .map(|h| h.stackable)
2801                .or(stack.stackable)
2802                .unwrap_or(true);
2803            if stackable {
2804                if let Some(existing) = self
2805                    .inventory_stacks
2806                    .iter_mut()
2807                    .find(|s| s.template_id == stack.template_id)
2808                {
2809                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
2810                    if stack.display_name.is_some() {
2811                        existing.display_name = stack.display_name.clone();
2812                    }
2813                    if stack.category.is_some() {
2814                        existing.category = stack.category.clone();
2815                    }
2816                    continue;
2817                }
2818            }
2819            self.inventory_stacks.push(stack.clone());
2820        }
2821        if notice.coins_delta != 0 {
2822            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2823        }
2824        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2825            let stacks = self.inventory_stacks.clone();
2826            self.sync_inventory_from_stacks(&stacks);
2827        }
2828        self.record_shop_trade_notice(notice);
2829    }
2830
2831    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
2832    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
2833    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
2834    /// iteration alone gives a stable row order.
2835    pub fn worn_rows(&self) -> Vec<InventoryRow> {
2836        let mut rows = Vec::new();
2837        for (slot, item) in &self.worn {
2838            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2839            rows.push(InventoryRow {
2840                depth: 0,
2841                stack: item.clone(),
2842                from: from.clone(),
2843                from_parent_instance_id: None,
2844                is_equip_shell: true,
2845                is_chest_shell: false,
2846                section: InventorySection::Worn,
2847            });
2848            for child in &item.contents {
2849                push_inventory_rows(
2850                    &mut rows,
2851                    1,
2852                    child,
2853                    &from,
2854                    item.item_instance_id,
2855                    InventorySection::Worn,
2856                );
2857            }
2858        }
2859        rows
2860    }
2861
2862    /// Root on-person stacks that can be handed to a hired worker.
2863    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2864        self.inventory_stacks
2865            .iter()
2866            .filter_map(|stack| {
2867                let item_instance_id = stack.item_instance_id?;
2868                let label = stack
2869                    .display_name
2870                    .clone()
2871                    .unwrap_or_else(|| stack.template_id.clone());
2872                let label = if stack.quantity > 1 {
2873                    format!("{label} ×{}", stack.quantity)
2874                } else {
2875                    label
2876                };
2877                Some(WorkerGiveOption {
2878                    item_instance_id,
2879                    label,
2880                    quantity: stack.quantity,
2881                    template_id: stack.template_id.clone(),
2882                })
2883            })
2884            .collect()
2885    }
2886
2887    /// Employer-known blueprints the selected worker does not yet know.
2888    pub fn teachable_blueprint_options(
2889        &self,
2890        worker: &flatland_protocol::HiredWorkerView,
2891    ) -> Vec<WorkerTeachOption> {
2892        let copper = crate::currency::copper_from_counts(&self.inventory);
2893        let mut options: Vec<WorkerTeachOption> = self
2894            .blueprints
2895            .iter()
2896            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2897            .map(|bp| {
2898                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2899                let cost = bp.worker_train_copper;
2900                WorkerTeachOption {
2901                    blueprint_id: bp.id.clone(),
2902                    label: if bp.label.is_empty() {
2903                        bp.id.clone()
2904                    } else {
2905                        bp.label.clone()
2906                    },
2907                    cost_copper: cost,
2908                    min_level,
2909                    worker_level: worker.level,
2910                    can_afford: copper >= cost,
2911                    level_ok: worker.level >= min_level,
2912                }
2913            })
2914            .collect();
2915        options.sort_by(|a, b| a.label.cmp(&b.label));
2916        options
2917    }
2918
2919    /// Loose on-person inventory (not worn, not inside a placed chest).
2920    /// Root stacks are ordered by category group; nested contents stay under their parent.
2921    pub fn person_rows(&self) -> Vec<InventoryRow> {
2922        self.person_rows_filtered("")
2923    }
2924
2925    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2926        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2927        roots.sort_by(|a, b| {
2928            let ca = a
2929                .category
2930                .as_deref()
2931                .or_else(|| self.inventory_item_category(&a.template_id))
2932                .unwrap_or("");
2933            let cb = b
2934                .category
2935                .as_deref()
2936                .or_else(|| self.inventory_item_category(&b.template_id))
2937                .unwrap_or("");
2938            let ga = inventory_category_group(ca).1;
2939            let gb = inventory_category_group(cb).1;
2940            ga.cmp(&gb).then_with(|| {
2941                let na = a
2942                    .display_name
2943                    .as_deref()
2944                    .unwrap_or(a.template_id.as_str());
2945                let nb = b
2946                    .display_name
2947                    .as_deref()
2948                    .unwrap_or(b.template_id.as_str());
2949                na.cmp(nb)
2950            })
2951        });
2952        let mut rows = Vec::new();
2953        for stack in roots {
2954            push_inventory_rows_filtered(
2955                &mut rows,
2956                0,
2957                stack,
2958                &flatland_protocol::InventoryLocation::Root,
2959                None,
2960                InventorySection::Person,
2961                filter,
2962            );
2963        }
2964        rows
2965    }
2966
2967    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2968        if filter.is_empty() {
2969            return self.worn_rows();
2970        }
2971        let mut rows = Vec::new();
2972        for (slot, item) in &self.worn {
2973            if !stack_matches_filter(item, filter) {
2974                continue;
2975            }
2976            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2977            let self_hit = {
2978                let f = filter.to_ascii_lowercase();
2979                let name = item
2980                    .display_name
2981                    .as_deref()
2982                    .unwrap_or("")
2983                    .to_ascii_lowercase();
2984                let tid = item.template_id.to_ascii_lowercase();
2985                name.contains(&f) || tid.contains(&f)
2986            };
2987            rows.push(InventoryRow {
2988                depth: 0,
2989                stack: item.clone(),
2990                from: from.clone(),
2991                from_parent_instance_id: None,
2992                is_equip_shell: true,
2993                is_chest_shell: false,
2994                section: InventorySection::Worn,
2995            });
2996            for child in &item.contents {
2997                if self_hit || stack_matches_filter(child, filter) {
2998                    push_inventory_rows_filtered(
2999                        &mut rows,
3000                        1,
3001                        child,
3002                        &from,
3003                        item.item_instance_id,
3004                        InventorySection::Worn,
3005                        if self_hit { "" } else { filter },
3006                    );
3007                }
3008            }
3009        }
3010        rows
3011    }
3012
3013    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3014    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3015        let mut rows = self.worn_rows();
3016        rows.extend(self.person_rows());
3017        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3018    }
3019
3020    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3021    /// populated when `accessible` — this is what makes a chest's contents
3022    /// disappear the moment you walk away or it's locked without your key.
3023    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3024        let (px, py) = self.player_position();
3025        let mut list: Vec<NearbyContainer> = self
3026            .placed_containers
3027            .iter()
3028            .filter_map(|c| {
3029                let distance_m = (c.x - px).hypot(c.y - py);
3030                if distance_m > CONTAINER_RANGE_M {
3031                    return None;
3032                }
3033                let mut rows = Vec::new();
3034                let from = flatland_protocol::InventoryLocation::Placed {
3035                    container_id: c.id.clone(),
3036                };
3037                rows.push(InventoryRow {
3038                    depth: 0,
3039                    stack: flatland_protocol::ItemStack {
3040                        template_id: c.template_id.clone(),
3041                        quantity: 1,
3042                        item_instance_id: c.item_instance_id,
3043                        props: Default::default(),
3044                        status_bindings: Vec::new(),
3045                        contents: Vec::new(),
3046                        display_name: Some(c.display_name.clone()),
3047                        category: Some("container".into()),
3048                        capacity_volume: c.capacity_volume,
3049                        worker_lodging_capacity: c.worker_lodging_capacity,
3050                        ..Default::default()
3051                    },
3052                    from: from.clone(),
3053                    from_parent_instance_id: None,
3054                    is_equip_shell: false,
3055                    is_chest_shell: true,
3056                    section: InventorySection::Nearby,
3057                });
3058                if c.accessible {
3059                    for child in &c.contents {
3060                        push_inventory_rows(
3061                            &mut rows,
3062                            1,
3063                            child,
3064                            &from,
3065                            c.item_instance_id,
3066                            InventorySection::Nearby,
3067                        );
3068                    }
3069                }
3070                Some(NearbyContainer {
3071                    view: c.clone(),
3072                    distance_m,
3073                    rows,
3074                })
3075            })
3076            .collect();
3077        list.sort_by(|a, b| {
3078            a.distance_m
3079                .partial_cmp(&b.distance_m)
3080                .unwrap_or(std::cmp::Ordering::Equal)
3081        });
3082        list
3083    }
3084
3085    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3086    pub fn nearest_placed_container(
3087        &self,
3088        max_dist: f32,
3089    ) -> Option<flatland_protocol::PlacedContainerView> {
3090        let (px, py) = self.player_position();
3091        self.placed_containers
3092            .iter()
3093            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3094            .min_by(|a, b| {
3095                let da = (a.x - px).hypot(a.y - py);
3096                let db = (b.x - px).hypot(b.y - py);
3097                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3098            })
3099            .cloned()
3100    }
3101
3102    /// Selectable rows for the **active inventory tab** (and current filter).
3103    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3104    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3105        let filter = self.inventory_filter.as_str();
3106        match self.inventory_tab {
3107            InventoryTab::OnPerson => {
3108                let mut rows = self.worn_rows_filtered(filter);
3109                rows.extend(self.person_rows_filtered(filter));
3110                rows
3111            }
3112            InventoryTab::Nearby => {
3113                let mut rows = Vec::new();
3114                for nc in self.nearby_containers() {
3115                    if filter.is_empty() {
3116                        rows.extend(nc.rows);
3117                        continue;
3118                    }
3119                    let shell = nc.rows.first().cloned();
3120                    let contents: Vec<_> = nc
3121                        .rows
3122                        .iter()
3123                        .skip(1)
3124                        .filter(|r| stack_matches_filter(&r.stack, filter))
3125                        .cloned()
3126                        .collect();
3127                    let shell_hit = shell
3128                        .as_ref()
3129                        .map(|s| stack_matches_filter(&s.stack, filter))
3130                        .unwrap_or(false);
3131                    if shell_hit || !contents.is_empty() {
3132                        if let Some(s) = shell {
3133                            rows.push(s);
3134                        }
3135                        if shell_hit {
3136                            rows.extend(nc.rows.into_iter().skip(1));
3137                        } else {
3138                            rows.extend(contents);
3139                        }
3140                    }
3141                }
3142                rows
3143            }
3144        }
3145    }
3146
3147    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3148        self.inventory_selectable_rows()
3149            .into_iter()
3150            .nth(self.inventory_menu_index)
3151    }
3152
3153    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3154        let cat = self
3155            .inventory_item_category(&row.stack.template_id)
3156            .unwrap_or("");
3157        if cat == "key" {
3158            self.key_inventory_label(&row.stack)
3159        } else {
3160            row.stack
3161                .display_name
3162                .clone()
3163                .unwrap_or_else(|| row.stack.template_id.clone())
3164        }
3165    }
3166
3167    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3168    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3169        let bindings = format_status_bindings_suffix(
3170            &row.stack.status_bindings,
3171            self.tick,
3172            DEFAULT_TICK_HZ,
3173        );
3174        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3175            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3176            let mode = Self::grant_mode(&row.stack);
3177            format!(" [grant {effect} · {mode} — e apply]")
3178        } else {
3179            String::new()
3180        };
3181        let qty = if row.stack.quantity > 1 {
3182            format!(" ×{}", row.stack.quantity)
3183        } else {
3184            String::new()
3185        };
3186        let worn_slot = if row.is_equip_shell {
3187            match row.from {
3188                flatland_protocol::InventoryLocation::Worn { slot } => {
3189                    format!(" ({})", body_slot_label(slot))
3190                }
3191                _ => String::new(),
3192            }
3193        } else {
3194            String::new()
3195        };
3196        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3197    }
3198
3199    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3200        (
3201            row.stack.template_id.clone(),
3202            self.inventory_row_base_label(row),
3203            self.inventory_row_visible_mod_signature(row),
3204        )
3205    }
3206
3207    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3208    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3209        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3210        for row in self.inventory_selectable_rows() {
3211            if row.stack.item_instance_id.is_none() {
3212                continue;
3213            }
3214            let key = self.inventory_row_instance_identity_key(&row);
3215            *counts.entry(key).or_default() += 1;
3216        }
3217        counts
3218            .into_iter()
3219            .filter(|(_, n)| *n > 1)
3220            .map(|(k, _)| k)
3221            .collect()
3222    }
3223
3224    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3225        let hex: String = id
3226            .as_simple()
3227            .to_string()
3228            .chars()
3229            .filter(|c| c.is_ascii_hexdigit())
3230            .collect();
3231        let short = if hex.len() >= 4 {
3232            &hex[hex.len() - 4..]
3233        } else {
3234            hex.as_str()
3235        };
3236        format!("Instance {id} (#{short})")
3237    }
3238
3239    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3240    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3241        let cat = self
3242            .inventory_item_category(&row.stack.template_id)
3243            .unwrap_or("");
3244        let label = self.inventory_row_base_label(row);
3245        let hint: String = if row.is_equip_shell {
3246            " [worn — Enter to unequip]".into()
3247        } else if row.is_chest_shell {
3248            let (locked, lodging_note) = match &row.from {
3249                flatland_protocol::InventoryLocation::Placed { container_id } => {
3250                    let locked = self
3251                        .placed_containers
3252                        .iter()
3253                        .find(|c| c.id == *container_id)
3254                        .map(|c| c.locked)
3255                        .unwrap_or(false);
3256                    let lodging_note = self
3257                        .lodging_occupancy_label(container_id)
3258                        .map(|who| format!(" [lodging: {who}]"))
3259                        .unwrap_or_default();
3260                    (locked, lodging_note)
3261                }
3262                _ => (false, String::new()),
3263            };
3264            if locked {
3265                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3266            } else {
3267                format!(" [Enter pick up · l lock]{lodging_note}")
3268            }
3269        } else if cat == "key" {
3270            self.key_inventory_hint(&row.stack)
3271        } else {
3272            match cat {
3273                "weapon" => " [weapon]".into(),
3274                "container" => " [bag/chest/belt]".into(),
3275                "lodging" => " [worker lodging]".into(),
3276                "armor" => " [armor]".into(),
3277                _ => String::new(),
3278            }
3279        };
3280        let qty = if row.stack.quantity > 1 {
3281            format!(" ×{}", row.stack.quantity)
3282        } else {
3283            String::new()
3284        };
3285        let bindings = format_status_bindings_suffix(
3286            &row.stack.status_bindings,
3287            self.tick,
3288            DEFAULT_TICK_HZ,
3289        );
3290        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3291            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3292            let mode = Self::grant_mode(&row.stack);
3293            format!(" [grant {effect} · {mode} — e apply]")
3294        } else {
3295            String::new()
3296        };
3297        let mass = self.stack_mass(&row.stack);
3298        let mass_kg = (mass >= 0.05).then_some(mass);
3299        let mass_str = mass_kg
3300            .map(|m| format!("  {m:.1} kg"))
3301            .unwrap_or_default();
3302        let volume = self.container_volume_stats(row);
3303        let vol_str = self.container_volume_label(row);
3304
3305        let mut title = label.clone();
3306        title.push_str(&qty);
3307        if row.is_equip_shell {
3308            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3309                title.push_str(&format!(" ({})", body_slot_label(slot)));
3310            }
3311        }
3312
3313        InventoryRowView {
3314            depth: row.depth,
3315            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3316            title: format!("{title}{grant_hint}{bindings}"),
3317            mass_kg,
3318            volume,
3319            instance_tooltip: None,
3320        }
3321    }
3322
3323    fn push_browser_item(
3324        &self,
3325        lines: &mut Vec<InventoryBrowserLine>,
3326        row: &InventoryRow,
3327        global_idx: &mut usize,
3328        target: usize,
3329        highlight: bool,
3330        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3331    ) {
3332        let mut view = self.format_inventory_row(row);
3333        if let Some(id) = row.stack.item_instance_id {
3334            let key = self.inventory_row_instance_identity_key(row);
3335            if ambiguous_instance_keys.contains(&key) {
3336                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3337            }
3338        }
3339        lines.push(InventoryBrowserLine::Item {
3340            selectable_index: *global_idx,
3341            selected: highlight && *global_idx == target,
3342            depth: view.depth,
3343            text: view.text,
3344            title: view.title,
3345            mass_kg: view.mass_kg,
3346            volume: view.volume,
3347            instance_tooltip: view.instance_tooltip,
3348        });
3349        *global_idx += 1;
3350    }
3351
3352    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3353    /// `selectable_index` matching `inventory_menu_index`.
3354    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3355        let mut lines = Vec::new();
3356        let target = self.inventory_menu_index;
3357        let highlight = !self.show_move_picker && !self.show_grant_picker;
3358        let filter = self.inventory_filter.as_str();
3359        let mut global_idx = 0usize;
3360        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3361
3362        match self.inventory_tab {
3363            InventoryTab::OnPerson => {
3364                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3365                let worn = self.worn_rows_filtered(filter);
3366                if worn.is_empty() {
3367                    lines.push(InventoryBrowserLine::Hint(
3368                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3369                    ));
3370                } else {
3371                    for row in &worn {
3372                        if row.is_equip_shell {
3373                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3374                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3375                                    "  {}:",
3376                                    body_slot_label(slot)
3377                                )));
3378                            }
3379                        }
3380                        self.push_browser_item(
3381                            &mut lines,
3382                            row,
3383                            &mut global_idx,
3384                            target,
3385                            highlight,
3386                            &ambiguous_instance_keys,
3387                        );
3388                    }
3389                }
3390
3391                lines.push(InventoryBrowserLine::Blank);
3392                lines.push(InventoryBrowserLine::Section(
3393                    "— On you (loose, not worn) —".into(),
3394                ));
3395                let person = self.person_rows_filtered(filter);
3396                if person.is_empty() {
3397                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3398                } else {
3399                    let mut last_group: Option<&'static str> = None;
3400                    for row in &person {
3401                        if row.depth == 0 {
3402                            let cat = row
3403                                .stack
3404                                .category
3405                                .as_deref()
3406                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3407                                .unwrap_or("");
3408                            let (group, _) = inventory_category_group(cat);
3409                            if last_group != Some(group) {
3410                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3411                                    "  {group}"
3412                                )));
3413                                last_group = Some(group);
3414                            }
3415                        }
3416                        self.push_browser_item(
3417                            &mut lines,
3418                            row,
3419                            &mut global_idx,
3420                            target,
3421                            highlight,
3422                            &ambiguous_instance_keys,
3423                        );
3424                    }
3425                }
3426            }
3427            InventoryTab::Nearby => {
3428                let nearby = self.nearby_containers();
3429                if nearby.is_empty() {
3430                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3431                    lines.push(InventoryBrowserLine::Hint(
3432                        "  (none within reach — walk up to a chest)".into(),
3433                    ));
3434                    lines.push(InventoryBrowserLine::Hint(
3435                        "  Select an on-person item, then m / Enter → move into chest.".into(),
3436                    ));
3437                } else {
3438                    let mut any_visible = false;
3439                    for nc in &nearby {
3440                        let shell = nc.rows.first();
3441                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
3442                            nc.rows.iter().skip(1).collect()
3443                        } else {
3444                            let shell_hit = shell
3445                                .map(|s| {
3446                                    let f = filter.to_ascii_lowercase();
3447                                    let name = s
3448                                        .stack
3449                                        .display_name
3450                                        .as_deref()
3451                                        .unwrap_or("")
3452                                        .to_ascii_lowercase();
3453                                    let tid = s.stack.template_id.to_ascii_lowercase();
3454                                    name.contains(&f) || tid.contains(&f)
3455                                })
3456                                .unwrap_or(false);
3457                            if shell_hit {
3458                                nc.rows.iter().skip(1).collect()
3459                            } else {
3460                                nc.rows
3461                                    .iter()
3462                                    .skip(1)
3463                                    .filter(|r| stack_matches_filter(&r.stack, filter))
3464                                    .collect()
3465                            }
3466                        };
3467                        let shell_visible = filter.is_empty()
3468                            || shell
3469                                .map(|s| stack_matches_filter(&s.stack, filter))
3470                                .unwrap_or(false)
3471                            || !contents.is_empty();
3472                        if !shell_visible && shell.is_some() {
3473                            continue;
3474                        }
3475                        any_visible = true;
3476                        lines.push(InventoryBrowserLine::Blank);
3477                        let lock_note = if nc.view.locked && nc.view.accessible {
3478                            "  unlocked with your key"
3479                        } else if nc.view.locked {
3480                            "  locked"
3481                        } else {
3482                            ""
3483                        };
3484                        lines.push(InventoryBrowserLine::Section(format!(
3485                            "— {} ({:.0}m away){lock_note} —",
3486                            nc.view.display_name, nc.distance_m
3487                        )));
3488                        if !nc.view.accessible {
3489                            lines.push(InventoryBrowserLine::Hint(
3490                                "  locked — need the matching key (l to try)".into(),
3491                            ));
3492                        } else if nc.rows.is_empty() {
3493                            lines.push(InventoryBrowserLine::Hint(
3494                                "  (empty — switch to On person, select an item, m to move in)"
3495                                    .into(),
3496                            ));
3497                        } else if let Some(shell_row) = shell {
3498                            self.push_browser_item(
3499                                &mut lines,
3500                                shell_row,
3501                                &mut global_idx,
3502                                target,
3503                                highlight,
3504                                &ambiguous_instance_keys,
3505                            );
3506                            for row in contents {
3507                                self.push_browser_item(
3508                                    &mut lines,
3509                                    row,
3510                                    &mut global_idx,
3511                                    target,
3512                                    highlight,
3513                                    &ambiguous_instance_keys,
3514                                );
3515                            }
3516                        }
3517                    }
3518                    if !any_visible {
3519                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3520                        lines.push(InventoryBrowserLine::Hint(
3521                            "  (no matching items — clear filter with Esc)".into(),
3522                        ));
3523                    }
3524                }
3525            }
3526        }
3527        lines
3528    }
3529
3530    /// Destinations for picking up a placed chest/crate into inventory.
3531    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3532        let mut opts = Vec::new();
3533        opts.push(MoveOption {
3534            label: "Relocate…".into(),
3535            kind: MoveOptionKind::RelocatePlaced {
3536                container_id: container_id.to_string(),
3537            },
3538        });
3539        opts.push(MoveOption {
3540            label: "On your person (loose)".into(),
3541            kind: MoveOptionKind::PickupPlaced {
3542                container_id: container_id.to_string(),
3543                nest_location: flatland_protocol::InventoryLocation::Root,
3544                nest_parent_instance_id: None,
3545            },
3546        });
3547        for (slot, item) in &self.worn {
3548            if item.category.as_deref() != Some("container") {
3549                continue;
3550            }
3551            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3552                continue;
3553            }
3554            let Some(parent_id) = item.item_instance_id else {
3555                continue;
3556            };
3557            let shell_name = item
3558                .display_name
3559                .clone()
3560                .unwrap_or_else(|| item.template_id.clone());
3561            opts.push(MoveOption {
3562                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3563                kind: MoveOptionKind::PickupPlaced {
3564                    container_id: container_id.to_string(),
3565                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3566                    nest_parent_instance_id: Some(parent_id),
3567                },
3568            });
3569            // Nested pouches inside the worn bag.
3570            Self::append_chest_pickup_nested(
3571                &mut opts,
3572                container_id,
3573                flatland_protocol::InventoryLocation::Worn { slot: *slot },
3574                item,
3575                &format!("in {shell_name}"),
3576            );
3577        }
3578        opts.push(MoveOption {
3579            label: "Cancel".into(),
3580            kind: MoveOptionKind::Cancel,
3581        });
3582        opts
3583    }
3584
3585    fn append_chest_pickup_nested(
3586        opts: &mut Vec<MoveOption>,
3587        container_id: &str,
3588        location: flatland_protocol::InventoryLocation,
3589        parent: &flatland_protocol::ItemStack,
3590        context: &str,
3591    ) {
3592        for child in &parent.contents {
3593            if child.category.as_deref() != Some("container") {
3594                continue;
3595            }
3596            if !Self::is_volume_container_stack(child) {
3597                continue;
3598            }
3599            // Skip placeable nested chests — relocating into another chest is not useful.
3600            if child.world_placeable == Some(true) {
3601                continue;
3602            }
3603            let Some(child_id) = child.item_instance_id else {
3604                continue;
3605            };
3606            let name = child
3607                .display_name
3608                .clone()
3609                .unwrap_or_else(|| child.template_id.clone());
3610            opts.push(MoveOption {
3611                label: format!("{name} ({context})"),
3612                kind: MoveOptionKind::PickupPlaced {
3613                    container_id: container_id.to_string(),
3614                    nest_location: location.clone(),
3615                    nest_parent_instance_id: Some(child_id),
3616                },
3617            });
3618            Self::append_chest_pickup_nested(
3619                opts,
3620                container_id,
3621                location.clone(),
3622                child,
3623                &format!("in {name}"),
3624            );
3625        }
3626    }
3627
3628    /// Build the "move to…" destination list for an item currently at `from`.
3629    pub fn move_destinations_for(
3630        &self,
3631        from: &flatland_protocol::InventoryLocation,
3632        from_parent_instance_id: Option<uuid::Uuid>,
3633        moving_instance_id: Option<uuid::Uuid>,
3634        moving_template_id: &str,
3635    ) -> Vec<MoveOption> {
3636        let mut opts = Vec::new();
3637        if *from != flatland_protocol::InventoryLocation::Root {
3638            opts.push(MoveOption {
3639                label: "On your person (loose)".into(),
3640                kind: MoveOptionKind::Move {
3641                    location: flatland_protocol::InventoryLocation::Root,
3642                    parent_instance_id: None,
3643                },
3644            });
3645        }
3646        for (slot, item) in &self.worn {
3647            if item.category.as_deref() != Some("container") {
3648                continue;
3649            }
3650            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3651            let shell_name = item
3652                .display_name
3653                .clone()
3654                .unwrap_or_else(|| item.template_id.clone());
3655
3656            // Backpack and other worn volume containers — store directly inside the shell.
3657            if *slot != BodySlot::Waist
3658                && item.item_instance_id != moving_instance_id
3659                && Self::is_volume_container_stack(item)
3660            {
3661                Self::push_move_destination(
3662                    &mut opts,
3663                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3664                    location.clone(),
3665                    item.item_instance_id,
3666                    from,
3667                    from_parent_instance_id,
3668                );
3669            }
3670
3671            // Belt loops only accept pouch attachments — not loose materials.
3672            if *slot == BodySlot::Waist
3673                && Self::attaches_to_belt_loop(moving_template_id)
3674                && item.item_instance_id != moving_instance_id
3675            {
3676                Self::push_move_destination(
3677                    &mut opts,
3678                    format!("{shell_name} (belt loop)"),
3679                    location.clone(),
3680                    item.item_instance_id,
3681                    from,
3682                    from_parent_instance_id,
3683                );
3684            }
3685
3686            let context = if *slot == BodySlot::Waist {
3687                format!("on {shell_name}")
3688            } else {
3689                format!("in {shell_name}")
3690            };
3691            Self::append_nested_container_destinations(
3692                &mut opts,
3693                location,
3694                item,
3695                &context,
3696                from,
3697                from_parent_instance_id,
3698                moving_instance_id,
3699            );
3700        }
3701        for nc in self.nearby_containers() {
3702            if !nc.view.accessible {
3703                continue;
3704            }
3705            let location = flatland_protocol::InventoryLocation::Placed {
3706                container_id: nc.view.id.clone(),
3707            };
3708            Self::push_move_destination(
3709                &mut opts,
3710                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3711                location,
3712                nc.view.item_instance_id,
3713                from,
3714                from_parent_instance_id,
3715            );
3716        }
3717        let allow_drop = moving_instance_id
3718            .and_then(|id| self.stack_for_instance(id))
3719            .map(|stack| {
3720                !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3721            })
3722            .unwrap_or(
3723                moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_TEMPLATE,
3724            );
3725        if allow_drop {
3726            opts.push(MoveOption {
3727                label: "Drop on the ground".into(),
3728                kind: MoveOptionKind::Drop,
3729            });
3730        }
3731        opts.push(MoveOption {
3732            label: "Cancel".into(),
3733            kind: MoveOptionKind::Cancel,
3734        });
3735        opts
3736    }
3737
3738    fn is_same_container_dest(
3739        dest_location: &flatland_protocol::InventoryLocation,
3740        dest_parent: Option<uuid::Uuid>,
3741        from: &flatland_protocol::InventoryLocation,
3742        from_parent: Option<uuid::Uuid>,
3743    ) -> bool {
3744        dest_location == from && dest_parent == from_parent
3745    }
3746
3747    fn push_move_destination(
3748        opts: &mut Vec<MoveOption>,
3749        label: String,
3750        location: flatland_protocol::InventoryLocation,
3751        parent_instance_id: Option<uuid::Uuid>,
3752        from: &flatland_protocol::InventoryLocation,
3753        from_parent_instance_id: Option<uuid::Uuid>,
3754    ) {
3755        if Self::is_same_container_dest(
3756            &location,
3757            parent_instance_id,
3758            from,
3759            from_parent_instance_id,
3760        ) {
3761            return;
3762        }
3763        opts.push(MoveOption {
3764            label,
3765            kind: MoveOptionKind::Move {
3766                location,
3767                parent_instance_id,
3768            },
3769        });
3770    }
3771
3772    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3773        stack.capacity_volume.is_some_and(|c| c > 0.0)
3774    }
3775
3776    fn attaches_to_belt_loop(template_id: &str) -> bool {
3777        matches!(template_id, "leather_pouch" | "dimensional_pouch")
3778    }
3779
3780    fn append_nested_container_destinations(
3781        opts: &mut Vec<MoveOption>,
3782        location: flatland_protocol::InventoryLocation,
3783        container: &flatland_protocol::ItemStack,
3784        context: &str,
3785        from: &flatland_protocol::InventoryLocation,
3786        from_parent_instance_id: Option<uuid::Uuid>,
3787        moving_instance_id: Option<uuid::Uuid>,
3788    ) {
3789        for child in &container.contents {
3790            if Self::is_volume_container_stack(child)
3791                && child.item_instance_id != moving_instance_id
3792            {
3793                let name = child
3794                    .display_name
3795                    .clone()
3796                    .unwrap_or_else(|| child.template_id.clone());
3797                Self::push_move_destination(
3798                    opts,
3799                    format!("{name} ({context})"),
3800                    location.clone(),
3801                    child.item_instance_id,
3802                    from,
3803                    from_parent_instance_id,
3804                );
3805            }
3806            let nested_context = format!(
3807                "in {}",
3808                child.display_name.as_deref().unwrap_or(&child.template_id)
3809            );
3810            Self::append_nested_container_destinations(
3811                opts,
3812                location.clone(),
3813                child,
3814                &nested_context,
3815                from,
3816                from_parent_instance_id,
3817                moving_instance_id,
3818            );
3819        }
3820    }
3821
3822    fn clamp_inventory_indices(&mut self) {
3823        let n = self.inventory_selectable_rows().len();
3824        self.inventory_menu_index = if n == 0 {
3825            0
3826        } else {
3827            self.inventory_menu_index.min(n - 1)
3828        };
3829        if let Some(picker) = &self.move_picker {
3830            let pn = picker.options.len();
3831            self.move_picker_index = if pn == 0 {
3832                0
3833            } else {
3834                self.move_picker_index.min(pn - 1)
3835            };
3836        }
3837    }
3838
3839    /// Drop interior map layers whenever the player is outdoors.
3840    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
3841    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
3842    /// force `build_grid` into a full-map z pass on every click until client restart.
3843    fn sync_interior_map_context(&mut self) {
3844        if self.effective_inside_building().is_none() {
3845            self.interior_map = None;
3846            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
3847                self.z_platforms = platforms;
3848                self.z_transitions = transitions;
3849            }
3850            return;
3851        }
3852        self.sync_interior_z_bands();
3853    }
3854
3855    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
3856    fn sync_interior_z_bands(&mut self) {
3857        if self.effective_inside_building().is_some() {
3858            if let Some(map) = &self.interior_map {
3859                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
3860                    if self.z_bands_outdoor_backup.is_none() {
3861                        self.z_bands_outdoor_backup = Some((
3862                            std::mem::take(&mut self.z_platforms),
3863                            std::mem::take(&mut self.z_transitions),
3864                        ));
3865                    }
3866                    self.z_platforms = map.z_platforms.clone();
3867                    self.z_transitions = map.z_transitions.clone();
3868                }
3869            }
3870        }
3871    }
3872
3873    fn apply_snapshot_fields(
3874        &mut self,
3875        snapshot: &flatland_protocol::Snapshot,
3876        entity_id: EntityId,
3877    ) {
3878        self.tick = snapshot.tick;
3879        self.chunk_rev = snapshot.chunk_rev;
3880        self.content_rev = snapshot.content_rev;
3881        self.publish_rev = snapshot.publish_rev;
3882        self.resource_nodes = snapshot.resource_nodes.clone();
3883        self.ground_drops = snapshot.ground_drops.clone();
3884        self.placed_containers = snapshot.placed_containers.clone();
3885        self.world_x0 = snapshot.world_x0;
3886        self.world_y0 = snapshot.world_y0;
3887        self.world_width_m = snapshot.world_width_m;
3888        self.world_height_m = snapshot.world_height_m;
3889        self.world_clock = snapshot.world_clock;
3890        self.terrain_zones = snapshot.terrain_zones.clone();
3891        self.z_platforms = snapshot.z_platforms.clone();
3892        self.z_transitions = snapshot.z_transitions.clone();
3893        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
3894        self.z_bands_outdoor_backup = None;
3895        self.buildings = snapshot.buildings.clone();
3896        self.doors = snapshot.doors.clone();
3897        self.interior_map = snapshot.interior_map.clone();
3898        self.npcs = snapshot.npcs.clone();
3899        self.blueprints = snapshot.blueprints.clone();
3900        self.sync_inventory_from_stacks(&snapshot.inventory);
3901        self.player = snapshot
3902            .entities
3903            .iter()
3904            .find(|e| e.id == entity_id)
3905            .cloned();
3906        self.entities = snapshot.entities.clone();
3907        self.quest_log = snapshot.quest_log.clone();
3908        self.apply_hired_workers(snapshot.hired_workers.clone());
3909        self.interactables = snapshot.interactables.clone();
3910        self.ledger = snapshot.ledger.clone();
3911        self.career = snapshot.career.clone();
3912        self.combat_fx = snapshot.combat_fx.clone();
3913        self.property_zones = snapshot.property_zones.clone();
3914        self.tax_zones = snapshot.tax_zones.clone();
3915        self.growth_zones = snapshot.growth_zones.clone();
3916        self.biome_zones = snapshot.biome_zones.clone();
3917        self.property_plots = snapshot.property_plots.clone();
3918        self.property_plot_settings = snapshot.property_plot_settings.clone();
3919        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
3920        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
3921        if self.effective_inside_building().is_some() {
3922            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
3923        }
3924        self.sync_interior_map_context();
3925        self.refresh_whisper_range();
3926    }
3927
3928    /// Re-derive inventory selection state after a snapshot/tick — chests that
3929    /// went out of range or got locked simply vanish from the row list, and the
3930    /// move picker (if any) closes once its item is no longer reachable.
3931    fn refresh_inventory_ui(&mut self) {
3932        if let Some(picker) = &self.move_picker {
3933            let instance_id = picker.item_instance_id;
3934            let still_exists = self
3935                .inventory_selectable_rows()
3936                .iter()
3937                .any(|r| r.stack.item_instance_id == Some(instance_id));
3938            if !still_exists {
3939                self.move_picker = None;
3940                self.show_move_picker = false;
3941            }
3942        }
3943        if let Some(picker) = &self.destroy_picker {
3944            let instance_id = picker.item_instance_id;
3945            let still_exists = self
3946                .inventory_selectable_rows()
3947                .iter()
3948                .any(|r| r.stack.item_instance_id == Some(instance_id));
3949            if !still_exists {
3950                self.destroy_picker = None;
3951                self.show_destroy_picker = false;
3952                self.destroy_confirm_pending = false;
3953            }
3954        }
3955        self.clamp_inventory_indices();
3956    }
3957
3958    /// Replace the hired-worker list from a snapshot/delta.
3959    ///
3960    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
3961    /// selected worker (and its step line) does not jump when the server rebuilds
3962    /// the list.
3963    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3964        let selected_id = self
3965            .hired_workers
3966            .get(self.workers_menu_index)
3967            .map(|w| w.instance_id.clone());
3968        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3969        let now = Instant::now();
3970        for w in &workers {
3971            let prev_err = self
3972                .hired_workers
3973                .iter()
3974                .find(|p| p.instance_id == w.instance_id)
3975                .and_then(|p| p.last_error.as_deref());
3976            let new_err = w.last_error.as_deref();
3977            if new_err != prev_err {
3978                if let Some(err) = new_err {
3979                    if !worker_error_is_transient(err) {
3980                        self.push_log(format!("Worker {}: {err}", w.label));
3981                    }
3982                }
3983            }
3984        }
3985        let mut next_display = BTreeMap::new();
3986        let mut next_errors = BTreeMap::new();
3987        for w in &workers {
3988            let mut sticky = self
3989                .worker_step_display
3990                .remove(&w.instance_id)
3991                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3992            sticky.observe(&w.step_label, now);
3993            next_display.insert(w.instance_id.clone(), sticky);
3994
3995            let mut err_sticky = self
3996                .worker_error_display
3997                .remove(&w.instance_id)
3998                .unwrap_or_default();
3999            err_sticky.observe(w.last_error.as_deref(), now);
4000            if err_sticky.shown(now).is_some() {
4001                next_errors.insert(w.instance_id.clone(), err_sticky);
4002            }
4003        }
4004        self.worker_step_display = next_display;
4005        self.worker_error_display = next_errors;
4006        self.hired_workers = workers;
4007        self.sync_worker_take_picker_from_hired();
4008        if let Some(id) = selected_id {
4009            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4010                self.workers_menu_index = idx;
4011                return;
4012            }
4013        }
4014        if self.workers_menu_index >= self.hired_workers.len() {
4015            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4016        }
4017    }
4018
4019    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4020    fn sync_worker_take_picker_from_hired(&mut self) {
4021        if !self.show_worker_take_picker {
4022            return;
4023        }
4024        let Some(picker) = self.worker_take_picker.clone() else {
4025            return;
4026        };
4027        let Some(worker) = self
4028            .hired_workers
4029            .iter()
4030            .find(|w| w.instance_id == picker.worker_instance_id)
4031            .cloned()
4032        else {
4033            self.show_worker_take_picker = false;
4034            self.worker_take_picker = None;
4035            self.worker_take_picker_index = 0;
4036            return;
4037        };
4038        let options: Vec<WorkerGiveOption> = worker
4039            .inventory
4040            .iter()
4041            .filter_map(|stack| {
4042                let item_instance_id = stack.item_instance_id?;
4043                let label = stack
4044                    .display_name
4045                    .clone()
4046                    .unwrap_or_else(|| stack.template_id.clone());
4047                let label = if stack.quantity > 1 {
4048                    format!("{label} ×{}", stack.quantity)
4049                } else {
4050                    label
4051                };
4052                Some(WorkerGiveOption {
4053                    item_instance_id,
4054                    label,
4055                    quantity: stack.quantity,
4056                    template_id: stack.template_id.clone(),
4057                })
4058            })
4059            .collect();
4060        if options.is_empty() {
4061            self.show_worker_take_picker = false;
4062            self.worker_take_picker = None;
4063            self.worker_take_picker_index = 0;
4064            return;
4065        }
4066        let prev_id = picker
4067            .options
4068            .get(self.worker_take_picker_index)
4069            .map(|o| o.item_instance_id);
4070        let idx = prev_id
4071            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4072            .unwrap_or(0)
4073            .min(options.len().saturating_sub(1));
4074        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4075        let quantity = picker.quantity.clamp(1, max_qty);
4076        self.worker_take_picker_index = idx;
4077        self.worker_take_picker = Some(WorkerTakePicker {
4078            worker_instance_id: picker.worker_instance_id,
4079            worker_label: picker.worker_label,
4080            options,
4081            quantity,
4082        });
4083    }
4084
4085    /// Held coarse step label for the workers menu (`step:` line).
4086    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4087        self.worker_step_display
4088            .get(worker_instance_id)
4089            .map(|s| s.shown.as_str())
4090            .or_else(|| {
4091                self.hired_workers
4092                    .iter()
4093                    .find(|w| w.instance_id == worker_instance_id)
4094                    .map(|w| w.step_label.as_str())
4095            })
4096            .unwrap_or("")
4097    }
4098
4099    /// Held error line for workers UI (detail + compact).
4100    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4101        let now = Instant::now();
4102        self.worker_error_display
4103            .get(worker_instance_id)
4104            .and_then(|s| s.shown(now))
4105            .or_else(|| {
4106                self.hired_workers
4107                    .iter()
4108                    .find(|w| w.instance_id == worker_instance_id)
4109                    .and_then(|w| w.last_error.as_deref())
4110                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4111            })
4112            .filter(|e| !worker_error_is_hud_noise(e))
4113    }
4114
4115    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4116        self.in_combat = combat.in_combat;
4117        self.auto_attack = combat.auto_attack;
4118        self.combat_has_los = combat.has_los;
4119        self.attack_cd_ticks = combat.attack_cd_ticks;
4120        self.gcd_ticks = combat.gcd_ticks;
4121        self.weapon_ability_id = combat.ability_id.clone();
4122        self.mainhand_template_id = combat.mainhand_template_id.clone();
4123        self.mainhand_label = combat.mainhand_label.clone();
4124        self.offhand_template_id = combat.offhand_template_id.clone();
4125        self.offhand_label = combat.offhand_label.clone();
4126        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4127            1
4128        } else {
4129            combat.mainhand_hand_slots
4130        };
4131        self.defense = combat.defense.clone();
4132        self.worn = combat.worn.iter().cloned().collect();
4133        self.carry_mass = combat.carry_mass;
4134        self.carry_mass_max = combat.carry_mass_max;
4135        self.encumbrance = combat.encumbrance;
4136        self.cast_progress = combat.cast.clone();
4137        self.timed_channel = combat.timed_channel.clone();
4138        self.ability_cooldowns = combat.ability_cooldowns.clone();
4139        self.blocking_active = combat.blocking_active;
4140        self.max_target_slots = combat.max_target_slots.max(1);
4141        self.combat_slots = combat.slots.clone();
4142        self.rotation_presets = combat.rotation_presets.clone();
4143        self.known_abilities = combat.known_abilities.clone();
4144        self.ability_meta = combat
4145            .ability_meta
4146            .iter()
4147            .cloned()
4148            .map(|meta| (meta.id.clone(), meta))
4149            .collect();
4150        self.ability_mastery = combat
4151            .ability_mastery
4152            .iter()
4153            .cloned()
4154            .map(|row| (row.ability_id.clone(), row))
4155            .collect();
4156        self.hotbar = combat.hotbar.clone();
4157        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4158        self.keychain_stacks = combat.keychain.clone();
4159        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4160        self.combat_target_detail = combat.target.clone();
4161        self.statuses = combat.statuses.clone();
4162        self.combat_target = combat.target_entity_id;
4163        if combat.progression_xp_base > 0.0 {
4164            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4165                baseline_display: combat.progression_baseline,
4166                xp_base: combat.progression_xp_base,
4167                xp_growth: combat.progression_xp_growth,
4168            });
4169        }
4170        if let Some(xp) = &combat.progression_xp {
4171            if let Some(player) = &mut self.player {
4172                player.progression_xp = Some(xp.clone());
4173                if let Some(attrs) = combat.attributes {
4174                    player.attributes = Some(attrs);
4175                }
4176                if let Some(skills) = &combat.skills {
4177                    player.skills = Some(skills.clone());
4178                }
4179            }
4180        }
4181        if let Some(label) = &combat.target_label {
4182            self.combat_target_label = Some(label.clone());
4183        } else if let Some(id) = combat.target_entity_id {
4184            self.combat_target_label = self
4185                .entities
4186                .iter()
4187                .find(|e| e.id == id)
4188                .map(|e| e.label.clone())
4189                .or_else(|| self.combat_target_label.clone());
4190        }
4191        self.refresh_inventory_ui();
4192    }
4193
4194    /// Entity id assigned to a combat slot (from server HUD).
4195    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4196        self.combat_slots
4197            .iter()
4198            .find(|s| s.slot_index == slot)
4199            .and_then(|s| s.target_entity_id)
4200            .or_else(|| if slot == 1 { self.combat_target } else { None })
4201    }
4202
4203    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4204    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4205        self.ability_meta
4206            .get(ability_id)
4207            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4208            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4209            // the server validates aim_mode.
4210            .unwrap_or(self.ground_target.is_some())
4211    }
4212
4213    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4214    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4215        self.ability_meta
4216            .get(ability_id)
4217            .map(|meta| meta.aim_mode == "ground")
4218            .unwrap_or(false)
4219    }
4220
4221    /// True when the ability may be placed in an auto-rotation preset.
4222    /// Missing meta (pre-sync) defaults to eligible; server still validates.
4223    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4224        self.ability_meta
4225            .get(ability_id)
4226            .map(|meta| meta.auto_rotation_eligible)
4227            .unwrap_or(true)
4228    }
4229
4230    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4231    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4232        self.ground_target = Some((x, y, 0.0));
4233    }
4234
4235    /// Clear the local free-aim ground target.
4236    pub fn clear_ground_target(&mut self) {
4237        self.ground_target = None;
4238    }
4239
4240    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4241    /// May be an ability id or `item:<template_id>` consumable binding.
4242    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4243        if !(1..=9).contains(&slot_1_to_9) {
4244            return None;
4245        }
4246        self.hotbar
4247            .get((slot_1_to_9 - 1) as usize)
4248            .and_then(|a| a.as_deref())
4249            .filter(|id| !id.is_empty())
4250    }
4251
4252    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4253    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4254        let binding = self.hotbar_ability(slot_1_to_9)?;
4255        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4256            let name = self
4257                .inventory_hints
4258                .get(template_id)
4259                .map(|h| h.display_name.as_str())
4260                .unwrap_or(template_id);
4261            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4262            Some(format!("{name}×{qty}"))
4263        } else {
4264            Some(binding.to_string())
4265        }
4266    }
4267
4268    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4269    pub fn loadout_ability_choices(&self) -> Vec<String> {
4270        let mut out = self.known_abilities.clone();
4271        let weapon = self.weapon_ability_id.trim();
4272        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4273            out.push(weapon.to_string());
4274        }
4275        out
4276    }
4277
4278    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4279    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4280        let mut out = Vec::new();
4281        for ability in self.loadout_ability_choices() {
4282            let meta = if ability == self.weapon_ability_id {
4283                Some("weapon".into())
4284            } else {
4285                None
4286            };
4287            out.push(LoadoutHotbarChoice {
4288                binding: ability.clone(),
4289                label: ability,
4290                meta,
4291            });
4292        }
4293        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4294        for stack in &self.inventory_stacks {
4295            if Self::stack_is_item_grant(stack) {
4296                continue;
4297            }
4298            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4299                continue;
4300            }
4301            let qty = stack.quantity.max(1);
4302            if let Some((_, _, existing)) = consumables
4303                .iter_mut()
4304                .find(|(id, _, _)| id == &stack.template_id)
4305            {
4306                *existing = existing.saturating_add(qty);
4307            } else {
4308                let label = stack
4309                    .display_name
4310                    .clone()
4311                    .or_else(|| {
4312                        self.inventory_hints
4313                            .get(&stack.template_id)
4314                            .map(|h| h.display_name.clone())
4315                    })
4316                    .unwrap_or_else(|| stack.template_id.clone());
4317                consumables.push((stack.template_id.clone(), label, qty));
4318            }
4319        }
4320        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4321        for (template_id, label, qty) in consumables {
4322            out.push(LoadoutHotbarChoice {
4323                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4324                label: format!("{label} ×{qty}"),
4325                meta: Some("use".into()),
4326            });
4327        }
4328        out
4329    }
4330
4331    /// Hostile wildlife / monsters for T1 (`Tab`).
4332    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4333        self.combat_candidates()
4334    }
4335
4336    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4337    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4338        let (px, py) = self.player_position();
4339        let dist = |id: EntityId| {
4340            self.entities
4341                .iter()
4342                .find(|e| e.id == id)
4343                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4344                .unwrap_or(f32::MAX)
4345        };
4346
4347        let mut allies = Vec::new();
4348        // Self first — heal_touch on T2.
4349        if let Some(me) = self.player.as_ref() {
4350            let alive = me
4351                .vitals
4352                .as_ref()
4353                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4354                .unwrap_or(true);
4355            if alive {
4356                allies.push((self.entity_id, "Yourself".into()));
4357            }
4358        }
4359        for entity in &self.entities {
4360            if entity.id == self.entity_id {
4361                continue;
4362            }
4363            if entity.vitals.is_some() {
4364                let alive = entity
4365                    .vitals
4366                    .as_ref()
4367                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4368                    .unwrap_or(true);
4369                if alive {
4370                    allies.push((entity.id, entity.label.clone()));
4371                }
4372            }
4373        }
4374        allies.sort_by(|(a, _), (b, _)| {
4375            if *a == self.entity_id {
4376                return std::cmp::Ordering::Less;
4377            }
4378            if *b == self.entity_id {
4379                return std::cmp::Ordering::Greater;
4380            }
4381            dist(*a)
4382                .partial_cmp(&dist(*b))
4383                .unwrap_or(std::cmp::Ordering::Equal)
4384        });
4385
4386        let mut monsters = self.combat_candidates();
4387        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4388        allies.into_iter().chain(monsters).collect()
4389    }
4390
4391    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4392        match slot_index {
4393            2 => self.t2_candidates(),
4394            _ => self.t1_candidates(),
4395        }
4396    }
4397
4398    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
4399    pub fn pick_combat_target_at(
4400        &self,
4401        wx: f32,
4402        wy: f32,
4403        slot_index: u8,
4404        radius_m: f32,
4405    ) -> Option<(EntityId, String)> {
4406        let mut best: Option<(f32, EntityId, String)> = None;
4407        for (id, label) in self.candidates_for_slot(slot_index) {
4408            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4409                // NPCs may only appear in NpcView — fall back to npc list coords.
4410                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4411                    let d = distance(wx, wy, npc.x, npc.y);
4412                    if d <= radius_m {
4413                        best = match best {
4414                            Some((bd, _, _)) if bd <= d => best,
4415                            _ => Some((d, id, label)),
4416                        };
4417                    }
4418                }
4419                continue;
4420            };
4421            let d = distance(
4422                wx,
4423                wy,
4424                entity.transform.position.x,
4425                entity.transform.position.y,
4426            );
4427            if d <= radius_m {
4428                best = match best {
4429                    Some((bd, _, _)) if bd <= d => best,
4430                    _ => Some((d, id, label)),
4431                };
4432            }
4433        }
4434        best.map(|(_, id, label)| (id, label))
4435    }
4436
4437    /// Full client reset after Welcome (initial connect or reconnect).
4438    pub(crate) fn restore_from_welcome(
4439        &mut self,
4440        session_id: SessionId,
4441        entity_id: EntityId,
4442        snapshot: &flatland_protocol::Snapshot,
4443    ) {
4444        self.clear_harvest_state();
4445        self.disconnect_reason = None;
4446        self.show_stats = false;
4447        self.show_craft_menu = false;
4448        self.show_shop_menu = false;
4449        self.shop_catalog = None;
4450        self.show_inventory_menu = false;
4451        self.session_id = session_id;
4452        self.entity_id = entity_id;
4453        self.connected = true;
4454        self.apply_snapshot_fields(snapshot, entity_id);
4455        if let Some(combat) = &snapshot.combat {
4456            self.apply_combat_hud(combat);
4457            let stacks = self.inventory_stacks.clone();
4458            self.sync_inventory_from_stacks(&stacks);
4459        }
4460    }
4461
4462    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4463        self.tick = delta.tick;
4464        self.world_clock = delta.world_clock;
4465
4466        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
4467        if delta.entities.is_empty() {
4468            self.ground_drops = delta.ground_drops.clone();
4469            self.combat_fx = delta.combat_fx.clone();
4470            self.property_plots = delta.property_plots.clone();
4471            self.apply_terrain_overlays(&delta.terrain_overlays);
4472            if let Some(combat) = &delta.combat {
4473                self.apply_combat_hud(combat);
4474                let stacks = self.inventory_stacks.clone();
4475                self.sync_inventory_from_stacks(&stacks);
4476            }
4477            // Peer vanished from AOI — drop directed whisper.
4478            self.refresh_whisper_range();
4479            return;
4480        }
4481        if !delta.buildings.is_empty() {
4482            self.buildings = delta.buildings.clone();
4483        }
4484        if !delta.blueprints.is_empty() {
4485            self.blueprints = delta.blueprints.clone();
4486        }
4487        self.sync_inventory_from_stacks(&delta.inventory);
4488
4489        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4490            self.player = Some(updated.clone());
4491        }
4492        self.entities = delta.entities.clone();
4493        if self.player.is_none() {
4494            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4495        }
4496
4497        self.sync_interior_map_context();
4498
4499        // Static world layers: ticks often omit these (indoors, or unchanged).
4500        // Never wipe the welcome snapshot with an empty vec — except when indoors,
4501        // where empty means "no nodes in this building" (clear outdoor leftovers).
4502        if !delta.resource_nodes.is_empty() {
4503            self.resource_nodes = delta.resource_nodes.clone();
4504        } else if delta.interior_map.is_some()
4505            || self.effective_inside_building().is_some()
4506        {
4507            self.resource_nodes = delta.resource_nodes.clone();
4508        }
4509        self.ground_drops = delta.ground_drops.clone();
4510        if self
4511            .player
4512            .as_ref()
4513            .is_none_or(|p| p.inside_building.is_none())
4514        {
4515            self.placed_containers = delta.placed_containers.clone();
4516        }
4517        if !delta.doors.is_empty() {
4518            self.doors = delta.doors.clone();
4519        }
4520        if self.effective_inside_building().is_some() {
4521            if let Some(map) = &delta.interior_map {
4522                self.interior_map = Some(map.clone());
4523            }
4524        } else {
4525            self.interior_map = None;
4526        }
4527        self.sync_interior_z_bands();
4528        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
4529        self.npcs = delta.npcs.clone();
4530        if !delta.quest_log.is_empty() {
4531            self.quest_log = delta.quest_log.clone();
4532        }
4533        self.apply_hired_workers(delta.hired_workers.clone());
4534        if !delta.interactables.is_empty() {
4535            self.interactables = delta.interactables.clone();
4536        }
4537        if delta.ledger.is_some() {
4538            self.ledger = delta.ledger.clone();
4539        }
4540        if delta.career.is_some() {
4541            self.career = delta.career.clone();
4542        }
4543        self.combat_fx = delta.combat_fx.clone();
4544        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
4545        if !delta.property_plots.is_empty() {
4546            self.property_plots = delta.property_plots.clone();
4547        }
4548        self.apply_terrain_overlays(&delta.terrain_overlays);
4549        if let Some(combat) = &delta.combat {
4550            self.apply_combat_hud(combat);
4551            let stacks = self.inventory_stacks.clone();
4552            self.sync_inventory_from_stacks(&stacks);
4553        } else {
4554            self.refresh_inventory_ui();
4555        }
4556        self.refresh_whisper_range();
4557    }
4558
4559    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
4560    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
4561    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4562        self.terrain_zones
4563            .retain(|z| !z.id.starts_with("rt:"));
4564        self.terrain_zones.extend(overlays.iter().cloned());
4565    }
4566
4567    /// End directed whisper when the peer is farther than interact range (or missing).
4568    /// Each client runs this locally so both players drop whisper mode when either walks away.
4569    fn refresh_whisper_range(&mut self) {
4570        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4571            return;
4572        };
4573        let (px, py) = self.player_position();
4574        let in_range = self.entities.iter().any(|e| {
4575            e.id == peer
4576                && distance(
4577                    px,
4578                    py,
4579                    e.transform.position.x,
4580                    e.transform.position.y,
4581                ) <= INTERACTION_RADIUS_M
4582        });
4583        if !in_range {
4584            self.social_chat.cancel_whisper_out_of_range();
4585        }
4586    }
4587
4588    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
4589    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4590        let (px, py) = self.player_position();
4591        let mut out = Vec::new();
4592        for npc in &self.npcs {
4593            let Some(eid) = npc.entity_id else {
4594                continue;
4595            };
4596            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4597            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4598            if alive && has_hp {
4599                out.push((eid, npc.label.clone()));
4600            }
4601        }
4602        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4603            let dist = |id: EntityId| {
4604                self.entities
4605                    .iter()
4606                    .find(|e| e.id == id)
4607                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4608                    .unwrap_or(f32::MAX)
4609            };
4610            dist(*a_id)
4611                .partial_cmp(&dist(*b_id))
4612                .unwrap_or(std::cmp::Ordering::Equal)
4613                .then_with(|| a_label.cmp(b_label))
4614                .then_with(|| a_id.cmp(b_id))
4615        });
4616        out
4617    }
4618
4619    pub fn refresh_combat_target_label(&mut self) {
4620        let Some(id) = self.combat_target else {
4621            return;
4622        };
4623        if let Some((_, label)) = self
4624            .combat_candidates()
4625            .into_iter()
4626            .find(|(eid, _)| *eid == id)
4627        {
4628            self.combat_target_label = Some(label);
4629        } else if let Some(label) = self
4630            .entities
4631            .iter()
4632            .find(|e| e.id == id)
4633            .map(|e| e.label.clone())
4634        {
4635            self.combat_target_label = Some(label);
4636        }
4637    }
4638
4639    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4640        self.quest_log
4641            .iter()
4642            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4643            .collect()
4644    }
4645
4646    /// True when the observer has at least one free lodging slot (max workers = beds).
4647    pub fn has_worker_lodging(&self) -> bool {
4648        self.free_worker_lodging_slots() > 0
4649    }
4650
4651    /// Owned lodging capacity minus currently hired workers.
4652    pub fn free_worker_lodging_slots(&self) -> i64 {
4653        let slots: u32 = self
4654            .placed_containers
4655            .iter()
4656            .filter(|c| match (self.character_id, c.owner_character_id) {
4657                (Some(me), Some(owner)) => me == owner,
4658                (Some(_), None) => false,
4659                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4660            })
4661            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4662            .sum();
4663        let used = self.hired_workers.len() as u32;
4664        slots as i64 - used as i64
4665    }
4666
4667    /// Display names of hired workers assigned to this lodging container.
4668    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4669        let mut names: Vec<String> = self
4670            .hired_workers
4671            .iter()
4672            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4673            .map(|w| w.label.clone())
4674            .collect();
4675        names.sort();
4676        names
4677    }
4678
4679    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
4680    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4681        let is_lodging = self
4682            .placed_containers
4683            .iter()
4684            .find(|c| c.id == container_id)
4685            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4686        if !is_lodging {
4687            return None;
4688        }
4689        let names = self.lodging_occupant_labels(container_id);
4690        Some(if names.is_empty() {
4691            "vacant".into()
4692        } else {
4693            names.join(", ")
4694        })
4695    }
4696
4697    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4698        self.quest_log
4699            .iter()
4700            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4701            .or_else(|| {
4702                self.quest_log
4703                    .iter()
4704                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4705            })
4706    }
4707
4708    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
4709    pub fn nearest_interact_target(&self) -> Option<String> {
4710        let (px, py) = self.player_position();
4711        let inside = self.effective_inside_building();
4712
4713        #[derive(Clone, Copy, PartialEq, Eq)]
4714        enum Kind {
4715            Player,
4716            Npc,
4717            HiredWorker,
4718            QuestBoard,
4719            ExitDoor,
4720            EnterDoor,
4721            Well,
4722            Water,
4723        }
4724
4725        fn kind_priority(kind: Kind) -> u8 {
4726            match kind {
4727                Kind::Player => 0,
4728                Kind::Npc => 0,
4729                Kind::HiredWorker => 0,
4730                Kind::QuestBoard => 1,
4731                Kind::ExitDoor => 2,
4732                Kind::EnterDoor => 3,
4733                Kind::Well => 4,
4734                Kind::Water => 5,
4735            }
4736        }
4737
4738        let mut best: Option<(f32, Kind, String)> = None;
4739
4740        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
4741            if dist > max {
4742                return;
4743            }
4744            let replace = match best {
4745                None => true,
4746                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
4747                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
4748                    kind_priority(kind) < kind_priority(bk)
4749                }
4750                _ => false,
4751            };
4752            if replace {
4753                best = Some((dist, kind, id));
4754            }
4755        };
4756
4757        for npc in &self.npcs {
4758            consider(
4759                distance(px, py, npc.x, npc.y),
4760                INTERACTION_RADIUS_M,
4761                Kind::Npc,
4762                npc.id.clone(),
4763            );
4764        }
4765
4766        for worker in &self.hired_workers {
4767            consider(
4768                distance(px, py, worker.x, worker.y),
4769                INTERACTION_RADIUS_M,
4770                Kind::HiredWorker,
4771                worker.instance_id.clone(),
4772            );
4773        }
4774
4775        for entity in &self.entities {
4776            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
4777            {
4778                continue;
4779            }
4780            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
4781            if self
4782                .hired_workers
4783                .iter()
4784                .any(|w| w.entity_id == entity.id)
4785            {
4786                continue;
4787            }
4788            consider(
4789                distance(
4790                    px,
4791                    py,
4792                    entity.transform.position.x,
4793                    entity.transform.position.y,
4794                ),
4795                INTERACTION_RADIUS_M,
4796                Kind::Player,
4797                entity.id.to_string(),
4798            );
4799        }
4800
4801        for door in &self.doors {
4802            if let Some(ref bid) = inside {
4803                if door.building_id != *bid {
4804                    continue;
4805                }
4806                let is_exit = door.portal.is_some();
4807                let max = if is_exit {
4808                    INTERACTION_RADIUS_M
4809                } else {
4810                    DOOR_INTERACTION_RADIUS_M
4811                };
4812                let kind = if is_exit {
4813                    Kind::ExitDoor
4814                } else {
4815                    Kind::EnterDoor
4816                };
4817                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4818                continue;
4819            }
4820            consider(
4821                distance(px, py, door.x, door.y),
4822                DOOR_INTERACTION_RADIUS_M,
4823                Kind::EnterDoor,
4824                door.id.clone(),
4825            );
4826        }
4827
4828        if inside.is_none() {
4829            for inter in &self.interactables {
4830                if inter.kind == "quest_board" {
4831                    consider(
4832                        distance(px, py, inter.x, inter.y),
4833                        QUEST_BOARD_INTERACTION_RADIUS_M,
4834                        Kind::QuestBoard,
4835                        inter.id.clone(),
4836                    );
4837                }
4838            }
4839            for building in &self.buildings {
4840                if !building.tags.iter().any(|t| t == "well") {
4841                    continue;
4842                }
4843                consider(
4844                    distance(px, py, building.x, building.y),
4845                    INTERACTION_RADIUS_M,
4846                    Kind::Well,
4847                    building.id.clone(),
4848                );
4849            }
4850            if self.in_shallow_water() {
4851                consider(
4852                    0.0,
4853                    INTERACTION_RADIUS_M,
4854                    Kind::Water,
4855                    "water_source".into(),
4856                );
4857            }
4858        }
4859
4860        best.map(|(_, _, id)| id)
4861    }
4862
4863    /// Nearest quest board and distance (any distance), for out-of-range feedback.
4864    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4865        if self.effective_inside_building().is_some() {
4866            return None;
4867        }
4868        let (px, py) = self.player_position();
4869        self.interactables
4870            .iter()
4871            .filter(|i| i.kind == "quest_board")
4872            .map(|i| {
4873                let label = if i.label.is_empty() {
4874                    "Quest board".to_string()
4875                } else {
4876                    i.label.clone()
4877                };
4878                (label, distance(px, py, i.x, i.y))
4879            })
4880            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4881    }
4882
4883    /// Human-readable template name from synced catalog hints.
4884    pub fn template_display_name(&self, template_id: &str) -> String {
4885        self.inventory_hints
4886            .get(template_id)
4887            .map(|h| h.display_name.clone())
4888            .filter(|n| !n.is_empty())
4889            .unwrap_or_else(|| humanize_template_id(template_id))
4890    }
4891
4892    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
4893    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
4894        use crate::worker_route_editor::{
4895            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
4896        };
4897        let lodging = self
4898            .worker_route_editor
4899            .as_ref()
4900            .and_then(|ed| ed.lodging_container_id.as_deref());
4901        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
4902            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
4903            None => node_candidates_stable(&self.resource_nodes),
4904        }
4905    }
4906
4907    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
4908        if dist_m.is_nan() {
4909            return "—".into();
4910        }
4911        let from_bed = self
4912            .worker_route_editor
4913            .as_ref()
4914            .and_then(|ed| ed.lodging_container_id.as_deref())
4915            .and_then(|id| {
4916                self.placed_containers
4917                    .iter()
4918                    .find(|c| c.id == id)
4919                    .map(|c| c.display_name.clone())
4920            });
4921        match from_bed {
4922            Some(bed) => format!("{dist_m:.0}m from {bed}"),
4923            None => format!("{dist_m:.0}m"),
4924        }
4925    }
4926
4927    /// Chest label for the location panel — generic type unless this player owns it.
4928    pub fn placed_container_public_label(
4929        &self,
4930        c: &flatland_protocol::PlacedContainerView,
4931    ) -> String {
4932        let is_owner = match (self.character_id, c.owner_character_id) {
4933            (Some(me), Some(owner)) => me == owner,
4934            _ => false,
4935        };
4936        if is_owner {
4937            c.display_name.clone()
4938        } else {
4939            self.template_display_name(&c.template_id)
4940        }
4941    }
4942
4943    /// Keys on person and stowed on the keychain for the keychain overlay.
4944    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4945        let mut out = Vec::new();
4946        for stack in &self.inventory_stacks {
4947            if stack.template_id == KEY_TEMPLATE {
4948                out.push(KeychainEntry {
4949                    stack: stack.clone(),
4950                    stowed: false,
4951                });
4952            }
4953        }
4954        for stack in &self.keychain_stacks {
4955            if stack.template_id == KEY_TEMPLATE {
4956                out.push(KeychainEntry {
4957                    stack: stack.clone(),
4958                    stowed: true,
4959                });
4960            }
4961        }
4962        out
4963    }
4964
4965    /// Display name of the chest a `container_key` opens, when known on the client.
4966    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4967        if stack.template_id != KEY_TEMPLATE {
4968            return None;
4969        }
4970        if let Some(name) = stack
4971            .props
4972            .get(PROP_OPENS_CONTAINER_NAME)
4973            .filter(|n| !n.is_empty())
4974        {
4975            return Some(name.clone());
4976        }
4977        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4978        self.container_name_for_lock_id(opens)
4979    }
4980
4981    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
4982    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4983        if stack.template_id == KEY_TEMPLATE {
4984            self.template_display_name(KEY_TEMPLATE)
4985        } else {
4986            stack
4987                .display_name
4988                .clone()
4989                .unwrap_or_else(|| stack.template_id.clone())
4990        }
4991    }
4992
4993    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
4994    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4995        if stack.template_id != KEY_TEMPLATE {
4996            return String::new();
4997        }
4998        match self.key_pair_chest_label(stack) {
4999            Some(chest) if self.key_drop_blocked(stack) => {
5000                format!(" [key for {chest} — can't drop while locked]")
5001            }
5002            Some(chest) => format!(" [key for {chest}]"),
5003            None => " [key — unpaired]".into(),
5004        }
5005    }
5006
5007    /// Resolve a lock id to a container label (placed chest or one still on your person).
5008    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5009        for c in &self.placed_containers {
5010            if c.lock_id.as_deref() == Some(lock) {
5011                return Some(c.display_name.clone());
5012            }
5013        }
5014        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5015            self.worn
5016                .values()
5017                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5018        })
5019    }
5020
5021    /// Keys cannot be dropped while their paired chest is locked.
5022    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5023        if stack.template_id != KEY_TEMPLATE {
5024            return false;
5025        }
5026        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5027            return false;
5028        };
5029        for c in &self.placed_containers {
5030            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5031                return true;
5032            }
5033        }
5034        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5035            return true;
5036        }
5037        self.worn
5038            .values()
5039            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5040    }
5041
5042    /// Property deeds cannot be dropped or destroyed (store or trade only).
5043    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5044        stack.template_id == PROPERTY_DEED_TEMPLATE
5045    }
5046
5047    pub fn is_property_deed_template(template_id: &str) -> bool {
5048        template_id == PROPERTY_DEED_TEMPLATE
5049    }
5050
5051    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5052        stack
5053            .props
5054            .get("plot_id")
5055            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5056    }
5057
5058    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5059    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5060        let (px, py) = self.player_position();
5061        let (cx, cy) = self.farm_plot_cell_under_player()?;
5062        let tx = cx as f32 + 0.5;
5063        let ty = cy as f32 + 0.5;
5064        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5065            return None;
5066        }
5067        let kind = self
5068            .terrain_at(tx, ty)
5069            .or_else(|| self.terrain_at(px, py));
5070        if kind == Some(TerrainKindView::Tilled) {
5071            return None;
5072        }
5073        if matches!(
5074            kind,
5075            Some(TerrainKindView::ShallowWater)
5076                | Some(TerrainKindView::DeepWater)
5077                | Some(TerrainKindView::Rock)
5078        ) {
5079            return None;
5080        }
5081        Some((tx, ty))
5082    }
5083
5084    fn container_name_in_stacks(
5085        stacks: &[flatland_protocol::ItemStack],
5086        lock: &str,
5087    ) -> Option<String> {
5088        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5089            for s in stacks {
5090                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5091                    return Some(GameState::stack_container_label(s));
5092                }
5093                if let Some(name) = walk(&s.contents, lock) {
5094                    return Some(name);
5095                }
5096            }
5097            None
5098        }
5099        walk(stacks, lock)
5100    }
5101
5102    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5103        stack
5104            .props
5105            .get(PROP_CUSTOM_NAME)
5106            .cloned()
5107            .or_else(|| stack.display_name.clone())
5108            .unwrap_or_else(|| stack.template_id.clone())
5109    }
5110
5111    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5112        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5113            for s in stacks {
5114                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5115                    return true;
5116                }
5117                if walk(&s.contents, lock) {
5118                    return true;
5119                }
5120            }
5121            false
5122        }
5123        walk(stacks, lock)
5124    }
5125
5126    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5127        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5128            return Some(stack.clone());
5129        }
5130        for worn in self.worn.values() {
5131            if worn.item_instance_id == Some(instance_id) {
5132                return Some(worn.clone());
5133            }
5134            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5135                return Some(stack.clone());
5136            }
5137        }
5138        None
5139    }
5140
5141    /// Highest-`z_order` property zone covering `(x, y)`.
5142    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5143        self.property_zones
5144            .iter()
5145            .enumerate()
5146            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5147            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5148            .map(|(_, z)| z)
5149    }
5150
5151    /// Highest-`z_order` tax zone covering `(x, y)`.
5152    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5153        self.tax_zones
5154            .iter()
5155            .enumerate()
5156            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5157            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5158            .map(|(_, z)| z)
5159    }
5160
5161    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5162    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5163        let mut max_bps = 0u32;
5164        let mut y = y0 + 0.5;
5165        while y < y1 {
5166            let mut x = x0 + 0.5;
5167            while x < x1 {
5168                if let Some(tz) = self.tax_zone_at(x, y) {
5169                    max_bps = max_bps.max(tz.rate_bps);
5170                }
5171                x += 1.0;
5172            }
5173            y += 1.0;
5174        }
5175        max_bps
5176    }
5177
5178    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5179    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5180        let mode = self.claim_mode.as_ref()?;
5181        let w = mode.width_m.max(1) as f32;
5182        let h = mode.height_m.max(1) as f32;
5183        Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5184    }
5185
5186    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5187    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5188        let mode = self.relocate_mode.as_ref()?;
5189        let x0 = mode.cursor_x.floor();
5190        let y0 = mode.cursor_y.floor();
5191        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5192    }
5193
5194    /// Client quote matching server `quote_plot`:
5195    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5196    pub fn claim_quote(
5197        &self,
5198    ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5199        let mode = self.claim_mode.as_ref()?;
5200        let zone = self
5201            .property_zones
5202            .iter()
5203            .find(|z| z.id == mode.zone_id)?;
5204        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5205        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5206        let zone_area = zone_view_area_m2(zone).max(1.0);
5207        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5208        let weight = self
5209            .property_plot_settings
5210            .as_ref()
5211            .map(|s| s.tax_premium_weight)
5212            .unwrap_or(0.5)
5213            .max(0.0);
5214        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5215        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5216        let purchase = ((zone.crown_price_copper as f64)
5217            * (area_frac as f64)
5218            * (premium as f64))
5219            .ceil()
5220            .max(0.0) as u64;
5221        let upkeep = if zone.upkeep_copper_per_day == 0 {
5222            0
5223        } else {
5224            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5225                .ceil()
5226                .max(1.0) as u64
5227        };
5228        let copper = crate::currency::copper_from_counts(&self.inventory);
5229        let can_afford = copper >= purchase;
5230        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5231        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5232    }
5233
5234    fn validate_claim_footprint(
5235        &self,
5236        zone: &flatland_protocol::PropertyZoneView,
5237        x0: f32,
5238        y0: f32,
5239        x1: f32,
5240        y1: f32,
5241        area: f32,
5242    ) -> (bool, String) {
5243        let min_area = self
5244            .property_plot_settings
5245            .as_ref()
5246            .map(|s| s.min_plot_area_m2)
5247            .unwrap_or(4.0);
5248        if area + f32::EPSILON < min_area {
5249            return (false, "plot too small".into());
5250        }
5251        if zone.max_area_m2.is_some_and(|m| area > m) {
5252            return (false, "plot exceeds max area".into());
5253        }
5254        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5255            return (false, "plot must lie inside the property zone".into());
5256        }
5257        if self.property_plots.iter().any(|p| {
5258            rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5259        }) {
5260            return (false, "plot overlaps an existing claim".into());
5261        }
5262        (true, String::new())
5263    }
5264
5265    /// Standing in a property zone on a free cell (not already claimed).
5266    pub fn free_property_zone_under_player(
5267        &self,
5268    ) -> Option<&flatland_protocol::PropertyZoneView> {
5269        let (px, py) = self.player_position();
5270        let zone = self.property_zone_at(px, py)?;
5271        if self
5272            .property_plots
5273            .iter()
5274            .any(|p| point_in_plot(px, py, p))
5275        {
5276            return None;
5277        }
5278        Some(zone)
5279    }
5280
5281    /// Own plot covering the player (`is_mine`).
5282    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5283        let (px, py) = self.player_position();
5284        self.property_plots
5285            .iter()
5286            .find(|p| p.is_mine && point_in_plot(px, py, p))
5287    }
5288
5289    /// Plot under the player that they may farm (deed, grant, or public).
5290    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5291        let (px, py) = self.player_position();
5292        self.property_plots
5293            .iter()
5294            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5295    }
5296
5297    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5298    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5299        if self.farmable_plot_under_player().is_none() {
5300            return None;
5301        }
5302        let (px, py) = self.player_position();
5303        Some((px.floor() as i32, py.floor() as i32))
5304    }
5305
5306    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5307        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5308        self.resource_nodes.iter().any(|n| {
5309            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5310            ncx == cx && ncy == cy
5311                || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5312        })
5313    }
5314
5315    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5316        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5317        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5318            || self
5319                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5320                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5321        if !tilled {
5322            return false;
5323        }
5324        !self.resource_node_occupies_farm_cell(cx, cy)
5325    }
5326
5327    /// Empty tilled soil on the cell the player is standing on (no crop node).
5328    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5329        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5330            return false;
5331        };
5332        self.free_tilled_plant_slot_at(cx, cy)
5333    }
5334
5335    /// True when a free tilled cell (no resource node) is within interact range.
5336    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5337        let (px, py) = self.player_position();
5338        for dy in -2..=2 {
5339            for dx in -2..=2 {
5340                let cx = px.floor() as i32 + dx;
5341                let cy = py.floor() as i32 + dy;
5342                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5343                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5344                    continue;
5345                }
5346                if self.free_tilled_plant_slot_at(cx, cy) {
5347                    return true;
5348                }
5349            }
5350        }
5351        false
5352    }
5353
5354    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5355        stack.quantity > 0
5356            && (stack.props.contains_key("seed_for")
5357                || stack.template_id.ends_with("_seed")
5358                || stack.template_id == "potato_seed"
5359                || stack.template_id == "carrot_seed")
5360    }
5361
5362    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
5363    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5364        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5365        fn walk(
5366            stacks: &[flatland_protocol::ItemStack],
5367            counts: &mut std::collections::HashMap<String, u32>,
5368        ) {
5369            for s in stacks {
5370                if GameState::stack_is_farm_seed(s) {
5371                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5372                }
5373                walk(&s.contents, counts);
5374            }
5375        }
5376        walk(&self.inventory_stacks, &mut counts);
5377        for worn in self.worn.values() {
5378            walk(std::slice::from_ref(worn), &mut counts);
5379        }
5380        let mut out: Vec<_> = counts
5381            .into_iter()
5382            .map(|(template_id, quantity)| {
5383                let label = self
5384                    .inventory_hints
5385                    .get(&template_id)
5386                    .map(|h| h.display_name.clone())
5387                    .filter(|n| !n.trim().is_empty())
5388                    .unwrap_or_else(|| humanize_template_id(&template_id));
5389                (template_id, quantity, label)
5390            })
5391            .collect();
5392        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5393        out
5394    }
5395
5396    /// First farm seed template in inventory (root + nested bags).
5397    pub fn first_farm_seed_template(&self) -> Option<String> {
5398        self.farm_seed_entries()
5399            .into_iter()
5400            .next()
5401            .map(|(id, _, _)| id)
5402    }
5403
5404    pub fn clamp_plant_menu(&mut self) {
5405        let n = self.farm_seed_entries().len();
5406        if n == 0 {
5407            self.plant_menu_index = 0;
5408            self.plant_quantity = 1;
5409            return;
5410        }
5411        self.plant_menu_index = self.plant_menu_index.min(n - 1);
5412        let max_qty = self
5413            .farm_seed_entries()
5414            .get(self.plant_menu_index)
5415            .map(|(_, q, _)| *q)
5416            .unwrap_or(1)
5417            .max(1);
5418        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5419    }
5420
5421    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5422        let entries = self.farm_seed_entries();
5423        let (id, max, label) = entries.get(self.plant_menu_index)?;
5424        let qty = self.plant_quantity.min(*max).max(1);
5425        Some((id.clone(), qty, label.clone()))
5426    }
5427
5428    /// Terrain, interactables, and map objects near the player for the HUD location panel.
5429    pub fn location_context_lines(&self) -> Vec<ContextLine> {
5430        let (px, py) = self.player_position();
5431        let inside = self.effective_inside_building();
5432        let mut lines = Vec::new();
5433
5434        if let Some(kind) = self.terrain_at(px, py) {
5435            lines.push(ContextLine {
5436                on_top: true,
5437                text: format!("Terrain: {}", terrain_kind_label(kind)),
5438            });
5439        }
5440
5441        if let Some(id) = inside.as_ref() {
5442            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5443                lines.push(ContextLine {
5444                    on_top: true,
5445                    text: format!("Inside: {}", b.label),
5446                });
5447            }
5448        }
5449
5450        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5451
5452        for node in &self.resource_nodes {
5453            if node.id.starts_with("preview:") {
5454                continue;
5455            }
5456            let dist = distance(px, py, node.x, node.y);
5457            if dist > NEARBY_SCAN_M {
5458                continue;
5459            }
5460            let on_top = dist <= ON_TOP_RADIUS_M;
5461            let prefix = if on_top { "On" } else { "Near" };
5462            let name = resource_node_near_display_label(&node.label);
5463            let action = resource_node_near_action_suffix(node);
5464            nearby.push((
5465                dist,
5466                ContextLine {
5467                    on_top,
5468                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5469                },
5470            ));
5471        }
5472
5473        for drop in &self.ground_drops {
5474            let dist = distance(px, py, drop.x, drop.y);
5475            if dist > INTERACTION_RADIUS_M {
5476                continue;
5477            }
5478            let on_top = dist <= ON_TOP_RADIUS_M;
5479            let name = self.template_display_name(&drop.template_id);
5480            let prefix = if on_top { "On" } else { "Near" };
5481            let qty = if drop.quantity > 1 {
5482                format!(" ×{}", drop.quantity)
5483            } else {
5484                String::new()
5485            };
5486            nearby.push((
5487                dist,
5488                ContextLine {
5489                    on_top,
5490                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5491                },
5492            ));
5493        }
5494
5495        for c in &self.placed_containers {
5496            let dist = distance(px, py, c.x, c.y);
5497            if dist > CONTAINER_RANGE_M {
5498                continue;
5499            }
5500            let on_top = dist <= ON_TOP_RADIUS_M;
5501            let name = self.placed_container_public_label(c);
5502            let lock = if c.locked { " [locked]" } else { "" };
5503            let prefix = if on_top { "On" } else { "Near" };
5504            nearby.push((
5505                dist,
5506                ContextLine {
5507                    on_top,
5508                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5509                },
5510            ));
5511        }
5512
5513        for npc in &self.npcs {
5514            let dist = distance(px, py, npc.x, npc.y);
5515            if dist > NEARBY_SCAN_M {
5516                continue;
5517            }
5518            let on_top = dist <= ON_TOP_RADIUS_M;
5519            let prefix = if on_top { "On" } else { "Near" };
5520            nearby.push((
5521                dist,
5522                ContextLine {
5523                    on_top,
5524                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5525                },
5526            ));
5527        }
5528
5529        for door in &self.doors {
5530            let dist = distance(px, py, door.x, door.y);
5531            if dist > DOOR_INTERACTION_RADIUS_M {
5532                continue;
5533            }
5534            let building = self
5535                .buildings
5536                .iter()
5537                .find(|b| b.id == door.building_id)
5538                .map(|b| b.label.as_str())
5539                .unwrap_or(door.building_id.as_str());
5540            let action = if inside.is_some() && door.portal.is_some() {
5541                "exit"
5542            } else {
5543                "enter"
5544            };
5545            nearby.push((
5546                dist,
5547                ContextLine {
5548                    on_top: dist <= ON_TOP_RADIUS_M,
5549                    text: format!("{building} door ({dist:.1}m) — f {action}"),
5550                },
5551            ));
5552        }
5553
5554        if inside.is_none() {
5555            for inter in &self.interactables {
5556                if inter.kind != "quest_board" {
5557                    continue;
5558                }
5559                let dist = distance(px, py, inter.x, inter.y);
5560                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5561                    continue;
5562                }
5563                let on_top = dist <= ON_TOP_RADIUS_M;
5564                let prefix = if on_top { "On" } else { "Near" };
5565                let label = if inter.label.is_empty() {
5566                    "Quest board".to_string()
5567                } else {
5568                    inter.label.clone()
5569                };
5570                nearby.push((
5571                    dist,
5572                    ContextLine {
5573                        on_top,
5574                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5575                    },
5576                ));
5577            }
5578        }
5579
5580        if self.in_shallow_water() {
5581            let already = self
5582                .terrain_at(px, py)
5583                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5584            if !already {
5585                nearby.push((
5586                    0.0,
5587                    ContextLine {
5588                        on_top: true,
5589                        text: "Shallow water — f fill bottle".into(),
5590                    },
5591                ));
5592            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5593                line.text.push_str(" — f fill bottle");
5594            }
5595        }
5596
5597        if self.claim_mode.is_some() {
5598            nearby.push((
5599                0.0,
5600                ContextLine {
5601                    on_top: true,
5602                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5603                        .into(),
5604                },
5605            ));
5606        } else if let Some(plot) = self.my_plot_under_player() {
5607            let zone = plot
5608                .zone_label
5609                .as_deref()
5610                .filter(|s| !s.trim().is_empty())
5611                .or_else(|| {
5612                    self.property_zones
5613                        .iter()
5614                        .find(|z| z.id == plot.property_zone_id)
5615                        .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5616                })
5617                .unwrap_or(plot.property_zone_id.as_str());
5618            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5619                format!("Your plot ({zone}) — f again to sell to crown")
5620            } else {
5621                format!(
5622                    "Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
5623                )
5624            };
5625            nearby.push((
5626                0.0,
5627                ContextLine {
5628                    on_top: true,
5629                    text: prompt,
5630                },
5631            ));
5632        } else if let Some(plot) = self.farmable_plot_under_player() {
5633            let owner = plot
5634                .owner_label
5635                .as_deref()
5636                .filter(|s| !s.trim().is_empty())
5637                .unwrap_or("owner");
5638            let disc = if plot.farm_public {
5639                plot.public_tax_discount_bps / 100
5640            } else {
5641                plot.farm_allow
5642                    .iter()
5643                    .find(|g| Some(g.character_id) == self.character_id)
5644                    .map(|g| g.tax_discount_bps / 100)
5645                    .unwrap_or(0)
5646            };
5647            nearby.push((
5648                0.0,
5649                ContextLine {
5650                    on_top: true,
5651                    text: format!(
5652                        "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5653                    ),
5654                },
5655            ));
5656        } else if let Some(zone) = self.free_property_zone_under_player() {
5657            let label = zone
5658                .label
5659                .as_deref()
5660                .filter(|s| !s.trim().is_empty())
5661                .unwrap_or(zone.id.as_str());
5662            nearby.push((
5663                0.0,
5664                ContextLine {
5665                    on_top: true,
5666                    text: format!("Claimable land: {label} — k buy plot"),
5667                },
5668            ));
5669        }
5670
5671        for entity in &self.entities {
5672            if entity.id == self.entity_id {
5673                continue;
5674            }
5675            let dist = distance(
5676                px,
5677                py,
5678                entity.transform.position.x,
5679                entity.transform.position.y,
5680            );
5681            if dist > NEARBY_SCAN_M {
5682                continue;
5683            }
5684            let label = if entity.label.is_empty() {
5685                format!("entity {}", entity.id)
5686            } else {
5687                entity.label.clone()
5688            };
5689            nearby.push((
5690                dist,
5691                ContextLine {
5692                    on_top: dist <= ON_TOP_RADIUS_M,
5693                    text: format!("Near: {label} ({dist:.1}m)"),
5694                },
5695            ));
5696        }
5697
5698        nearby.sort_by(|a, b| {
5699            a.0.partial_cmp(&b.0)
5700                .unwrap_or(std::cmp::Ordering::Equal)
5701                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
5702        });
5703        lines.extend(nearby.into_iter().map(|(_, l)| l));
5704
5705        if lines.is_empty() {
5706            lines.push(ContextLine {
5707                on_top: false,
5708                text: "(nothing notable nearby)".into(),
5709            });
5710        }
5711
5712        lines
5713    }
5714}
5715
5716/// HUD line for the location / nearby panel.
5717#[derive(Debug, Clone)]
5718pub struct ContextLine {
5719    pub on_top: bool,
5720    pub text: String,
5721}
5722
5723const ON_TOP_RADIUS_M: f32 = 0.65;
5724const NEARBY_SCAN_M: f32 = 5.0;
5725
5726/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
5727pub fn resource_node_near_display_label(label: &str) -> String {
5728    label
5729        .strip_suffix(" (growing)")
5730        .unwrap_or(label)
5731        .to_string()
5732}
5733
5734/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
5735pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
5736    use flatland_protocol::ResourceNodeState;
5737    if let Some(p) = node.growth_progress {
5738        if p < 1.0 - f32::EPSILON {
5739            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
5740            return format!(" (growing, {pct}%)");
5741        }
5742        return " — f harvest".to_string();
5743    }
5744    match node.state {
5745        ResourceNodeState::Available => " — f harvest".to_string(),
5746        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
5747        ResourceNodeState::Cooldown => " (depleted)".to_string(),
5748    }
5749}
5750
5751fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
5752    use flatland_protocol::TerrainKindView;
5753    match kind {
5754        TerrainKindView::Grass => "Grass",
5755        TerrainKindView::Dirt => "Dirt",
5756        TerrainKindView::Tilled => "Tilled",
5757        TerrainKindView::Desert => "Desert",
5758        TerrainKindView::Hill => "Hills",
5759        TerrainKindView::Bog => "Bog",
5760        TerrainKindView::Beach => "Beach",
5761        TerrainKindView::ShallowWater => "Shallow water",
5762        TerrainKindView::DeepWater => "Deep water",
5763        TerrainKindView::Trail => "Trail",
5764        TerrainKindView::Road => "Road",
5765        TerrainKindView::Rock => "Rock",
5766    }
5767}
5768
5769fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
5770    crate::world_zones::zone_rects_contain(rects, x, y)
5771}
5772
5773fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
5774    zone.rects
5775        .iter()
5776        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
5777        .sum()
5778}
5779
5780fn claim_rect_fully_inside_zone(
5781    zone: &flatland_protocol::PropertyZoneView,
5782    x0: f32,
5783    y0: f32,
5784    x1: f32,
5785    y1: f32,
5786) -> bool {
5787    let mut y = y0 + 0.5;
5788    while y < y1 {
5789        let mut x = x0 + 0.5;
5790        while x < x1 {
5791            if !zone_rects_contain(&zone.rects, x, y) {
5792                return false;
5793            }
5794            x += 1.0;
5795        }
5796        y += 1.0;
5797    }
5798    true
5799}
5800
5801fn rects_overlap_half_open(
5802    ax0: f32,
5803    ay0: f32,
5804    ax1: f32,
5805    ay1: f32,
5806    bx0: f32,
5807    by0: f32,
5808    bx1: f32,
5809    by1: f32,
5810) -> bool {
5811    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
5812}
5813
5814fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
5815    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
5816}
5817
5818fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
5819    p.zone_label
5820        .as_deref()
5821        .filter(|s| !s.trim().is_empty())
5822        .map(|s| s.to_string())
5823        .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
5824}
5825
5826/// Match `flatland_sim::economy_zones::snap_claim_rect`.
5827fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
5828    let a = x0.min(x1).floor();
5829    let b = y0.min(y1).floor();
5830    let mut c = x0.max(x1).ceil();
5831    let mut d = y0.max(y1).ceil();
5832    if (c - a) < 1.0 {
5833        c = a + 1.0;
5834    }
5835    if (d - b) < 1.0 {
5836        d = b + 1.0;
5837    }
5838    (a, b, c, d)
5839}
5840
5841fn humanize_template_id(template_id: &str) -> String {
5842    template_id
5843        .split('_')
5844        .map(|word| {
5845            let mut chars = word.chars();
5846            match chars.next() {
5847                None => String::new(),
5848                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5849            }
5850        })
5851        .collect::<Vec<_>>()
5852        .join(" ")
5853}
5854
5855/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
5856const HARVEST_RANGE_M: f32 = 1.5;
5857
5858pub struct GameClient<S: PlayConnection> {
5859    session: S,
5860    seq: Seq,
5861    pub state: GameState,
5862    last_move_forward: f32,
5863    last_move_strafe: f32,
5864}
5865
5866impl<S: PlayConnection> GameClient<S> {
5867    pub fn new(session: S) -> Self {
5868        let session_id = session.session_id();
5869        let entity_id = session.entity_id();
5870        let mut client = Self {
5871            session,
5872            seq: 0,
5873            last_move_forward: 0.0,
5874            last_move_strafe: 0.0,
5875            state: GameState {
5876                session_id,
5877                entity_id,
5878                character_id: None,
5879                tick: 0,
5880                chunk_rev: 0,
5881                content_rev: 0,
5882                publish_rev: 0,
5883                entities: Vec::new(),
5884                player: None,
5885                resource_nodes: Vec::new(),
5886                ground_drops: Vec::new(),
5887                placed_containers: Vec::new(),
5888                buildings: Vec::new(),
5889                doors: Vec::new(),
5890                interior_map: None,
5891                npcs: Vec::new(),
5892                blueprints: Vec::new(),
5893                world_x0: 0.0,
5894                world_y0: 0.0,
5895                world_width_m: 0.0,
5896                world_height_m: 0.0,
5897                terrain_zones: Vec::new(),
5898                z_platforms: Vec::new(),
5899                z_transitions: Vec::new(),
5900                z_bands_outdoor_backup: None,
5901                world_clock: flatland_protocol::WorldClock::default(),
5902                inventory: std::collections::HashMap::new(),
5903                inventory_hints: std::collections::HashMap::new(),
5904                logs: VecDeque::new(),
5905                intents_sent: 0,
5906                ticks_received: 0,
5907                connected: false,
5908                disconnect_reason: None,
5909                show_stats: false,
5910            hud_log_hidden: false,
5911                show_equip_menu: false,
5912                equip_menu_index: 0,
5913                show_craft_menu: false,
5914                craft_menu_index: 0,
5915                craft_batch_quantity: 1,
5916                show_shop_menu: false,
5917                shop_catalog: None,
5918                bank_panel: None,
5919                bank_menu_index: 0,
5920                bank_ui_mode: BankUiMode::Menu,
5921                storage_panel: None,
5922                market_panel: None,
5923                market_menu_index: 0,
5924                market_filter: String::new(),
5925                market_filter_focused: false,
5926                market_category_filter: None,
5927                market_buy_confirm: None,
5928                market_ui_mode: MarketUiMode::Browse,
5929                storage_menu_index: 0,
5930                storage_ui_mode: StorageUiMode::Menu,
5931                shop_tab: ShopTab::default(),
5932                shop_menu_index: 0,
5933                shop_quantity: 1,
5934                shop_trade_log: VecDeque::new(),
5935                show_npc_verb_menu: false,
5936                npc_verb_target: None,
5937                npc_verb_index: 0,
5938                player_verbs: crate::social::PlayerVerbState::default(),
5939                social_chat: crate::social::SocialChatState::default(),
5940                trade_ui: crate::social::TradeUiState::default(),
5941                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
5942                show_npc_chat: false,
5943                npc_chat: None,
5944                show_inventory_menu: false,
5945                inventory_menu_index: 0,
5946                inventory_tab: InventoryTab::OnPerson,
5947                inventory_filter: String::new(),
5948                inventory_filter_focused: false,
5949                show_move_picker: false,
5950                show_rename_prompt: false,
5951                show_worker_rename: false,
5952                rename_buffer: String::new(),
5953                move_picker_index: 0,
5954                move_picker: None,
5955                show_grant_picker: false,
5956                grant_picker_index: 0,
5957                grant_picker: None,
5958                show_destroy_picker: false,
5959                destroy_confirm_pending: false,
5960                destroy_picker: None,
5961                combat_target: None,
5962                combat_target_label: None,
5963                ground_target: None,
5964                combat_fx: Vec::new(),
5965                property_zones: Vec::new(),
5966                tax_zones: Vec::new(),
5967                growth_zones: Vec::new(),
5968                biome_zones: Vec::new(),
5969                property_plots: Vec::new(),
5970                property_plot_settings: None,
5971                claim_mode: None,
5972                relocate_mode: None,
5973                sell_plot_confirm: None,
5974                sell_plot_armed_at: None,
5975                show_plant_menu: false,
5976                plant_menu_index: 0,
5977                show_farm_access: false,
5978                farm_access_name_draft: String::new(),
5979                farm_access_discount_bps: 0,
5980                farm_access_index: 0,
5981                plant_quantity: 1,
5982                in_combat: false,
5983                auto_attack: true,
5984                combat_has_los: false,
5985                attack_cd_ticks: 0,
5986                gcd_ticks: 0,
5987                weapon_ability_id: "unarmed".into(),
5988                mainhand_template_id: None,
5989                mainhand_label: None,
5990                offhand_template_id: None,
5991                offhand_label: None,
5992                mainhand_hand_slots: 1,
5993                defense: None,
5994                worn: BTreeMap::new(),
5995                carry_mass: 0.0,
5996                carry_mass_max: 0.0,
5997                encumbrance: flatland_protocol::EncumbranceState::Light,
5998                inventory_stacks: Vec::new(),
5999                keychain_stacks: Vec::new(),
6000            whisper_pouch_stacks: Vec::new(),
6001                combat_target_detail: None,
6002                statuses: Vec::new(),
6003                cast_progress: None,
6004                timed_channel: None,
6005                ability_cooldowns: Vec::new(),
6006                blocking_active: false,
6007                max_target_slots: 1,
6008                combat_slots: Vec::new(),
6009                rotation_presets: Vec::new(),
6010                known_abilities: Vec::new(),
6011                ability_meta: std::collections::HashMap::new(),
6012                ability_mastery: std::collections::HashMap::new(),
6013                hotbar: vec![None; 9],
6014                max_abilities_per_rotation: 0,
6015                show_loadout_menu: false,
6016                show_keychain_menu: false,
6017                keychain_menu_index: 0,
6018                show_rotation_editor: false,
6019                loadout_menu_index: 0,
6020                loadout_hotbar_slot: 1,
6021                loadout_ability_index: 0,
6022                loadout_focus_presets: false,
6023                rotation_editor: RotationEditorState::default(),
6024                harvest_in_progress: false,
6025                harvest_started_at: None,
6026                pending_craft_ack: None,
6027                pending_worker_job_ack: None,
6028                attending_worker_instance_id: None,
6029                quest_log: Vec::new(),
6030                interactables: Vec::new(),
6031                ledger: None,
6032                career: None,
6033                character_sheet_tab: CharacterSheetTab::Character,
6034                ledger_period: LedgerPeriod::Day,
6035                show_quest_offer: false,
6036                pending_quest_offer: None,
6037                show_quest_menu: false,
6038                quest_menu_index: 0,
6039                quest_withdraw_confirm: false,
6040                hired_workers: Vec::new(),
6041                show_workers_menu: false,
6042                workers_menu_index: 0,
6043                workers_menu_compact: false,
6044                worker_step_display: BTreeMap::new(),
6045                worker_error_display: BTreeMap::new(),
6046                show_worker_give_picker: false,
6047                worker_give_picker_index: 0,
6048                worker_give_picker: None,
6049                show_worker_give_target_picker: false,
6050                worker_give_target_picker_index: 0,
6051                worker_give_target_picker: None,
6052                show_worker_take_picker: false,
6053                worker_take_picker_index: 0,
6054                worker_take_picker: None,
6055                show_worker_teach_picker: false,
6056                worker_teach_picker_index: 0,
6057                worker_teach_picker: None,
6058                worker_route_editor: None,
6059                progression_curve: None,
6060            },
6061        };
6062        client.state.apply_client_ui_prefs();
6063        client
6064    }
6065
6066    pub fn entity_id(&self) -> EntityId {
6067        self.state.entity_id
6068    }
6069
6070    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6071        if self.state.connected {
6072            return Ok(());
6073        }
6074
6075        loop {
6076            match self.session.next_event().await {
6077                Some(SessionEvent::Welcome {
6078                    session_id,
6079                    entity_id,
6080                    snapshot,
6081                }) => {
6082                    self.state
6083                        .restore_from_welcome(session_id, entity_id, &snapshot);
6084                    self.state.apply_client_ui_prefs();
6085                    self.state.push_log(format!(
6086                        "Connected — session {session_id}, entity {entity_id}"
6087                    ));
6088                    return Ok(());
6089                }
6090                Some(SessionEvent::Disconnected { .. }) => {
6091                    anyhow::bail!("disconnected before welcome");
6092                }
6093                Some(_) => continue,
6094                None => anyhow::bail!("session closed before welcome"),
6095            }
6096        }
6097    }
6098
6099    /// Drain all pending server events (non-blocking).
6100    pub fn drain_events(&mut self) {
6101        while let Some(event) = self.session.try_next_event() {
6102            if self.handle_event_sync(event).is_err() {
6103                break;
6104            }
6105        }
6106    }
6107
6108    /// Wait for the next server event.
6109    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6110        self.session.next_event().await
6111    }
6112
6113    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6114        self.handle_event_sync(event)
6115    }
6116
6117    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6118        match event {
6119            SessionEvent::Welcome {
6120                session_id,
6121                entity_id,
6122                snapshot,
6123            } => {
6124                let resumed = self.state.connected;
6125                self.state
6126                    .restore_from_welcome(session_id, entity_id, &snapshot);
6127                if resumed {
6128                    self.state.push_log(format!(
6129                        "Session restored — session {session_id}, entity {entity_id}"
6130                    ));
6131                }
6132            }
6133            SessionEvent::ContentUpdated { snapshot } => {
6134                self.state
6135                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6136                self.state.push_log(format!(
6137                    "World updated (content rev {})",
6138                    snapshot.content_rev
6139                ));
6140            }
6141            SessionEvent::Tick(delta) => {
6142                self.state.apply_tick_fields(&delta, self.state.entity_id);
6143                self.state.ticks_received += 1;
6144            }
6145            SessionEvent::IntentAck {
6146                entity_id,
6147                seq,
6148                tick,
6149            } => {
6150                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6151                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6152                    if *craft_seq == seq {
6153                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6154                        if batches > 1 {
6155                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6156                        } else {
6157                            self.state.push_log(format!("Crafting {label}…"));
6158                        }
6159                    }
6160                }
6161                if self
6162                    .state
6163                    .pending_worker_job_ack
6164                    .as_ref()
6165                    .is_some_and(|p| p.seq == seq)
6166                {
6167                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6168                    if pending.idle {
6169                        self.state.push_log(format!(
6170                            "Route cleared for {} — worker idle",
6171                            pending.worker_label
6172                        ));
6173                    } else {
6174                        self.state.push_log(format!(
6175                            "Route saved for {} — {} stop(s), job loop active",
6176                            pending.worker_label, pending.stop_count
6177                        ));
6178                    }
6179                    if self
6180                        .state
6181                        .worker_route_editor
6182                        .as_ref()
6183                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6184                    {
6185                        self.close_worker_route_editor();
6186                    }
6187                }
6188            }
6189            SessionEvent::Chat(msg) => {
6190                let label = match msg.channel {
6191                    flatland_protocol::ChatChannel::Nearby => "nearby",
6192                    flatland_protocol::ChatChannel::Direct => "speak",
6193                    flatland_protocol::ChatChannel::Whisper => "whisper",
6194                    flatland_protocol::ChatChannel::WhisperStone => "stone",
6195                };
6196                let clarity = match msg.clarity {
6197                    flatland_protocol::ChatClarity::Clear => "",
6198                    flatland_protocol::ChatClarity::Partial => "~",
6199                    flatland_protocol::ChatClarity::Heavy => "…",
6200                };
6201                self.state.push_log(format!(
6202                    "[{label}{clarity}] {}: {}",
6203                    msg.from_name, msg.text
6204                ));
6205                let now_ms = std::time::SystemTime::now()
6206                    .duration_since(std::time::UNIX_EPOCH)
6207                    .map(|d| d.as_millis() as u64)
6208                    .unwrap_or(0);
6209                self.state
6210                    .social_chat
6211                    .note_speech(&msg, self.state.entity_id, now_ms);
6212                self.state
6213                    .social_chat
6214                    .push(crate::social::ChatLogEntry::from_message(
6215                        msg,
6216                        self.state.entity_id,
6217                    ));
6218            }
6219            SessionEvent::TradeOpened(panel) => {
6220                self.state.social_chat.pending_trade = None;
6221                let peer = panel.peer_name.clone();
6222                self.state.trade_ui.open(panel);
6223                self.state
6224                    .social_chat
6225                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6226                self.state
6227                    .social_chat
6228                    .push_cue(crate::social::AudioCue::TradeOpened);
6229            }
6230            SessionEvent::TradeClosed { reason } => {
6231                self.state.push_log(reason.clone());
6232                self.state.social_chat.push_system(reason);
6233                self.state.trade_ui.close();
6234            }
6235            SessionEvent::HarvestResult(result) => {
6236                self.state.clear_harvest_state();
6237                crate::harvest_trace!(
6238                    entity_id = self.state.entity_id,
6239                    node_id = %result.node_id,
6240                    template = %result.item_template,
6241                    quantity = result.quantity,
6242                    client_tick = self.state.tick,
6243                    "client applied harvest result"
6244                );
6245                let msg = if result.quantity == 0 {
6246                    format!(
6247                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6248                        result.item_template
6249                    )
6250                } else {
6251                    format!(
6252                        "Harvested {} x{} (on the ground — press P to pick up)",
6253                        result.item_template, result.quantity
6254                    )
6255                };
6256                self.state.push_log(msg);
6257            }
6258            SessionEvent::CraftResult(result) => {
6259                for stack in &result.consumed {
6260                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6261                        *qty = qty.saturating_sub(stack.quantity);
6262                        if *qty == 0 {
6263                            self.state.inventory.remove(&stack.template_id);
6264                        }
6265                    }
6266                }
6267                for stack in &result.outputs {
6268                    *self
6269                        .state
6270                        .inventory
6271                        .entry(stack.template_id.clone())
6272                        .or_insert(0) += stack.quantity;
6273                }
6274                if let Some(output) = result.outputs.first() {
6275                    if result.batch_total > 1 {
6276                        self.state.push_log(format!(
6277                            "Crafted {} x{} ({}/{})",
6278                            output.template_id,
6279                            output.quantity,
6280                            result.batch_index,
6281                            result.batch_total
6282                        ));
6283                    } else {
6284                        self.state.push_log(format!(
6285                            "Crafted {} x{}",
6286                            output.template_id, output.quantity
6287                        ));
6288                    }
6289                } else {
6290                    self.state
6291                        .push_log(format!("Craft finished: {}", result.blueprint_id));
6292                }
6293            }
6294            SessionEvent::Death(notice) => {
6295                self.state.clear_harvest_state();
6296                self.state.push_log(notice.message.clone());
6297                self.state.push_log(format!(
6298                    "Respawned at ({:.1}, {:.1})",
6299                    notice.respawn_x, notice.respawn_y
6300                ));
6301            }
6302            SessionEvent::Interaction(notice) => {
6303                if notice.message.starts_with("Harvest failed:") {
6304                    self.state.clear_harvest_state();
6305                }
6306                if notice.message.starts_with("Can't do that:") {
6307                    self.state.pending_craft_ack = None;
6308                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
6309                        if let Some(w) = self
6310                            .state
6311                            .hired_workers
6312                            .iter_mut()
6313                            .find(|w| w.instance_id == pending.worker_instance_id)
6314                        {
6315                            w.route = pending.prev_route;
6316                            w.mode = pending.prev_mode;
6317                            w.step_label = pending.prev_step_label;
6318                            w.last_error = pending.prev_last_error;
6319                        }
6320                        let reason = notice
6321                            .message
6322                            .strip_prefix("Can't do that:")
6323                            .unwrap_or(&notice.message)
6324                            .trim();
6325                        self.state.push_log(format!(
6326                            "Route save failed for {}: {reason}",
6327                            pending.worker_label
6328                        ));
6329                    }
6330                    let reason = notice
6331                        .message
6332                        .strip_prefix("Can't do that:")
6333                        .unwrap_or(&notice.message)
6334                        .trim();
6335                    if reason.contains("already tilled") {
6336                        if let Some(plot) = self.state.my_plot_under_player() {
6337                            self.state.sell_plot_confirm = Some(plot.plot_id);
6338                            self.state.sell_plot_armed_at = Some(Instant::now());
6339                        }
6340                    }
6341                }
6342                if notice.message.starts_with("Cast failed:") {
6343                    self.state.cast_progress = None;
6344                }
6345                if notice.message.contains("slain the") {
6346                    self.state.combat_target = None;
6347                    self.state.combat_target_label = None;
6348                }
6349                // Inbound trade request → inline Y/N in the CHAT column (no popup).
6350                if notice.message.contains("wants to trade") {
6351                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6352                        let from_name = notice
6353                            .message
6354                            .split(" wants to trade")
6355                            .next()
6356                            .unwrap_or("Player")
6357                            .to_string();
6358                        self.state.social_chat.pending_trade =
6359                            Some(crate::social::PendingTradeRequest {
6360                                from_entity,
6361                                from_name: from_name.clone(),
6362                            });
6363                        self.state.social_chat.push_system(format!(
6364                            "{from_name} wants to trade — [Y] accept · [N] decline"
6365                        ));
6366                        self.state
6367                            .social_chat
6368                            .push_cue(crate::social::AudioCue::TradeOffer);
6369                    }
6370                }
6371                if notice.message.starts_with("trade request declined") {
6372                    self.state
6373                        .social_chat
6374                        .push_system(notice.message.clone());
6375                    self.state
6376                        .social_chat
6377                        .push_cue(crate::social::AudioCue::TradeDeclined);
6378                }
6379                self.state.apply_interaction_notice(&notice);
6380                self.state.push_log(notice.message.clone());
6381            }
6382            SessionEvent::ShopOpened(catalog) => {
6383                self.state.apply_shop_catalog(catalog);
6384            }
6385            SessionEvent::BankOpened(panel) => {
6386                self.state.apply_bank_panel(panel);
6387            }
6388            SessionEvent::StorageOpened(panel) => {
6389                self.state.apply_storage_panel(panel);
6390            }
6391            SessionEvent::MarketOpened(panel) => {
6392                self.state.apply_market_panel(panel);
6393            }
6394            SessionEvent::NpcTalkOpened(opened) => {
6395                self.state.show_npc_verb_menu = false;
6396                if self.state.npc_verb_target.is_none() {
6397                    self.state.npc_verb_target = Some(opened.npc_id.clone());
6398                }
6399                let label = opened.npc_label.clone();
6400                let banner = if !opened.trade_allowed {
6401                    Some("Trade is unavailable right now.".to_string())
6402                } else {
6403                    None
6404                };
6405                self.state.show_npc_chat = true;
6406                self.state.npc_chat = Some(NpcChatState {
6407                    npc_id: opened.npc_id,
6408                    npc_label: opened.npc_label,
6409                    lines: if opened.greeting.is_empty() {
6410                        vec![]
6411                    } else {
6412                        vec![format!("{label}: {}", opened.greeting)]
6413                    },
6414                    input: String::new(),
6415                    pending: opened.greeting.is_empty(),
6416                    talk_depth: opened.talk_depth,
6417                    trade_allowed: opened.trade_allowed,
6418                    banner,
6419                });
6420            }
6421            SessionEvent::NpcTalkPending(_) => {
6422                if let Some(chat) = self.state.npc_chat.as_mut() {
6423                    chat.pending = true;
6424                }
6425            }
6426            SessionEvent::NpcTalkReply(reply) => {
6427                if let Some(chat) = self.state.npc_chat.as_mut() {
6428                    if chat.npc_id == reply.npc_id {
6429                        chat.pending = false;
6430                        if reply.trade_disabled {
6431                            chat.trade_allowed = false;
6432                            chat.banner = Some("Trade is unavailable right now.".to_string());
6433                        }
6434                        if reply.wind_down {
6435                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6436                            if chat.banner.is_none() {
6437                                chat.banner =
6438                                    Some("They're wrapping up — keep it brief.".to_string());
6439                            }
6440                        }
6441                        chat.lines
6442                            .push(format!("{}: {}", chat.npc_label, reply.line));
6443                    }
6444                }
6445            }
6446            SessionEvent::NpcTalkClosed(closed) => {
6447                if self
6448                    .state
6449                    .npc_chat
6450                    .as_ref()
6451                    .is_some_and(|c| c.npc_id == closed.npc_id)
6452                {
6453                    self.state.show_npc_chat = false;
6454                    self.state.npc_chat = None;
6455                }
6456            }
6457            SessionEvent::NpcTalkError(err) => {
6458                self.state.push_log(format!("Talk failed: {}", err.reason));
6459                if let Some(chat) = self.state.npc_chat.as_mut() {
6460                    chat.pending = false;
6461                }
6462            }
6463            SessionEvent::UseResult(result) => {
6464                // Inventory count hint only — the Interaction notice already logs
6465                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
6466                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6467                    *qty = qty.saturating_sub(1);
6468                    if *qty == 0 {
6469                        self.state.inventory.remove(&result.template_id);
6470                    }
6471                }
6472            }
6473            SessionEvent::QuestOffer(offer) => {
6474                self.state.pending_quest_offer = Some(offer.clone());
6475                self.state.show_quest_offer = true;
6476                self.state
6477                    .push_log(format!("Quest offered: {}", offer.title));
6478            }
6479            SessionEvent::QuestAccepted(notice) => {
6480                self.state.show_quest_offer = false;
6481                self.state.pending_quest_offer = None;
6482                self.state.push_log(notice.message);
6483            }
6484            SessionEvent::QuestWithdrawn(notice) => {
6485                self.state.show_quest_menu = false;
6486                self.state.quest_withdraw_confirm = false;
6487                self.state.push_log(notice.message);
6488            }
6489            SessionEvent::QuestStepCompleted(notice) => {
6490                self.state.push_log(notice.message);
6491            }
6492            SessionEvent::QuestCompleted(notice) => {
6493                self.state.push_log(notice.message);
6494            }
6495            SessionEvent::Disconnected { reason } => {
6496                self.state.clear_harvest_state();
6497                self.state.connected = false;
6498                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6499                if let Some(r) = &self.state.disconnect_reason {
6500                    self.state.push_log(format!("Disconnected: {r}"));
6501                } else {
6502                    self.state.push_log("Disconnected from server");
6503                }
6504            }
6505        }
6506        Ok(())
6507    }
6508
6509    pub fn is_connected(&self) -> bool {
6510        self.state.connected
6511    }
6512
6513    pub fn close_overlays(&mut self) {
6514        self.state.show_stats = false;
6515        self.state.show_craft_menu = false;
6516        self.state.show_shop_menu = false;
6517        self.state.shop_catalog = None;
6518        self.state.show_npc_verb_menu = false;
6519        self.state.npc_verb_target = None;
6520        self.state.show_npc_chat = false;
6521        self.state.npc_chat = None;
6522        self.state.show_inventory_menu = false;
6523        self.state.show_loadout_menu = false;
6524        self.state.show_rotation_editor = false;
6525        self.state.rotation_editor.reset();
6526        self.state.show_rename_prompt = false;
6527        self.state.show_worker_rename = false;
6528        self.state.rename_buffer.clear();
6529        self.state.show_move_picker = false;
6530        self.state.move_picker = None;
6531        self.state.show_destroy_picker = false;
6532        self.state.destroy_confirm_pending = false;
6533        self.state.destroy_picker = None;
6534        self.state.show_quest_offer = false;
6535        self.state.pending_quest_offer = None;
6536        self.state.show_quest_menu = false;
6537        self.state.quest_withdraw_confirm = false;
6538        self.state.show_workers_menu = false;
6539        self.close_worker_give_picker();
6540        self.close_worker_give_target_picker();
6541        self.close_worker_take_picker();
6542        self.close_worker_teach_picker();
6543        self.state.worker_route_editor = None;
6544        self.state.claim_mode = None;
6545        self.state.relocate_mode = None;
6546        self.state.sell_plot_confirm = None;
6547        self.state.sell_plot_armed_at = None;
6548        self.close_farm_access_panel();
6549        if self.state.show_plant_menu {
6550            self.close_plant_menu();
6551        }
6552    }
6553
6554    /// Esc / back — pop one UI layer instead of closing every overlay at once.
6555    pub fn back_on_esc(&mut self) -> bool {
6556        if self.state.social_chat.composer_open() {
6557            self.state.social_chat.close_composer();
6558            return true;
6559        }
6560        if self.state.player_verbs.open {
6561            self.state.player_verbs.close();
6562            return true;
6563        }
6564        if self.state.whisper_pouch_ui.open {
6565            self.state.whisper_pouch_ui.open = false;
6566            return true;
6567        }
6568        if self.state.trade_ui.panel.is_some() {
6569            // async cancel — caller should prefer trade_cancel; close UI optimistically
6570            self.state.trade_ui.close();
6571            return true;
6572        }
6573        if self.state.show_rename_prompt {
6574            self.cancel_rename_prompt();
6575            return true;
6576        }
6577        if self.state.show_worker_rename {
6578            self.cancel_worker_rename();
6579            return true;
6580        }
6581        if self.state.show_destroy_picker {
6582            if self.state.destroy_confirm_pending {
6583                self.cancel_destroy_confirm();
6584            } else {
6585                self.close_destroy_picker();
6586            }
6587            return true;
6588        }
6589        if self.state.claim_mode.is_some() {
6590            self.cancel_claim_mode();
6591            return true;
6592        }
6593        if self.state.relocate_mode.is_some() {
6594            self.cancel_relocate_mode();
6595            return true;
6596        }
6597        if self.state.show_plant_menu {
6598            self.close_plant_menu();
6599            return true;
6600        }
6601        if self.state.show_farm_access {
6602            self.close_farm_access_panel();
6603            return true;
6604        }
6605        if self.state.sell_plot_confirm.is_some() {
6606            self.state.sell_plot_confirm = None;
6607            self.state.sell_plot_armed_at = None;
6608            self.state.push_log("Sell cancelled");
6609            return true;
6610        }
6611        if self.state.show_move_picker {
6612            self.close_move_picker();
6613            return true;
6614        }
6615        if self.state.show_rotation_editor {
6616            match self.state.rotation_editor.mode {
6617                RotationEditorMode::List => {
6618                    self.state.show_rotation_editor = false;
6619                    self.state.rotation_editor.reset();
6620                }
6621                RotationEditorMode::EditLabel => {
6622                    self.state.rotation_editor.label_buffer.clear();
6623                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6624                }
6625                RotationEditorMode::PickAbility => {
6626                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6627                }
6628                RotationEditorMode::EditSequence => {
6629                    self.state.rotation_editor.draft = None;
6630                    self.state.rotation_editor.mode = RotationEditorMode::List;
6631                }
6632            }
6633            return true;
6634        }
6635        if self.state.show_inventory_menu {
6636            self.close_inventory_menu();
6637            return true;
6638        }
6639        if self.state.show_craft_menu {
6640            self.close_craft_menu();
6641            return true;
6642        }
6643        if self.state.show_keychain_menu {
6644            self.close_keychain_menu();
6645            return true;
6646        }
6647        if self.state.show_quest_offer {
6648            self.quest_offer_decline();
6649            return true;
6650        }
6651        if self.state.show_shop_menu {
6652            // Caller must await shop close (npc_interaction_back / play-loop).
6653            return false;
6654        }
6655        if self.state.bank_panel.is_some() {
6656            return false;
6657        }
6658        if self.state.storage_panel.is_some() {
6659            return false;
6660        }
6661        if self.state.market_panel.is_some() {
6662            return false;
6663        }
6664        if self.state.show_npc_chat {
6665            // play.rs calls npc_interaction_back().await on Esc
6666            return false;
6667        }
6668        if self.state.show_npc_verb_menu {
6669            self.state.show_npc_verb_menu = false;
6670            self.state.npc_verb_target = None;
6671            return true;
6672        }
6673        if self.state.show_quest_menu {
6674            if self.state.quest_withdraw_confirm {
6675                self.state.quest_withdraw_confirm = false;
6676            } else {
6677                self.state.show_quest_menu = false;
6678            }
6679            return true;
6680        }
6681        if self.state.worker_route_editor.is_some() {
6682            // Pop one sheet level; only close the editor at the root stop list.
6683            if self.re_at_root_sheet() {
6684                let reopen = self.state.attending_worker_instance_id.clone();
6685                self.close_worker_route_editor();
6686                if let Some(id) = reopen {
6687                    if let Some(idx) = self
6688                        .state
6689                        .hired_workers
6690                        .iter()
6691                        .position(|w| w.instance_id == id)
6692                    {
6693                        self.state.workers_menu_index = idx;
6694                        self.state.show_workers_menu = true;
6695                    }
6696                }
6697            } else {
6698                self.re_sheet_back();
6699            }
6700            return true;
6701        }
6702        if self.state.show_worker_give_picker {
6703            self.close_worker_give_picker();
6704            return true;
6705        }
6706        if self.state.show_worker_give_target_picker {
6707            self.close_worker_give_target_picker();
6708            return true;
6709        }
6710        if self.state.show_worker_take_picker {
6711            self.close_worker_take_picker();
6712            return true;
6713        }
6714        if self.state.show_worker_teach_picker {
6715            self.close_worker_teach_picker();
6716            return true;
6717        }
6718        if self.state.show_workers_menu {
6719            self.close_workers_menu_ui();
6720            return true;
6721        }
6722        if self.state.show_loadout_menu {
6723            self.state.show_loadout_menu = false;
6724            return true;
6725        }
6726        if self.state.show_stats {
6727            self.state.show_stats = false;
6728            return true;
6729        }
6730        if self.state.show_equip_menu {
6731            self.state.show_equip_menu = false;
6732            return true;
6733        }
6734        false
6735    }
6736
6737    pub fn toggle_stats(&mut self) {
6738        self.state.show_stats = !self.state.show_stats;
6739        if self.state.show_stats {
6740            self.state.character_sheet_tab = CharacterSheetTab::Character;
6741            self.state.show_craft_menu = false;
6742            self.state.show_shop_menu = false;
6743            self.state.shop_catalog = None;
6744            self.state.show_inventory_menu = false;
6745            self.state.show_equip_menu = false;
6746        }
6747    }
6748
6749    pub fn toggle_equip_menu(&mut self) {
6750        self.state.show_equip_menu = !self.state.show_equip_menu;
6751        if self.state.show_equip_menu {
6752            self.state.show_stats = false;
6753            self.state.show_craft_menu = false;
6754            self.state.show_shop_menu = false;
6755            self.state.shop_catalog = None;
6756            self.state.show_inventory_menu = false;
6757            self.state.show_loadout_menu = false;
6758        }
6759    }
6760
6761    pub fn cycle_character_sheet_tab(&mut self) {
6762        if self.state.show_stats {
6763            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
6764        }
6765    }
6766
6767    pub fn set_ledger_period_digit(&mut self, c: char) {
6768        if self.state.show_stats {
6769            if let Some(p) = LedgerPeriod::from_digit(c) {
6770                self.state.ledger_period = p;
6771                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
6772            }
6773        }
6774    }
6775
6776    pub fn cycle_ledger_period(&mut self) {
6777        if self.state.show_stats
6778            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
6779        {
6780            self.state.ledger_period = self.state.ledger_period.cycle();
6781        }
6782    }
6783
6784    pub fn open_inventory_menu(&mut self) {
6785        self.state.show_inventory_menu = true;
6786        self.state.show_craft_menu = false;
6787        self.state.show_shop_menu = false;
6788        self.state.shop_catalog = None;
6789        self.state.show_stats = false;
6790        self.state.show_move_picker = false;
6791        self.state.move_picker = None;
6792        self.state.show_destroy_picker = false;
6793        self.state.destroy_confirm_pending = false;
6794        self.state.destroy_picker = None;
6795        self.state.show_rename_prompt = false;
6796        self.state.rename_buffer.clear();
6797        self.state.inventory_filter_focused = false;
6798        self.state.clamp_inventory_indices();
6799    }
6800
6801    pub fn close_inventory_menu(&mut self) {
6802        self.state.show_inventory_menu = false;
6803        self.state.show_move_picker = false;
6804        self.state.move_picker = None;
6805        self.close_grant_picker();
6806        self.state.show_destroy_picker = false;
6807        self.state.destroy_confirm_pending = false;
6808        self.state.destroy_picker = None;
6809        self.state.show_rename_prompt = false;
6810        self.state.rename_buffer.clear();
6811        self.state.inventory_filter_focused = false;
6812    }
6813
6814    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
6815        let Some(row) = self.state.inventory_selected_row() else {
6816            anyhow::bail!("inventory empty");
6817        };
6818        if !self.state.row_is_renameable_container(&row) {
6819            anyhow::bail!("only storage containers can be renamed");
6820        }
6821        let current = row
6822            .stack
6823            .display_name
6824            .clone()
6825            .unwrap_or_else(|| row.stack.template_id.clone());
6826        self.state.rename_buffer = current;
6827        self.state.show_rename_prompt = true;
6828        self.state.show_worker_rename = false;
6829        self.state.show_move_picker = false;
6830        self.state.show_destroy_picker = false;
6831        self.state.destroy_confirm_pending = false;
6832        Ok(())
6833    }
6834
6835    pub fn cancel_rename_prompt(&mut self) {
6836        self.state.show_rename_prompt = false;
6837        self.state.rename_buffer.clear();
6838    }
6839
6840    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
6841        let name = self.state.rename_buffer.trim().to_string();
6842        if name.is_empty() {
6843            anyhow::bail!("name cannot be empty");
6844        }
6845        let Some(row) = self.state.inventory_selected_row() else {
6846            anyhow::bail!("inventory empty");
6847        };
6848        let Some(instance_id) = row.stack.item_instance_id else {
6849            anyhow::bail!("item has no instance id");
6850        };
6851        self.seq += 1;
6852        self.session
6853            .submit_intent(Intent::RenameContainer {
6854                entity_id: self.state.entity_id,
6855                item_instance_id: instance_id,
6856                location: row.from.clone(),
6857                name,
6858                seq: self.seq,
6859            })
6860            .await?;
6861        self.state.intents_sent += 1;
6862        self.state.show_rename_prompt = false;
6863        self.state.rename_buffer.clear();
6864        Ok(())
6865    }
6866
6867    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
6868        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6869            anyhow::bail!("no worker selected");
6870        };
6871        self.state.rename_buffer = worker.label.clone();
6872        self.state.show_worker_rename = true;
6873        self.state.show_rename_prompt = false;
6874        Ok(())
6875    }
6876
6877    pub fn cancel_worker_rename(&mut self) {
6878        self.state.show_worker_rename = false;
6879        self.state.rename_buffer.clear();
6880    }
6881
6882    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
6883        let name = self.state.rename_buffer.trim().to_string();
6884        if name.is_empty() {
6885            anyhow::bail!("name cannot be empty");
6886        }
6887        if name.chars().count() > 32 {
6888            anyhow::bail!("name must be 1–32 characters");
6889        }
6890        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6891            anyhow::bail!("no worker selected");
6892        };
6893        let worker_instance_id = worker.instance_id.clone();
6894        self.seq += 1;
6895        self.session
6896            .submit_intent(Intent::RenameHiredWorker {
6897                entity_id: self.state.entity_id,
6898                worker_instance_id: worker_instance_id.clone(),
6899                name: name.clone(),
6900                seq: self.seq,
6901            })
6902            .await?;
6903        self.state.intents_sent += 1;
6904        if let Some(w) = self
6905            .state
6906            .hired_workers
6907            .iter_mut()
6908            .find(|w| w.instance_id == worker_instance_id)
6909        {
6910            w.label = name.clone();
6911        }
6912        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6913            if ed.worker_instance_id == worker_instance_id {
6914                ed.worker_label = name.clone();
6915            }
6916        }
6917        self.state.show_worker_rename = false;
6918        self.state.rename_buffer.clear();
6919        self.state.push_log(format!("Renamed worker to \"{name}\""));
6920        Ok(())
6921    }
6922
6923    pub fn toggle_inventory_menu(&mut self) {
6924        if self.state.show_inventory_menu {
6925            self.close_inventory_menu();
6926        } else {
6927            self.open_inventory_menu();
6928        }
6929    }
6930
6931    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
6932    pub fn inventory_menu_move(&mut self, delta: i32) {
6933        if self.state.show_grant_picker {
6934            let Some(picker) = self.state.grant_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.grant_picker_index = step_filtered_index(
6944                self.state.grant_picker_index,
6945                delta,
6946                n,
6947                |i| list_label_matches(&labels[i], &filter),
6948            );
6949            return;
6950        }
6951        if self.state.show_move_picker {
6952            let Some(picker) = self.state.move_picker.as_ref() else {
6953                return;
6954            };
6955            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6956            let filter = picker.filter.clone();
6957            let n = labels.len();
6958            if n == 0 {
6959                return;
6960            }
6961            self.state.move_picker_index = step_filtered_index(
6962                self.state.move_picker_index,
6963                delta,
6964                n,
6965                |i| list_label_matches(&labels[i], &filter),
6966            );
6967            self.state.clamp_move_picker_quantity();
6968            return;
6969        }
6970        let n = self.state.inventory_selectable_rows().len();
6971        if n == 0 {
6972            return;
6973        }
6974        let idx = self.state.inventory_menu_index as i32;
6975        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6976    }
6977
6978    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
6979    pub fn inventory_menu_page(&mut self, pages: i32) {
6980        if self.state.show_grant_picker {
6981            let Some(picker) = self.state.grant_picker.as_ref() else {
6982                return;
6983            };
6984            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6985            let filter = picker.filter.clone();
6986            let n = labels.len();
6987            self.state.grant_picker_index = page_filtered_index(
6988                self.state.grant_picker_index,
6989                pages,
6990                n,
6991                |i| list_label_matches(&labels[i], &filter),
6992            );
6993            return;
6994        }
6995        if self.state.show_move_picker {
6996            let Some(picker) = self.state.move_picker.as_ref() else {
6997                return;
6998            };
6999            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7000            let filter = picker.filter.clone();
7001            let n = labels.len();
7002            self.state.move_picker_index = page_filtered_index(
7003                self.state.move_picker_index,
7004                pages,
7005                n,
7006                |i| list_label_matches(&labels[i], &filter),
7007            );
7008            self.state.clamp_move_picker_quantity();
7009            return;
7010        }
7011        let n = self.state.inventory_selectable_rows().len();
7012        self.state.inventory_menu_index =
7013            page_list_index(self.state.inventory_menu_index, pages, n);
7014    }
7015
7016    pub fn cycle_inventory_tab(&mut self, forward: bool) {
7017        if self.state.show_move_picker
7018            || self.state.show_grant_picker
7019            || self.state.show_destroy_picker
7020            || self.state.show_rename_prompt
7021            || self.state.inventory_filter_focused
7022        {
7023            return;
7024        }
7025        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7026        self.state.inventory_menu_index = 0;
7027        self.state.clamp_inventory_indices();
7028    }
7029
7030    pub fn focus_inventory_filter(&mut self) {
7031        if self.state.show_grant_picker {
7032            if let Some(p) = self.state.grant_picker.as_mut() {
7033                p.filter_focused = true;
7034            }
7035            return;
7036        }
7037        if self.state.show_move_picker {
7038            if let Some(p) = self.state.move_picker.as_mut() {
7039                p.filter_focused = true;
7040            }
7041            return;
7042        }
7043        self.state.inventory_filter_focused = true;
7044    }
7045
7046    pub fn set_inventory_filter(&mut self, filter: String) {
7047        self.state.inventory_filter = filter;
7048        self.state.inventory_menu_index = 0;
7049        self.state.clamp_inventory_indices();
7050    }
7051
7052    pub fn append_inventory_filter_char(&mut self, ch: char) {
7053        if ch.is_control() {
7054            return;
7055        }
7056        if self.state.show_grant_picker {
7057            if let Some(p) = self.state.grant_picker.as_mut() {
7058                if p.filter_focused {
7059                    p.filter.push(ch);
7060                    self.state.grant_picker_index = 0;
7061                }
7062            }
7063            return;
7064        }
7065        if self.state.show_move_picker {
7066            if let Some(p) = self.state.move_picker.as_mut() {
7067                if p.filter_focused {
7068                    p.filter.push(ch);
7069                    self.state.move_picker_index = 0;
7070                    self.state.clamp_move_picker_quantity();
7071                }
7072            }
7073            return;
7074        }
7075        if !self.state.inventory_filter_focused {
7076            return;
7077        }
7078        self.state.inventory_filter.push(ch);
7079        self.state.inventory_menu_index = 0;
7080        self.state.clamp_inventory_indices();
7081    }
7082
7083    pub fn inventory_filter_backspace(&mut self) {
7084        if self.state.show_grant_picker {
7085            if let Some(p) = self.state.grant_picker.as_mut() {
7086                if p.filter_focused {
7087                    p.filter.pop();
7088                    self.state.grant_picker_index = 0;
7089                }
7090            }
7091            return;
7092        }
7093        if self.state.show_move_picker {
7094            if let Some(p) = self.state.move_picker.as_mut() {
7095                if p.filter_focused {
7096                    p.filter.pop();
7097                    self.state.move_picker_index = 0;
7098                    self.state.clamp_move_picker_quantity();
7099                }
7100            }
7101            return;
7102        }
7103        if !self.state.inventory_filter_focused {
7104            return;
7105        }
7106        self.state.inventory_filter.pop();
7107        self.state.inventory_menu_index = 0;
7108        self.state.clamp_inventory_indices();
7109    }
7110
7111    /// Esc while filter focused: clear filter then blur. Returns true if handled.
7112    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7113        if self.state.show_grant_picker {
7114            if let Some(p) = self.state.grant_picker.as_mut() {
7115                if p.filter_focused {
7116                    if !p.filter.is_empty() {
7117                        p.filter.clear();
7118                        self.state.grant_picker_index = 0;
7119                    } else {
7120                        p.filter_focused = false;
7121                    }
7122                    return true;
7123                }
7124                if !p.filter.is_empty() {
7125                    p.filter.clear();
7126                    self.state.grant_picker_index = 0;
7127                    return true;
7128                }
7129            }
7130            return false;
7131        }
7132        if self.state.show_move_picker {
7133            if let Some(p) = self.state.move_picker.as_mut() {
7134                if p.filter_focused {
7135                    if !p.filter.is_empty() {
7136                        p.filter.clear();
7137                        self.state.move_picker_index = 0;
7138                        self.state.clamp_move_picker_quantity();
7139                    } else {
7140                        p.filter_focused = false;
7141                    }
7142                    return true;
7143                }
7144                if !p.filter.is_empty() {
7145                    p.filter.clear();
7146                    self.state.move_picker_index = 0;
7147                    self.state.clamp_move_picker_quantity();
7148                    return true;
7149                }
7150            }
7151            return false;
7152        }
7153        if self.state.inventory_filter_focused {
7154            if !self.state.inventory_filter.is_empty() {
7155                self.state.inventory_filter.clear();
7156                self.state.inventory_menu_index = 0;
7157                self.state.clamp_inventory_indices();
7158            } else {
7159                self.state.inventory_filter_focused = false;
7160            }
7161            return true;
7162        }
7163        if !self.state.inventory_filter.is_empty() {
7164            self.state.inventory_filter.clear();
7165            self.state.inventory_menu_index = 0;
7166            self.state.clamp_inventory_indices();
7167            return true;
7168        }
7169        false
7170    }
7171
7172    pub fn craft_menu_page(&mut self, pages: i32) {
7173        let n = self.state.blueprints.len();
7174        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7175        self.state.clamp_craft_batch_quantity();
7176    }
7177
7178    pub fn shop_menu_page(&mut self, pages: i32) {
7179        let n = self.state.shop_list_len();
7180        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7181        self.state.clamp_shop_quantity();
7182    }
7183
7184    pub fn workers_menu_page(&mut self, pages: i32) {
7185        let n = self.state.hired_workers.len();
7186        self.state.workers_menu_index =
7187            page_list_index(self.state.workers_menu_index, pages, n);
7188    }
7189
7190    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
7191    /// chest — or fall back to the "move to…" destination picker (including
7192    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
7193    /// Placed chest shells open a pick-up destination picker (`l` still locks).
7194    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7195        if self.state.show_destroy_picker {
7196            if self.state.destroy_confirm_pending {
7197                return self.confirm_destroy_item().await;
7198            }
7199            return self.request_destroy_confirm();
7200        }
7201        if self.state.show_grant_picker {
7202            return self.confirm_grant_picker().await;
7203        }
7204        if self.state.show_move_picker {
7205            return self.confirm_move_picker().await;
7206        }
7207        let Some(row) = self.state.inventory_selected_row() else {
7208            anyhow::bail!("inventory empty");
7209        };
7210        if row.is_equip_shell {
7211            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7212                anyhow::bail!("not a worn item");
7213            };
7214            return self.equip_worn(slot, None).await;
7215        }
7216        if row.is_chest_shell {
7217            return self.open_chest_pickup_picker();
7218        }
7219        let template_id = row.stack.template_id.clone();
7220        let instance_id = row.stack.item_instance_id;
7221        let category = self.state.inventory_item_category(&template_id);
7222        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7223
7224        if category == Some("weapon") {
7225            return self.equip_mainhand(Some(template_id)).await;
7226        }
7227        if category == Some("lodging") && on_person {
7228            if let Some(inst) = instance_id {
7229                return self.place_container(inst).await;
7230            }
7231        }
7232        if (category == Some("container") || category == Some("armor")) && on_person {
7233            if let Some(inst) = instance_id {
7234                let world_placeable = row.stack.world_placeable == Some(true)
7235                    || template_id.contains("chest");
7236                if world_placeable {
7237                    return self.place_container(inst).await;
7238                }
7239                // Pouches no longer equip directly — they clip onto a worn belt's loops
7240                // instead, so fall through to the move picker (offers "belt loop" when a
7241                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
7242                if let Some(slot) = guess_body_slot(&template_id) {
7243                    return self.equip_worn(slot, Some(inst)).await;
7244                }
7245            }
7246        }
7247        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
7248        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
7249        // instead of guessing.
7250        self.open_move_picker()
7251    }
7252
7253    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
7254    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7255        let Some(row) = self.state.inventory_selected_row() else {
7256            anyhow::bail!("inventory empty");
7257        };
7258        if row.from != flatland_protocol::InventoryLocation::Root {
7259            anyhow::bail!("select a consumable on your person");
7260        }
7261        if GameState::stack_is_item_grant(&row.stack) {
7262            return self.open_grant_target_picker();
7263        }
7264        if GameState::is_property_deed_template(&row.stack.template_id) {
7265            return self.open_move_picker();
7266        }
7267        let category = self
7268            .state
7269            .inventory_item_category(&row.stack.template_id);
7270        if category != Some("consumable") {
7271            anyhow::bail!("selected item is not consumable");
7272        }
7273        self.use_item(&row.stack.template_id).await
7274    }
7275
7276    /// Open picker: apply selected grant item onto inventory / worn gear.
7277    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7278        let Some(row) = self.state.inventory_selected_row() else {
7279            anyhow::bail!("inventory empty");
7280        };
7281        if row.from != flatland_protocol::InventoryLocation::Root {
7282            anyhow::bail!("select a grant item on your person");
7283        }
7284        if !GameState::stack_is_item_grant(&row.stack) {
7285            anyhow::bail!("selected item does not grant onto gear");
7286        }
7287        let Some(grant_instance_id) = row.stack.item_instance_id else {
7288            anyhow::bail!("grant has no instance id");
7289        };
7290        let effect_id = GameState::grant_effect_id(&row.stack)
7291            .unwrap_or("?")
7292            .to_string();
7293        let mode = GameState::grant_mode(&row.stack).to_string();
7294        let options = self.state.grant_target_options(&row.stack);
7295        if options.is_empty() {
7296            anyhow::bail!("no valid gear to apply {effect_id} to");
7297        }
7298        let grant_label = row
7299            .stack
7300            .display_name
7301            .clone()
7302            .unwrap_or_else(|| row.stack.template_id.clone());
7303        self.state.show_grant_picker = true;
7304        self.state.grant_picker_index = 0;
7305        self.state.grant_picker = Some(GrantTargetPicker {
7306            grant_instance_id,
7307            grant_label,
7308            effect_id,
7309            mode,
7310            options,
7311            filter: String::new(),
7312            filter_focused: false,
7313        });
7314        Ok(())
7315    }
7316
7317    pub fn close_grant_picker(&mut self) {
7318        self.state.show_grant_picker = false;
7319        self.state.grant_picker = None;
7320        self.state.grant_picker_index = 0;
7321    }
7322
7323    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7324        let Some(picker) = self.state.grant_picker.clone() else {
7325            self.close_grant_picker();
7326            return Ok(());
7327        };
7328        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7329            self.close_grant_picker();
7330            return Ok(());
7331        };
7332        self.close_grant_picker();
7333        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7334            .await?;
7335        self.state.push_log(format!(
7336            "Applying {} onto {}…",
7337            picker.effect_id, opt.label
7338        ));
7339        Ok(())
7340    }
7341
7342    /// `m`: always open the "move to…" picker for the selected item, even for
7343    /// weapons/wearables that Enter would otherwise equip/wear directly.
7344    /// Placed chests open the pick-up destination picker instead.
7345    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7346        let Some(row) = self.state.inventory_selected_row() else {
7347            anyhow::bail!("inventory empty");
7348        };
7349        if row.is_equip_shell {
7350            anyhow::bail!("this is a worn bag — press Enter to unequip it");
7351        }
7352        if row.is_chest_shell {
7353            return self.open_chest_pickup_picker();
7354        }
7355        let Some(instance_id) = row.stack.item_instance_id else {
7356            anyhow::bail!("item has no instance id");
7357        };
7358        let mut options = self.state.move_destinations_for(
7359            &row.from,
7360            row.from_parent_instance_id,
7361            row.stack.item_instance_id,
7362            &row.stack.template_id,
7363        );
7364        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7365        let category = self.state.inventory_item_category(&row.stack.template_id);
7366        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7367            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7368                options.insert(
7369                    0,
7370                    MoveOption {
7371                        label: "Sell plot to crown…".into(),
7372                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
7373                    },
7374                );
7375            }
7376        }
7377        if on_person && category == Some("consumable") {
7378            if GameState::stack_is_item_grant(&row.stack) {
7379                options.insert(
7380                    0,
7381                    MoveOption {
7382                        label: "Apply onto gear…".into(),
7383                        kind: MoveOptionKind::GrantApply,
7384                    },
7385                );
7386            } else {
7387                options.insert(
7388                    0,
7389                    MoveOption {
7390                        label: "Use (eat / drink)".into(),
7391                        kind: MoveOptionKind::Use,
7392                    },
7393                );
7394            }
7395        }
7396        let item_label = row
7397            .stack
7398            .display_name
7399            .clone()
7400            .unwrap_or_else(|| row.stack.template_id.clone());
7401        // Default to 1 so withdrawing from storage is partial unless the player
7402        // presses `a` for max fit (full stack when the destination allows it).
7403        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7404        self.state.move_picker = Some(MovePicker {
7405            item_instance_id: instance_id,
7406            from: row.from,
7407            item_label,
7408            template_id: row.stack.template_id.clone(),
7409            stack_quantity: row.stack.quantity,
7410            quantity: initial_qty.max(1),
7411            options,
7412            filter: String::new(),
7413            filter_focused: false,
7414        });
7415        self.state.move_picker_index = 0;
7416        self.state.show_move_picker = true;
7417        self.state.show_destroy_picker = false;
7418        self.state.destroy_confirm_pending = false;
7419        self.state.destroy_picker = None;
7420        self.state.clamp_move_picker_quantity();
7421        Ok(())
7422    }
7423
7424    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
7425    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7426        let Some(row) = self.state.inventory_selected_row() else {
7427            anyhow::bail!("inventory empty");
7428        };
7429        if !row.is_chest_shell {
7430            anyhow::bail!("not a placed chest");
7431        }
7432        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7433            anyhow::bail!("not a placed chest");
7434        };
7435        let Some(instance_id) = row.stack.item_instance_id else {
7436            anyhow::bail!("chest has no instance id");
7437        };
7438        let chest = self
7439            .state
7440            .placed_containers
7441            .iter()
7442            .find(|c| c.id == *container_id)
7443            .cloned()
7444            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7445        let (px, py) = self.state.player_position();
7446        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7447            anyhow::bail!("too far from {}", chest.display_name);
7448        }
7449        if chest.locked && !chest.accessible {
7450            anyhow::bail!(
7451                "need the matching key for {} before picking it up",
7452                chest.display_name
7453            );
7454        }
7455        let options = self.state.chest_pickup_destinations(container_id);
7456        let item_label = row
7457            .stack
7458            .display_name
7459            .clone()
7460            .unwrap_or_else(|| row.stack.template_id.clone());
7461        self.state.move_picker = Some(MovePicker {
7462            item_instance_id: instance_id,
7463            from: row.from.clone(),
7464            item_label,
7465            template_id: row.stack.template_id.clone(),
7466            stack_quantity: 1,
7467            quantity: 1,
7468            options,
7469            filter: String::new(),
7470            filter_focused: false,
7471        });
7472        self.state.move_picker_index = 0;
7473        self.state.show_move_picker = true;
7474        self.state.show_destroy_picker = false;
7475        self.state.destroy_confirm_pending = false;
7476        self.state.destroy_picker = None;
7477        Ok(())
7478    }
7479
7480    pub fn close_move_picker(&mut self) {
7481        self.state.show_move_picker = false;
7482        self.state.move_picker = None;
7483    }
7484
7485    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7486        self.state.move_picker_adjust_quantity(delta);
7487    }
7488
7489    pub fn move_picker_set_quantity_max(&mut self) {
7490        self.state.move_picker_set_quantity_max();
7491    }
7492
7493    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7494        self.state.destroy_picker_adjust_quantity(delta);
7495    }
7496
7497    pub fn destroy_picker_set_quantity_max(&mut self) {
7498        self.state.destroy_picker_set_quantity_max();
7499    }
7500
7501    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7502        let Some(picker) = self.state.move_picker.clone() else {
7503            self.close_move_picker();
7504            return Ok(());
7505        };
7506        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7507            self.close_move_picker();
7508            return Ok(());
7509        };
7510        match option.kind {
7511            MoveOptionKind::Cancel => {
7512                self.close_move_picker();
7513            }
7514            MoveOptionKind::Use => {
7515                self.close_move_picker();
7516                self.use_item(&picker.template_id).await?;
7517            }
7518            MoveOptionKind::GrantApply => {
7519                self.close_move_picker();
7520                self.open_grant_target_picker()?;
7521            }
7522            MoveOptionKind::SellPlotToCrown { plot_id } => {
7523                self.close_move_picker();
7524                self.confirm_sell_plot_to_crown(plot_id).await?;
7525            }
7526            MoveOptionKind::RelocatePlaced { container_id } => {
7527                self.close_move_picker();
7528                self.state.show_inventory_menu = false;
7529                self.begin_relocate_container(&container_id)?;
7530            }
7531            MoveOptionKind::Drop => {
7532                self.close_move_picker();
7533                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7534                    if self.state.deed_bound(&stack) {
7535                        anyhow::bail!(
7536                            "cannot drop a property deed — store it or trade it to another player"
7537                        );
7538                    }
7539                    if self.state.key_drop_blocked(&stack) {
7540                        anyhow::bail!("cannot drop the key while its chest is locked");
7541                    }
7542                }
7543                self.drop_item(picker.item_instance_id, picker.from).await?;
7544                self.state
7545                    .push_log(format!("Dropped {}", picker.item_label));
7546            }
7547            MoveOptionKind::PickupPlaced {
7548                container_id,
7549                nest_location,
7550                nest_parent_instance_id,
7551            } => {
7552                self.close_move_picker();
7553                self.pickup_container(container_id.clone()).await?;
7554                let nest_into_bag = nest_parent_instance_id.is_some()
7555                    || !matches!(
7556                        nest_location,
7557                        flatland_protocol::InventoryLocation::Root
7558                    );
7559                if nest_into_bag {
7560                    self.move_item(
7561                        picker.item_instance_id,
7562                        flatland_protocol::InventoryLocation::Root,
7563                        nest_location,
7564                        nest_parent_instance_id,
7565                        None,
7566                    )
7567                    .await?;
7568                    self.state
7569                        .push_log(format!("Picked up {} into bag", picker.item_label));
7570                } else {
7571                    self.state
7572                        .push_log(format!("Picked up {}", picker.item_label));
7573                }
7574            }
7575            MoveOptionKind::Move {
7576                location,
7577                parent_instance_id,
7578            } => {
7579                self.close_move_picker();
7580                let qty = if picker.quantity >= picker.stack_quantity {
7581                    None
7582                } else {
7583                    Some(picker.quantity)
7584                };
7585                self.move_item(
7586                    picker.item_instance_id,
7587                    picker.from,
7588                    location,
7589                    parent_instance_id,
7590                    qty,
7591                )
7592                .await?;
7593                let moved = qty.unwrap_or(picker.stack_quantity);
7594                if moved >= picker.stack_quantity {
7595                    self.state.push_log(format!("Moved {}", picker.item_label));
7596                } else {
7597                    self.state.push_log(format!(
7598                        "Moved {} ×{} of {}",
7599                        picker.item_label, moved, picker.stack_quantity
7600                    ));
7601                }
7602            }
7603        }
7604        Ok(())
7605    }
7606
7607    /// `d`: drop the selected item on the ground immediately (no picker).
7608    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7609        let Some(row) = self.state.inventory_selected_row() else {
7610            anyhow::bail!("inventory empty");
7611        };
7612        if row.is_equip_shell {
7613            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7614        }
7615        if row.is_chest_shell {
7616            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
7617        }
7618        let Some(inst) = row.stack.item_instance_id else {
7619            anyhow::bail!("item has no instance id");
7620        };
7621        if self.state.deed_bound(&row.stack) {
7622            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
7623        }
7624        if self.state.key_drop_blocked(&row.stack) {
7625            anyhow::bail!("cannot drop the key while its chest is locked");
7626        }
7627        let label = row
7628            .stack
7629            .display_name
7630            .clone()
7631            .unwrap_or_else(|| row.stack.template_id.clone());
7632        self.drop_item(inst, row.from).await?;
7633        self.state.push_log(format!("Dropped {label}"));
7634        Ok(())
7635    }
7636
7637    pub async fn drop_item(
7638        &mut self,
7639        item_instance_id: uuid::Uuid,
7640        from: flatland_protocol::InventoryLocation,
7641    ) -> anyhow::Result<()> {
7642        self.seq += 1;
7643        self.session
7644            .submit_intent(Intent::DropItem {
7645                entity_id: self.state.entity_id,
7646                item_instance_id,
7647                from,
7648                seq: self.seq,
7649            })
7650            .await?;
7651        self.state.intents_sent += 1;
7652        Ok(())
7653    }
7654
7655    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
7656    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
7657        let Some(row) = self.state.inventory_selected_row() else {
7658            anyhow::bail!("inventory empty");
7659        };
7660        if row.is_equip_shell {
7661            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
7662        }
7663        if row.is_chest_shell {
7664            anyhow::bail!("can't destroy a placed chest from the inventory list");
7665        }
7666        let Some(instance_id) = row.stack.item_instance_id else {
7667            anyhow::bail!("item has no instance id");
7668        };
7669        if self.state.deed_bound(&row.stack) {
7670            anyhow::bail!(
7671                "cannot destroy a property deed — store it or trade it to another player"
7672            );
7673        }
7674        if self.state.key_drop_blocked(&row.stack) {
7675            anyhow::bail!("cannot destroy the key while its chest is locked");
7676        }
7677        let item_label = row
7678            .stack
7679            .display_name
7680            .clone()
7681            .unwrap_or_else(|| row.stack.template_id.clone());
7682        self.state.destroy_picker = Some(DestroyPicker {
7683            item_instance_id: instance_id,
7684            from: row.from,
7685            item_label,
7686            stack_quantity: row.stack.quantity,
7687            quantity: row.stack.quantity,
7688        });
7689        self.state.destroy_confirm_pending = false;
7690        self.state.show_destroy_picker = true;
7691        self.state.show_move_picker = false;
7692        self.state.move_picker = None;
7693        Ok(())
7694    }
7695
7696    pub fn close_destroy_picker(&mut self) {
7697        self.state.show_destroy_picker = false;
7698        self.state.destroy_confirm_pending = false;
7699        self.state.destroy_picker = None;
7700    }
7701
7702    pub fn cancel_destroy_confirm(&mut self) {
7703        self.state.destroy_confirm_pending = false;
7704    }
7705
7706    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
7707        if self.state.destroy_picker.is_none() {
7708            self.close_destroy_picker();
7709            return Ok(());
7710        }
7711        self.state.destroy_confirm_pending = true;
7712        Ok(())
7713    }
7714
7715    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
7716        let Some(picker) = self.state.destroy_picker.clone() else {
7717            self.close_destroy_picker();
7718            return Ok(());
7719        };
7720        let qty = if picker.quantity >= picker.stack_quantity {
7721            None
7722        } else {
7723            Some(picker.quantity)
7724        };
7725        self.destroy_item(picker.item_instance_id, picker.from, qty)
7726            .await?;
7727        let destroyed = qty.unwrap_or(picker.stack_quantity);
7728        if destroyed >= picker.stack_quantity {
7729            self.state
7730                .push_log(format!("Destroyed {}", picker.item_label));
7731        } else {
7732            self.state.push_log(format!(
7733                "Destroyed {} ×{} of {}",
7734                picker.item_label, destroyed, picker.stack_quantity
7735            ));
7736        }
7737        self.close_destroy_picker();
7738        Ok(())
7739    }
7740
7741    pub async fn destroy_item(
7742        &mut self,
7743        item_instance_id: uuid::Uuid,
7744        from: flatland_protocol::InventoryLocation,
7745        quantity: Option<u32>,
7746    ) -> anyhow::Result<()> {
7747        self.seq += 1;
7748        self.session
7749            .submit_intent(Intent::DestroyItem {
7750                entity_id: self.state.entity_id,
7751                item_instance_id,
7752                from,
7753                quantity,
7754                seq: self.seq,
7755            })
7756            .await?;
7757        self.state.intents_sent += 1;
7758        Ok(())
7759    }
7760
7761    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
7762    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
7763        if let Some(row) = self.state.inventory_selected_row() {
7764            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
7765                return self.toggle_placed_chest_lock(container_id).await;
7766            }
7767        }
7768        self.toggle_nearby_chest_lock().await
7769    }
7770
7771    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
7772        let chest = self
7773            .state
7774            .placed_containers
7775            .iter()
7776            .find(|c| c.id == container_id)
7777            .cloned()
7778            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7779        let (px, py) = self.state.player_position();
7780        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7781            anyhow::bail!("too far from {}", chest.display_name);
7782        }
7783        if !chest.accessible && chest.locked {
7784            anyhow::bail!(
7785                "need the matching key for {} (each crafted chest has its own key)",
7786                chest.display_name
7787            );
7788        }
7789        let lock = !chest.locked;
7790        self.set_container_locked(
7791            flatland_protocol::InventoryLocation::Placed {
7792                container_id: chest.id.clone(),
7793            },
7794            lock,
7795        )
7796        .await?;
7797        self.state.push_log(if lock {
7798            format!("Locked {}", chest.display_name)
7799        } else {
7800            format!("Unlocked {}", chest.display_name)
7801        });
7802        Ok(())
7803    }
7804
7805    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
7806    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
7807        let chest = self
7808            .state
7809            .nearest_placed_container(CONTAINER_RANGE_M)
7810            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
7811        self.toggle_placed_chest_lock(&chest.id).await
7812    }
7813
7814    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
7815        self.equip_mainhand(None).await
7816    }
7817
7818    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7819        if !self.state.is_alive() {
7820            anyhow::bail!("you are dead");
7821        }
7822        self.seq += 1;
7823        self.session
7824            .submit_intent(Intent::EquipOffhand {
7825                entity_id: self.state.entity_id,
7826                template_id,
7827                instance_id: None,
7828                seq: self.seq,
7829            })
7830            .await?;
7831        self.state.intents_sent += 1;
7832        Ok(())
7833    }
7834
7835    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
7836        self.equip_offhand(None).await
7837    }
7838
7839    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
7840        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
7841        for slot in slots {
7842            self.equip_worn(slot, None).await?;
7843        }
7844        Ok(())
7845    }
7846
7847    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
7848        let (px, py) = self.state.player_position();
7849        let nearest = self
7850            .state
7851            .placed_containers
7852            .iter()
7853            .min_by(|a, b| {
7854                let da = (a.x - px).hypot(a.y - py);
7855                let db = (b.x - px).hypot(b.y - py);
7856                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7857            })
7858            .cloned();
7859        let Some(chest) = nearest else {
7860            anyhow::bail!("no chest nearby");
7861        };
7862        if (chest.x - px).hypot(chest.y - py) > 2.0 {
7863            anyhow::bail!("too far from chest");
7864        }
7865        self.pickup_container(chest.id).await
7866    }
7867
7868    pub async fn equip_worn(
7869        &mut self,
7870        slot: BodySlot,
7871        instance_id: Option<uuid::Uuid>,
7872    ) -> anyhow::Result<()> {
7873        self.seq += 1;
7874        self.session
7875            .submit_intent(Intent::EquipWorn {
7876                entity_id: self.state.entity_id,
7877                slot,
7878                instance_id,
7879                seq: self.seq,
7880            })
7881            .await?;
7882        self.state.intents_sent += 1;
7883        Ok(())
7884    }
7885
7886    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
7887        self.seq += 1;
7888        self.session
7889            .submit_intent(Intent::PlaceContainer {
7890                entity_id: self.state.entity_id,
7891                item_instance_id,
7892                seq: self.seq,
7893            })
7894            .await?;
7895        self.state.intents_sent += 1;
7896        Ok(())
7897    }
7898
7899    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
7900        self.seq += 1;
7901        self.session
7902            .submit_intent(Intent::PickupContainer {
7903                entity_id: self.state.entity_id,
7904                container_id,
7905                seq: self.seq,
7906            })
7907            .await?;
7908        self.state.intents_sent += 1;
7909        Ok(())
7910    }
7911
7912    pub async fn move_item(
7913        &mut self,
7914        item_instance_id: uuid::Uuid,
7915        from: flatland_protocol::InventoryLocation,
7916        to: flatland_protocol::InventoryLocation,
7917        to_parent_instance_id: Option<uuid::Uuid>,
7918        quantity: Option<u32>,
7919    ) -> anyhow::Result<()> {
7920        self.seq += 1;
7921        self.session
7922            .submit_intent(Intent::MoveItem {
7923                entity_id: self.state.entity_id,
7924                item_instance_id,
7925                from,
7926                to,
7927                to_parent_instance_id,
7928                quantity,
7929                seq: self.seq,
7930            })
7931            .await?;
7932        self.state.intents_sent += 1;
7933        Ok(())
7934    }
7935
7936    pub async fn set_container_locked(
7937        &mut self,
7938        location: flatland_protocol::InventoryLocation,
7939        locked: bool,
7940    ) -> anyhow::Result<()> {
7941        self.seq += 1;
7942        self.session
7943            .submit_intent(Intent::SetContainerLocked {
7944                entity_id: self.state.entity_id,
7945                location,
7946                locked,
7947                seq: self.seq,
7948            })
7949            .await?;
7950        self.state.intents_sent += 1;
7951        Ok(())
7952    }
7953
7954    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
7955        if !self.state.is_alive() {
7956            anyhow::bail!("you are dead");
7957        }
7958        self.seq += 1;
7959        self.session
7960            .submit_intent(Intent::Use {
7961                entity_id: self.state.entity_id,
7962                template_id: template_id.to_string(),
7963                seq: self.seq,
7964            })
7965            .await?;
7966        self.state.intents_sent += 1;
7967        Ok(())
7968    }
7969
7970    /// Bind a grant consumable onto a specific item instance (unique gear status).
7971    pub async fn use_grant(
7972        &mut self,
7973        grant_instance_id: uuid::Uuid,
7974        target_instance_id: uuid::Uuid,
7975    ) -> anyhow::Result<()> {
7976        if !self.state.is_alive() {
7977            anyhow::bail!("you are dead");
7978        }
7979        self.seq += 1;
7980        self.session
7981            .submit_intent(Intent::UseGrant {
7982                entity_id: self.state.entity_id,
7983                grant_instance_id,
7984                target_instance_id,
7985                seq: self.seq,
7986            })
7987            .await?;
7988        self.state.intents_sent += 1;
7989        Ok(())
7990    }
7991
7992    pub fn open_craft_menu(&mut self) {
7993        self.state.show_craft_menu = true;
7994        self.state.show_shop_menu = false;
7995        self.state.shop_catalog = None;
7996        self.state.show_stats = false;
7997        self.state.show_inventory_menu = false;
7998        if self.state.blueprints.is_empty() {
7999            self.state.craft_menu_index = 0;
8000            self.state.craft_batch_quantity = 1;
8001            return;
8002        }
8003        self.state.craft_menu_index = self
8004            .state
8005            .craft_menu_index
8006            .min(self.state.blueprints.len() - 1);
8007        if let Some(idx) = self
8008            .state
8009            .blueprints
8010            .iter()
8011            .position(|bp| self.state.can_craft_blueprint(bp))
8012        {
8013            self.state.craft_menu_index = idx;
8014        }
8015        self.state.clamp_craft_batch_quantity();
8016    }
8017
8018    pub fn close_craft_menu(&mut self) {
8019        self.state.show_craft_menu = false;
8020    }
8021
8022    pub fn toggle_keychain_menu(&mut self) {
8023        if self.state.show_keychain_menu {
8024            self.close_keychain_menu();
8025        } else {
8026            self.state.show_keychain_menu = true;
8027            self.state.show_craft_menu = false;
8028            self.state.show_shop_menu = false;
8029            self.state.show_inventory_menu = false;
8030            let n = self.state.keychain_entries().len();
8031            if n == 0 {
8032                self.state.keychain_menu_index = 0;
8033            } else {
8034                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8035            }
8036        }
8037    }
8038
8039    pub fn close_keychain_menu(&mut self) {
8040        self.state.show_keychain_menu = false;
8041    }
8042
8043    pub fn keychain_menu_move(&mut self, delta: i32) {
8044        let n = self.state.keychain_entries().len();
8045        if n == 0 {
8046            self.state.keychain_menu_index = 0;
8047            return;
8048        }
8049        let idx = self.state.keychain_menu_index as i32 + delta;
8050        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8051    }
8052
8053    pub fn keychain_menu_page(&mut self, pages: i32) {
8054        let n = self.state.keychain_entries().len();
8055        self.state.keychain_menu_index =
8056            page_list_index(self.state.keychain_menu_index, pages, n);
8057    }
8058
8059    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8060        if !self.state.is_alive() {
8061            anyhow::bail!("you are dead");
8062        }
8063        let entries = self.state.keychain_entries();
8064        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8065            anyhow::bail!("nothing selected");
8066        };
8067        let Some(instance_id) = entry.stack.item_instance_id else {
8068            anyhow::bail!("key has no instance id");
8069        };
8070        if entry.stowed {
8071            self.move_item(
8072                instance_id,
8073                flatland_protocol::InventoryLocation::Keychain,
8074                flatland_protocol::InventoryLocation::Root,
8075                None,
8076                Some(1),
8077            )
8078            .await
8079        } else {
8080            self.move_item(
8081                instance_id,
8082                flatland_protocol::InventoryLocation::Root,
8083                flatland_protocol::InventoryLocation::Keychain,
8084                None,
8085                Some(1),
8086            )
8087            .await
8088        }
8089    }
8090
8091    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8092        let npc_id = self
8093            .state
8094            .shop_catalog
8095            .as_ref()
8096            .map(|c| c.npc_id.clone());
8097        self.state.show_shop_menu = false;
8098        self.state.shop_catalog = None;
8099        self.state.clear_shop_trade_log();
8100        if let Some(npc_id) = npc_id {
8101            self.seq += 1;
8102            self.session
8103                .submit_intent(Intent::ShopClose {
8104                    entity_id: self.state.entity_id,
8105                    npc_id,
8106                    seq: self.seq,
8107                })
8108                .await?;
8109            self.state.intents_sent += 1;
8110        }
8111        Ok(())
8112    }
8113
8114    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8115        let Some(panel) = self.state.bank_panel.clone() else {
8116            return Ok(());
8117        };
8118        self.seq += 1;
8119        self.session
8120            .submit_intent(Intent::BankDeposit {
8121                entity_id: self.state.entity_id,
8122                npc_id: panel.npc_id,
8123                amount_copper,
8124                seq: self.seq,
8125            })
8126            .await?;
8127        self.state.intents_sent += 1;
8128        Ok(())
8129    }
8130
8131    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8132        let Some(panel) = self.state.bank_panel.clone() else {
8133            return Ok(());
8134        };
8135        self.seq += 1;
8136        self.session
8137            .submit_intent(Intent::BankWithdraw {
8138                entity_id: self.state.entity_id,
8139                npc_id: panel.npc_id,
8140                amount_copper,
8141                seq: self.seq,
8142            })
8143            .await?;
8144        self.state.intents_sent += 1;
8145        Ok(())
8146    }
8147
8148    pub async fn bank_transfer(
8149        &mut self,
8150        to_character_id: Option<uuid::Uuid>,
8151        to_name: String,
8152        amount_copper: u64,
8153    ) -> anyhow::Result<()> {
8154        let Some(panel) = self.state.bank_panel.clone() else {
8155            return Ok(());
8156        };
8157        self.seq += 1;
8158        self.session
8159            .submit_intent(Intent::BankTransfer {
8160                entity_id: self.state.entity_id,
8161                npc_id: panel.npc_id,
8162                to_character_id,
8163                to_name,
8164                amount_copper,
8165                seq: self.seq,
8166            })
8167            .await?;
8168        self.state.intents_sent += 1;
8169        Ok(())
8170    }
8171
8172    pub fn bank_menu_move(&mut self, delta: i32) {
8173        let n = self.state.bank_menu_options().len();
8174        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8175            return;
8176        }
8177        let idx = self.state.bank_menu_index as i32;
8178        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8179    }
8180
8181    pub fn storage_menu_move(&mut self, delta: i32) {
8182        let n = self.state.storage_menu_options().len();
8183        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8184            return;
8185        }
8186        let idx = self.state.storage_menu_index as i32;
8187        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8188    }
8189
8190    pub fn storage_pick_move(&mut self, delta: i32) {
8191        let n = match &self.state.storage_ui_mode {
8192            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8193            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8194                self.state.storage_vault_options().len()
8195            }
8196            StorageUiMode::Menu
8197            | StorageUiMode::StoreAmount { .. }
8198            | StorageUiMode::TakeAmount { .. }
8199            | StorageUiMode::ShipAmount { .. } => 0,
8200        };
8201        if n == 0 {
8202            return;
8203        }
8204        match &mut self.state.storage_ui_mode {
8205            StorageUiMode::StorePick { index }
8206            | StorageUiMode::TakePick { index }
8207            | StorageUiMode::ShipPick { index, .. } => {
8208                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8209            }
8210            StorageUiMode::Menu
8211            | StorageUiMode::StoreAmount { .. }
8212            | StorageUiMode::TakeAmount { .. }
8213            | StorageUiMode::ShipAmount { .. } => {}
8214        }
8215    }
8216
8217    pub fn storage_ui_back(&mut self) {
8218        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8219            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8220                index: *pick_index,
8221            },
8222            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8223                index: *pick_index,
8224            },
8225            StorageUiMode::ShipAmount {
8226                dest_building_id,
8227                dest_label,
8228                pick_index,
8229                ..
8230            } => StorageUiMode::ShipPick {
8231                dest_building_id: dest_building_id.clone(),
8232                dest_label: dest_label.clone(),
8233                index: *pick_index,
8234            },
8235            StorageUiMode::StorePick { .. }
8236            | StorageUiMode::TakePick { .. }
8237            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8238            StorageUiMode::Menu => StorageUiMode::Menu,
8239        };
8240    }
8241
8242    pub fn storage_amount_append_char(&mut self, c: char) {
8243        match &mut self.state.storage_ui_mode {
8244            StorageUiMode::StoreAmount { input, .. }
8245            | StorageUiMode::TakeAmount { input, .. }
8246            | StorageUiMode::ShipAmount { input, .. } => {
8247                if c.is_ascii_digit() && input.len() < 8 {
8248                    input.push(c);
8249                }
8250            }
8251            _ => {}
8252        }
8253    }
8254
8255    pub fn storage_amount_backspace(&mut self) {
8256        match &mut self.state.storage_ui_mode {
8257            StorageUiMode::StoreAmount { input, .. }
8258            | StorageUiMode::TakeAmount { input, .. }
8259            | StorageUiMode::ShipAmount { input, .. } => {
8260                input.pop();
8261            }
8262            _ => {}
8263        }
8264    }
8265
8266    pub fn storage_ui_typing(&self) -> bool {
8267        matches!(
8268            self.state.storage_ui_mode,
8269            StorageUiMode::StoreAmount { .. }
8270                | StorageUiMode::TakeAmount { .. }
8271                | StorageUiMode::ShipAmount { .. }
8272        )
8273    }
8274
8275    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8276        match self.state.storage_ui_mode.clone() {
8277            StorageUiMode::Menu => {
8278                let index = self.state.storage_menu_index;
8279                match index {
8280                    0 => {
8281                        let opts = self.state.storage_store_options();
8282                        if opts.is_empty() {
8283                            self.state.push_log("Nothing loose to store.");
8284                            return Ok(());
8285                        }
8286                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8287                    }
8288                    1 => {
8289                        let opts = self.state.storage_vault_options();
8290                        if opts.is_empty() {
8291                            self.state.push_log("Vault is empty.");
8292                            return Ok(());
8293                        }
8294                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8295                    }
8296                    n => {
8297                        let dest = self
8298                            .state
8299                            .storage_panel
8300                            .as_ref()
8301                            .and_then(|p| p.ship_destinations.get(n - 2))
8302                            .cloned();
8303                        let Some(dest) = dest else {
8304                            return Ok(());
8305                        };
8306                        let opts = self.state.storage_vault_options();
8307                        if opts.is_empty() {
8308                            self.state
8309                                .push_log("Vault is empty — nothing to ship.");
8310                            return Ok(());
8311                        }
8312                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
8313                            dest_building_id: dest.building_id,
8314                            dest_label: dest.label,
8315                            index: 0,
8316                        };
8317                    }
8318                }
8319            }
8320            StorageUiMode::StorePick { index } => {
8321                let opts = self.state.storage_store_options();
8322                let Some(opt) = opts.get(index) else {
8323                    self.state.push_log("Nothing loose to store.");
8324                    self.state.storage_ui_mode = StorageUiMode::Menu;
8325                    return Ok(());
8326                };
8327                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8328                    pick_index: index,
8329                    item_instance_id: opt.item_instance_id,
8330                    label: opt.label.clone(),
8331                    max_qty: opt.quantity.max(1),
8332                    input: String::new(),
8333                };
8334            }
8335            StorageUiMode::TakePick { index } => {
8336                let opts = self.state.storage_vault_options();
8337                let Some(opt) = opts.get(index) else {
8338                    self.state.push_log("Vault is empty.");
8339                    self.state.storage_ui_mode = StorageUiMode::Menu;
8340                    return Ok(());
8341                };
8342                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8343                    pick_index: index,
8344                    item_instance_id: opt.item_instance_id,
8345                    label: opt.label.clone(),
8346                    max_qty: opt.quantity.max(1),
8347                    input: String::new(),
8348                };
8349            }
8350            StorageUiMode::ShipPick {
8351                dest_building_id,
8352                dest_label,
8353                index,
8354            } => {
8355                let opts = self.state.storage_vault_options();
8356                let Some(opt) = opts.get(index) else {
8357                    self.state
8358                        .push_log("Vault is empty — nothing to ship.");
8359                    self.state.storage_ui_mode = StorageUiMode::Menu;
8360                    return Ok(());
8361                };
8362                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8363                    dest_building_id,
8364                    dest_label,
8365                    pick_index: index,
8366                    item_instance_id: opt.item_instance_id,
8367                    label: opt.label.clone(),
8368                    max_qty: opt.quantity.max(1),
8369                    input: String::new(),
8370                };
8371            }
8372            StorageUiMode::StoreAmount {
8373                item_instance_id,
8374                max_qty,
8375                input,
8376                ..
8377            } => {
8378                let Some(qty) = parse_storage_quantity(&input) else {
8379                    self.state
8380                        .push_log("Enter a quantity (blank or 0 = all).");
8381                    return Ok(());
8382                };
8383                let qty = qty.map(|n| n.min(max_qty).max(1));
8384                self.storage_store(item_instance_id, qty).await?;
8385                self.state.storage_ui_mode = StorageUiMode::Menu;
8386            }
8387            StorageUiMode::TakeAmount {
8388                item_instance_id,
8389                max_qty,
8390                input,
8391                ..
8392            } => {
8393                let Some(qty) = parse_storage_quantity(&input) else {
8394                    self.state
8395                        .push_log("Enter a quantity (blank or 0 = all).");
8396                    return Ok(());
8397                };
8398                let qty = qty.map(|n| n.min(max_qty).max(1));
8399                self.storage_take(item_instance_id, qty).await?;
8400                self.state.storage_ui_mode = StorageUiMode::Menu;
8401            }
8402            StorageUiMode::ShipAmount {
8403                dest_building_id,
8404                item_instance_id,
8405                max_qty,
8406                input,
8407                ..
8408            } => {
8409                let Some(qty) = parse_storage_quantity(&input) else {
8410                    self.state
8411                        .push_log("Enter a quantity (blank or 0 = all).");
8412                    return Ok(());
8413                };
8414                let qty = qty.map(|n| n.min(max_qty).max(1));
8415                self.storage_ship(dest_building_id, item_instance_id, qty)
8416                    .await?;
8417                self.state.storage_ui_mode = StorageUiMode::Menu;
8418            }
8419        }
8420        Ok(())
8421    }
8422
8423    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8424        match self.state.bank_ui_mode.clone() {
8425            BankUiMode::Menu => {
8426                let choice = self
8427                    .state
8428                    .bank_menu_options()
8429                    .get(self.state.bank_menu_index)
8430                    .copied()
8431                    .unwrap_or("Deposit…");
8432                match choice {
8433                    "Withdraw…" => {
8434                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8435                            input: String::new(),
8436                        };
8437                    }
8438                    "Deposit all" => self.bank_deposit(0).await?,
8439                    "Withdraw all" => self.bank_withdraw(0).await?,
8440                    "Transfer…" => {
8441                        self.state.bank_ui_mode = BankUiMode::TransferName {
8442                            input: String::new(),
8443                        };
8444                    }
8445                    _ => {
8446                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
8447                            input: String::new(),
8448                        };
8449                    }
8450                }
8451            }
8452            BankUiMode::DepositAmount { input } => {
8453                let Some(amount) = parse_bank_copper_amount(&input) else {
8454                    self.state
8455                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
8456                    return Ok(());
8457                };
8458                self.bank_deposit(amount).await?;
8459                self.state.bank_ui_mode = BankUiMode::Menu;
8460            }
8461            BankUiMode::WithdrawAmount { input } => {
8462                let Some(amount) = parse_bank_copper_amount(&input) else {
8463                    self.state
8464                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
8465                    return Ok(());
8466                };
8467                self.bank_withdraw(amount).await?;
8468                self.state.bank_ui_mode = BankUiMode::Menu;
8469            }
8470            BankUiMode::TransferName { input } => {
8471                let name = input.trim().to_string();
8472                if name.is_empty() {
8473                    self.state.push_log("Enter the recipient character name.");
8474                    return Ok(());
8475                }
8476                self.state.bank_ui_mode = BankUiMode::TransferAmount {
8477                    to_name: name,
8478                    input: String::new(),
8479                };
8480            }
8481            BankUiMode::TransferAmount { to_name, input } => {
8482                let amount: u64 = match input.trim().parse() {
8483                    Ok(v) if v > 0 => v,
8484                    _ => {
8485                        self.state
8486                            .push_log("Enter a positive copper amount to transfer.");
8487                        return Ok(());
8488                    }
8489                };
8490                self.bank_transfer(None, to_name, amount).await?;
8491                self.state.bank_ui_mode = BankUiMode::Menu;
8492            }
8493        }
8494        Ok(())
8495    }
8496
8497    pub fn bank_transfer_back(&mut self) {
8498        match &self.state.bank_ui_mode {
8499            BankUiMode::TransferAmount { to_name, .. } => {
8500                self.state.bank_ui_mode = BankUiMode::TransferName {
8501                    input: to_name.clone(),
8502                };
8503            }
8504            BankUiMode::TransferName { .. }
8505            | BankUiMode::DepositAmount { .. }
8506            | BankUiMode::WithdrawAmount { .. } => {
8507                self.state.bank_ui_mode = BankUiMode::Menu;
8508            }
8509            BankUiMode::Menu => {}
8510        }
8511    }
8512
8513    pub fn bank_transfer_append_char(&mut self, c: char) {
8514        match &mut self.state.bank_ui_mode {
8515            BankUiMode::TransferName { input } => {
8516                if input.len() < 32 && !c.is_control() {
8517                    input.push(c);
8518                }
8519            }
8520            BankUiMode::DepositAmount { input }
8521            | BankUiMode::WithdrawAmount { input }
8522            | BankUiMode::TransferAmount { input, .. } => {
8523                if c.is_ascii_digit() && input.len() < 12 {
8524                    input.push(c);
8525                }
8526            }
8527            BankUiMode::Menu => {}
8528        }
8529    }
8530
8531    pub fn bank_transfer_backspace(&mut self) {
8532        match &mut self.state.bank_ui_mode {
8533            BankUiMode::TransferName { input }
8534            | BankUiMode::DepositAmount { input }
8535            | BankUiMode::WithdrawAmount { input }
8536            | BankUiMode::TransferAmount { input, .. } => {
8537                input.pop();
8538            }
8539            BankUiMode::Menu => {}
8540        }
8541    }
8542
8543    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8544        let npc_id = self
8545            .state
8546            .bank_panel
8547            .as_ref()
8548            .map(|p| p.npc_id.clone());
8549        self.state.clear_bank_panel();
8550        if let Some(npc_id) = npc_id {
8551            self.seq += 1;
8552            self.session
8553                .submit_intent(Intent::BankClose {
8554                    entity_id: self.state.entity_id,
8555                    npc_id,
8556                    seq: self.seq,
8557                })
8558                .await?;
8559            self.state.intents_sent += 1;
8560        }
8561        Ok(())
8562    }
8563
8564    pub async fn storage_store(
8565        &mut self,
8566        item_instance_id: uuid::Uuid,
8567        quantity: Option<u32>,
8568    ) -> anyhow::Result<()> {
8569        let Some(panel) = self.state.storage_panel.clone() else {
8570            return Ok(());
8571        };
8572        self.seq += 1;
8573        self.session
8574            .submit_intent(Intent::StorageStore {
8575                entity_id: self.state.entity_id,
8576                npc_id: panel.npc_id,
8577                item_instance_id,
8578                quantity,
8579                seq: self.seq,
8580            })
8581            .await?;
8582        self.state.intents_sent += 1;
8583        Ok(())
8584    }
8585
8586    pub async fn storage_take(
8587        &mut self,
8588        item_instance_id: uuid::Uuid,
8589        quantity: Option<u32>,
8590    ) -> anyhow::Result<()> {
8591        let Some(panel) = self.state.storage_panel.clone() else {
8592            return Ok(());
8593        };
8594        self.seq += 1;
8595        self.session
8596            .submit_intent(Intent::StorageTake {
8597                entity_id: self.state.entity_id,
8598                npc_id: panel.npc_id,
8599                item_instance_id,
8600                quantity,
8601                seq: self.seq,
8602            })
8603            .await?;
8604        self.state.intents_sent += 1;
8605        Ok(())
8606    }
8607
8608    pub async fn storage_ship(
8609        &mut self,
8610        dest_building_id: String,
8611        item_instance_id: uuid::Uuid,
8612        quantity: Option<u32>,
8613    ) -> anyhow::Result<()> {
8614        let Some(panel) = self.state.storage_panel.clone() else {
8615            return Ok(());
8616        };
8617        self.seq += 1;
8618        self.session
8619            .submit_intent(Intent::StorageShip {
8620                entity_id: self.state.entity_id,
8621                npc_id: panel.npc_id,
8622                dest_building_id,
8623                item_instance_id,
8624                quantity,
8625                seq: self.seq,
8626            })
8627            .await?;
8628        self.state.intents_sent += 1;
8629        Ok(())
8630    }
8631
8632    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
8633        let npc_id = self
8634            .state
8635            .storage_panel
8636            .as_ref()
8637            .map(|p| p.npc_id.clone());
8638        self.state.clear_storage_panel();
8639        if let Some(npc_id) = npc_id {
8640            self.seq += 1;
8641            self.session
8642                .submit_intent(Intent::StorageClose {
8643                    entity_id: self.state.entity_id,
8644                    npc_id,
8645                    seq: self.seq,
8646                })
8647                .await?;
8648            self.state.intents_sent += 1;
8649        }
8650        Ok(())
8651    }
8652
8653    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
8654        let npc_id = self
8655            .state
8656            .market_panel
8657            .as_ref()
8658            .map(|p| p.npc_id.clone());
8659        self.state.clear_market_panel();
8660        if let Some(npc_id) = npc_id {
8661            self.seq += 1;
8662            self.session
8663                .submit_intent(Intent::MarketClose {
8664                    entity_id: self.state.entity_id,
8665                    npc_id,
8666                    seq: self.seq,
8667                })
8668                .await?;
8669            self.state.intents_sent += 1;
8670        }
8671        Ok(())
8672    }
8673
8674    pub fn market_move_selection(&mut self, delta: i32) {
8675        let indices = self.state.market_filtered_listing_indices();
8676        let n = indices.len();
8677        if n == 0 {
8678            self.state.market_menu_index = 0;
8679            return;
8680        }
8681        let cur = self.state.market_menu_index as i32;
8682        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
8683    }
8684
8685    pub fn market_page_selection(&mut self, pages: i32) {
8686        let indices = self.state.market_filtered_listing_indices();
8687        let n = indices.len();
8688        if n == 0 {
8689            self.state.market_menu_index = 0;
8690            return;
8691        }
8692        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
8693    }
8694
8695    pub fn market_list_page(&mut self, pages: i32) {
8696        match &self.state.market_ui_mode {
8697            MarketUiMode::ListSource { index } => {
8698                let n = self.state.market_list_source_options().len();
8699                if n == 0 {
8700                    return;
8701                }
8702                let next = page_list_index(*index, pages, n);
8703                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8704            }
8705            MarketUiMode::ListPick { source, index } => {
8706                let opts = self.state.market_list_item_options(source);
8707                let n = opts.len();
8708                if n == 0 {
8709                    return;
8710                }
8711                let next = page_list_index(*index, pages, n);
8712                self.state.market_ui_mode = MarketUiMode::ListPick {
8713                    source: source.clone(),
8714                    index: next,
8715                };
8716            }
8717            _ => {}
8718        }
8719    }
8720
8721    pub fn market_cycle_category(&mut self, delta: i32) {
8722        let groups = self.state.market_available_category_groups();
8723        // All + groups
8724        let mut labels: Vec<Option<&'static str>> = vec![None];
8725        labels.extend(groups.into_iter().map(Some));
8726        let n = labels.len() as i32;
8727        let cur = labels
8728            .iter()
8729            .position(|g| *g == self.state.market_category_filter)
8730            .unwrap_or(0) as i32;
8731        let next = (cur + delta).rem_euclid(n) as usize;
8732        self.state.market_category_filter = labels[next];
8733        self.state.market_menu_index = 0;
8734        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8735            let source = source.clone();
8736            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8737        }
8738    }
8739
8740    pub fn focus_market_filter(&mut self) {
8741        self.state.market_filter_focused = true;
8742    }
8743
8744    pub fn append_market_filter_char(&mut self, ch: char) {
8745        if !self.state.market_filter_focused {
8746            return;
8747        }
8748        if ch.is_control() {
8749            return;
8750        }
8751        if self.state.market_filter.len() < 48 {
8752            self.state.market_filter.push(ch);
8753            self.state.market_menu_index = 0;
8754            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8755                let source = source.clone();
8756                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8757            }
8758        }
8759    }
8760
8761    pub fn market_filter_backspace(&mut self) {
8762        if !self.state.market_filter_focused {
8763            return;
8764        }
8765        self.state.market_filter.pop();
8766        self.state.market_menu_index = 0;
8767        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8768            let source = source.clone();
8769            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8770        }
8771    }
8772
8773    /// Clears filter text, blurs search, or returns false if already idle.
8774    pub fn clear_or_blur_market_filter(&mut self) -> bool {
8775        if self.state.market_filter_focused {
8776            if !self.state.market_filter.is_empty() {
8777                self.state.market_filter.clear();
8778                self.state.market_menu_index = 0;
8779                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8780                    let source = source.clone();
8781                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8782                }
8783                return true;
8784            }
8785            self.state.market_filter_focused = false;
8786            return true;
8787        }
8788        if !self.state.market_filter.is_empty() {
8789            self.state.market_filter.clear();
8790            self.state.market_menu_index = 0;
8791            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8792                let source = source.clone();
8793                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8794            }
8795            return true;
8796        }
8797        false
8798    }
8799
8800    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
8801        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
8802            return self.market_confirm_buy(listing_id, qty).await;
8803        }
8804        let Some(panel) = self.state.market_panel.clone() else {
8805            return Ok(());
8806        };
8807        let indices = self.state.market_filtered_listing_indices();
8808        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
8809            return Ok(());
8810        };
8811        let Some(listing) = panel.listings.get(raw_idx) else {
8812            return Ok(());
8813        };
8814        if listing.mine {
8815            self.seq += 1;
8816            self.session
8817                .submit_intent(Intent::MarketDelist {
8818                    entity_id: self.state.entity_id,
8819                    npc_id: panel.npc_id.clone(),
8820                    listing_id: listing.listing_id,
8821                    dest: flatland_protocol::GoodsLocation::Person,
8822                    seq: self.seq,
8823                })
8824                .await?;
8825            self.state.intents_sent += 1;
8826            return Ok(());
8827        }
8828        let qty = 1u32.min(listing.quantity).max(1);
8829        let line = listing.unit_price_copper.saturating_mul(qty as u64);
8830        self.state.market_buy_confirm = Some((
8831            listing.listing_id,
8832            qty,
8833            listing.unit_price_copper,
8834            line,
8835            listing.display_name.clone(),
8836        ));
8837        Ok(())
8838    }
8839
8840    pub async fn market_confirm_buy(
8841        &mut self,
8842        listing_id: uuid::Uuid,
8843        quantity: u32,
8844    ) -> anyhow::Result<()> {
8845        let Some(panel) = self.state.market_panel.clone() else {
8846            self.state.market_buy_confirm = None;
8847            return Ok(());
8848        };
8849        self.state.market_buy_confirm = None;
8850        self.seq += 1;
8851        self.session
8852            .submit_intent(Intent::MarketBuy {
8853                entity_id: self.state.entity_id,
8854                npc_id: panel.npc_id,
8855                listing_id,
8856                quantity,
8857                dest: flatland_protocol::GoodsLocation::Person,
8858                seq: self.seq,
8859            })
8860            .await?;
8861        self.state.intents_sent += 1;
8862        Ok(())
8863    }
8864
8865    /// Begin the list wizard (`l` in market browse).
8866    pub fn market_begin_list(&mut self) {
8867        if self.state.market_panel.is_none() {
8868            return;
8869        }
8870        let sources = self.state.market_list_source_options();
8871        if sources.is_empty() {
8872            self.state.push_log("Nothing to list from.");
8873            return;
8874        }
8875        // One source (person only) → skip straight to item pick when it has stacks.
8876        if sources.len() == 1 {
8877            let (source, _) = sources[0].clone();
8878            let opts = self.state.market_list_item_options(&source);
8879            if opts.is_empty() {
8880                self.state.push_log("Nothing loose to list.");
8881                return;
8882            }
8883            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8884            self.state.market_buy_confirm = None;
8885            return;
8886        }
8887        self.state.market_buy_confirm = None;
8888        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
8889    }
8890
8891    pub fn market_ui_back(&mut self) {
8892        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
8893            MarketUiMode::Browse => MarketUiMode::Browse,
8894            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
8895            MarketUiMode::ListPick { .. } => {
8896                if self.state.market_list_source_options().len() <= 1 {
8897                    MarketUiMode::Browse
8898                } else {
8899                    MarketUiMode::ListSource { index: 0 }
8900                }
8901            }
8902            MarketUiMode::ListAmount {
8903                source,
8904                pick_index,
8905                ..
8906            } => MarketUiMode::ListPick {
8907                source,
8908                index: pick_index,
8909            },
8910            MarketUiMode::ListPrice {
8911                source,
8912                item_instance_id,
8913                label,
8914                max_qty,
8915                quantity,
8916                ..
8917            } => {
8918                let input = quantity
8919                    .map(|q| q.to_string())
8920                    .unwrap_or_default();
8921                MarketUiMode::ListAmount {
8922                    source,
8923                    pick_index: 0,
8924                    item_instance_id,
8925                    label,
8926                    max_qty,
8927                    input,
8928                }
8929            }
8930        };
8931    }
8932
8933    pub fn market_list_move(&mut self, delta: i32) {
8934        match &self.state.market_ui_mode {
8935            MarketUiMode::ListSource { index } => {
8936                let n = self.state.market_list_source_options().len();
8937                if n == 0 {
8938                    return;
8939                }
8940                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8941                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8942            }
8943            MarketUiMode::ListPick { source, index } => {
8944                let opts = self.state.market_list_item_options(source);
8945                let n = opts.len();
8946                if n == 0 {
8947                    return;
8948                }
8949                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8950                self.state.market_ui_mode = MarketUiMode::ListPick {
8951                    source: source.clone(),
8952                    index: next,
8953                };
8954            }
8955            _ => {}
8956        }
8957    }
8958
8959    pub fn market_list_amount_append_char(&mut self, c: char) {
8960        if !c.is_ascii_digit() {
8961            return;
8962        }
8963        match &mut self.state.market_ui_mode {
8964            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8965                if input.len() < 12 {
8966                    input.push(c);
8967                }
8968            }
8969            _ => {}
8970        }
8971    }
8972
8973    pub fn market_list_amount_backspace(&mut self) {
8974        match &mut self.state.market_ui_mode {
8975            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8976                input.pop();
8977            }
8978            _ => {}
8979        }
8980    }
8981
8982    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
8983        match self.state.market_ui_mode.clone() {
8984            MarketUiMode::Browse => Ok(()),
8985            MarketUiMode::ListSource { index } => {
8986                let sources = self.state.market_list_source_options();
8987                let Some((source, _)) = sources.get(index).cloned() else {
8988                    return Ok(());
8989                };
8990                let opts = self.state.market_list_item_options(&source);
8991                if opts.is_empty() {
8992                    self.state.push_log("Nothing to list from that source.");
8993                    return Ok(());
8994                }
8995                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8996                Ok(())
8997            }
8998            MarketUiMode::ListPick { source, index } => {
8999                let opts = self.state.market_list_item_options(&source);
9000                let Some(opt) = opts.get(index) else {
9001                    self.state.push_log("Nothing to list.");
9002                    self.state.market_ui_mode = MarketUiMode::Browse;
9003                    return Ok(());
9004                };
9005                self.state.market_ui_mode = MarketUiMode::ListAmount {
9006                    source,
9007                    pick_index: index,
9008                    item_instance_id: opt.item_instance_id,
9009                    label: opt.label.clone(),
9010                    max_qty: opt.quantity.max(1),
9011                    input: String::new(),
9012                };
9013                Ok(())
9014            }
9015            MarketUiMode::ListAmount {
9016                source,
9017                item_instance_id,
9018                label,
9019                max_qty,
9020                input,
9021                ..
9022            } => {
9023                let Some(qty_opt) = parse_storage_quantity(&input) else {
9024                    self.state.push_log("Enter a quantity (blank = all).");
9025                    return Ok(());
9026                };
9027                if let Some(q) = qty_opt {
9028                    if q > max_qty {
9029                        self.state
9030                            .push_log(format!("Only {max_qty} available."));
9031                        return Ok(());
9032                    }
9033                }
9034                self.state.market_ui_mode = MarketUiMode::ListPrice {
9035                    source,
9036                    item_instance_id,
9037                    label,
9038                    quantity: qty_opt,
9039                    max_qty,
9040                    input: String::new(),
9041                };
9042                Ok(())
9043            }
9044            MarketUiMode::ListPrice {
9045                source,
9046                item_instance_id,
9047                label,
9048                quantity,
9049                input,
9050                ..
9051            } => {
9052                let price = input.trim().parse::<u64>().unwrap_or(0);
9053                if price == 0 {
9054                    self.state.push_log("Enter a unit price of at least 1 copper.");
9055                    return Ok(());
9056                }
9057                let Some(panel) = self.state.market_panel.clone() else {
9058                    self.state.market_ui_mode = MarketUiMode::Browse;
9059                    return Ok(());
9060                };
9061                let goods = match source {
9062                    MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9063                    MarketListSourceKind::TownStorage { building_id } => {
9064                        flatland_protocol::GoodsLocation::TownStorage { building_id }
9065                    }
9066                };
9067                self.seq += 1;
9068                self.session
9069                    .submit_intent(Intent::MarketList {
9070                        entity_id: self.state.entity_id,
9071                        npc_id: panel.npc_id,
9072                        source: goods,
9073                        item_instance_id,
9074                        quantity,
9075                        unit_price_copper: price,
9076                        seq: self.seq,
9077                    })
9078                    .await?;
9079                self.state.intents_sent += 1;
9080                self.state
9081                    .push_log(format!("Listing {label} @ {price} cp…"));
9082                self.state.market_ui_mode = MarketUiMode::Browse;
9083                Ok(())
9084            }
9085        }
9086    }
9087
9088    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
9089    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9090        let return_to_verbs = self.state.npc_verb_target.is_some();
9091        self.close_shop_menu().await?;
9092        if return_to_verbs {
9093            self.state.show_npc_verb_menu = true;
9094        }
9095        Ok(())
9096    }
9097
9098    pub fn shop_tab_toggle(&mut self) {
9099        self.state.shop_tab = match self.state.shop_tab {
9100            ShopTab::Buy => ShopTab::Sell,
9101            ShopTab::Sell => ShopTab::Buy,
9102        };
9103        self.state.shop_menu_index = 0;
9104        if self.state.shop_tab == ShopTab::Sell {
9105            self.state.shop_quantity_set_max();
9106        }
9107        self.state.clamp_shop_selection();
9108    }
9109
9110    pub fn shop_menu_move(&mut self, delta: i32) {
9111        self.state.shop_menu_move(delta);
9112    }
9113
9114    pub fn shop_quantity_adjust(&mut self, delta: i32) {
9115        self.state.shop_quantity_adjust(delta);
9116    }
9117
9118    pub fn shop_quantity_set_max(&mut self) {
9119        self.state.shop_quantity_set_max();
9120    }
9121
9122    pub fn toggle_quest_menu(&mut self) {
9123        self.state.show_quest_menu = !self.state.show_quest_menu;
9124        if self.state.show_quest_menu {
9125            self.state.quest_menu_index = 0;
9126            self.state.quest_withdraw_confirm = false;
9127            self.state.show_workers_menu = false;
9128        }
9129    }
9130
9131    pub fn toggle_workers_menu(&mut self) {
9132        if self.state.show_workers_menu {
9133            self.close_workers_menu_ui();
9134        } else {
9135            self.state.show_workers_menu = true;
9136            self.state.workers_menu_index = 0;
9137            self.state.show_quest_menu = false;
9138            self.close_worker_give_picker();
9139            self.close_worker_give_target_picker();
9140            self.close_worker_take_picker();
9141            self.close_worker_teach_picker();
9142            self.cancel_worker_rename();
9143        }
9144    }
9145
9146    /// Close workers UI layers without network (caller should release attend).
9147    pub fn close_workers_menu_ui(&mut self) {
9148        self.state.show_workers_menu = false;
9149        self.close_worker_give_picker();
9150        self.close_worker_give_target_picker();
9151        self.close_worker_take_picker();
9152        self.close_worker_teach_picker();
9153        self.cancel_worker_rename();
9154    }
9155
9156    /// Open the workers menu focused on `instance_id` and pause that worker's job.
9157    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9158        let Some(idx) = self
9159            .state
9160            .hired_workers
9161            .iter()
9162            .position(|w| w.instance_id == instance_id)
9163        else {
9164            anyhow::bail!("worker not found");
9165        };
9166        let label = self.state.hired_workers[idx].label.clone();
9167        self.state.show_workers_menu = true;
9168        self.state.workers_menu_index = idx;
9169        self.state.show_quest_menu = false;
9170        self.close_worker_give_picker();
9171        self.close_worker_give_target_picker();
9172        self.close_worker_take_picker();
9173        self.close_worker_teach_picker();
9174        self.cancel_worker_rename();
9175        self.set_worker_attending(instance_id, true).await?;
9176        self.state
9177            .push_log(format!("Managing {label} — job paused while menu is open"));
9178        Ok(())
9179    }
9180
9181    /// Close workers menu and release any attend pause.
9182    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9183        self.close_workers_menu_ui();
9184        self.release_worker_attend().await
9185    }
9186
9187    async fn set_worker_attending(
9188        &mut self,
9189        instance_id: &str,
9190        attending: bool,
9191    ) -> anyhow::Result<()> {
9192        if attending {
9193            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9194                return Ok(());
9195            }
9196            // Switch target: release previous first.
9197            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9198                if prev != instance_id {
9199                    self.send_attend_hired_worker(&prev, false).await?;
9200                }
9201            }
9202            self.send_attend_hired_worker(instance_id, true).await?;
9203            self.state.attending_worker_instance_id = Some(instance_id.to_string());
9204        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9205            self.send_attend_hired_worker(instance_id, false).await?;
9206            self.state.attending_worker_instance_id = None;
9207        }
9208        Ok(())
9209    }
9210
9211    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9212        let Some(id) = self.state.attending_worker_instance_id.take() else {
9213            return Ok(());
9214        };
9215        self.send_attend_hired_worker(&id, false).await
9216    }
9217
9218    async fn send_attend_hired_worker(
9219        &mut self,
9220        worker_instance_id: &str,
9221        attending: bool,
9222    ) -> anyhow::Result<()> {
9223        self.seq += 1;
9224        self.session
9225            .submit_intent(Intent::AttendHiredWorker {
9226                entity_id: self.state.entity_id,
9227                worker_instance_id: worker_instance_id.to_string(),
9228                attending,
9229                seq: self.seq,
9230            })
9231            .await?;
9232        self.state.intents_sent += 1;
9233        Ok(())
9234    }
9235
9236    pub fn workers_menu_move(&mut self, delta: i32) {
9237        let n = self.state.hired_workers.len();
9238        if n == 0 {
9239            return;
9240        }
9241        let idx = self.state.workers_menu_index as i32;
9242        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9243    }
9244
9245    pub fn toggle_workers_menu_compact(&mut self) {
9246        self.state.workers_menu_compact = !self.state.workers_menu_compact;
9247        let mut cfg = crate::client_config::ClientConfig::load();
9248        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9249    }
9250
9251    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9252        let Some(worker) = self
9253            .state
9254            .hired_workers
9255            .get(self.state.workers_menu_index)
9256            .cloned()
9257        else {
9258            anyhow::bail!("no worker selected");
9259        };
9260        self.seq += 1;
9261        self.session
9262            .submit_intent(Intent::DismissWorker {
9263                entity_id: self.state.entity_id,
9264                worker_instance_id: worker.instance_id.clone(),
9265                seq: self.seq,
9266            })
9267            .await?;
9268        self.state.intents_sent += 1;
9269        self.state
9270            .hired_workers
9271            .retain(|w| w.instance_id != worker.instance_id);
9272        if self.state.workers_menu_index >= self.state.hired_workers.len() {
9273            self.state.workers_menu_index = self
9274                .state
9275                .hired_workers
9276                .len()
9277                .saturating_sub(1);
9278        }
9279        self.state.push_log(format!("Dismissed {}", worker.label));
9280        Ok(())
9281    }
9282
9283    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9284        let Some(worker) = self
9285            .state
9286            .hired_workers
9287            .get(self.state.workers_menu_index)
9288            .cloned()
9289        else {
9290            anyhow::bail!("no worker selected");
9291        };
9292        let mode = match worker.mode {
9293            flatland_protocol::WorkerModeView::Companion => "job_loop",
9294            flatland_protocol::WorkerModeView::JobLoop => "idle",
9295            flatland_protocol::WorkerModeView::Idle => "companion",
9296        };
9297        self.seq += 1;
9298        self.session
9299            .submit_intent(Intent::SetWorkerMode {
9300                entity_id: self.state.entity_id,
9301                worker_instance_id: worker.instance_id,
9302                mode: mode.into(),
9303                seq: self.seq,
9304            })
9305            .await?;
9306        self.state.intents_sent += 1;
9307        Ok(())
9308    }
9309
9310    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9311        if self.state.hired_workers.is_empty() {
9312            return self.hire_worker_laborer().await;
9313        }
9314        self.workers_toggle_mode_selected().await
9315    }
9316
9317    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
9318    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
9319    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9320        let row = self
9321            .state
9322            .inventory_selected_row()
9323            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9324            .clone();
9325        if row.from != flatland_protocol::InventoryLocation::Root {
9326            anyhow::bail!("select a carried item to give");
9327        }
9328        let Some(instance_id) = row.stack.item_instance_id else {
9329            anyhow::bail!("that stack can't be given");
9330        };
9331        let options = self.nearby_worker_give_targets();
9332        if options.is_empty() {
9333            anyhow::bail!(
9334                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9335            );
9336        }
9337        let item_label = row
9338            .stack
9339            .display_name
9340            .as_deref()
9341            .unwrap_or(&row.stack.template_id)
9342            .to_string();
9343        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9344            item_instance_id: instance_id,
9345            item_label,
9346            quantity: None,
9347            options,
9348        });
9349        self.state.worker_give_target_picker_index = 0;
9350        self.state.show_worker_give_target_picker = true;
9351        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
9352        self.state.show_inventory_menu = false;
9353        Ok(())
9354    }
9355
9356    /// Hired workers within give/take range, nearest first.
9357    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9358        let (px, py, _) = self.state.player_position_with_z();
9359        let mut options: Vec<WorkerGiveTargetOption> = self
9360            .state
9361            .hired_workers
9362            .iter()
9363            .filter_map(|w| {
9364                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9365                if dist > WORKER_GIVE_RANGE_M {
9366                    return None;
9367                }
9368                Some(WorkerGiveTargetOption {
9369                    instance_id: w.instance_id.clone(),
9370                    label: w.label.clone(),
9371                    distance_m: dist,
9372                })
9373            })
9374            .collect();
9375        options.sort_by(|a, b| {
9376            a.distance_m
9377                .partial_cmp(&b.distance_m)
9378                .unwrap_or(std::cmp::Ordering::Equal)
9379        });
9380        options
9381    }
9382
9383    pub fn close_worker_give_target_picker(&mut self) {
9384        self.state.show_worker_give_target_picker = false;
9385        self.state.worker_give_target_picker = None;
9386        self.state.worker_give_target_picker_index = 0;
9387    }
9388
9389    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9390        let Some(picker) = &self.state.worker_give_target_picker else {
9391            return;
9392        };
9393        let n = picker.options.len();
9394        if n == 0 {
9395            return;
9396        }
9397        let idx = self.state.worker_give_target_picker_index as i32;
9398        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9399    }
9400
9401    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9402        let Some(picker) = self.state.worker_give_target_picker.clone() else {
9403            anyhow::bail!("give target picker not open");
9404        };
9405        let Some(opt) = picker
9406            .options
9407            .get(self.state.worker_give_target_picker_index)
9408            .cloned()
9409        else {
9410            anyhow::bail!("no worker selected");
9411        };
9412        let Some(worker) = self
9413            .state
9414            .hired_workers
9415            .iter()
9416            .find(|w| w.instance_id == opt.instance_id)
9417            .cloned()
9418        else {
9419            self.close_worker_give_target_picker();
9420            anyhow::bail!("worker no longer hired");
9421        };
9422        self.give_item_to_worker(
9423            &worker.instance_id,
9424            &worker.label,
9425            worker.x,
9426            worker.y,
9427            picker.item_instance_id,
9428            &picker.item_label,
9429            picker.quantity,
9430        )
9431        .await?;
9432        self.close_worker_give_target_picker();
9433        Ok(())
9434    }
9435
9436    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
9437    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9438        self.open_worker_give_target_picker()
9439    }
9440
9441    /// Open the workers-menu give picker for the selected worker (must be in range).
9442    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9443        let Some(worker) = self
9444            .state
9445            .hired_workers
9446            .get(self.state.workers_menu_index)
9447            .cloned()
9448        else {
9449            anyhow::bail!("select a hired worker first");
9450        };
9451        let (px, py, _) = self.state.player_position_with_z();
9452        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9453        if dist > WORKER_GIVE_RANGE_M {
9454            anyhow::bail!(
9455                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9456                worker.label
9457            );
9458        }
9459        let options = self.state.giveable_inventory_options();
9460        if options.is_empty() {
9461            anyhow::bail!("nothing in inventory to give");
9462        }
9463        self.state.worker_give_picker = Some(WorkerGivePicker {
9464            worker_instance_id: worker.instance_id,
9465            worker_label: worker.label,
9466            options,
9467        });
9468        self.state.worker_give_picker_index = 0;
9469        self.state.show_worker_give_picker = true;
9470        Ok(())
9471    }
9472
9473    pub fn close_worker_give_picker(&mut self) {
9474        self.state.show_worker_give_picker = false;
9475        self.state.worker_give_picker = None;
9476        self.state.worker_give_picker_index = 0;
9477    }
9478
9479    pub fn worker_give_picker_move(&mut self, delta: i32) {
9480        let Some(picker) = &self.state.worker_give_picker else {
9481            return;
9482        };
9483        let n = picker.options.len();
9484        if n == 0 {
9485            return;
9486        }
9487        let idx = self.state.worker_give_picker_index as i32;
9488        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9489    }
9490
9491    /// Confirm the selected row in the workers-menu give picker.
9492    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9493        let Some(picker) = self.state.worker_give_picker.clone() else {
9494            anyhow::bail!("give picker not open");
9495        };
9496        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9497            anyhow::bail!("no item selected");
9498        };
9499        let Some(worker) = self
9500            .state
9501            .hired_workers
9502            .iter()
9503            .find(|w| w.instance_id == picker.worker_instance_id)
9504            .cloned()
9505        else {
9506            self.close_worker_give_picker();
9507            anyhow::bail!("worker no longer hired");
9508        };
9509        self.give_item_to_worker(
9510            &worker.instance_id,
9511            &worker.label,
9512            worker.x,
9513            worker.y,
9514            opt.item_instance_id,
9515            &opt.label,
9516            None,
9517        )
9518        .await?;
9519        // Refresh options (stack may be gone / reduced) or close when empty.
9520        let options = self.state.giveable_inventory_options();
9521        if options.is_empty() {
9522            self.close_worker_give_picker();
9523        } else {
9524            self.state.worker_give_picker = Some(WorkerGivePicker {
9525                worker_instance_id: picker.worker_instance_id,
9526                worker_label: picker.worker_label,
9527                options,
9528            });
9529            if self.state.worker_give_picker_index
9530                >= self
9531                    .state
9532                    .worker_give_picker
9533                    .as_ref()
9534                    .map(|p| p.options.len())
9535                    .unwrap_or(0)
9536            {
9537                self.state.worker_give_picker_index = self
9538                    .state
9539                    .worker_give_picker
9540                    .as_ref()
9541                    .map(|p| p.options.len().saturating_sub(1))
9542                    .unwrap_or(0);
9543            }
9544        }
9545        Ok(())
9546    }
9547
9548    /// Open the workers-menu teach picker for the selected worker (must be in range).
9549    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9550        let Some(worker) = self
9551            .state
9552            .hired_workers
9553            .get(self.state.workers_menu_index)
9554            .cloned()
9555        else {
9556            anyhow::bail!("select a hired worker first");
9557        };
9558        let (px, py, _) = self.state.player_position_with_z();
9559        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9560        if dist > WORKER_GIVE_RANGE_M {
9561            anyhow::bail!(
9562                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
9563                worker.label
9564            );
9565        }
9566        let options = self.state.teachable_blueprint_options(&worker);
9567        if options.is_empty() {
9568            anyhow::bail!("no recipes you know that {} still needs", worker.label);
9569        }
9570        self.state.worker_teach_picker = Some(WorkerTeachPicker {
9571            worker_instance_id: worker.instance_id,
9572            worker_label: worker.label,
9573            worker_level: worker.level,
9574            options,
9575        });
9576        self.state.worker_teach_picker_index = 0;
9577        self.state.show_worker_teach_picker = true;
9578        Ok(())
9579    }
9580
9581    pub fn close_worker_teach_picker(&mut self) {
9582        self.state.show_worker_teach_picker = false;
9583        self.state.worker_teach_picker = None;
9584        self.state.worker_teach_picker_index = 0;
9585    }
9586
9587    pub fn worker_teach_picker_move(&mut self, delta: i32) {
9588        let Some(picker) = &self.state.worker_teach_picker else {
9589            return;
9590        };
9591        let n = picker.options.len();
9592        if n == 0 {
9593            return;
9594        }
9595        let idx = self.state.worker_teach_picker_index as i32;
9596        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9597    }
9598
9599    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9600        let Some(picker) = self.state.worker_teach_picker.clone() else {
9601            anyhow::bail!("teach picker not open");
9602        };
9603        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
9604            anyhow::bail!("nothing selected");
9605        };
9606        if !opt.level_ok {
9607            anyhow::bail!(
9608                "{} needs level {} (is level {})",
9609                picker.worker_label,
9610                opt.min_level,
9611                opt.worker_level
9612            );
9613        }
9614        if !opt.can_afford {
9615            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
9616        }
9617        let Some(worker) = self
9618            .state
9619            .hired_workers
9620            .iter()
9621            .find(|w| w.instance_id == picker.worker_instance_id)
9622            .cloned()
9623        else {
9624            anyhow::bail!("worker gone");
9625        };
9626        let (px, py, _) = self.state.player_position_with_z();
9627        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9628        if dist > WORKER_GIVE_RANGE_M {
9629            anyhow::bail!("worker {} too far — stand next to them", worker.label);
9630        }
9631        self.seq += 1;
9632        self.session
9633            .submit_intent(Intent::TeachWorkerBlueprint {
9634                entity_id: self.state.entity_id,
9635                worker_instance_id: picker.worker_instance_id.clone(),
9636                blueprint_id: opt.blueprint_id.clone(),
9637                seq: self.seq,
9638            })
9639            .await?;
9640        self.state.intents_sent += 1;
9641        self.state.push_log(format!(
9642            "Teaching {} to {} ({} cp)",
9643            opt.label, picker.worker_label, opt.cost_copper
9644        ));
9645        self.close_worker_teach_picker();
9646        Ok(())
9647    }
9648
9649    async fn give_item_to_worker(
9650        &mut self,
9651        worker_instance_id: &str,
9652        worker_label: &str,
9653        worker_x: f32,
9654        worker_y: f32,
9655        item_instance_id: uuid::Uuid,
9656        item_label: &str,
9657        quantity: Option<u32>,
9658    ) -> anyhow::Result<()> {
9659        let (px, py, _) = self.state.player_position_with_z();
9660        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9661        if dist > WORKER_GIVE_RANGE_M {
9662            anyhow::bail!("worker {worker_label} too far — stand next to them");
9663        }
9664        self.seq += 1;
9665        self.session
9666            .submit_intent(Intent::GiveWorkerItem {
9667                entity_id: self.state.entity_id,
9668                worker_instance_id: worker_instance_id.to_string(),
9669                item_instance_id,
9670                quantity,
9671                seq: self.seq,
9672            })
9673            .await?;
9674        self.state.intents_sent += 1;
9675        self.state
9676            .push_log(format!("Gave {item_label} to {worker_label}"));
9677        Ok(())
9678    }
9679
9680    /// Open the workers-menu take picker for the selected worker (must be in range).
9681    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
9682        let Some(worker) = self
9683            .state
9684            .hired_workers
9685            .get(self.state.workers_menu_index)
9686            .cloned()
9687        else {
9688            anyhow::bail!("select a hired worker first");
9689        };
9690        let (px, py, _) = self.state.player_position_with_z();
9691        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9692        if dist > WORKER_GIVE_RANGE_M {
9693            anyhow::bail!(
9694                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
9695                worker.label
9696            );
9697        }
9698        let options = Self::worker_inventory_options(&worker);
9699        if options.is_empty() {
9700            anyhow::bail!("{} isn't carrying anything", worker.label);
9701        }
9702        let initial_qty = options
9703            .first()
9704            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
9705            .unwrap_or(1);
9706        self.state.worker_take_picker = Some(WorkerTakePicker {
9707            worker_instance_id: worker.instance_id,
9708            worker_label: worker.label,
9709            options,
9710            quantity: initial_qty,
9711        });
9712        self.state.worker_take_picker_index = 0;
9713        self.state.show_worker_take_picker = true;
9714        Ok(())
9715    }
9716
9717    fn worker_inventory_options(
9718        worker: &flatland_protocol::HiredWorkerView,
9719    ) -> Vec<WorkerGiveOption> {
9720        worker
9721            .inventory
9722            .iter()
9723            .filter_map(|stack| {
9724                let item_instance_id = stack.item_instance_id?;
9725                let label = stack
9726                    .display_name
9727                    .clone()
9728                    .unwrap_or_else(|| stack.template_id.clone());
9729                let label = if stack.quantity > 1 {
9730                    format!("{label} ×{}", stack.quantity)
9731                } else {
9732                    label
9733                };
9734                Some(WorkerGiveOption {
9735                    item_instance_id,
9736                    label,
9737                    quantity: stack.quantity,
9738                    template_id: stack.template_id.clone(),
9739                })
9740            })
9741            .collect()
9742    }
9743
9744    pub fn close_worker_take_picker(&mut self) {
9745        self.state.show_worker_take_picker = false;
9746        self.state.worker_take_picker = None;
9747        self.state.worker_take_picker_index = 0;
9748    }
9749
9750    pub fn worker_take_picker_move(&mut self, delta: i32) {
9751        let Some(picker) = &self.state.worker_take_picker else {
9752            return;
9753        };
9754        let n = picker.options.len();
9755        if n == 0 {
9756            return;
9757        }
9758        let idx = self.state.worker_take_picker_index as i32;
9759        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9760        self.clamp_worker_take_quantity();
9761    }
9762
9763    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
9764        let Some(picker) = &mut self.state.worker_take_picker else {
9765            return;
9766        };
9767        let max = picker
9768            .options
9769            .get(self.state.worker_take_picker_index)
9770            .map(|o| o.quantity.max(1))
9771            .unwrap_or(1);
9772        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
9773        picker.quantity = next as u32;
9774    }
9775
9776    pub fn worker_take_picker_set_quantity_max(&mut self) {
9777        let Some(picker) = &mut self.state.worker_take_picker else {
9778            return;
9779        };
9780        let max = picker
9781            .options
9782            .get(self.state.worker_take_picker_index)
9783            .map(|o| o.quantity.max(1))
9784            .unwrap_or(1);
9785        picker.quantity = max;
9786    }
9787
9788    fn clamp_worker_take_quantity(&mut self) {
9789        let Some(picker) = &mut self.state.worker_take_picker else {
9790            return;
9791        };
9792        let max = picker
9793            .options
9794            .get(self.state.worker_take_picker_index)
9795            .map(|o| o.quantity.max(1))
9796            .unwrap_or(1);
9797        if picker.quantity == 0 || picker.quantity > max {
9798            picker.quantity = if max > 1 { 1 } else { max };
9799        }
9800    }
9801
9802    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
9803        let Some(picker) = self.state.worker_take_picker.clone() else {
9804            anyhow::bail!("take picker not open");
9805        };
9806        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
9807            anyhow::bail!("no item selected");
9808        };
9809        let Some(worker) = self
9810            .state
9811            .hired_workers
9812            .iter()
9813            .find(|w| w.instance_id == picker.worker_instance_id)
9814            .cloned()
9815        else {
9816            self.close_worker_take_picker();
9817            anyhow::bail!("worker no longer hired");
9818        };
9819        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
9820        let intent_qty = if qty >= opt.quantity {
9821            None
9822        } else {
9823            Some(qty)
9824        };
9825        self.take_item_from_worker(
9826            &worker.instance_id,
9827            &worker.label,
9828            worker.x,
9829            worker.y,
9830            opt.item_instance_id,
9831            &opt.label,
9832            intent_qty,
9833        )
9834        .await?;
9835        // Leave the picker open — server Interaction / hired-workers sync refresh
9836        // options. Do not optimistic-strip stacks (rejects used to look like success).
9837        Ok(())
9838    }
9839
9840    async fn take_item_from_worker(
9841        &mut self,
9842        worker_instance_id: &str,
9843        worker_label: &str,
9844        worker_x: f32,
9845        worker_y: f32,
9846        item_instance_id: uuid::Uuid,
9847        item_label: &str,
9848        quantity: Option<u32>,
9849    ) -> anyhow::Result<()> {
9850        let (px, py, _) = self.state.player_position_with_z();
9851        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9852        if dist > WORKER_GIVE_RANGE_M {
9853            anyhow::bail!("worker {worker_label} too far — stand next to them");
9854        }
9855        self.seq += 1;
9856        self.session
9857            .submit_intent(Intent::TakeWorkerItem {
9858                entity_id: self.state.entity_id,
9859                worker_instance_id: worker_instance_id.to_string(),
9860                item_instance_id,
9861                quantity,
9862                seq: self.seq,
9863            })
9864            .await?;
9865        self.state.intents_sent += 1;
9866        let qty_note = quantity
9867            .map(|q| format!(" ×{q}"))
9868            .unwrap_or_default();
9869        self.state
9870            .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
9871        Ok(())
9872    }
9873
9874    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
9875        if !self.state.has_worker_lodging() {
9876            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
9877        }
9878        self.seq += 1;
9879        self.session
9880            .submit_intent(Intent::HireWorker {
9881                entity_id: self.state.entity_id,
9882                def_id: "worker_laborer".into(),
9883                wage_copper_per_interval: 8,
9884                lodging_container_id: None,
9885                job_yaml: None,
9886                seq: self.seq,
9887            })
9888            .await?;
9889        self.state.intents_sent += 1;
9890        Ok(())
9891    }
9892
9893    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
9894        let Some(worker) = self
9895            .state
9896            .hired_workers
9897            .get(self.state.workers_menu_index)
9898            .cloned()
9899        else {
9900            anyhow::bail!("select a hired worker first");
9901        };
9902        let lodging = worker.lodging_container_id.clone().or_else(|| {
9903            crate::worker_route_editor::owned_lodging_container_ids(
9904                &self.state.placed_containers,
9905                self.state.character_id,
9906            )
9907            .into_iter()
9908            .next()
9909            .map(|(id, _)| id)
9910        });
9911        let label = worker.label.clone();
9912        let editor = if let Some(route) = &worker.route {
9913            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
9914                worker.instance_id,
9915                worker.label,
9916                route,
9917                lodging,
9918            )
9919        } else {
9920            crate::worker_route_editor::WorkerRouteEditorState::new(
9921                worker.instance_id,
9922                worker.label,
9923                lodging,
9924            )
9925        };
9926        self.state.worker_route_editor = Some(editor);
9927        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9928            if let Some(collapsed) =
9929                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
9930            {
9931                ed.panel_collapsed = collapsed;
9932            }
9933        }
9934        self.state.show_workers_menu = false;
9935        self.state.push_log(format!(
9936            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
9937        ));
9938        Ok(())
9939    }
9940
9941    pub fn close_worker_route_editor(&mut self) {
9942        self.state.worker_route_editor = None;
9943    }
9944
9945    pub fn worker_route_editor_toggle_panel(&mut self) {
9946        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9947            ed.toggle_panel_collapsed();
9948            let collapsed = ed.panel_collapsed;
9949            let mut cfg = crate::client_config::ClientConfig::load();
9950            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
9951        }
9952    }
9953
9954    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
9955        let n = {
9956            let Some(ed) = self.state.worker_route_editor.as_mut() else {
9957                return;
9958            };
9959            ed.append_waypoint(x, y, z);
9960            ed.stop_count()
9961        };
9962        self.state
9963            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
9964    }
9965
9966    // ---- picker candidate snapshots ----------------------------------
9967
9968    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
9969        let (px, py, _) = self.state.player_position_with_z();
9970        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
9971            &self.state.placed_containers,
9972            &self.state.buildings,
9973            self.state.character_id,
9974            px,
9975            py,
9976            &self.state.hired_workers,
9977        )
9978    }
9979
9980    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
9981        self.state.route_editor_node_candidates()
9982    }
9983
9984    fn re_open_harvest_picker(
9985        &mut self,
9986        index: usize,
9987        picked: std::collections::BTreeSet<String>,
9988    ) {
9989        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
9990        let nodes = self.state.route_editor_node_candidates();
9991        let index = if nodes.is_empty() {
9992            ROUTE_PICKER_DONE_ROW
9993        } else {
9994            index.max(1).min(nodes.len())
9995        };
9996        self.re_open_sheet(S::HarvestPicker {
9997            index,
9998            picked,
9999            nodes,
10000        });
10001    }
10002
10003    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10004        let (px, py, _) = self.state.player_position_with_z();
10005        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10006    }
10007
10008    fn re_template_candidates(&self) -> Vec<String> {
10009        let mut extra = Vec::new();
10010        if let Some(ed) = self.state.worker_route_editor.as_ref() {
10011            for stop in &ed.stops {
10012                match stop {
10013                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
10014                        filter: Some(filter),
10015                        ..
10016                    } => extra.extend(filter.iter().cloned()),
10017                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10018                        extra.push(template.clone());
10019                    }
10020                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10021                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10022                            extra.push(bp.output.clone());
10023                            for input in &bp.inputs {
10024                                extra.push(input.template_id.clone());
10025                            }
10026                        }
10027                    }
10028                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10029                        for it in items {
10030                            extra.push(it.template.clone());
10031                        }
10032                    }
10033                    _ => {}
10034                }
10035            }
10036            // Worker-known recipe outputs even if the player hasn't learned them yet.
10037            if let Some(worker) = self
10038                .state
10039                .hired_workers
10040                .iter()
10041                .find(|w| w.instance_id == ed.worker_instance_id)
10042            {
10043                for recipe in &worker.known_blueprint_ids {
10044                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10045                        extra.push(bp.output.clone());
10046                    }
10047                }
10048            }
10049        }
10050        crate::worker_route_editor::route_item_template_candidates(
10051            &self.state.placed_containers,
10052            self.state.character_id,
10053            &self.state.inventory,
10054            &self.state.blueprints,
10055            &self.state.resource_nodes,
10056            &extra,
10057        )
10058    }
10059
10060    fn re_blueprint_ids(&self) -> Vec<String> {
10061        let worker_known: Option<&[String]> = self
10062            .state
10063            .worker_route_editor
10064            .as_ref()
10065            .and_then(|ed| {
10066                self.state
10067                    .hired_workers
10068                    .iter()
10069                    .find(|w| w.instance_id == ed.worker_instance_id)
10070            })
10071            .map(|w| w.known_blueprint_ids.as_slice());
10072        crate::worker_route_editor::worker_craft_blueprint_ids(
10073            &self.state.blueprints,
10074            worker_known,
10075        )
10076    }
10077
10078    fn re_bed_candidates(&self) -> Vec<(String, String)> {
10079        crate::worker_route_editor::owned_lodging_container_ids(
10080            &self.state.placed_containers,
10081            self.state.character_id,
10082        )
10083    }
10084
10085    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10086        self.state
10087            .placed_containers
10088            .iter()
10089            .find(|c| c.id == container_id)
10090            .map(|c| c.contents.clone())
10091            .unwrap_or_default()
10092    }
10093
10094    // ---- sheet navigation (`plans/33`) --------------------------------
10095
10096    fn re_sheet_supports_filter(&self) -> bool {
10097        use crate::worker_route_editor::RouteEditorSheet as S;
10098        self.state
10099            .worker_route_editor
10100            .as_ref()
10101            .is_some_and(|ed| {
10102                matches!(
10103                    ed.sheet,
10104                    S::HarvestPicker { .. }
10105                        | S::SellItem { .. }
10106                        | S::DepositFilter { .. }
10107                        | S::WithdrawItems { .. }
10108                        | S::WithdrawContainers { .. }
10109                        | S::DepositContainers { .. }
10110                        | S::SellNpcs { .. }
10111                        | S::CraftBlueprint { .. }
10112                        | S::BedPicker { .. }
10113                )
10114            })
10115    }
10116
10117    /// Whether a sheet row is shown under the current `/` filter.
10118    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10119        use crate::worker_route_editor::{
10120            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10121            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10122        };
10123        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10124            return false;
10125        };
10126        let filter = &ed.sheet_filter;
10127        match &ed.sheet {
10128            S::HarvestPicker { nodes, .. } => {
10129                harvest_picker_row_matches(nodes, row, filter)
10130            }
10131            S::SellItem { templates, .. } => {
10132                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10133                    return true;
10134                }
10135                let slot = row.saturating_sub(2);
10136                templates.get(slot).is_some_and(|t| {
10137                    let label = self.state.template_display_name(t);
10138                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10139                })
10140            }
10141            S::DepositFilter { rows, .. } => {
10142                if row >= rows.len() {
10143                    return true;
10144                }
10145                rows.get(row).is_some_and(|(t, _)| {
10146                    let label = self.state.template_display_name(t);
10147                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10148                })
10149            }
10150            S::WithdrawItems { lines, .. } => {
10151                if row >= lines.len() {
10152                    return true;
10153                }
10154                lines.get(row).is_some_and(|l| {
10155                    let label = self.state.template_display_name(&l.template);
10156                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10157                })
10158            }
10159            S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10160                .re_container_candidates()
10161                .get(row)
10162                .is_some_and(|c| {
10163                    list_filter_row_matches(
10164                        filter,
10165                        Some(c.dist),
10166                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10167                    )
10168                }),
10169            S::SellNpcs { .. } => {
10170                if row == 0 {
10171                    return true;
10172                }
10173                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10174                    list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10175                })
10176            }
10177            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10178                let label = self
10179                    .state
10180                    .blueprints
10181                    .iter()
10182                    .find(|b| &b.id == id)
10183                    .map(|b| {
10184                        if b.label.is_empty() {
10185                            id.as_str()
10186                        } else {
10187                            b.label.as_str()
10188                        }
10189                    })
10190                    .unwrap_or(id.as_str());
10191                list_filter_row_matches(filter, None, &[id.as_str(), label])
10192            }),
10193            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10194                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10195            }),
10196            _ => true,
10197        }
10198    }
10199
10200    fn re_sheet_clamp_index(&mut self) {
10201        let count = self.re_sheet_row_count();
10202        if count == 0 {
10203            return;
10204        }
10205        let cur = self.re_sheet_index();
10206        if self.re_sheet_row_visible(cur) {
10207            return;
10208        }
10209        for offset in 1..count {
10210            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10211                self.re_sheet_set_index(cur + offset);
10212                return;
10213            }
10214            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10215                self.re_sheet_set_index(cur - offset);
10216                return;
10217            }
10218        }
10219    }
10220
10221    fn re_sheet_set_index(&mut self, index: usize) {
10222        use crate::worker_route_editor::RouteEditorSheet as S;
10223        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10224            return;
10225        };
10226        match &mut ed.sheet {
10227            S::AddMenu { index: slot }
10228            | S::WaypointMenu { index: slot }
10229            | S::HarvestPicker { index: slot, .. }
10230            | S::WithdrawContainers { index: slot }
10231            | S::DepositContainers { index: slot }
10232            | S::SellNpcs { index: slot }
10233            | S::CraftBlueprint { index: slot }
10234            | S::BedPicker { index: slot }
10235            | S::FarmPlotPicker { index: slot, .. }
10236            | S::FarmPlantSeed { index: slot, .. }
10237            | S::WithdrawItems { index: slot, .. }
10238            | S::DepositFilter { index: slot, .. }
10239            | S::SellItem { index: slot, .. } => *slot = index,
10240            _ => {}
10241        }
10242    }
10243
10244    pub fn re_focus_sheet_filter(&mut self) {
10245        if !self.re_sheet_supports_filter() {
10246            return;
10247        }
10248        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10249            ed.sheet_filter_focused = true;
10250        }
10251    }
10252
10253    pub fn re_blur_sheet_filter_keep_text(&mut self) {
10254        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10255            return;
10256        };
10257        if !ed.sheet_filter_focused {
10258            return;
10259        }
10260        ed.sheet_filter_focused = false;
10261        self.re_sheet_clamp_index();
10262    }
10263
10264    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10265        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10266            return false;
10267        };
10268        if ed.sheet_filter_focused {
10269            ed.sheet_filter_focused = false;
10270            self.re_sheet_clamp_index();
10271            return true;
10272        }
10273        if !ed.sheet_filter.is_empty() {
10274            ed.sheet_filter.clear();
10275            self.re_sheet_clamp_index();
10276            return true;
10277        }
10278        false
10279    }
10280
10281    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10282        if ch.is_control() {
10283            return;
10284        }
10285        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10286            return;
10287        };
10288        if !ed.sheet_filter_focused {
10289            return;
10290        }
10291        ed.sheet_filter.push(ch);
10292        self.re_sheet_set_index(0);
10293        self.re_sheet_clamp_index();
10294    }
10295
10296    pub fn re_sheet_filter_backspace(&mut self) {
10297        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10298            return;
10299        };
10300        if !ed.sheet_filter_focused {
10301            return;
10302        }
10303        ed.sheet_filter.pop();
10304        self.re_sheet_set_index(0);
10305        self.re_sheet_clamp_index();
10306    }
10307
10308    /// Row count of the current sheet (for cursor wrapping).
10309    pub fn re_sheet_row_count(&self) -> usize {
10310        use crate::worker_route_editor::{
10311            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10312        };
10313        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10314            return 0;
10315        };
10316        match &ed.sheet {
10317            S::Stops => ed.stops.len(),
10318            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10319            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10320            S::WaypointMapPick => 0,
10321            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10322            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10323                self.re_container_candidates().len()
10324            }
10325            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
10326            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
10327            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
10328            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
10329            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10330            S::WaitEntry { .. } => 1,
10331            S::BedPicker { .. } => self.re_bed_candidates().len(),
10332            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10333            S::FarmPlantSeed { seeds, .. } => seeds.len(),
10334        }
10335    }
10336
10337    /// Current sheet cursor index (0 for sheets without one).
10338    pub fn re_sheet_index(&self) -> usize {
10339        use crate::worker_route_editor::RouteEditorSheet as S;
10340        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10341            return 0;
10342        };
10343        match &ed.sheet {
10344            S::AddMenu { index }
10345            | S::WaypointMenu { index }
10346            | S::HarvestPicker { index, .. }
10347            | S::WithdrawContainers { index }
10348            | S::DepositContainers { index }
10349            | S::SellNpcs { index }
10350            | S::CraftBlueprint { index }
10351            | S::BedPicker { index }
10352            | S::FarmPlotPicker { index, .. }
10353            | S::FarmPlantSeed { index, .. }
10354            | S::WithdrawItems { index, .. }
10355            | S::DepositFilter { index, .. }
10356            | S::SellItem { index, .. } => *index,
10357            _ => 0,
10358        }
10359    }
10360
10361    /// Move the current sheet's cursor, wrapping within its rows.
10362    pub fn re_sheet_move(&mut self, delta: i32) {
10363        let count = self.re_sheet_row_count();
10364        if count == 0 {
10365            return;
10366        }
10367        let cur = self.re_sheet_index();
10368        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10369        self.re_sheet_set_index(next);
10370    }
10371
10372    pub fn re_sheet_page(&mut self, pages: i32) {
10373        let count = self.re_sheet_row_count();
10374        if count == 0 {
10375            return;
10376        }
10377        let cur = self.re_sheet_index();
10378        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10379        self.re_sheet_set_index(next);
10380    }
10381
10382    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
10383    pub fn re_sheet_adjust(&mut self, delta: i32) {
10384        use crate::worker_route_editor::RouteEditorSheet as S;
10385        let index = self.re_sheet_index();
10386        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10387            return;
10388        };
10389        match &mut ed.sheet {
10390            S::WithdrawItems { lines, .. } => {
10391                if let Some(line) = lines.get_mut(index) {
10392                    line.adjust_qty(delta);
10393                }
10394            }
10395            S::WaitEntry { ticks } => {
10396                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10397            }
10398            _ => {}
10399        }
10400    }
10401
10402    pub fn re_sheet_back(&mut self) {
10403        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10404            return;
10405        };
10406        use crate::worker_route_editor::RouteEditorSheet as S;
10407        let was_editing = ed.editing_index.is_some();
10408        let from_top_picker = matches!(
10409            ed.sheet,
10410            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10411        );
10412        ed.sheet_back();
10413        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10414            // Chest retarget commits on pick; Esc here just ends the edit sheet.
10415            self.state
10416                .push_log("Route: left edit sheet — press s to save current stops".to_string());
10417        }
10418    }
10419
10420    /// True when the editor is at its root sheet (Esc should close it).
10421    pub fn re_at_root_sheet(&self) -> bool {
10422        self.state
10423            .worker_route_editor
10424            .as_ref()
10425            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10426    }
10427
10428    pub fn re_open_add_menu(&mut self) {
10429        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10430            ed.open_add_menu();
10431        }
10432    }
10433
10434    pub fn re_open_bed_picker(&mut self) {
10435        let beds = self.re_bed_candidates();
10436        if beds.is_empty() {
10437            self.state
10438                .push_log("Route: place a camp bed first".to_string());
10439            return;
10440        }
10441        let current = self
10442            .state
10443            .worker_route_editor
10444            .as_ref()
10445            .and_then(|ed| ed.lodging_container_id.clone());
10446        let index = current
10447            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10448            .unwrap_or(0);
10449        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10450    }
10451
10452    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10453        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10454            ed.open_sheet(sheet);
10455        }
10456    }
10457
10458    /// Confirm a sheet's stop and log the outcome.
10459    fn re_confirm_stop(
10460        &mut self,
10461        stop: crate::worker_route_editor::WorkerRouteStop,
10462        what: String,
10463    ) {
10464        let appended = self
10465            .state
10466            .worker_route_editor
10467            .as_mut()
10468            .is_some_and(|ed| ed.confirm_stop(stop));
10469        if appended {
10470            self.state.push_log(format!("Route: + {what}"));
10471        } else {
10472            self.state
10473                .push_log(format!("Route: {what} already in route — selected it"));
10474        }
10475    }
10476
10477    fn re_open_withdraw_items(&mut self, container_id: String) {
10478        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10479        let contents = self.re_container_contents(&container_id);
10480        // When editing, keep the stop's item picks even if the player retargets
10481        // to a different (possibly empty) chest — otherwise Done has nothing to
10482        // confirm and the edit appears to "revert".
10483        let existing = self
10484            .state
10485            .worker_route_editor
10486            .as_ref()
10487            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10488            .and_then(|stop| match stop {
10489                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10490                _ => None,
10491            })
10492            .unwrap_or_default();
10493        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10494        // Commit the new chest immediately while editing so Esc-before-Done still
10495        // keeps the retarget (items update only when Done is pressed).
10496        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10497            let _ = ed.retarget_withdraw_container(container_id.clone());
10498        }
10499        self.re_open_sheet(S::WithdrawItems {
10500            container_id,
10501            lines,
10502            index: 0,
10503        });
10504    }
10505
10506    fn re_withdraw_items_activate(&mut self, index: usize) {
10507        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10508        enum Outcome {
10509            Cycled,
10510            Confirmed(String),
10511            Empty,
10512        }
10513        let outcome = {
10514            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10515                return;
10516            };
10517            let S::WithdrawItems {
10518                container_id,
10519                lines,
10520                index: sheet_index,
10521            } = &mut ed.sheet
10522            else {
10523                return;
10524            };
10525            *sheet_index = index;
10526            if index < lines.len() {
10527                lines[index].cycle();
10528                Outcome::Cycled
10529            } else {
10530                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
10531                if items.is_empty() {
10532                    Outcome::Empty
10533                } else {
10534                    let stop = WorkerRouteStop::WithdrawFrom {
10535                        container_id: container_id.clone(),
10536                        items,
10537                    };
10538                    let summary = stop.summary();
10539                    ed.confirm_stop(stop);
10540                    Outcome::Confirmed(summary)
10541                }
10542            }
10543        };
10544        match outcome {
10545            Outcome::Cycled => {}
10546            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
10547            Outcome::Empty => self
10548                .state
10549                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
10550        }
10551    }
10552
10553    fn re_open_deposit_filter(&mut self, container_id: String) {
10554        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10555        // Preserve filter when retargeting the deposit chest while editing.
10556        let existing_filter = self
10557            .state
10558            .worker_route_editor
10559            .as_ref()
10560            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10561            .and_then(|stop| match stop {
10562                WorkerRouteStop::DepositAt { filter, .. } => {
10563                    Some(filter.clone().unwrap_or_default())
10564                }
10565                _ => None,
10566            });
10567        let mut candidates = self.re_template_candidates();
10568        if let Some(ref chosen) = existing_filter {
10569            for t in chosen {
10570                if !candidates.iter().any(|c| c == t) {
10571                    candidates.push(t.clone());
10572                }
10573            }
10574            candidates.sort();
10575            candidates.dedup();
10576        }
10577        let rows: Vec<(String, bool)> = match existing_filter {
10578            Some(chosen) => candidates
10579                .iter()
10580                .map(|t| (t.clone(), chosen.contains(t)))
10581                .collect(),
10582            None => candidates.into_iter().map(|t| (t, false)).collect(),
10583        };
10584        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10585            let _ = ed.retarget_deposit_container(container_id.clone());
10586        }
10587        self.re_open_sheet(S::DepositFilter {
10588            container_id,
10589            rows,
10590            index: 0,
10591        });
10592    }
10593
10594    fn re_deposit_filter_activate(&mut self, index: usize) {
10595        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10596        let mut confirmed: Option<String> = None;
10597        {
10598            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10599                return;
10600            };
10601            let S::DepositFilter {
10602                container_id,
10603                rows,
10604                index: sheet_index,
10605            } = &mut ed.sheet
10606            else {
10607                return;
10608            };
10609            *sheet_index = index;
10610            if index < rows.len() {
10611                rows[index].1 = !rows[index].1;
10612            } else {
10613                // "Done" row.
10614                let chosen: Vec<String> = rows
10615                    .iter()
10616                    .filter(|(_, on)| *on)
10617                    .map(|(t, _)| t.clone())
10618                    .collect();
10619                let filter = if chosen.is_empty() { None } else { Some(chosen) };
10620                let stop = WorkerRouteStop::DepositAt {
10621                    container_id: container_id.clone(),
10622                    filter,
10623                };
10624                confirmed = Some(stop.summary());
10625                ed.confirm_stop(stop);
10626            }
10627        }
10628        if let Some(what) = confirmed {
10629            self.state.push_log(format!("Route: + {what}"));
10630        }
10631    }
10632
10633    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
10634        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10635        let templates = self.re_template_candidates();
10636        if templates.is_empty() {
10637            self.state.push_log(
10638                "Route: no item templates available — learn a craft recipe or place a harvest node first"
10639                    .to_string(),
10640            );
10641            return;
10642        }
10643        // Prefill when editing an existing sell stop.
10644        let (pre_npc, pre_template, pre_all) = self
10645            .state
10646            .worker_route_editor
10647            .as_ref()
10648            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10649            .and_then(|stop| match stop {
10650                WorkerRouteStop::TradeWith {
10651                    npc_id,
10652                    template,
10653                    sell_all,
10654                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
10655                _ => None,
10656            })
10657            .unwrap_or((None, None, true));
10658        let npc_id = npc_id.or(pre_npc);
10659        let mut picked = std::collections::BTreeSet::new();
10660        if let Some(t) = pre_template {
10661            picked.insert(t);
10662        }
10663        self.re_open_sheet(S::SellItem {
10664            npc_id,
10665            templates,
10666            index: if picked.is_empty() {
10667                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
10668            } else {
10669                2
10670            },
10671            sell_all: pre_all,
10672            picked,
10673        });
10674    }
10675
10676    fn re_sell_item_activate(&mut self, index: usize) {
10677        use crate::worker_route_editor::{
10678            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10679        };
10680        let mut batch_log: Option<String> = None;
10681        {
10682            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10683                return;
10684            };
10685            let S::SellItem {
10686                npc_id,
10687                templates,
10688                index: sheet_index,
10689                sell_all,
10690                picked,
10691            } = &mut ed.sheet
10692            else {
10693                return;
10694            };
10695            *sheet_index = index;
10696            if index == ROUTE_PICKER_DONE_ROW {
10697                if picked.is_empty() {
10698                    batch_log = Some(
10699                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
10700                    );
10701                } else {
10702                    let picks: Vec<String> = picked.iter().cloned().collect();
10703                    let npc = npc_id.clone();
10704                    let all = *sell_all;
10705                    let added = ed.confirm_trade_picks(npc, &picks, all);
10706                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
10707                }
10708            } else if index == SELL_ITEM_TOGGLE_ROW {
10709                *sell_all = !*sell_all;
10710            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
10711                if picked.contains(template) {
10712                    picked.remove(template);
10713                } else {
10714                    picked.insert(template.clone());
10715                }
10716            }
10717        }
10718        if let Some(msg) = batch_log {
10719            self.state.push_log(msg);
10720        }
10721    }
10722
10723    /// Enter on the stop list: open the selected stop's sheet, prefilled.
10724    pub fn re_edit_selected_stop(&mut self) {
10725        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10726        let Some(stop) = self
10727            .state
10728            .worker_route_editor
10729            .as_ref()
10730            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
10731        else {
10732            self.state
10733                .push_log("Route: no stop selected — press a to add one".to_string());
10734            return;
10735        };
10736        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10737            ed.begin_edit_selected();
10738        }
10739        match stop {
10740            WorkerRouteStop::Waypoint { .. } => {
10741                self.re_open_sheet(S::WaypointMenu { index: 0 });
10742            }
10743            WorkerRouteStop::HarvestNode { node_id } => {
10744                let nodes = self.state.route_editor_node_candidates();
10745                if nodes.is_empty() {
10746                    self.re_cancel_edit();
10747                    self.state
10748                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
10749                } else {
10750                    let mut picked = std::collections::BTreeSet::new();
10751                    picked.insert(node_id.clone());
10752                    let index = nodes
10753                        .iter()
10754                        .position(|n| n.id == node_id)
10755                        .map(|i| i + 1)
10756                        .unwrap_or(1);
10757                    self.re_open_harvest_picker(index, picked);
10758                }
10759            }
10760            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
10761                // Open the container picker so the player can retarget storage
10762                // (previously jumped straight to items on the same chest).
10763                let containers = self.re_container_candidates();
10764                if containers.is_empty() {
10765                    self.re_cancel_edit();
10766                    self.state
10767                        .push_log("Route: place a storage chest first".to_string());
10768                } else {
10769                    let index = containers
10770                        .iter()
10771                        .position(|c| c.id == container_id)
10772                        .unwrap_or(0);
10773                    self.re_open_sheet(S::WithdrawContainers { index });
10774                }
10775            }
10776            WorkerRouteStop::DepositAt { container_id, .. } => {
10777                let containers = self.re_container_candidates();
10778                if containers.is_empty() {
10779                    self.re_cancel_edit();
10780                    self.state
10781                        .push_log("Route: place a storage chest first".to_string());
10782                } else {
10783                    let index = containers
10784                        .iter()
10785                        .position(|c| c.id == container_id)
10786                        .unwrap_or(0);
10787                    self.re_open_sheet(S::DepositContainers { index });
10788                }
10789            }
10790            WorkerRouteStop::TradeWith { npc_id, .. } => {
10791                let npcs = self.re_npc_candidates();
10792                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
10793                let index = npc_id
10794                    .as_ref()
10795                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
10796                    .unwrap_or(0);
10797                self.re_open_sheet(S::SellNpcs { index });
10798            }
10799            WorkerRouteStop::CraftAt { blueprint, .. } => {
10800                let bps = self.re_blueprint_ids();
10801                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
10802                if bps.is_empty() {
10803                    self.re_cancel_edit();
10804                    self.state
10805                        .push_log("Route: no known blueprints to retarget".to_string());
10806                } else {
10807                    self.re_open_sheet(S::CraftBlueprint { index });
10808                }
10809            }
10810            WorkerRouteStop::CultivatePlot { .. } => {
10811                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
10812            }
10813            WorkerRouteStop::PlantPlot { .. } => {
10814                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
10815            }
10816            WorkerRouteStop::HarvestPlot { .. } => {
10817                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
10818            }
10819            WorkerRouteStop::RestIfNeeded => {
10820                self.re_cancel_edit();
10821                self.state
10822                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
10823            }
10824            WorkerRouteStop::Wait { wait_ticks } => {
10825                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
10826            }
10827        }
10828    }
10829
10830    fn re_cancel_edit(&mut self) {
10831        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10832            ed.editing_index = None;
10833        }
10834    }
10835
10836    // ---- mouse clicks inside the overlay ------------------------------
10837
10838    pub fn worker_route_editor_ui_click(
10839        &mut self,
10840        click: crate::worker_route_editor::RouteEditorClick,
10841    ) {
10842        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
10843        match click {
10844            RouteEditorClick::SelectStop(i) => {
10845                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10846                    ed.sheet = S::Stops;
10847                    ed.select_stop(i);
10848                }
10849            }
10850            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
10851            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
10852            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
10853        }
10854    }
10855
10856    /// Activate (Enter / click) a row of the current sheet.
10857    pub fn re_sheet_row_activate(&mut self, row: usize) {
10858        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10859        let Some(sheet) = self
10860            .state
10861            .worker_route_editor
10862            .as_ref()
10863            .map(|ed| ed.sheet.clone())
10864        else {
10865            return;
10866        };
10867        match sheet {
10868            S::Stops => {
10869                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10870                    ed.select_stop(row);
10871                }
10872            }
10873            S::AddMenu { .. } => match row {
10874                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
10875                1 => {
10876                    if self.re_node_candidates().is_empty() {
10877                        self.state
10878                            .push_log("Route: no harvestable nodes visible in this region".to_string());
10879                    } else {
10880                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
10881                    }
10882                }
10883                2 | 3 => {
10884                    if self.re_container_candidates().is_empty() {
10885                        self.state
10886                            .push_log("Route: place a storage chest first".to_string());
10887                    } else if row == 2 {
10888                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
10889                    } else {
10890                        self.re_open_sheet(S::DepositContainers { index: 0 });
10891                    }
10892                }
10893                4 => {
10894                    if self.re_template_candidates().is_empty() {
10895                        self.state.push_log(
10896                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
10897                                .to_string(),
10898                        );
10899                    } else {
10900                        self.re_open_sheet(S::SellNpcs { index: 0 });
10901                    }
10902                }
10903                5 => {
10904                    if self.re_blueprint_ids().is_empty() {
10905                        self.state.push_log(
10906                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
10907                                .to_string(),
10908                        );
10909                    } else {
10910                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
10911                    }
10912                }
10913                6 => self.re_confirm_stop(
10914                    WorkerRouteStop::RestIfNeeded,
10915                    "rest at lodging (if needed)".into(),
10916                ),
10917                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
10918                8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
10919                9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10920                10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
10921                _ => {}
10922            },
10923            S::WaypointMenu { .. } => match row {
10924                0 => {
10925                    let (x, y, z) = self.state.player_position_with_z();
10926                    let stop = WorkerRouteStop::Waypoint { x, y, z };
10927                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
10928                }
10929                1 => {
10930                    self.re_open_sheet(S::WaypointMapPick);
10931                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
10932                }
10933                _ => {}
10934            },
10935            S::HarvestPicker { .. } => {
10936                let mut log: Option<String> = None;
10937                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10938                    let S::HarvestPicker {
10939                        index: sheet_index,
10940                        picked,
10941                        nodes,
10942                    } = &mut ed.sheet
10943                    else {
10944                        return;
10945                    };
10946                    *sheet_index = row;
10947                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
10948                        if picked.is_empty() {
10949                            log = Some(
10950                                "Route: pick at least one node (Space toggles, Done confirms)"
10951                                    .into(),
10952                            );
10953                        } else {
10954                            let ids: Vec<String> = picked.iter().cloned().collect();
10955                            let added = ed.confirm_harvest_picks(&ids);
10956                            log = Some(format!("Route: + {added} harvest stop(s)"));
10957                        }
10958                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
10959                        if picked.contains(&n.id) {
10960                            picked.remove(&n.id);
10961                        } else {
10962                            picked.insert(n.id.clone());
10963                        }
10964                    }
10965                }
10966                if let Some(msg) = log {
10967                    self.state.push_log(msg);
10968                }
10969            }
10970            S::WithdrawContainers { .. } => {
10971                let containers = self.re_container_candidates();
10972                if let Some(c) = containers.get(row) {
10973                    let id = c.id.clone();
10974                    self.re_open_withdraw_items(id);
10975                }
10976            }
10977            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
10978            S::DepositContainers { .. } => {
10979                let containers = self.re_container_candidates();
10980                if let Some(c) = containers.get(row) {
10981                    let id = c.id.clone();
10982                    self.re_open_deposit_filter(id);
10983                }
10984            }
10985            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
10986            S::SellNpcs { .. } => {
10987                let npcs = self.re_npc_candidates();
10988                let npc_id = if row == 0 {
10989                    None
10990                } else {
10991                    npcs.get(row - 1).map(|n| n.id.clone())
10992                };
10993                if row == 0 || npc_id.is_some() {
10994                    self.re_open_sell_item(npc_id);
10995                }
10996            }
10997            S::SellItem { .. } => self.re_sell_item_activate(row),
10998            S::CraftBlueprint { .. } => {
10999                let bps = self.re_blueprint_ids();
11000                if let Some(bp) = bps.get(row) {
11001                    let stop = WorkerRouteStop::CraftAt {
11002                        device: "hand".into(),
11003                        blueprint: bp.clone(),
11004                        qty: None,
11005                    };
11006                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11007                }
11008            }
11009            S::WaitEntry { ticks } => {
11010                let stop = WorkerRouteStop::Wait {
11011                    wait_ticks: ticks,
11012                };
11013                self.re_confirm_stop(stop, format!("wait {ticks}t"));
11014            }
11015            S::BedPicker { .. } => {
11016                let beds = self.re_bed_candidates();
11017                if let Some((id, name)) = beds.get(row) {
11018                    let (id, name) = (id.clone(), name.clone());
11019                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11020                        ed.lodging_container_id = Some(id.clone());
11021                        ed.sheet = S::Stops;
11022                    }
11023                    self.state
11024                        .push_log(format!("Route: rest bed set to {name}"));
11025                }
11026            }
11027            S::FarmPlotPicker { action, .. } => {
11028                let plots = self.re_farm_plot_candidates();
11029                let Some(plot) = plots.get(row).cloned() else {
11030                    return;
11031                };
11032                match action {
11033                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
11034                        let label = plot_route_label(&plot);
11035                        self.re_confirm_stop(
11036                            WorkerRouteStop::CultivatePlot {
11037                                plot_id: plot.plot_id,
11038                            },
11039                            format!("cultivate {label}"),
11040                        );
11041                    }
11042                    crate::worker_route_editor::FarmPlotAction::Harvest => {
11043                        let label = plot_route_label(&plot);
11044                        self.re_confirm_stop(
11045                            WorkerRouteStop::HarvestPlot {
11046                                plot_id: plot.plot_id,
11047                            },
11048                            format!("harvest {label}"),
11049                        );
11050                    }
11051                    crate::worker_route_editor::FarmPlotAction::Plant => {
11052                        let seeds = self.re_farm_seed_candidates();
11053                        if seeds.is_empty() {
11054                            self.state.push_log(
11055                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11056                            );
11057                            return;
11058                        }
11059                        self.re_open_sheet(S::FarmPlantSeed {
11060                            plot_id: plot.plot_id,
11061                            seeds,
11062                            index: 0,
11063                        });
11064                    }
11065                }
11066            }
11067            S::FarmPlantSeed { plot_id, seeds, .. } => {
11068                if let Some(seed) = seeds.get(row).cloned() {
11069                    self.re_confirm_stop(
11070                        WorkerRouteStop::PlantPlot {
11071                            plot_id,
11072                            seed_template: seed.clone(),
11073                        },
11074                        format!("plant {seed}"),
11075                    );
11076                }
11077            }
11078            S::WaypointMapPick => {}
11079        }
11080    }
11081
11082    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11083        use crate::worker_route_editor::RouteEditorSheet as S;
11084        if self.re_farm_plot_candidates().is_empty() {
11085            self.state.push_log(
11086                "Route: no farmable plots visible — claim land or get farm access first",
11087            );
11088            return;
11089        }
11090        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11091    }
11092
11093    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11094        self.state
11095            .property_plots
11096            .iter()
11097            .filter(|p| p.is_mine || p.may_farm)
11098            .cloned()
11099            .collect()
11100    }
11101
11102    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
11103    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
11104    /// and known crop seeds so routes can withdraw-then-plant.
11105    fn re_farm_seed_candidates(&self) -> Vec<String> {
11106        let mut set = std::collections::BTreeSet::new();
11107        let looks_like_seed = |id: &str| {
11108            id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11109        };
11110        for (id, _, _) in self.state.farm_seed_entries() {
11111            set.insert(id);
11112        }
11113        for c in &self.state.placed_containers {
11114            let mine = match (self.state.character_id, c.owner_character_id) {
11115                (Some(a), Some(b)) => a == b,
11116                _ => false,
11117            };
11118            if !mine {
11119                continue;
11120            }
11121            for s in &c.contents {
11122                if s.quantity > 0
11123                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11124                {
11125                    set.insert(s.template_id.clone());
11126                }
11127            }
11128        }
11129        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11130            for stop in &ed.stops {
11131                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11132                {
11133                    for it in items {
11134                        if looks_like_seed(&it.template) {
11135                            set.insert(it.template.clone());
11136                        }
11137                    }
11138                }
11139                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11140                    seed_template, ..
11141                } = stop
11142                {
11143                    if !seed_template.is_empty() {
11144                        set.insert(seed_template.clone());
11145                    }
11146                }
11147            }
11148        }
11149        for id in self.state.inventory_hints.keys() {
11150            if looks_like_seed(id) {
11151                set.insert(id.clone());
11152            }
11153        }
11154        // Authored crop seeds (always offer so withdraw-then-plant routes work).
11155        for id in ["potato_seed", "carrot_seed"] {
11156            set.insert(id.to_string());
11157        }
11158        set.into_iter().collect()
11159    }
11160
11161    // ---- map clicks (sheet-scoped accelerators) ------------------------
11162
11163    /// Map click while the route editor is open. When a picker sheet is open
11164    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
11165    /// container, NPC clicks the merchant, node clicks the harvest target);
11166    /// otherwise the click quick-adds the nearest target (context-aware).
11167    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11168        use crate::worker_route_editor as wre;
11169        use wre::RouteEditorSheet as S;
11170        if self.state.worker_route_editor.is_none() {
11171            return;
11172        }
11173        let sheet = self
11174            .state
11175            .worker_route_editor
11176            .as_ref()
11177            .map(|ed| ed.sheet.clone())
11178            .unwrap_or(S::Stops);
11179        match sheet {
11180            S::WaypointMapPick => {
11181                let (_, _, z) = self.state.player_position_with_z();
11182                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11183                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11184                // Stay in map-pick mode when adding (not editing) for fast pathing.
11185                let editing = self
11186                    .state
11187                    .worker_route_editor
11188                    .as_ref()
11189                    .is_some_and(|ed| ed.editing_index.is_some());
11190                if !editing {
11191                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11192                        ed.sheet = S::WaypointMapPick;
11193                    }
11194                }
11195            }
11196            S::HarvestPicker { .. } => {
11197                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11198                    let mut log: Option<String> = None;
11199                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11200                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11201                            return;
11202                        };
11203                        let selected = if picked.contains(&node.id) {
11204                            picked.remove(&node.id);
11205                            false
11206                        } else {
11207                            picked.insert(node.id.clone());
11208                            true
11209                        };
11210                        log = Some(format!(
11211                            "Route: {} {}",
11212                            if selected { "selected" } else { "deselected" },
11213                            node.label
11214                        ));
11215                    }
11216                    if let Some(msg) = log {
11217                        self.state.push_log(msg);
11218                    }
11219                }
11220            }
11221            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11222                // Chest click picks (or switches) the withdraw source.
11223                if let Some(cid) = wre::pick_storage_container_at(
11224                    &self.state.placed_containers,
11225                    self.state.character_id,
11226                    x,
11227                    y,
11228                ) {
11229                    self.re_open_withdraw_items(cid);
11230                }
11231            }
11232            S::DepositContainers { .. } | S::DepositFilter { .. } => {
11233                if let Some(cid) = wre::pick_storage_container_at(
11234                    &self.state.placed_containers,
11235                    self.state.character_id,
11236                    x,
11237                    y,
11238                ) {
11239                    self.re_open_deposit_filter(cid);
11240                }
11241            }
11242            S::SellNpcs { .. } => {
11243                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11244                    self.re_open_sell_item(Some(npc_id));
11245                }
11246            }
11247            S::SellItem { .. } => {
11248                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11249                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11250                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11251                            *slot = Some(npc_id.clone());
11252                        }
11253                    }
11254                    self.state
11255                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11256                }
11257            }
11258            // Stop list / other sheets: context-aware quick add.
11259            _ => self.worker_route_editor_quick_add_click(x, y),
11260        }
11261    }
11262
11263    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
11264    /// node wins; duplicates select the existing stop; a click on a merchant
11265    /// while a sell stop is selected pins it.
11266    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11267        use crate::worker_route_editor as wre;
11268        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11269            let dx = ax - bx;
11270            let dy = ay - by;
11271            (dx * dx + dy * dy).sqrt()
11272        };
11273
11274        // 1. Retarget the selected stop when the click names its target:
11275        //    sell stop → pin the merchant; withdraw stop → set the source chest.
11276        let selected_stop_kind = self
11277            .state
11278            .worker_route_editor
11279            .as_ref()
11280            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11281            .map(|s| match s {
11282                wre::WorkerRouteStop::TradeWith { .. } => 1,
11283                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11284                _ => 0,
11285            })
11286            .unwrap_or(0);
11287        if selected_stop_kind == 1 {
11288            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11289                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11290                    ed.set_selected_trade_npc(npc_id.clone());
11291                }
11292                self.state
11293                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11294                return;
11295            }
11296        }
11297        if selected_stop_kind == 2 {
11298            if let Some(cid) = wre::pick_storage_container_at(
11299                &self.state.placed_containers,
11300                self.state.character_id,
11301                x,
11302                y,
11303            ) {
11304                let name = self
11305                    .state
11306                    .placed_containers
11307                    .iter()
11308                    .find(|c| c.id == cid)
11309                    .map(|c| c.display_name.clone())
11310                    .unwrap_or_else(|| "container".into());
11311                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11312                    ed.set_selected_withdraw_container(cid.clone());
11313                }
11314                self.state
11315                    .push_log(format!("Route: withdraw source → {name}"));
11316                return;
11317            }
11318        }
11319
11320        // 2. Nearest candidate of any kind wins. Category order is the
11321        //    tie-break for exact overlaps (bed over chest over merchant over node).
11322        enum Target {
11323            Bed(String),
11324            Container(String),
11325            Npc(String, String),
11326            Node(String, String),
11327        }
11328        let mut best: Option<(f32, u8, Target)> = None;
11329        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11330            let better = match best {
11331                None => true,
11332                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11333            };
11334            if better {
11335                *best = Some((d, rank, t));
11336            }
11337        };
11338        if let Some(bed_id) = wre::pick_lodging_container_at(
11339            &self.state.placed_containers,
11340            self.state.character_id,
11341            x,
11342            y,
11343        ) {
11344            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11345                // A bed that's already the rest bed doubles as a storage chest —
11346                // a second click on it means "deposit here", not "set bed again".
11347                let already_bed = self
11348                    .state
11349                    .worker_route_editor
11350                    .as_ref()
11351                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11352                if already_bed {
11353                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11354                } else {
11355                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11356                }
11357            }
11358        }
11359        if let Some(cid) = wre::pick_storage_container_at(
11360            &self.state.placed_containers,
11361            self.state.character_id,
11362            x,
11363            y,
11364        ) {
11365            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11366                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11367            }
11368        }
11369        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11370            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11371                consider(
11372                    dist(x, y, n.x, n.y),
11373                    2,
11374                    Target::Npc(npc_id, label),
11375                    &mut best,
11376                );
11377            }
11378        }
11379        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11380            let d = dist(x, y, node.x, node.y);
11381            consider(
11382                d,
11383                3,
11384                Target::Node(node.id.clone(), node.label.clone()),
11385                &mut best,
11386            );
11387        }
11388
11389        match best.map(|(_, _, t)| t) {
11390            Some(Target::Bed(bed_id)) => {
11391                let name = self
11392                    .state
11393                    .placed_containers
11394                    .iter()
11395                    .find(|c| c.id == bed_id)
11396                    .map(|c| c.display_name.clone())
11397                    .unwrap_or_else(|| "camp bed".into());
11398                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11399                    ed.lodging_container_id = Some(bed_id.clone());
11400                }
11401                self.state
11402                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11403            }
11404            Some(Target::Container(cid)) => {
11405                let name = self
11406                    .state
11407                    .placed_containers
11408                    .iter()
11409                    .find(|c| c.id == cid)
11410                    .map(|c| c.display_name.clone())
11411                    .unwrap_or_else(|| "container".into());
11412                let added = self
11413                    .state
11414                    .worker_route_editor
11415                    .as_mut()
11416                    .is_some_and(|ed| ed.append_deposit_at(&cid));
11417                if added {
11418                    self.state
11419                        .push_log(format!("Route: + deposit at {name} ({cid})"));
11420                } else {
11421                    self.state.push_log(format!(
11422                        "Route: {name} already in route — selected it (d to remove)"
11423                    ));
11424                }
11425            }
11426            Some(Target::Npc(npc_id, label)) => {
11427                // Quick-add sell: first storage template (the full sell flow
11428                // lives in the a → Sell sheet).
11429                let template = self.re_template_candidates().into_iter().next();
11430                let Some(template) = template else {
11431                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11432                    return;
11433                };
11434                let added = self
11435                    .state
11436                    .worker_route_editor
11437                    .as_mut()
11438                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11439                if added {
11440                    self.state
11441                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11442                } else {
11443                    self.state.push_log(format!(
11444                        "Route: {label} already sells {template} — selected it (d to remove)"
11445                    ));
11446                }
11447            }
11448            Some(Target::Node(id, label)) => {
11449                let added = self
11450                    .state
11451                    .worker_route_editor
11452                    .as_mut()
11453                    .is_some_and(|ed| ed.append_harvest_node(&id));
11454                if added {
11455                    self.state
11456                        .push_log(format!("Route: + harvest node {label} ({id})"));
11457                } else {
11458                    self.state.push_log(format!(
11459                        "Route: {label} already in route — selected it (d to remove)"
11460                    ));
11461                }
11462            }
11463            None => {}
11464        }
11465    }
11466
11467    pub fn worker_route_editor_select(&mut self, delta: i32) {
11468        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11469            return;
11470        };
11471        if ed.stops.is_empty() {
11472            return;
11473        }
11474        let n = ed.stops.len() as i32;
11475        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11476        ed.selected_stop_index = next;
11477    }
11478
11479    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11480        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11481            return;
11482        };
11483        if delta < 0 {
11484            ed.move_selected_up();
11485        } else if delta > 0 {
11486            ed.move_selected_down();
11487        }
11488    }
11489
11490    pub fn worker_route_editor_delete_selected(&mut self) {
11491        let removed = self
11492            .state
11493            .worker_route_editor
11494            .as_mut()
11495            .is_some_and(|ed| {
11496                let before = ed.stop_count();
11497                ed.remove_selected_stop();
11498                ed.stop_count() < before
11499            });
11500        if removed {
11501            self.state.push_log("Route: removed selected stop");
11502        }
11503    }
11504
11505    /// Clear all stops. Saving afterwards parks the worker in idle mode
11506    /// instead of leaving it in a broken job loop.
11507    pub fn worker_route_editor_clear_stops(&mut self) {
11508        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11509            return;
11510        };
11511        if ed.stops.is_empty() {
11512            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
11513            return;
11514        }
11515        ed.stops.clear();
11516        ed.selected_stop_index = 0;
11517        self.state
11518            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
11519    }
11520
11521    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
11522        if self.state.pending_worker_job_ack.is_some() {
11523            anyhow::bail!("route save still pending — wait for server ack");
11524        }
11525        let Some(ed) = self.state.worker_route_editor.clone() else {
11526            anyhow::bail!("route editor not open");
11527        };
11528        // Empty route = stand down: park the worker in idle mode rather than
11529        // erroring out or leaving it marching a ghost loop.
11530        let (job_yaml, idle) = if ed.stops.is_empty() {
11531            (ed.build_idle_job_yaml(), true)
11532        } else {
11533            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
11534        };
11535        let worker_id = ed.worker_instance_id.clone();
11536        let route_view = if idle {
11537            None
11538        } else {
11539            Some(ed.to_route_view())
11540        };
11541        let mode = if idle {
11542            flatland_protocol::WorkerModeView::Idle
11543        } else {
11544            flatland_protocol::WorkerModeView::JobLoop
11545        };
11546        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
11547            .state
11548            .hired_workers
11549            .iter()
11550            .find(|w| w.instance_id == worker_id)
11551            .map(|w| {
11552                (
11553                    w.route.clone(),
11554                    w.mode,
11555                    w.step_label.clone(),
11556                    w.last_error.clone(),
11557                )
11558            })
11559            .unwrap_or((
11560                None,
11561                flatland_protocol::WorkerModeView::Idle,
11562                String::new(),
11563                None,
11564            ));
11565        self.seq += 1;
11566        let seq = self.seq;
11567        self.session
11568            .submit_intent(Intent::SetWorkerJob {
11569                entity_id: self.state.entity_id,
11570                worker_instance_id: worker_id.clone(),
11571                job_yaml,
11572                seq,
11573            })
11574            .await?;
11575        self.state.intents_sent += 1;
11576        if let Some(w) = self
11577            .state
11578            .hired_workers
11579            .iter_mut()
11580            .find(|w| w.instance_id == worker_id)
11581        {
11582            w.route = route_view;
11583            w.mode = mode;
11584            w.last_error = None;
11585            if idle {
11586                w.step_label.clear();
11587                w.route_stop_index = None;
11588            }
11589        }
11590        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
11591            seq,
11592            worker_instance_id: worker_id,
11593            worker_label: ed.worker_label.clone(),
11594            idle,
11595            stop_count: ed.stops.len(),
11596            prev_route,
11597            prev_mode,
11598            prev_step_label,
11599            prev_last_error,
11600        });
11601        self.state.push_log(format!(
11602            "Route: saving for {}… (waiting for server)",
11603            ed.worker_label
11604        ));
11605        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
11606        Ok(())
11607    }
11608    pub fn quest_menu_move(&mut self, delta: i32) {
11609        let n = self.state.active_quest_entries().len();
11610        if n == 0 {
11611            return;
11612        }
11613        let idx = self.state.quest_menu_index as i32;
11614        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11615    }
11616
11617    pub fn quest_menu_page(&mut self, pages: i32) {
11618        let n = self.state.active_quest_entries().len();
11619        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
11620    }
11621
11622    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
11623        let Some(offer) = self.state.pending_quest_offer.clone() else {
11624            anyhow::bail!("no quest offer");
11625        };
11626        self.seq += 1;
11627        let seq = self.seq;
11628        self.session
11629            .submit_intent(Intent::AcceptQuest {
11630                entity_id: self.state.entity_id,
11631                quest_id: offer.quest_id,
11632                seq,
11633            })
11634            .await?;
11635        self.state.intents_sent += 1;
11636        Ok(())
11637    }
11638
11639    pub fn quest_offer_decline(&mut self) {
11640        self.state.show_quest_offer = false;
11641        self.state.pending_quest_offer = None;
11642        if !self.state.show_npc_chat
11643            && !self.state.show_shop_menu
11644            && self.state.npc_verb_target.is_some()
11645        {
11646            self.state.show_npc_verb_menu = true;
11647        }
11648    }
11649
11650    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
11651        if !self.state.show_quest_menu {
11652            return Ok(());
11653        }
11654        let active: Vec<_> = self
11655            .state
11656            .active_quest_entries()
11657            .into_iter()
11658            .cloned()
11659            .collect();
11660        let Some(entry) = active.get(self.state.quest_menu_index) else {
11661            return Ok(());
11662        };
11663        if self.state.quest_withdraw_confirm {
11664            if !entry.can_withdraw {
11665                anyhow::bail!("quest cannot be withdrawn");
11666            }
11667            self.seq += 1;
11668            let seq = self.seq;
11669            self.session
11670                .submit_intent(Intent::WithdrawQuest {
11671                    entity_id: self.state.entity_id,
11672                    quest_id: entry.quest_id.clone(),
11673                    seq,
11674                })
11675                .await?;
11676            self.state.intents_sent += 1;
11677            self.state.quest_withdraw_confirm = false;
11678            return Ok(());
11679        }
11680        self.seq += 1;
11681        let seq = self.seq;
11682        self.session
11683            .submit_intent(Intent::TrackQuest {
11684                entity_id: self.state.entity_id,
11685                quest_id: entry.quest_id.clone(),
11686                seq,
11687            })
11688            .await?;
11689        self.state.intents_sent += 1;
11690        Ok(())
11691    }
11692
11693    pub fn quest_request_withdraw(&mut self) {
11694        if self.state.show_quest_menu {
11695            self.state.quest_withdraw_confirm = true;
11696        }
11697    }
11698
11699    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
11700        if !self.state.is_alive() {
11701            anyhow::bail!("you are dead");
11702        }
11703        let Some(catalog) = self.state.shop_catalog.clone() else {
11704            anyhow::bail!("no shop open");
11705        };
11706        self.seq += 1;
11707        let seq = self.seq;
11708        match self.state.shop_tab {
11709            ShopTab::Buy => {
11710                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
11711                    anyhow::bail!("nothing selected");
11712                };
11713                if offer.already_owned {
11714                    anyhow::bail!("already owned");
11715                }
11716                self.session
11717                    .submit_intent(Intent::ShopBuy {
11718                        entity_id: self.state.entity_id,
11719                        npc_id: catalog.npc_id.clone(),
11720                        offer_id: offer.offer_id.clone(),
11721                        quantity: self.state.shop_quantity,
11722                        seq,
11723                    })
11724                    .await?;
11725            }
11726            ShopTab::Sell => {
11727                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
11728                    anyhow::bail!("nothing to sell");
11729                };
11730                if line.quantity == 0 {
11731                    anyhow::bail!("you have no {}", line.label);
11732                }
11733                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
11734                self.session
11735                    .submit_intent(Intent::ShopSell {
11736                        entity_id: self.state.entity_id,
11737                        npc_id: catalog.npc_id.clone(),
11738                        template_id: line.template_id.clone(),
11739                        quantity,
11740                        seq,
11741                    })
11742                    .await?;
11743            }
11744        }
11745        self.state.intents_sent += 1;
11746        Ok(())
11747    }
11748
11749    pub fn craft_menu_move(&mut self, delta: i32) {
11750        let n = self.state.blueprints.len();
11751        if n == 0 {
11752            return;
11753        }
11754        let idx = self.state.craft_menu_index as i32;
11755        let next = (idx + delta).rem_euclid(n as i32);
11756        self.state.craft_menu_index = next as usize;
11757        self.state.clamp_craft_batch_quantity();
11758    }
11759
11760    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
11761        self.state.craft_batch_adjust_quantity(delta);
11762    }
11763
11764    pub fn craft_batch_set_max(&mut self) {
11765        self.state.craft_batch_set_max();
11766    }
11767
11768    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
11769        let Some(blueprint) = self
11770            .state
11771            .blueprints
11772            .get(self.state.craft_menu_index)
11773            .cloned()
11774        else {
11775            anyhow::bail!("no blueprints known");
11776        };
11777        if !self.state.can_craft_blueprint(&blueprint) {
11778            let hint = self
11779                .state
11780                .craft_missing_hint(&blueprint)
11781                .unwrap_or_else(|| "missing materials".into());
11782            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
11783        }
11784        let count = self.state.craft_batch_quantity;
11785        let max = self.state.max_craft_batches(&blueprint);
11786        if max == 0 {
11787            anyhow::bail!("cannot craft {}", blueprint.label);
11788        }
11789        let batches = count.min(max);
11790        self.craft(&blueprint.id, Some(batches)).await?;
11791        self.state.show_craft_menu = false;
11792        Ok(())
11793    }
11794
11795    pub async fn move_by(
11796        &mut self,
11797        forward: f32,
11798        strafe: f32,
11799        vertical: f32,
11800        sprint: bool,
11801    ) -> anyhow::Result<()> {
11802        if !self.state.is_alive() {
11803            anyhow::bail!("you are dead");
11804        }
11805        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
11806            self.last_move_forward = forward;
11807            self.last_move_strafe = strafe;
11808        }
11809        self.seq += 1;
11810        self.session
11811            .submit_intent(Intent::Move {
11812                entity_id: self.state.entity_id,
11813                forward,
11814                strafe,
11815                vertical,
11816                sprint,
11817                seq: self.seq,
11818            })
11819            .await?;
11820        self.state.intents_sent += 1;
11821        Ok(())
11822    }
11823
11824    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
11825        if !self.state.connected {
11826            crate::harvest_trace!("harvest_nearest rejected: not connected");
11827            anyhow::bail!("not connected");
11828        }
11829        if !self.state.is_alive() {
11830            crate::harvest_trace!("harvest_nearest rejected: player dead");
11831            anyhow::bail!("you are dead");
11832        }
11833        if self.state.harvest_in_progress {
11834            if self.state.harvest_state_stale() {
11835                self.state.clear_harvest_state();
11836            } else {
11837                anyhow::bail!("already harvesting");
11838            }
11839        }
11840        let (px, py) = self
11841            .state
11842            .player
11843            .as_ref()
11844            .map(|p| (p.transform.position.x, p.transform.position.y))
11845            .unwrap_or((0.0, 0.0));
11846
11847        let available = self
11848            .state
11849            .resource_nodes
11850            .iter()
11851            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11852            .count();
11853        let node_id = self
11854            .state
11855            .resource_nodes
11856            .iter()
11857            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11858            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
11859            .min_by(|a, b| {
11860                let da = distance(px, py, a.x, a.y);
11861                let db = distance(px, py, b.x, b.y);
11862                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
11863            })
11864            .map(|n| n.id.clone());
11865
11866        let Some(node_id) = node_id else {
11867            let has_loot = self
11868                .state
11869                .ground_drops
11870                .iter()
11871                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11872            if has_loot {
11873                return self.pickup_nearest().await;
11874            }
11875            anyhow::bail!(
11876                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
11877            );
11878        };
11879
11880        self.seq += 1;
11881        let seq = self.seq;
11882        crate::harvest_trace!(
11883            entity_id = self.state.entity_id,
11884            node_id = %node_id,
11885            seq,
11886            px,
11887            py,
11888            available_nodes = available,
11889            "submitting harvest intent"
11890        );
11891        self.session
11892            .submit_intent(Intent::Harvest {
11893                entity_id: self.state.entity_id,
11894                node_id,
11895                seq,
11896            })
11897            .await?;
11898        self.state.intents_sent += 1;
11899        self.state.harvest_in_progress = true;
11900        self.state.harvest_started_at = Some(Instant::now());
11901        self.state.push_log("Harvesting…");
11902        crate::harvest_trace!(
11903            entity_id = self.state.entity_id,
11904            seq,
11905            "harvest intent queued to session"
11906        );
11907        Ok(())
11908    }
11909
11910    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
11911        if !self.state.is_alive() {
11912            anyhow::bail!("you are dead");
11913        }
11914        let blueprint_id = self
11915            .state
11916            .blueprints
11917            .iter()
11918            .find(|bp| self.state.can_craft_blueprint(bp))
11919            .map(|bp| bp.id.clone())
11920            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
11921        self.craft(&blueprint_id, None).await
11922    }
11923
11924    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
11925        if !self.state.is_alive() {
11926            anyhow::bail!("you are dead");
11927        }
11928        self.seq += 1;
11929        self.session
11930            .submit_intent(Intent::Craft {
11931                entity_id: self.state.entity_id,
11932                blueprint_id: blueprint_id.to_string(),
11933                count,
11934                seq: self.seq,
11935            })
11936            .await?;
11937        self.state.intents_sent += 1;
11938        let (label, batches) = self
11939            .state
11940            .blueprints
11941            .iter()
11942            .find(|b| b.id == blueprint_id)
11943            .map(|b| {
11944                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
11945                (b.label.as_str(), n)
11946            })
11947            .unwrap_or((blueprint_id, count.unwrap_or(1)));
11948        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
11949        Ok(())
11950    }
11951
11952    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
11953        if !self.state.is_alive() {
11954            anyhow::bail!("you are dead");
11955        }
11956        let target_id = match self.state.nearest_interact_target() {
11957            Some(id) => id,
11958            None => {
11959                anyhow::bail!("nothing to interact with nearby");
11960            }
11961        };
11962        if self.state.npcs.iter().any(|n| n.id == target_id) {
11963            self.state.show_npc_verb_menu = true;
11964            self.state.npc_verb_target = Some(target_id);
11965            self.state.npc_verb_index = 0;
11966            return Ok(());
11967        }
11968        if self
11969            .state
11970            .hired_workers
11971            .iter()
11972            .any(|w| w.instance_id == target_id)
11973        {
11974            return self.open_workers_menu_for(&target_id).await;
11975        }
11976        if let Ok(peer_id) = target_id.parse::<EntityId>() {
11977            if self
11978                .state
11979                .hired_workers
11980                .iter()
11981                .any(|w| w.entity_id == peer_id)
11982            {
11983                if let Some(w) = self
11984                    .state
11985                    .hired_workers
11986                    .iter()
11987                    .find(|w| w.entity_id == peer_id)
11988                {
11989                    let id = w.instance_id.clone();
11990                    return self.open_workers_menu_for(&id).await;
11991                }
11992            }
11993            if let Some(entity) = self
11994                .state
11995                .entities
11996                .iter()
11997                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
11998            {
11999                self.state
12000                    .player_verbs
12001                    .open_for(peer_id, &entity.label);
12002                return Ok(());
12003            }
12004        }
12005        self.seq += 1;
12006        self.session
12007            .submit_intent(Intent::Interact {
12008                entity_id: self.state.entity_id,
12009                target_id: target_id.clone(),
12010                seq: self.seq,
12011            })
12012            .await?;
12013        self.state.intents_sent += 1;
12014        Ok(())
12015    }
12016
12017    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
12018    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12019        if !self.state.is_alive() {
12020            anyhow::bail!("you are dead");
12021        }
12022        let (px, py) = self.state.player_position();
12023        let has_loot = self
12024            .state
12025            .ground_drops
12026            .iter()
12027            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12028        if has_loot {
12029            return self.pickup_nearest().await;
12030        }
12031        if self
12032            .state
12033            .placed_containers
12034            .iter()
12035            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12036        {
12037            return self.pickup_nearest_container().await;
12038        }
12039
12040        if let Some(plot) = self.state.my_plot_under_player().cloned() {
12041            // Double-tap f within 1.2s sells; otherwise farm the plot.
12042            const SELL_WINDOW: Duration = Duration::from_millis(1200);
12043            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12044                && self
12045                    .state
12046                    .sell_plot_armed_at
12047                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12048            if sell_armed {
12049                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12050            }
12051            self.state.sell_plot_confirm = None;
12052            self.state.sell_plot_armed_at = None;
12053
12054            // Prefer farm actions on your own land unless an NPC/player/door/board
12055            // is in interact range (those still win).
12056            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12057                self.state.npcs.iter().any(|n| n.id == id)
12058                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12059                    || self.state.doors.iter().any(|d| d.id == id)
12060                    || self.state.interactables.iter().any(|i| {
12061                        i.id == id
12062                            && matches!(
12063                                i.kind.as_str(),
12064                                "quest_board" | "well" | "exit" | "enter"
12065                            )
12066                    })
12067                    || id.parse::<EntityId>().is_ok_and(|eid| {
12068                        self.state
12069                            .entities
12070                            .iter()
12071                            .any(|e| e.id == eid && e.id != self.state.entity_id)
12072                    })
12073            });
12074            if !blocking_interact {
12075                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
12076                match self.harvest_nearest().await {
12077                    Ok(()) => return Ok(()),
12078                    Err(err) => {
12079                        let msg = err.to_string();
12080                        if !(msg.contains("no harvestable")
12081                            || msg.contains("press p")
12082                            || msg.contains("press f")
12083                            || msg.contains("nothing"))
12084                        {
12085                            return Err(err);
12086                        }
12087                    }
12088                }
12089                return Ok(());
12090            }
12091        }
12092        if self.state.nearest_interact_target().is_some() {
12093            return self.interact_nearest().await;
12094        }
12095        // Prefer a clear "move closer" when a board is visible but out of reach,
12096        // instead of silently falling through to harvest.
12097        if let Some((label, dist)) = self.state.nearest_quest_board() {
12098            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12099                anyhow::bail!(
12100                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12101                );
12102            }
12103        }
12104
12105        match self.harvest_nearest().await {
12106            Ok(()) => Ok(()),
12107            Err(err) => {
12108                let msg = err.to_string();
12109                if msg.contains("no harvestable")
12110                    || msg.contains("press p")
12111                    || msg.contains("press f")
12112                {
12113                    anyhow::bail!(
12114                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12115                    );
12116                }
12117                Err(err)
12118            }
12119        }
12120    }
12121
12122    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
12123    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12124        if !self.state.is_alive() {
12125            anyhow::bail!("you are dead");
12126        }
12127        if self.state.claim_mode.is_some() {
12128            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12129        }
12130        let zone = self
12131            .state
12132            .free_property_zone_under_player()
12133            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12134        let zone_id = zone.id.clone();
12135        let label = zone
12136            .label
12137            .as_deref()
12138            .filter(|s| !s.trim().is_empty())
12139            .unwrap_or(zone.id.as_str())
12140            .to_string();
12141        self.enter_claim_mode(&zone_id);
12142        self.state
12143            .push_log(format!(
12144                "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12145            ));
12146        Ok(())
12147    }
12148
12149    /// Enter claim mode over a property zone (default 4×4 or min size).
12150    pub fn enter_claim_mode(&mut self, zone_id: &str) {
12151        let Some(zone) = self
12152            .state
12153            .property_zones
12154            .iter()
12155            .find(|z| z.id == zone_id)
12156            .cloned()
12157        else {
12158            self.state.push_log("unknown property zone");
12159            return;
12160        };
12161        self.state.sell_plot_confirm = None;
12162        self.state.sell_plot_armed_at = None;
12163        let min_area = self
12164            .state
12165            .property_plot_settings
12166            .as_ref()
12167            .map(|s| s.min_plot_area_m2)
12168            .unwrap_or(4.0)
12169            .max(1.0);
12170        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12171        let side = 4u32.max(min_side);
12172        let (px, py) = self.state.player_position();
12173        let anchor_x = px.floor();
12174        let anchor_y = py.floor();
12175        self.state.claim_mode = Some(ClaimModeState {
12176            zone_id: zone.id.clone(),
12177            width_m: side,
12178            height_m: side,
12179            anchor_x,
12180            anchor_y,
12181        });
12182        let label = zone
12183            .label
12184            .as_deref()
12185            .filter(|s| !s.trim().is_empty())
12186            .unwrap_or(zone.id.as_str());
12187        self.state.push_log(format!(
12188            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12189        ));
12190    }
12191
12192    pub fn cancel_claim_mode(&mut self) {
12193        if self.state.claim_mode.take().is_some() {
12194            self.state.push_log("Claim cancelled");
12195        }
12196    }
12197
12198    /// Enter relocate mode for a placed container (1×1 ghost).
12199    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12200        if !self.state.is_alive() {
12201            anyhow::bail!("you are dead");
12202        }
12203        if self.state.relocate_mode.is_some() {
12204            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12205        }
12206        if self.state.claim_mode.is_some() {
12207            anyhow::bail!("finish or cancel claim mode first");
12208        }
12209        let chest = self
12210            .state
12211            .placed_containers
12212            .iter()
12213            .find(|c| c.id == container_id)
12214            .cloned()
12215            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12216        let (px, py) = self.state.player_position();
12217        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12218            anyhow::bail!("too far from {}", chest.display_name);
12219        }
12220        if chest.locked && !chest.accessible {
12221            anyhow::bail!(
12222                "need the matching key for {} before moving it",
12223                chest.display_name
12224            );
12225        }
12226        let label = if chest.display_name.trim().is_empty() {
12227            chest.template_id.clone()
12228        } else {
12229            chest.display_name.clone()
12230        };
12231        self.state.relocate_mode = Some(RelocateModeState {
12232            container_id: chest.id.clone(),
12233            label: label.clone(),
12234            cursor_x: chest.x.floor() + 0.5,
12235            cursor_y: chest.y.floor() + 0.5,
12236        });
12237        self.state.push_log(format!(
12238            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12239        ));
12240        Ok(())
12241    }
12242
12243    /// World `Shift+m`: relocate nearest owned/accessible placed container.
12244    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12245        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12246            anyhow::bail!("no chest nearby to relocate");
12247        };
12248        if chest.locked && !chest.accessible {
12249            anyhow::bail!(
12250                "need the matching key for {} before moving it",
12251                chest.display_name
12252            );
12253        }
12254        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
12255        // Server enforces ownership; client starts on nearest accessible chest in range.
12256        self.begin_relocate_container(&chest.id)
12257    }
12258
12259    pub fn cancel_relocate_mode(&mut self) {
12260        if self.state.relocate_mode.take().is_some() {
12261            self.state.push_log("Relocate cancelled");
12262        }
12263    }
12264
12265    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12266        let Some(mode) = self.state.relocate_mode.as_mut() else {
12267            return;
12268        };
12269        let max_x = self.state.world_width_m.max(1.0);
12270        let max_y = self.state.world_height_m.max(1.0);
12271        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12272        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12273        mode.cursor_x = nx.floor() + 0.5;
12274        mode.cursor_y = ny.floor() + 0.5;
12275    }
12276
12277    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12278        let Some(mode) = self.state.relocate_mode.as_mut() else {
12279            return;
12280        };
12281        let max_x = self.state.world_width_m.max(1.0);
12282        let max_y = self.state.world_height_m.max(1.0);
12283        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12284        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12285    }
12286
12287    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12288        if !self.state.is_alive() {
12289            anyhow::bail!("you are dead");
12290        }
12291        let Some(mode) = self.state.relocate_mode.clone() else {
12292            anyhow::bail!("not relocating");
12293        };
12294        let (px, py) = self.state.player_position();
12295        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12296        if dist > 8.0 {
12297            anyhow::bail!("destination too far (max 8 m)");
12298        }
12299        self.seq += 1;
12300        self.session
12301            .submit_intent(Intent::MovePlacedContainer {
12302                entity_id: self.state.entity_id,
12303                container_id: mode.container_id.clone(),
12304                x: mode.cursor_x,
12305                y: mode.cursor_y,
12306                seq: self.seq,
12307            })
12308            .await?;
12309        self.state.intents_sent += 1;
12310        self.state.relocate_mode = None;
12311        self.state
12312            .push_log(format!("Moving {}…", mode.label));
12313        Ok(())
12314    }
12315
12316    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12317        let Some(mode) = self.state.claim_mode.as_mut() else {
12318            return;
12319        };
12320        mode.width_m = w.max(1);
12321        mode.height_m = h.max(1);
12322    }
12323
12324    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12325        let Some(mode) = self.state.claim_mode.as_mut() else {
12326            return;
12327        };
12328        let w = (mode.width_m as i32 + dw).max(1) as u32;
12329        let h = (mode.height_m as i32 + dh).max(1) as u32;
12330        mode.width_m = w;
12331        mode.height_m = h;
12332    }
12333
12334    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
12335    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12336        let Some(mode) = self.state.claim_mode.as_mut() else {
12337            return;
12338        };
12339        let max_x = self.state.world_width_m.max(1.0);
12340        let max_y = self.state.world_height_m.max(1.0);
12341        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12342        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12343        mode.anchor_x = nx.floor();
12344        mode.anchor_y = ny.floor();
12345    }
12346
12347    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12348        if !self.state.is_alive() {
12349            anyhow::bail!("you are dead");
12350        }
12351        let Some(mode) = self.state.claim_mode.clone() else {
12352            anyhow::bail!("not in claim mode");
12353        };
12354        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12355            self.state.claim_quote()
12356        else {
12357            anyhow::bail!("cannot quote claim");
12358        };
12359        if !valid {
12360            anyhow::bail!(reason);
12361        }
12362        if !can_afford {
12363            anyhow::bail!(
12364                "not enough copper (need {})",
12365                crate::currency::format_copper(purchase)
12366            );
12367        }
12368        let (x0, y0, x1, y1) = self
12369            .state
12370            .claim_footprint_rect()
12371            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12372        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12373        self.seq += 1;
12374        self.session
12375            .submit_intent(Intent::BuyPlot {
12376                entity_id: self.state.entity_id,
12377                zone_id: mode.zone_id,
12378                x0,
12379                y0,
12380                x1,
12381                y1,
12382                seq: self.seq,
12383            })
12384            .await?;
12385        self.state.intents_sent += 1;
12386        self.state.claim_mode = None;
12387        self.state
12388            .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12389        Ok(())
12390    }
12391
12392    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12393        if !self.state.is_alive() {
12394            anyhow::bail!("you are dead");
12395        }
12396        let zone_id = self
12397            .state
12398            .claim_mode
12399            .as_ref()
12400            .map(|m| m.zone_id.clone())
12401            .or_else(|| {
12402                self.state
12403                    .free_property_zone_under_player()
12404                    .map(|z| z.id.clone())
12405            })
12406            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12407        self.seq += 1;
12408        self.session
12409            .submit_intent(Intent::BuyPlotAllFree {
12410                entity_id: self.state.entity_id,
12411                zone_id,
12412                seq: self.seq,
12413            })
12414            .await?;
12415        self.state.intents_sent += 1;
12416        self.state.claim_mode = None;
12417        self.state.push_log("Claiming largest free plot…");
12418        Ok(())
12419    }
12420
12421    pub async fn confirm_sell_plot_to_crown(
12422        &mut self,
12423        plot_id: uuid::Uuid,
12424    ) -> anyhow::Result<()> {
12425        if !self.state.is_alive() {
12426            anyhow::bail!("you are dead");
12427        }
12428        self.seq += 1;
12429        self.session
12430            .submit_intent(Intent::SellPlotToCrown {
12431                entity_id: self.state.entity_id,
12432                plot_id,
12433                seq: self.seq,
12434            })
12435            .await?;
12436        self.state.intents_sent += 1;
12437        self.state.sell_plot_confirm = None;
12438        self.state.sell_plot_armed_at = None;
12439        self.state.push_log("Selling plot to the crown…");
12440        Ok(())
12441    }
12442
12443    pub async fn set_plot_farm_public(
12444        &mut self,
12445        plot_id: uuid::Uuid,
12446        public: bool,
12447        public_tax_discount_bps: u32,
12448    ) -> anyhow::Result<()> {
12449        self.seq += 1;
12450        self.session
12451            .submit_intent(Intent::SetPlotFarmPublic {
12452                entity_id: self.state.entity_id,
12453                plot_id,
12454                public,
12455                public_tax_discount_bps,
12456                seq: self.seq,
12457            })
12458            .await?;
12459        self.state.intents_sent += 1;
12460        Ok(())
12461    }
12462
12463    pub async fn plot_farm_allow_upsert(
12464        &mut self,
12465        plot_id: uuid::Uuid,
12466        character_id: Option<uuid::Uuid>,
12467        character_name: String,
12468        tax_discount_bps: u32,
12469    ) -> anyhow::Result<()> {
12470        self.seq += 1;
12471        self.session
12472            .submit_intent(Intent::PlotFarmAllowUpsert {
12473                entity_id: self.state.entity_id,
12474                plot_id,
12475                character_id,
12476                character_name,
12477                tax_discount_bps,
12478                seq: self.seq,
12479            })
12480            .await?;
12481        self.state.intents_sent += 1;
12482        Ok(())
12483    }
12484
12485    pub async fn plot_farm_allow_remove(
12486        &mut self,
12487        plot_id: uuid::Uuid,
12488        character_id: uuid::Uuid,
12489    ) -> anyhow::Result<()> {
12490        self.seq += 1;
12491        self.session
12492            .submit_intent(Intent::PlotFarmAllowRemove {
12493                entity_id: self.state.entity_id,
12494                plot_id,
12495                character_id,
12496                seq: self.seq,
12497            })
12498            .await?;
12499        self.state.intents_sent += 1;
12500        Ok(())
12501    }
12502
12503    pub fn open_farm_access_panel(&mut self) {
12504        let Some(plot) = self.state.my_plot_under_player() else {
12505            self.state
12506                .push_log("Stand on your deed plot to manage farm access");
12507            return;
12508        };
12509        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
12510        self.state.farm_access_index = 0;
12511        self.state.show_farm_access = true;
12512    }
12513
12514    pub fn close_farm_access_panel(&mut self) {
12515        self.state.show_farm_access = false;
12516        self.state.farm_access_name_draft.clear();
12517        self.state.farm_access_index = 0;
12518    }
12519
12520    pub fn farm_access_move(&mut self, delta: i32) {
12521        let n = self.farm_access_row_count().max(1);
12522        let idx = self.state.farm_access_index as i32 + delta;
12523        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
12524    }
12525
12526    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
12527        let Some(plot) = self.state.my_plot_under_player() else {
12528            return vec![FarmAccessRow::PublicToggle];
12529        };
12530        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
12531        for g in &plot.farm_allow {
12532            rows.push(FarmAccessRow::AllowRemove {
12533                character_id: g.character_id,
12534                label: if g.character_label.trim().is_empty() {
12535                    g.character_id.to_string()[..8].to_string()
12536                } else {
12537                    g.character_label.clone()
12538                },
12539                tax_discount_bps: g.tax_discount_bps,
12540            });
12541        }
12542        for e in &self.state.entities {
12543            if e.id == self.state.entity_id || e.label.trim().is_empty() {
12544                continue;
12545            }
12546            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
12547                continue;
12548            }
12549            if self
12550                .state
12551                .npcs
12552                .iter()
12553                .any(|n| n.id == e.label || n.label == e.label)
12554            {
12555                continue;
12556            }
12557            if plot
12558                .farm_allow
12559                .iter()
12560                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
12561            {
12562                continue;
12563            }
12564            rows.push(FarmAccessRow::NearbyAdd {
12565                name: e.label.clone(),
12566            });
12567        }
12568        rows
12569    }
12570
12571    pub fn farm_access_row_count(&self) -> usize {
12572        self.farm_access_rows().len().max(1)
12573    }
12574
12575    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
12576        let Some(plot) = self.state.my_plot_under_player().cloned() else {
12577            self.close_farm_access_panel();
12578            return Ok(());
12579        };
12580        let rows = self.farm_access_rows();
12581        let Some(row) = rows.get(self.state.farm_access_index) else {
12582            return Ok(());
12583        };
12584        match row {
12585            FarmAccessRow::PublicToggle => {
12586                self.set_plot_farm_public(
12587                    plot.plot_id,
12588                    !plot.farm_public,
12589                    plot.public_tax_discount_bps,
12590                )
12591                .await
12592            }
12593            FarmAccessRow::PublicDiscount => Ok(()),
12594            FarmAccessRow::AllowRemove { character_id, .. } => {
12595                self.plot_farm_allow_remove(plot.plot_id, *character_id)
12596                    .await
12597            }
12598            FarmAccessRow::NearbyAdd { name } => {
12599                let disc = self
12600                    .state
12601                    .farm_access_discount_bps
12602                    .max(plot.public_tax_discount_bps);
12603                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
12604                    .await
12605            }
12606        }
12607    }
12608
12609    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
12610        let Some(plot) = self.state.my_plot_under_player().cloned() else {
12611            return Ok(());
12612        };
12613        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
12614        self.state.farm_access_discount_bps = next;
12615        self.state.farm_access_index = 1;
12616        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
12617            .await
12618    }
12619
12620    /// Till the cell under you on a farmable plot (`c`).
12621    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
12622        if self.state.farmable_plot_under_player().is_none() {
12623            anyhow::bail!("stand on a farmable plot to cultivate");
12624        }
12625        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
12626            let (px, py) = self.state.player_position();
12627            if self
12628                .state
12629                .terrain_at(px, py)
12630                .is_some_and(|k| k == TerrainKindView::Tilled)
12631            {
12632                anyhow::bail!("already tilled — stand on bare soil and press c");
12633            }
12634            anyhow::bail!("cannot till this cell — move onto soil on your plot");
12635        };
12636        self.cultivate_at(tx, ty).await
12637    }
12638
12639    /// Plant seeds on empty tilled soil under you (`p`).
12640    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
12641        if self.state.farmable_plot_under_player().is_none() {
12642            anyhow::bail!("stand on a farmable plot to plant");
12643        }
12644        if !self.state.underfoot_free_tilled_plant_slot() {
12645            anyhow::bail!("stand on empty tilled soil and press p");
12646        }
12647        let seeds = self.state.farm_seed_entries();
12648        if seeds.is_empty() {
12649            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
12650        }
12651        if seeds.len() == 1 {
12652            return self.plant_seeds(seeds[0].0.clone(), 1).await;
12653        }
12654        self.open_plant_menu();
12655        Ok(())
12656    }
12657
12658    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
12659        if !self.state.is_alive() {
12660            anyhow::bail!("you are dead");
12661        }
12662        self.seq += 1;
12663        self.session
12664            .submit_intent(Intent::Cultivate {
12665                entity_id: self.state.entity_id,
12666                x,
12667                y,
12668                seq: self.seq,
12669            })
12670            .await?;
12671        self.state.intents_sent += 1;
12672        Ok(())
12673    }
12674
12675    pub async fn plant_seeds(
12676        &mut self,
12677        seed_template_id: String,
12678        quantity: u32,
12679    ) -> anyhow::Result<()> {
12680        if !self.state.is_alive() {
12681            anyhow::bail!("you are dead");
12682        }
12683        self.seq += 1;
12684        self.session
12685            .submit_intent(Intent::PlantSeeds {
12686                entity_id: self.state.entity_id,
12687                seed_template_id: seed_template_id.clone(),
12688                quantity,
12689                seq: self.seq,
12690            })
12691            .await?;
12692        self.state.intents_sent += 1;
12693        self.state
12694            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
12695        Ok(())
12696    }
12697
12698    pub fn open_plant_menu(&mut self) {
12699        if self.state.farm_seed_entries().is_empty() {
12700            self.state.push_log("No seeds in inventory to plant");
12701            return;
12702        }
12703        self.state.show_plant_menu = true;
12704        self.state.plant_menu_index = 0;
12705        self.state.plant_quantity = 1;
12706        self.state.clamp_plant_menu();
12707    }
12708
12709    pub fn close_plant_menu(&mut self) {
12710        self.state.show_plant_menu = false;
12711    }
12712
12713    pub fn plant_menu_move(&mut self, delta: i32) {
12714        let n = self.state.farm_seed_entries().len();
12715        if n == 0 {
12716            return;
12717        }
12718        let idx = self.state.plant_menu_index as i32 + delta;
12719        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
12720        self.state.clamp_plant_menu();
12721    }
12722
12723    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
12724        let next = self.state.plant_quantity as i32 + delta;
12725        self.state.plant_quantity = next.max(1) as u32;
12726        self.state.clamp_plant_menu();
12727    }
12728
12729    pub fn plant_menu_set_quantity_max(&mut self) {
12730        if let Some((_, max, _)) = self.state.plant_menu_selection() {
12731            self.state.plant_quantity = max;
12732        }
12733        self.state.clamp_plant_menu();
12734    }
12735
12736    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
12737        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
12738            self.close_plant_menu();
12739            anyhow::bail!("no seeds to plant");
12740        };
12741        self.close_plant_menu();
12742        self.plant_seeds(seed, qty).await?;
12743        self.state.push_log(format!("Planted {qty}× {label}"));
12744        Ok(())
12745    }
12746
12747    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
12748    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
12749    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
12750        if !self.state.is_alive() {
12751            anyhow::bail!("you are dead");
12752        }
12753        let binding = self
12754            .state
12755            .hotbar_ability(slot)
12756            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
12757            .to_string();
12758        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
12759            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
12760            if qty == 0 {
12761                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
12762            }
12763            return self.use_item(template_id).await;
12764        }
12765        let ability_id = binding;
12766        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
12767            return self
12768                .cast_ability(&ability_id, Some(self.state.entity_id))
12769                .await;
12770        }
12771        let is_heal = ability_id == "heal_touch"
12772            || self
12773                .state
12774                .ability_meta
12775                .get(&ability_id)
12776                .map(|meta| meta.is_heal)
12777                .unwrap_or(false);
12778        let target = if is_heal {
12779            Some(
12780                self.state
12781                    .target_for_slot(2)
12782                    .unwrap_or(self.state.entity_id),
12783            )
12784        } else {
12785            self.state
12786                .target_for_slot(1)
12787                .or_else(|| self.state.target_for_slot(2))
12788        };
12789        let Some(target_id) = target else {
12790            anyhow::bail!("no target — Tab to select, then press the hotbar key");
12791        };
12792        self.cast_ability(&ability_id, Some(target_id)).await
12793    }
12794
12795    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
12796    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
12797    pub async fn set_hotbar_slot(
12798        &mut self,
12799        slot: u8,
12800        ability_id: Option<&str>,
12801    ) -> anyhow::Result<()> {
12802        if !self.state.is_alive() {
12803            anyhow::bail!("you are dead");
12804        }
12805        if !(1..=9).contains(&slot) {
12806            anyhow::bail!("hotbar slot must be 1–9");
12807        }
12808        let ability_id = ability_id
12809            .map(str::trim)
12810            .filter(|id| !id.is_empty())
12811            .map(str::to_string);
12812        self.seq += 1;
12813        self.session
12814            .submit_intent(Intent::SetHotbarSlot {
12815                entity_id: self.state.entity_id,
12816                slot,
12817                ability_id: ability_id.clone(),
12818                seq: self.seq,
12819            })
12820            .await?;
12821        self.state.intents_sent += 1;
12822        let idx = (slot - 1) as usize;
12823        if self.state.hotbar.len() < 9 {
12824            self.state.hotbar.resize(9, None);
12825        }
12826        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
12827            *slot_mut = ability_id.clone();
12828        }
12829        match ability_id {
12830            Some(id) => {
12831                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
12832                    format!("use {tid}")
12833                } else {
12834                    id
12835                };
12836                self.state.push_log(format!("Hotbar {slot} → {label}"))
12837            }
12838            None => self.state.push_log(format!("Hotbar {slot} cleared")),
12839        }
12840        Ok(())
12841    }
12842
12843    pub fn npc_verb_options(&self) -> Vec<&'static str> {
12844        self.state.npc_verb_options()
12845    }
12846
12847    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
12848        let Some(npc_id) = self.state.npc_verb_target.clone() else {
12849            return Ok(());
12850        };
12851        let options = self.npc_verb_options();
12852        let choice = options
12853            .get(self.state.npc_verb_index)
12854            .copied()
12855            .unwrap_or("Talk");
12856        self.seq += 1;
12857        match choice {
12858            "Trade" | "Bank" | "Storage" | "Market" => {
12859                self.session
12860                    .submit_intent(Intent::Interact {
12861                        entity_id: self.state.entity_id,
12862                        target_id: npc_id,
12863                        seq: self.seq,
12864                    })
12865                    .await?;
12866            }
12867            _ => {
12868                self.session
12869                    .submit_intent(Intent::NpcTalkOpen {
12870                        entity_id: self.state.entity_id,
12871                        npc_id,
12872                        seq: self.seq,
12873                    })
12874                    .await?;
12875            }
12876        }
12877        self.state.intents_sent += 1;
12878        Ok(())
12879    }
12880
12881    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
12882        let Some(chat) = self.state.npc_chat.clone() else {
12883            return Ok(());
12884        };
12885        let message = chat.input.trim().to_string();
12886        if message.is_empty() || chat.pending {
12887            return Ok(());
12888        }
12889        if let Some(c) = self.state.npc_chat.as_mut() {
12890            c.lines.push(format!("You: {message}"));
12891            c.input.clear();
12892            c.pending = true;
12893        }
12894        self.seq += 1;
12895        self.session
12896            .submit_intent(Intent::NpcTalkSay {
12897                entity_id: self.state.entity_id,
12898                npc_id: chat.npc_id,
12899                message,
12900                seq: self.seq,
12901            })
12902            .await?;
12903        self.state.intents_sent += 1;
12904        Ok(())
12905    }
12906
12907    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
12908        let return_to_verbs = self.state.npc_verb_target.is_some();
12909        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
12910            self.state.show_npc_chat = false;
12911            if return_to_verbs {
12912                self.state.show_npc_verb_menu = true;
12913            }
12914            return Ok(());
12915        };
12916        self.seq += 1;
12917        self.session
12918            .submit_intent(Intent::NpcTalkClose {
12919                entity_id: self.state.entity_id,
12920                npc_id,
12921                seq: self.seq,
12922            })
12923            .await?;
12924        self.state.intents_sent += 1;
12925        self.state.show_npc_chat = false;
12926        self.state.npc_chat = None;
12927        if return_to_verbs {
12928            self.state.show_npc_verb_menu = true;
12929        }
12930        Ok(())
12931    }
12932
12933    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
12934    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
12935        if self.state.show_quest_offer
12936            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
12937        {
12938            self.quest_offer_decline();
12939            return Ok(());
12940        }
12941        if self.state.show_npc_chat {
12942            return self.npc_talk_close().await;
12943        }
12944        if self.state.show_shop_menu {
12945            return self.back_from_shop_menu().await;
12946        }
12947        if self.state.bank_panel.is_some() {
12948            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
12949                self.bank_transfer_back();
12950                return Ok(());
12951            }
12952            return self.close_bank_panel().await;
12953        }
12954        if self.state.storage_panel.is_some() {
12955            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
12956                self.storage_ui_back();
12957                return Ok(());
12958            }
12959            return self.close_storage_panel().await;
12960        }
12961        if self.state.market_panel.is_some() {
12962            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
12963                self.market_ui_back();
12964                return Ok(());
12965            }
12966            if self.state.market_buy_confirm.is_some() {
12967                self.state.market_buy_confirm = None;
12968                return Ok(());
12969            }
12970            return self.close_market_panel().await;
12971        }
12972        if self.state.show_npc_verb_menu {
12973            self.state.show_npc_verb_menu = false;
12974            self.state.npc_verb_target = None;
12975        }
12976        Ok(())
12977    }
12978
12979    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
12980        self.seq += 1;
12981        self.session
12982            .submit_intent(Intent::TestDamage {
12983                entity_id: self.state.entity_id,
12984                amount,
12985                seq: self.seq,
12986            })
12987            .await?;
12988        self.state.intents_sent += 1;
12989        Ok(())
12990    }
12991
12992    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
12993        self.cycle_combat_target_slot(1, reverse).await
12994    }
12995
12996    pub async fn cycle_combat_target_slot(
12997        &mut self,
12998        slot_index: u8,
12999        reverse: bool,
13000    ) -> anyhow::Result<()> {
13001        if !self.state.is_alive() {
13002            anyhow::bail!("you are dead");
13003        }
13004        let candidates = self.state.candidates_for_slot(slot_index);
13005        if candidates.is_empty() {
13006            anyhow::bail!("no targets nearby");
13007        }
13008        let current = self.state.target_for_slot(slot_index);
13009        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
13010        let next_idx = match idx {
13011            None => 0,
13012            Some(i) if reverse => {
13013                if i == 0 {
13014                    candidates.len() - 1
13015                } else {
13016                    i - 1
13017                }
13018            }
13019            Some(i) => (i + 1) % candidates.len(),
13020        };
13021        if idx == Some(next_idx) && candidates.len() == 1 {
13022            self.clear_combat_target_slot(slot_index).await?;
13023            return Ok(());
13024        }
13025        let (target_id, label) = candidates[next_idx].clone();
13026        self.set_combat_target_slot(slot_index, target_id, &label)
13027            .await
13028    }
13029
13030    pub async fn set_combat_target_slot(
13031        &mut self,
13032        slot_index: u8,
13033        target_id: EntityId,
13034        label: &str,
13035    ) -> anyhow::Result<()> {
13036        if !self.state.is_alive() {
13037            anyhow::bail!("you are dead");
13038        }
13039        self.seq += 1;
13040        self.session
13041            .submit_intent(Intent::SetTargetSlot {
13042                entity_id: self.state.entity_id,
13043                slot_index,
13044                target_id,
13045                seq: self.seq,
13046            })
13047            .await?;
13048        self.state.intents_sent += 1;
13049        if slot_index == 1 {
13050            self.state.combat_target = Some(target_id);
13051            self.state.combat_target_label = Some(label.to_string());
13052        }
13053        self.state
13054            .push_log(format!("Slot {slot_index} target: {label}"));
13055        Ok(())
13056    }
13057
13058    pub async fn set_combat_target(
13059        &mut self,
13060        target_id: EntityId,
13061        label: &str,
13062    ) -> anyhow::Result<()> {
13063        self.set_combat_target_slot(1, target_id, label).await
13064    }
13065
13066    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13067        if slot_index == 1 && self.state.combat_target.is_none() {
13068            return Ok(());
13069        }
13070        self.seq += 1;
13071        self.session
13072            .submit_intent(Intent::ClearTargetSlot {
13073                entity_id: self.state.entity_id,
13074                slot_index,
13075                seq: self.seq,
13076            })
13077            .await?;
13078        if slot_index == 1 {
13079            self.state.combat_target = None;
13080            self.state.combat_target_label = None;
13081        }
13082        self.state.intents_sent += 1;
13083        self.state
13084            .push_log(format!("Slot {slot_index} target cleared"));
13085        Ok(())
13086    }
13087
13088    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
13089        self.clear_combat_target_slot(1).await
13090    }
13091
13092    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
13093        if !self.state.is_alive() {
13094            anyhow::bail!("you are dead");
13095        }
13096        self.seq += 1;
13097        self.session
13098            .submit_intent(Intent::AdvanceRotation {
13099                entity_id: self.state.entity_id,
13100                slot_index,
13101                seq: self.seq,
13102            })
13103            .await?;
13104        self.state.intents_sent += 1;
13105        Ok(())
13106    }
13107
13108    pub async fn assign_slot_preset(
13109        &mut self,
13110        slot_index: u8,
13111        preset_id: &str,
13112    ) -> anyhow::Result<()> {
13113        if !self.state.is_alive() {
13114            anyhow::bail!("you are dead");
13115        }
13116        self.seq += 1;
13117        self.session
13118            .submit_intent(Intent::AssignSlotPreset {
13119                entity_id: self.state.entity_id,
13120                slot_index,
13121                preset_id: preset_id.to_string(),
13122                seq: self.seq,
13123            })
13124            .await?;
13125        self.state.intents_sent += 1;
13126        if let Some(slot) = self
13127            .state
13128            .combat_slots
13129            .iter_mut()
13130            .find(|s| s.slot_index == slot_index)
13131        {
13132            slot.preset_id = Some(preset_id.to_string());
13133            if let Some(preset) = self
13134                .state
13135                .rotation_presets
13136                .iter()
13137                .find(|p| p.id == preset_id)
13138            {
13139                slot.preset_label = Some(preset.label.clone());
13140                slot.rotation = preset.abilities.clone();
13141                slot.rotation_index = 0;
13142            }
13143        }
13144        self.state
13145            .push_log(format!("T{slot_index} loadout → {preset_id}"));
13146        Ok(())
13147    }
13148
13149    pub async fn cast_ability(
13150        &mut self,
13151        ability_id: &str,
13152        target_id: Option<EntityId>,
13153    ) -> anyhow::Result<()> {
13154        if !self.state.is_alive() {
13155            anyhow::bail!("you are dead");
13156        }
13157        let allows_ground = self.state.ability_allows_ground(ability_id);
13158        let requires_ground = self.state.ability_requires_ground(ability_id);
13159        if requires_ground && self.state.ground_target.is_none() {
13160            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
13161        }
13162        let (resolved_target_id, target_point) = if allows_ground {
13163            if let Some((x, y, z)) = self.state.ground_target {
13164                (
13165                    target_id.unwrap_or(self.state.entity_id),
13166                    Some(flatland_protocol::AimPoint { x, y, z }),
13167                )
13168            } else {
13169                (
13170                    target_id
13171                        .or_else(|| self.state.target_for_slot(2))
13172                        .or_else(|| self.state.target_for_slot(1))
13173                        .unwrap_or(self.state.entity_id),
13174                    None,
13175                )
13176            }
13177        } else {
13178            (
13179                target_id
13180                    .or_else(|| self.state.target_for_slot(2))
13181                    .or_else(|| self.state.target_for_slot(1))
13182                    .unwrap_or(self.state.entity_id),
13183                None,
13184            )
13185        };
13186        self.seq += 1;
13187        self.session
13188            .submit_intent(Intent::Cast {
13189                entity_id: self.state.entity_id,
13190                ability_id: ability_id.to_string(),
13191                target_id: resolved_target_id,
13192                target_point,
13193                seq: self.seq,
13194            })
13195            .await?;
13196        self.state.intents_sent += 1;
13197        match target_point {
13198            Some(point) => self.state.push_log(format!(
13199                "Cast {ability_id} → ({:.1}, {:.1})",
13200                point.x, point.y
13201            )),
13202            None => self
13203                .state
13204                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
13205        }
13206        Ok(())
13207    }
13208
13209    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13210        self.seq += 1;
13211        self.session
13212            .submit_intent(Intent::UpsertRotationPreset {
13213                entity_id: self.state.entity_id,
13214                preset: preset.clone(),
13215                seq: self.seq,
13216            })
13217            .await?;
13218        self.state.intents_sent += 1;
13219        if let Some(existing) = self
13220            .state
13221            .rotation_presets
13222            .iter_mut()
13223            .find(|p| p.id == preset.id)
13224        {
13225            *existing = preset.clone();
13226        } else {
13227            self.state.rotation_presets.push(preset.clone());
13228        }
13229        for slot in &mut self.state.combat_slots {
13230            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
13231                slot.preset_label = Some(preset.label.clone());
13232                slot.rotation = preset.abilities.clone();
13233            }
13234        }
13235        self.state
13236            .push_log(format!("Saved rotation: {}", preset.label));
13237        Ok(())
13238    }
13239
13240    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
13241        self.seq += 1;
13242        self.session
13243            .submit_intent(Intent::DeleteRotationPreset {
13244                entity_id: self.state.entity_id,
13245                preset_id: preset_id.to_string(),
13246                seq: self.seq,
13247            })
13248            .await?;
13249        self.state.intents_sent += 1;
13250        self.state.rotation_presets.retain(|p| p.id != preset_id);
13251        for slot in &mut self.state.combat_slots {
13252            if slot.preset_id.as_deref() == Some(preset_id) {
13253                slot.preset_id = None;
13254                slot.preset_label = None;
13255                slot.rotation.clear();
13256                slot.rotation_index = 0;
13257            }
13258        }
13259        self.state
13260            .push_log(format!("Deleted rotation: {preset_id}"));
13261        Ok(())
13262    }
13263
13264    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13265        if !self.state.is_alive() {
13266            anyhow::bail!("you are dead");
13267        }
13268        let enabled = !self
13269            .state
13270            .combat_slots
13271            .iter()
13272            .find(|s| s.slot_index == slot_index)
13273            .map(|s| s.auto_enabled)
13274            .unwrap_or(false);
13275        self.seq += 1;
13276        self.session
13277            .submit_intent(Intent::SetAutoAttack {
13278                entity_id: self.state.entity_id,
13279                slot_index,
13280                enabled,
13281                seq: self.seq,
13282            })
13283            .await?;
13284        if slot_index == 1 {
13285            self.state.auto_attack = enabled;
13286        }
13287        self.state.intents_sent += 1;
13288        self.state.push_log(format!(
13289            "T{slot_index} auto {}",
13290            if enabled { "ON" } else { "OFF" }
13291        ));
13292        Ok(())
13293    }
13294
13295    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
13296        if !self.state.connected {
13297            anyhow::bail!("not connected");
13298        }
13299        if !self.state.is_alive() {
13300            anyhow::bail!("you are dead");
13301        }
13302        let (px, py) = self.state.player_position();
13303        if self
13304            .state
13305            .ground_drops
13306            .iter()
13307            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
13308        {
13309            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
13310        }
13311        self.seq += 1;
13312        self.session
13313            .submit_intent(Intent::Pickup {
13314                entity_id: self.state.entity_id,
13315                drop_id: None,
13316                seq: self.seq,
13317            })
13318            .await?;
13319        self.state.intents_sent += 1;
13320        Ok(())
13321    }
13322
13323    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
13324        self.toggle_auto_attack_slot(1).await
13325    }
13326
13327    pub async fn dodge(&mut self) -> anyhow::Result<()> {
13328        if !self.state.is_alive() {
13329            anyhow::bail!("you are dead");
13330        }
13331        self.seq += 1;
13332        self.session
13333            .submit_intent(Intent::Dodge {
13334                entity_id: self.state.entity_id,
13335                seq: self.seq,
13336            })
13337            .await?;
13338        self.state.intents_sent += 1;
13339        self.state.push_log("Dodge!");
13340        Ok(())
13341    }
13342
13343    pub async fn lunge(&mut self) -> anyhow::Result<()> {
13344        if !self.state.is_alive() {
13345            anyhow::bail!("you are dead");
13346        }
13347        let (forward, strafe) = self.last_move_axes();
13348        self.seq += 1;
13349        self.session
13350            .submit_intent(Intent::Lunge {
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("Lunge!");
13359        Ok(())
13360    }
13361
13362    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
13363        if !self.state.is_alive() {
13364            anyhow::bail!("you are dead");
13365        }
13366        self.seq += 1;
13367        self.session
13368            .submit_intent(Intent::DirectionalJump {
13369                entity_id: self.state.entity_id,
13370                forward,
13371                strafe,
13372                seq: self.seq,
13373            })
13374            .await?;
13375        self.state.intents_sent += 1;
13376        self.state.push_log("Jump!");
13377        Ok(())
13378    }
13379
13380    /// Remembered WASD axes for lunge when not currently moving.
13381    pub fn last_move_axes(&self) -> (f32, f32) {
13382        (self.last_move_forward, self.last_move_strafe)
13383    }
13384
13385    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
13386        if !self.state.is_alive() {
13387            anyhow::bail!("you are dead");
13388        }
13389        self.seq += 1;
13390        self.session
13391            .submit_intent(Intent::Block {
13392                entity_id: self.state.entity_id,
13393                enabled,
13394                seq: self.seq,
13395            })
13396            .await?;
13397        self.state.intents_sent += 1;
13398        if enabled {
13399            self.state.push_log("Blocking");
13400        }
13401        Ok(())
13402    }
13403
13404    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
13405        if !self.state.is_alive() {
13406            anyhow::bail!("you are dead");
13407        }
13408        self.seq += 1;
13409        self.session
13410            .submit_intent(Intent::EquipMainhand {
13411                entity_id: self.state.entity_id,
13412                template_id,
13413                instance_id: None,
13414                seq: self.seq,
13415            })
13416            .await?;
13417        self.state.intents_sent += 1;
13418        Ok(())
13419    }
13420
13421    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
13422    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
13423        let idx = self.state.equip_menu_index;
13424        let slots = equip_paperdoll_rows(&self.state);
13425        let Some(row) = slots.get(idx) else {
13426            return Ok(());
13427        };
13428        match row {
13429            EquipPaperdollRow::Body { slot, filled } => {
13430                if *filled {
13431                    self.equip_worn(*slot, None).await
13432                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
13433                    self.equip_worn(*slot, Some(inst)).await
13434                } else {
13435                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
13436                    Ok(())
13437                }
13438            }
13439            EquipPaperdollRow::Mainhand { filled } => {
13440                if *filled {
13441                    self.unequip_mainhand().await
13442                } else if let Some(tid) = first_inventory_weapon(&self.state) {
13443                    self.equip_mainhand(Some(tid)).await
13444                } else {
13445                    self.state.push_log("No weapon in inventory".to_string());
13446                    Ok(())
13447                }
13448            }
13449            EquipPaperdollRow::Offhand { filled, locked } => {
13450                if *locked {
13451                    self.state
13452                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
13453                    Ok(())
13454                } else if *filled {
13455                    self.unequip_offhand().await
13456                } else if let Some(tid) = first_inventory_offhand(&self.state) {
13457                    self.equip_offhand(Some(tid)).await
13458                } else {
13459                    self.state
13460                        .push_log("No offhand item in inventory".to_string());
13461                    Ok(())
13462                }
13463            }
13464        }
13465    }
13466
13467    pub async fn say(
13468        &mut self,
13469        channel: flatland_protocol::ChatChannel,
13470        text: &str,
13471    ) -> anyhow::Result<()> {
13472        self.say_to(channel, text, None).await
13473    }
13474
13475    pub async fn say_to(
13476        &mut self,
13477        channel: flatland_protocol::ChatChannel,
13478        text: &str,
13479        to_entity: Option<EntityId>,
13480    ) -> anyhow::Result<()> {
13481        self.seq += 1;
13482        self.session
13483            .submit_intent(Intent::Say {
13484                entity_id: self.state.entity_id,
13485                channel,
13486                text: text.to_string(),
13487                to_entity,
13488                seq: self.seq,
13489            })
13490            .await?;
13491        self.state.intents_sent += 1;
13492        Ok(())
13493    }
13494
13495    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
13496        let Some(peer) = self.state.player_verbs.target_entity else {
13497            return Ok(());
13498        };
13499        let label = self.state.player_verbs.target_label.clone();
13500        let choice = crate::social::PlayerVerbState::options()
13501            .get(self.state.player_verbs.index)
13502            .copied()
13503            .unwrap_or("Whisper");
13504        self.state.player_verbs.close();
13505        match choice {
13506            "Trade" => {
13507                // Only request — peer must accept (Y in chat). Do not auto-respond;
13508                // TradeRespond requires a pending inbound request from the peer.
13509                self.seq += 1;
13510                self.session
13511                    .submit_intent(Intent::TradeRequest {
13512                        entity_id: self.state.entity_id,
13513                        peer_entity_id: peer,
13514                        seq: self.seq,
13515                    })
13516                    .await?;
13517                self.state.intents_sent += 1;
13518                self.state
13519                    .social_chat
13520                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
13521            }
13522            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
13523            _ => self.state.social_chat.focus_nearby(),
13524        }
13525        Ok(())
13526    }
13527
13528    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
13529        let Some(pending) = self.state.social_chat.pending_trade.take() else {
13530            return Ok(());
13531        };
13532        self.seq += 1;
13533        self.session
13534            .submit_intent(Intent::TradeRespond {
13535                entity_id: self.state.entity_id,
13536                peer_entity_id: pending.from_entity,
13537                accept,
13538                seq: self.seq,
13539            })
13540            .await?;
13541        self.state.intents_sent += 1;
13542        if accept {
13543            self.state
13544                .social_chat
13545                .push_system(format!("Accepted trade with {}", pending.from_name));
13546        } else {
13547            self.state
13548                .social_chat
13549                .push_system(format!("Declined trade with {}", pending.from_name));
13550        }
13551        Ok(())
13552    }
13553
13554    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
13555        let text = self.state.social_chat.buffer.trim().to_string();
13556        if text.is_empty() {
13557            return Ok(());
13558        }
13559        self.state.social_chat.buffer.clear();
13560        if crate::social::is_chat_slash_line(&text) {
13561            match crate::social::parse_chat_slash(&text) {
13562                Some(cmd) => return self.apply_chat_slash(cmd).await,
13563                None => {
13564                    self.state.social_chat.push_system(format!(
13565                        "Unknown command — {}",
13566                        crate::social::chat_slash_help_text()
13567                    ));
13568                    return Ok(());
13569                }
13570            }
13571        }
13572        let thread = self.state.social_chat.thread;
13573        let channel = thread.channel();
13574        let to = thread.to_entity();
13575        if let Some(peer) = to {
13576            let label = self.state.social_chat.peer_label.clone();
13577            self.state
13578                .social_chat
13579                .remember_whisper_peer(peer, &label, channel);
13580        }
13581        self.say_to(channel, &text, to).await
13582    }
13583
13584    async fn apply_chat_slash(
13585        &mut self,
13586        cmd: crate::social::ChatSlashCommand,
13587    ) -> anyhow::Result<()> {
13588        use crate::social::{chat_slash_help_text, ChatSlashCommand};
13589        match cmd {
13590            ChatSlashCommand::Help => {
13591                self.state
13592                    .social_chat
13593                    .push_system(chat_slash_help_text().to_string());
13594                Ok(())
13595            }
13596            ChatSlashCommand::Nearby { message } => {
13597                self.state.social_chat.focus_nearby();
13598                self.state
13599                    .social_chat
13600                    .push_system("Nearby speech — everyone close can hear");
13601                if let Some(msg) = message {
13602                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
13603                        .await
13604                } else {
13605                    Ok(())
13606                }
13607            }
13608            ChatSlashCommand::Reply { message } => {
13609                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13610                    self.state.social_chat.push_system(
13611                        "No one to reply to — wait for a whisper, or /whisper Name",
13612                    );
13613                    return Ok(());
13614                };
13615                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
13616                self.state
13617                    .social_chat
13618                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
13619                self.state.social_chat.push_system(format!(
13620                    "Replying to {} — type and Enter · /nearby",
13621                    peer.label
13622                ));
13623                if let Some(msg) = message {
13624                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
13625                } else {
13626                    Ok(())
13627                }
13628            }
13629            ChatSlashCommand::Whisper { name, message } => {
13630                let (peer_id, label, stone) = if let Some(name) = name {
13631                    match self.resolve_whisper_target(&name) {
13632                        Ok(t) => t,
13633                        Err(err) => {
13634                            self.state.social_chat.push_system(err);
13635                            return Ok(());
13636                        }
13637                    }
13638                } else {
13639                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13640                        self.state.social_chat.push_system(
13641                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
13642                        );
13643                        return Ok(());
13644                    };
13645                    (
13646                        peer.entity_id,
13647                        peer.label,
13648                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
13649                    )
13650                };
13651                self.state
13652                    .social_chat
13653                    .set_whisper_thread(peer_id, &label, stone);
13654                let channel = if stone {
13655                    flatland_protocol::ChatChannel::WhisperStone
13656                } else {
13657                    flatland_protocol::ChatChannel::Whisper
13658                };
13659                if let Some(msg) = message {
13660                    self.state.social_chat.push_system(format!(
13661                        "Whisper → {label}"
13662                    ));
13663                    self.say_to(channel, &msg, Some(peer_id)).await
13664                } else {
13665                    self.state.social_chat.push_system(format!(
13666                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
13667                    ));
13668                    Ok(())
13669                }
13670            }
13671        }
13672    }
13673
13674    /// Resolve `/whisper Name` against AOI players (label match).
13675    fn resolve_whisper_target(
13676        &self,
13677        name: &str,
13678    ) -> Result<(EntityId, String, bool), String> {
13679        let needle = name.trim().to_ascii_lowercase();
13680        if needle.is_empty() {
13681            return Err("Usage: /whisper Name [message]".into());
13682        }
13683        let mut candidates: Vec<(EntityId, String)> = self
13684            .state
13685            .entities
13686            .iter()
13687            .filter(|e| e.id != self.state.entity_id)
13688            .filter(|e| !e.label.trim().is_empty())
13689            .filter(|e| e.vitals.is_some())
13690            .filter(|e| {
13691                !self
13692                    .state
13693                    .npcs
13694                    .iter()
13695                    .any(|n| n.id == e.id.to_string())
13696            })
13697            .filter(|e| {
13698                !self
13699                    .state
13700                    .hired_workers
13701                    .iter()
13702                    .any(|w| w.entity_id == e.id)
13703            })
13704            .map(|e| (e.id, e.label.clone()))
13705            .collect();
13706
13707        // Also allow matching the last whisper peer by name even if they left AOI briefly.
13708        if let Some(last) = &self.state.social_chat.last_whisper_peer {
13709            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
13710                candidates.push((last.entity_id, last.label.clone()));
13711            }
13712        }
13713
13714        let exact: Vec<_> = candidates
13715            .iter()
13716            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
13717            .cloned()
13718            .collect();
13719        let pool = if exact.len() == 1 {
13720            exact
13721        } else if exact.len() > 1 {
13722            return Err(format!(
13723                "Several players named '{name}' nearby — move closer and try again"
13724            ));
13725        } else {
13726            let starts: Vec<_> = candidates
13727                .iter()
13728                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
13729                .cloned()
13730                .collect();
13731            if starts.len() == 1 {
13732                starts
13733            } else if starts.len() > 1 {
13734                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
13735                return Err(format!(
13736                    "Ambiguous name '{name}' — matches: {}",
13737                    names.join(", ")
13738                ));
13739            } else {
13740                let contains: Vec<_> = candidates
13741                    .iter()
13742                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
13743                    .cloned()
13744                    .collect();
13745                if contains.len() == 1 {
13746                    contains
13747                } else if contains.is_empty() {
13748                    return Err(format!(
13749                        "No player matching '{name}' in range — get closer or check the spelling"
13750                    ));
13751                } else {
13752                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
13753                    return Err(format!(
13754                        "Ambiguous name '{name}' — matches: {}",
13755                        names.join(", ")
13756                    ));
13757                }
13758            }
13759        };
13760
13761        let (id, label) = pool.into_iter().next().unwrap();
13762        let stone = self
13763            .state
13764            .social_chat
13765            .last_whisper_peer
13766            .as_ref()
13767            .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
13768        Ok((id, label, stone))
13769    }
13770
13771    pub async fn trade_present_selected(
13772        &mut self,
13773        item_instance_id: uuid::Uuid,
13774    ) -> anyhow::Result<()> {
13775        self.trade_present_quantity(item_instance_id, None).await
13776    }
13777
13778    pub async fn trade_present_quantity(
13779        &mut self,
13780        item_instance_id: uuid::Uuid,
13781        quantity: Option<u32>,
13782    ) -> anyhow::Result<()> {
13783        self.seq += 1;
13784        self.session
13785            .submit_intent(Intent::TradePresent {
13786                entity_id: self.state.entity_id,
13787                item_instance_id,
13788                quantity,
13789                seq: self.seq,
13790            })
13791            .await?;
13792        self.state.intents_sent += 1;
13793        self.state.trade_ui.qty_entry = None;
13794        self.state.trade_ui.picking_inventory = false;
13795        Ok(())
13796    }
13797
13798    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
13799    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
13800        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
13801            let qty = self.state.trade_ui.present_quantity();
13802            return self
13803                .trade_present_quantity(entry.item_instance_id, qty)
13804                .await;
13805        }
13806        if !self.state.trade_ui.picking_inventory {
13807            return Ok(());
13808        }
13809        let Some(stack) = self
13810            .state
13811            .inventory_stacks
13812            .get(self.state.trade_ui.inventory_index)
13813            .cloned()
13814        else {
13815            return Ok(());
13816        };
13817        let Some(id) = stack.item_instance_id else {
13818            return Ok(());
13819        };
13820        let label = stack
13821            .display_name
13822            .clone()
13823            .unwrap_or_else(|| stack.template_id.clone());
13824        if stack.quantity <= 1 {
13825            self.trade_present_quantity(id, Some(1)).await
13826        } else {
13827            self.state
13828                .trade_ui
13829                .begin_qty_entry(id, label, stack.quantity);
13830            Ok(())
13831        }
13832    }
13833
13834    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
13835        self.seq += 1;
13836        self.session
13837            .submit_intent(Intent::TradeSetReady {
13838                entity_id: self.state.entity_id,
13839                ready,
13840                seq: self.seq,
13841            })
13842            .await?;
13843        self.state.intents_sent += 1;
13844        Ok(())
13845    }
13846
13847    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
13848        self.seq += 1;
13849        self.session
13850            .submit_intent(Intent::TradeCancel {
13851                entity_id: self.state.entity_id,
13852                seq: self.seq,
13853            })
13854            .await?;
13855        self.state.intents_sent += 1;
13856        self.state.trade_ui.close();
13857        Ok(())
13858    }
13859
13860    pub async fn destroy_whisper_stone(
13861        &mut self,
13862        item_instance_id: uuid::Uuid,
13863    ) -> anyhow::Result<()> {
13864        self.seq += 1;
13865        self.session
13866            .submit_intent(Intent::DestroyWhisperStone {
13867                entity_id: self.state.entity_id,
13868                item_instance_id,
13869                seq: self.seq,
13870            })
13871            .await?;
13872        self.state.intents_sent += 1;
13873        Ok(())
13874    }
13875
13876    pub async fn stop(&mut self) -> anyhow::Result<()> {
13877        self.seq += 1;
13878        self.session
13879            .submit_intent(Intent::Stop {
13880                entity_id: self.state.entity_id,
13881                seq: self.seq,
13882            })
13883            .await?;
13884        self.state.intents_sent += 1;
13885        Ok(())
13886    }
13887
13888    pub fn disconnect(&self) {
13889        self.session.disconnect();
13890    }
13891}
13892
13893fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
13894    let dx = ax - bx;
13895    let dy = ay - by;
13896    (dx * dx + dy * dy).sqrt()
13897}
13898
13899#[cfg(test)]
13900mod tests {
13901    use std::collections::BTreeMap;
13902
13903    use super::*;
13904    use flatland_protocol::{
13905        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
13906    };
13907
13908    fn sample_state() -> GameState {
13909        let mut state = GameState {
13910            session_id: 1,
13911            entity_id: 1,
13912            character_id: None,
13913            tick: 0,
13914            chunk_rev: 0,
13915            content_rev: 0,
13916            publish_rev: 0,
13917            entities: vec![EntityState {
13918                id: 1,
13919                label: "You".into(),
13920                transform: Transform {
13921                    position: WorldCoord::surface(128.0, 128.0),
13922                    yaw: 0.0,
13923                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
13924                },
13925                vitals: None,
13926                attributes: None,
13927                skills: None,
13928                inside_building: None,
13929                tile_id: None,
13930                paperdoll_ref: None,
13931                presentation_state: None,
13932                sprite_mode: None,
13933                progression_xp: None,
13934                combat_cues: vec![],
13935            }],
13936            player: None,
13937            resource_nodes: vec![ResourceNodeView {
13938                id: "oak-1".into(),
13939                label: "Oak".into(),
13940                x: 130.0,
13941                y: 128.0,
13942                z: 0.0,
13943                item_template: "oak_log".into(),
13944                state: ResourceNodeState::Available,
13945                blocking: true,
13946                blocking_radius_m: 0.8,
13947                tile_id: None,
13948                                yaw: 0.0,
13949                pitch: 0.0,
13950                roll: 0.0,
13951                draw_scale: 1.0,
13952                sprite_mode: None,
13953                growth_progress: None,
13954                presentation_state: None,
13955                channel_start_tick: None,
13956                channel_end_tick: None,
13957                harvest_drop_templates: vec![],
13958            }],
13959            ground_drops: vec![],
13960            placed_containers: vec![],
13961            buildings: vec![BuildingView {
13962                id: "broker-hut".into(),
13963                label: "Broker".into(),
13964                x: 148.0,
13965                y: 118.0,
13966                width_m: 8.0,
13967                depth_m: 6.0,
13968                interior_blueprint: Some("broker_hut".into()),
13969                tags: vec![],
13970                market_boundary_zone_ids: vec![],
13971                market_max_volume: None,
13972                wall_set: None,
13973                roof_set: None,
13974            }],
13975            doors: vec![flatland_protocol::DoorView {
13976                id: "door-1".into(),
13977                building_id: "broker-hut".into(),
13978                x: 148.0,
13979                y: 118.0,
13980                open: false,
13981                portal: Some("front".into()),
13982            }],
13983            interior_map: None,
13984            npcs: vec![],
13985            blueprints: vec![],
13986            world_x0: 0.0,
13987            world_y0: 0.0,
13988            world_width_m: 256.0,
13989            world_height_m: 256.0,
13990            terrain_zones: Vec::new(),
13991            z_platforms: Vec::new(),
13992            z_transitions: Vec::new(),
13993            z_bands_outdoor_backup: None,
13994            world_clock: flatland_protocol::WorldClock::default(),
13995            inventory: std::collections::HashMap::new(),
13996            inventory_hints: std::collections::HashMap::new(),
13997            logs: VecDeque::new(),
13998            intents_sent: 0,
13999            ticks_received: 0,
14000            connected: true,
14001            disconnect_reason: None,
14002            show_stats: false,
14003            hud_log_hidden: false,
14004            show_equip_menu: false,
14005            equip_menu_index: 0,
14006            show_craft_menu: false,
14007            craft_menu_index: 0,
14008            craft_batch_quantity: 1,
14009            show_shop_menu: false,
14010            shop_catalog: None,
14011            bank_panel: None,
14012            bank_menu_index: 0,
14013            bank_ui_mode: BankUiMode::Menu,
14014            storage_panel: None,
14015                market_panel: None,
14016            market_menu_index: 0,
14017            market_filter: String::new(),
14018            market_filter_focused: false,
14019            market_category_filter: None,
14020            market_buy_confirm: None,
14021            market_ui_mode: MarketUiMode::Browse,
14022            storage_menu_index: 0,
14023            storage_ui_mode: StorageUiMode::Menu,
14024            shop_tab: ShopTab::default(),
14025            shop_menu_index: 0,
14026            shop_quantity: 1,
14027            shop_trade_log: VecDeque::new(),
14028            show_npc_verb_menu: false,
14029            npc_verb_target: None,
14030            npc_verb_index: 0,
14031            player_verbs: crate::social::PlayerVerbState::default(),
14032            social_chat: crate::social::SocialChatState::default(),
14033            trade_ui: crate::social::TradeUiState::default(),
14034            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14035            show_npc_chat: false,
14036            npc_chat: None,
14037            show_inventory_menu: false,
14038            inventory_menu_index: 0,
14039            inventory_tab: InventoryTab::OnPerson,
14040            inventory_filter: String::new(),
14041            inventory_filter_focused: false,
14042            show_move_picker: false,
14043            show_rename_prompt: false,
14044            show_worker_rename: false,
14045            rename_buffer: String::new(),
14046            move_picker_index: 0,
14047            move_picker: None,
14048            show_grant_picker: false,
14049            grant_picker_index: 0,
14050            grant_picker: None,
14051            show_destroy_picker: false,
14052            destroy_confirm_pending: false,
14053            destroy_picker: None,
14054            combat_target: None,
14055            combat_target_label: None,
14056            ground_target: None,
14057            combat_fx: Vec::new(),
14058            property_zones: Vec::new(),
14059            tax_zones: Vec::new(),
14060            growth_zones: Vec::new(),
14061            biome_zones: Vec::new(),
14062            property_plots: Vec::new(),
14063            property_plot_settings: None,
14064            claim_mode: None,
14065            relocate_mode: None,
14066            sell_plot_confirm: None,
14067            sell_plot_armed_at: None,
14068            show_plant_menu: false,
14069            plant_menu_index: 0,
14070            show_farm_access: false,
14071            farm_access_name_draft: String::new(),
14072            farm_access_discount_bps: 0,
14073            farm_access_index: 0,
14074            plant_quantity: 1,
14075            in_combat: false,
14076            auto_attack: true,
14077            combat_has_los: false,
14078            attack_cd_ticks: 0,
14079            gcd_ticks: 0,
14080            weapon_ability_id: "unarmed".into(),
14081            mainhand_template_id: None,
14082            mainhand_label: None,
14083            offhand_template_id: None,
14084            offhand_label: None,
14085            mainhand_hand_slots: 1,
14086            defense: None,
14087            worn: BTreeMap::new(),
14088            carry_mass: 0.0,
14089            carry_mass_max: 0.0,
14090            encumbrance: flatland_protocol::EncumbranceState::Light,
14091            inventory_stacks: Vec::new(),
14092            keychain_stacks: Vec::new(),
14093            whisper_pouch_stacks: Vec::new(),
14094            combat_target_detail: None,
14095            statuses: Vec::new(),
14096            cast_progress: None,
14097            timed_channel: None,
14098            ability_cooldowns: Vec::new(),
14099            blocking_active: false,
14100            max_target_slots: 1,
14101            combat_slots: Vec::new(),
14102            rotation_presets: Vec::new(),
14103            known_abilities: Vec::new(),
14104            ability_meta: std::collections::HashMap::new(),
14105            ability_mastery: std::collections::HashMap::new(),
14106            hotbar: vec![None; 9],
14107            max_abilities_per_rotation: 0,
14108            show_loadout_menu: false,
14109            show_keychain_menu: false,
14110            keychain_menu_index: 0,
14111            show_rotation_editor: false,
14112            loadout_menu_index: 0,
14113            loadout_hotbar_slot: 1,
14114            loadout_ability_index: 0,
14115            loadout_focus_presets: false,
14116            rotation_editor: RotationEditorState::default(),
14117            harvest_in_progress: false,
14118            harvest_started_at: None,
14119            pending_craft_ack: None,
14120            pending_worker_job_ack: None,
14121            attending_worker_instance_id: None,
14122            quest_log: Vec::new(),
14123            interactables: Vec::new(),
14124            ledger: None,
14125            career: None,
14126            character_sheet_tab: CharacterSheetTab::Character,
14127            ledger_period: LedgerPeriod::Day,
14128            show_quest_offer: false,
14129            pending_quest_offer: None,
14130            show_quest_menu: false,
14131            quest_menu_index: 0,
14132            quest_withdraw_confirm: false,
14133            hired_workers: Vec::new(),
14134            show_workers_menu: false,
14135            workers_menu_index: 0,
14136            workers_menu_compact: false,
14137            worker_step_display: BTreeMap::new(),
14138            worker_error_display: BTreeMap::new(),
14139            show_worker_give_picker: false,
14140            worker_give_picker_index: 0,
14141            worker_give_picker: None,
14142            show_worker_give_target_picker: false,
14143            worker_give_target_picker_index: 0,
14144            worker_give_target_picker: None,
14145            show_worker_take_picker: false,
14146            worker_take_picker_index: 0,
14147            worker_take_picker: None,
14148            show_worker_teach_picker: false,
14149            worker_teach_picker_index: 0,
14150            worker_teach_picker: None,
14151            worker_route_editor: None,
14152            progression_curve: None,
14153        };
14154        state.player = state.entities.first().cloned();
14155        state
14156    }
14157
14158    #[test]
14159    fn whisper_cancels_when_peer_walks_out_of_range() {
14160        let mut state = sample_state();
14161        state.player = state.entities.first().cloned();
14162        let mut peer = state.entities[0].clone();
14163        peer.id = 2;
14164        peer.label = "Ada".into();
14165        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
14166        state.entities.push(peer.clone());
14167        state.social_chat.focus_whisper(2, "Ada");
14168        state.refresh_whisper_range();
14169        assert!(matches!(
14170            state.social_chat.thread,
14171            crate::social::ChatThreadKind::Whisper { peer: 2 }
14172        ));
14173
14174        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
14175        state.entities[1] = peer;
14176        state.refresh_whisper_range();
14177        assert_eq!(
14178            state.social_chat.thread,
14179            crate::social::ChatThreadKind::Nearby
14180        );
14181        assert!(!state.social_chat.input_focused);
14182    }
14183
14184    #[test]
14185    fn probe_use_world_hired_worker_manage() {
14186        let mut state = sample_state();
14187        state.hired_workers.push(flatland_protocol::HiredWorkerView {
14188            instance_id: "worker-1".into(),
14189            entity_id: 42,
14190            def_id: "worker_laborer".into(),
14191            label: "Sam".into(),
14192            x: 129.0,
14193            y: 128.0,
14194            z: 0.0,
14195            mode: flatland_protocol::WorkerModeView::JobLoop,
14196            state: flatland_protocol::WorkerStateView::Working,
14197            step_label: "cultivate".into(),
14198            vitals: flatland_protocol::WorkerVitalsSummary {
14199                health_pct: 100.0,
14200                stamina_pct: 100.0,
14201            },
14202            carry_pct: 0.0,
14203            last_error: None,
14204            wage_copper_per_interval: 1,
14205            effective_wage_copper: 1,
14206            wage_meters_walked: 0.0,
14207            lodging_container_id: None,
14208            route: None,
14209            route_stop_index: None,
14210            known_blueprint_ids: Vec::new(),
14211            level: 1,
14212            worker_xp: 0.0,
14213            inventory: Vec::new(),
14214        });
14215        let probe = state.probe_use_world();
14216        let primary = probe.primary.expect("primary");
14217        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
14218        assert_eq!(primary.id, "worker-1");
14219        assert!(primary.hint_line().contains("Manage"));
14220        assert!(primary.hint_line().contains("Sam"));
14221        assert_eq!(
14222            state.nearest_interact_target().as_deref(),
14223            Some("worker-1")
14224        );
14225    }
14226
14227    #[test]
14228    fn market_clerk_verb_options_include_market() {
14229        let mut state = sample_state();
14230        state.npcs.push(flatland_protocol::NpcView {
14231            id: "mira_market".into(),
14232            label: "Mira".into(),
14233            role: "market_clerk".into(),
14234            x: 129.0,
14235            y: 128.0,
14236            building_id: Some("town_market".into()),
14237            entity_id: None,
14238            life_state: None,
14239            hp_pct: None,
14240            can_trade: false,
14241            tile_id: None,
14242            behavior_state: None,
14243            presentation_state: None,
14244            sprite_mode: None,
14245            paperdoll_ref: None,
14246        });
14247        state.npc_verb_target = Some("mira_market".into());
14248        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
14249    }
14250
14251    #[test]
14252    fn market_list_excludes_currency_stacks() {
14253        let mut state = sample_state();
14254        state.inventory_stacks = vec![
14255            flatland_protocol::ItemStack {
14256                template_id: "copper_coin".into(),
14257                quantity: 50,
14258                item_instance_id: Some(uuid::Uuid::from_u128(10)),
14259                display_name: Some("Copper Coin".into()),
14260                ..Default::default()
14261            },
14262            flatland_protocol::ItemStack {
14263                template_id: "oak_log".into(),
14264                quantity: 2,
14265                item_instance_id: Some(uuid::Uuid::from_u128(11)),
14266                display_name: Some("Oak Log".into()),
14267                ..Default::default()
14268            },
14269            flatland_protocol::ItemStack {
14270                template_id: "whisper_stone".into(),
14271                quantity: 1,
14272                item_instance_id: Some(uuid::Uuid::from_u128(12)),
14273                display_name: Some("Whisper Stone".into()),
14274                category: Some("quest".into()),
14275                listable: Some(false),
14276                ..Default::default()
14277            },
14278        ];
14279        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
14280        assert_eq!(opts.len(), 1);
14281        assert!(opts[0].label.contains("Oak"));
14282    }
14283
14284    #[test]
14285    fn market_browse_filters_by_category_and_search() {
14286        let mut state = sample_state();
14287        state.market_panel = Some(flatland_protocol::MarketPanel {
14288            npc_id: "mira_market".into(),
14289            npc_label: "Mira".into(),
14290            building_id: "town_market".into(),
14291            building_label: "Town Market".into(),
14292            used_volume: 0.0,
14293            max_volume: 100.0,
14294            listings: vec![
14295                flatland_protocol::MarketListingView {
14296                    listing_id: uuid::Uuid::from_u128(1),
14297                    seller_character_id: uuid::Uuid::from_u128(2),
14298                    seller_label: "Ada".into(),
14299                    hall_building_id: "town_market".into(),
14300                    hall_label: "Town Market".into(),
14301                    template_id: "oak_log".into(),
14302                    display_name: "Oak Log".into(),
14303                    category: "resource".into(),
14304                    quantity: 3,
14305                    unit_price_copper: 10,
14306                    line_total_copper: 30,
14307                    mine: false,
14308                },
14309                flatland_protocol::MarketListingView {
14310                    listing_id: uuid::Uuid::from_u128(3),
14311                    seller_character_id: uuid::Uuid::from_u128(2),
14312                    seller_label: "Ada".into(),
14313                    hall_building_id: "town_market".into(),
14314                    hall_label: "Town Market".into(),
14315                    template_id: "short_sword".into(),
14316                    display_name: "Short Sword".into(),
14317                    category: "weapon".into(),
14318                    quantity: 1,
14319                    unit_price_copper: 100,
14320                    line_total_copper: 100,
14321                    mine: false,
14322                },
14323            ],
14324            tax_bps: 0,
14325            tax_flat_copper: 0,
14326            list_vaults: vec![],
14327        });
14328        assert_eq!(state.market_filtered_listing_indices().len(), 2);
14329        state.market_category_filter = Some("Weapons");
14330        let weapons = state.market_filtered_listing_indices();
14331        assert_eq!(weapons.len(), 1);
14332        assert_eq!(
14333            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
14334            "Short Sword"
14335        );
14336        state.market_category_filter = None;
14337        state.market_filter = "oak".into();
14338        let oak = state.market_filtered_listing_indices();
14339        assert_eq!(oak.len(), 1);
14340        assert_eq!(
14341            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
14342            "Oak Log"
14343        );
14344    }
14345
14346    #[test]
14347    fn market_list_source_includes_person_and_vaults() {
14348        let mut state = sample_state();
14349        let item_id = uuid::Uuid::from_u128(1);
14350        state.inventory_stacks = vec![flatland_protocol::ItemStack {
14351            template_id: "oak_log".into(),
14352            quantity: 2,
14353            item_instance_id: Some(item_id),
14354            display_name: Some("Oak Log".into()),
14355            ..Default::default()
14356        }];
14357        state.market_panel = Some(flatland_protocol::MarketPanel {
14358            npc_id: "mira_market".into(),
14359            npc_label: "Mira".into(),
14360            building_id: "town_market".into(),
14361            building_label: "Town Market".into(),
14362            used_volume: 0.0,
14363            max_volume: 100.0,
14364            listings: vec![],
14365            tax_bps: 0,
14366            tax_flat_copper: 0,
14367            list_vaults: vec![flatland_protocol::MarketListVault {
14368                building_id: "town_storage".into(),
14369                building_label: "Town Storage".into(),
14370                contents: vec![flatland_protocol::ItemStack {
14371                    template_id: "lumber".into(),
14372                    quantity: 1,
14373                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
14374                    display_name: Some("Lumber".into()),
14375                    ..Default::default()
14376                }],
14377            }],
14378        });
14379        let sources = state.market_list_source_options();
14380        assert_eq!(sources.len(), 2);
14381        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
14382        assert!(matches!(
14383            sources[1].0,
14384            MarketListSourceKind::TownStorage { .. }
14385        ));
14386        assert!(sources[1].1.contains("Town Storage"));
14387    }
14388
14389    #[test]
14390    fn probe_use_world_npc_beats_nearby_loot() {
14391        let mut state = sample_state();
14392        state.npcs.push(flatland_protocol::NpcView {
14393            id: "ada".into(),
14394            label: "Ada".into(),
14395            role: "broker".into(),
14396            x: 129.0,
14397            y: 128.0,
14398            building_id: None,
14399            entity_id: None,
14400            life_state: None,
14401            hp_pct: None,
14402            can_trade: true,
14403            tile_id: None,
14404            behavior_state: None,
14405            presentation_state: None,
14406            sprite_mode: None,
14407            paperdoll_ref: None,
14408        });
14409        state.ground_drops.push(flatland_protocol::GroundDropView {
14410            id: "d1".into(),
14411            template_id: "lumber".into(),
14412            quantity: 1,
14413            x: 128.5,
14414            y: 128.0,
14415            z: 0.0,
14416            tile_id: None,
14417            display_name: None,
14418            yaw: 0.0,
14419            pitch: 0.0,
14420            roll: 0.0,
14421            draw_scale: 1.0,
14422        });
14423        let probe = state.probe_use_world();
14424        let primary = probe.primary.expect("primary");
14425        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
14426        assert_eq!(primary.id, "ada");
14427    }
14428
14429    #[test]
14430    fn probe_use_world_harvest_when_in_range() {
14431        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
14432        let probe = state.probe_use_world();
14433        assert!(
14434            probe.primary.is_none(),
14435            "oak is 2m away, out of harvest range"
14436        );
14437        assert!(probe
14438            .candidates
14439            .iter()
14440            .any(|c| c.kind == crate::UseWorldKind::Harvest));
14441
14442        let mut state = sample_state();
14443        state.resource_nodes[0].x = 129.0;
14444        let probe = state.probe_use_world();
14445        let primary = probe.primary.expect("primary");
14446        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
14447    }
14448
14449    #[test]
14450    fn probe_use_world_door_uses_building_label() {
14451        let mut state = sample_state();
14452        state.doors[0].x = 129.0;
14453        state.doors[0].y = 128.0;
14454        let probe = state.probe_use_world();
14455        let primary = probe.primary.expect("primary");
14456        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
14457        assert_eq!(primary.label, "Broker");
14458        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
14459    }
14460
14461    #[test]
14462    fn empty_entity_tick_preserves_welcome_snapshot() {
14463        let mut state = sample_state();
14464        state.inventory.insert("carrot".into(), 3);
14465        let delta = TickDelta {
14466            tick: 1,
14467            entities: vec![],
14468            resource_nodes: vec![],
14469            ground_drops: vec![],
14470            placed_containers: vec![],
14471            buildings: vec![],
14472            doors: vec![],
14473            interior_map: None,
14474            npcs: vec![],
14475            inventory: vec![],
14476            blueprints: vec![],
14477            world_clock: flatland_protocol::WorldClock::default(),
14478            combat: None,
14479            quest_log: vec![],
14480            hired_workers: Vec::new(),
14481            interactables: vec![],
14482            ledger: None,
14483            career: None,
14484            combat_fx: Vec::new(),
14485            property_plots: Vec::new(),
14486        terrain_overlays: Vec::new(),
14487        };
14488
14489        state.apply_tick_fields(&delta, 1);
14490
14491        assert_eq!(state.entities.len(), 1);
14492        assert!(state.player.is_some());
14493        assert_eq!(state.inventory.get("carrot"), Some(&3));
14494        assert_eq!(state.resource_nodes.len(), 1);
14495    }
14496
14497    #[test]
14498    fn tick_preserves_world_layers_when_delta_omits_them() {
14499        let mut state = sample_state();
14500        let delta = TickDelta {
14501            tick: 1,
14502            entities: state.entities.clone(),
14503            resource_nodes: vec![],
14504            ground_drops: vec![],
14505            placed_containers: vec![],
14506            buildings: vec![],
14507            doors: vec![],
14508            interior_map: None,
14509            npcs: vec![],
14510            inventory: vec![],
14511            blueprints: vec![],
14512            world_clock: flatland_protocol::WorldClock::default(),
14513            combat: None,
14514            quest_log: vec![],
14515            hired_workers: Vec::new(),
14516            interactables: vec![],
14517            ledger: None,
14518            career: None,
14519            combat_fx: Vec::new(),
14520            property_plots: Vec::new(),
14521        terrain_overlays: Vec::new(),
14522        };
14523
14524        state.apply_tick_fields(&delta, 1);
14525
14526        assert_eq!(state.resource_nodes.len(), 1);
14527        assert_eq!(state.buildings.len(), 1);
14528        assert_eq!(state.doors.len(), 1);
14529    }
14530
14531    #[test]
14532    fn tick_updates_resource_nodes_when_server_sends_them() {
14533        let mut state = sample_state();
14534        let delta = TickDelta {
14535            tick: 1,
14536            entities: state.entities.clone(),
14537            resource_nodes: vec![ResourceNodeView {
14538                id: "oak-1".into(),
14539                label: "Oak".into(),
14540                x: 130.0,
14541                y: 128.0,
14542                z: 0.0,
14543                item_template: "oak_log".into(),
14544                state: ResourceNodeState::Cooldown,
14545                blocking: true,
14546                blocking_radius_m: 0.8,
14547                tile_id: None,
14548                                yaw: 0.0,
14549                pitch: 0.0,
14550                roll: 0.0,
14551                draw_scale: 1.0,
14552                sprite_mode: None,
14553                growth_progress: None,
14554                presentation_state: None,
14555                channel_start_tick: None,
14556                channel_end_tick: None,
14557                harvest_drop_templates: vec![],
14558            }],
14559            buildings: vec![],
14560            doors: vec![],
14561            interior_map: None,
14562            npcs: vec![],
14563            inventory: vec![],
14564            blueprints: vec![],
14565            world_clock: flatland_protocol::WorldClock::default(),
14566            ground_drops: vec![],
14567            placed_containers: vec![],
14568            combat: None,
14569            quest_log: vec![],
14570            hired_workers: Vec::new(),
14571            interactables: vec![],
14572            ledger: None,
14573            career: None,
14574            combat_fx: Vec::new(),
14575            property_plots: Vec::new(),
14576        terrain_overlays: Vec::new(),
14577        };
14578
14579        state.apply_tick_fields(&delta, 1);
14580
14581        assert!(matches!(
14582            state.resource_nodes[0].state,
14583            ResourceNodeState::Cooldown
14584        ));
14585    }
14586
14587    #[test]
14588    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
14589        let mut state = GameState {
14590            session_id: 1,
14591            entity_id: 1,
14592            character_id: None,
14593            tick: 0,
14594            chunk_rev: 0,
14595            content_rev: 0,
14596            publish_rev: 0,
14597            entities: vec![EntityState {
14598                id: 1,
14599                label: "You".into(),
14600                transform: Transform {
14601                    position: WorldCoord::surface(4.5, 2.0),
14602                    yaw: 0.0,
14603                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14604                },
14605                vitals: None,
14606                attributes: None,
14607                skills: None,
14608                inside_building: Some("broker_hut".into()),
14609                tile_id: None,
14610                paperdoll_ref: None,
14611                presentation_state: None,
14612                sprite_mode: None,
14613                progression_xp: None,
14614                combat_cues: vec![],
14615            }],
14616            player: None,
14617            resource_nodes: vec![],
14618            ground_drops: vec![],
14619            placed_containers: vec![],
14620            buildings: vec![BuildingView {
14621                id: "broker_hut".into(),
14622                label: "Broker".into(),
14623                x: 158.0,
14624                y: 124.0,
14625                width_m: 8.0,
14626                depth_m: 6.0,
14627                interior_blueprint: Some("broker_hut".into()),
14628                tags: vec![],
14629                market_boundary_zone_ids: vec![],
14630                market_max_volume: None,
14631                wall_set: None,
14632                roof_set: None,
14633            }],
14634            doors: vec![flatland_protocol::DoorView {
14635                id: "broker_hut_exit".into(),
14636                building_id: "broker_hut".into(),
14637                x: 4.3,
14638                y: 0.9,
14639                open: true,
14640                portal: Some("front".into()),
14641            }],
14642            interior_map: None,
14643            npcs: vec![flatland_protocol::NpcView {
14644                id: "ada_broker".into(),
14645                label: "Ada".into(),
14646                x: 4.5,
14647                y: 2.0,
14648                building_id: Some("broker_hut".into()),
14649                role: "broker".into(),
14650                entity_id: None,
14651                life_state: None,
14652                hp_pct: None,
14653                can_trade: true,
14654                tile_id: None,
14655                behavior_state: None,
14656                presentation_state: None,
14657                sprite_mode: None,
14658                paperdoll_ref: None,
14659            }],
14660            blueprints: vec![],
14661            world_x0: 0.0,
14662            world_y0: 0.0,
14663            world_width_m: 256.0,
14664            world_height_m: 256.0,
14665            terrain_zones: Vec::new(),
14666            z_platforms: Vec::new(),
14667            z_transitions: Vec::new(),
14668            z_bands_outdoor_backup: None,
14669            world_clock: flatland_protocol::WorldClock::default(),
14670            inventory: std::collections::HashMap::new(),
14671            inventory_hints: std::collections::HashMap::new(),
14672            logs: VecDeque::new(),
14673            intents_sent: 0,
14674            ticks_received: 0,
14675            connected: true,
14676            disconnect_reason: None,
14677            show_stats: false,
14678            hud_log_hidden: false,
14679            show_equip_menu: false,
14680            equip_menu_index: 0,
14681            show_craft_menu: false,
14682            craft_menu_index: 0,
14683            craft_batch_quantity: 1,
14684            show_shop_menu: false,
14685            shop_catalog: None,
14686            bank_panel: None,
14687            bank_menu_index: 0,
14688            bank_ui_mode: BankUiMode::Menu,
14689            storage_panel: None,
14690                market_panel: None,
14691            market_menu_index: 0,
14692            market_filter: String::new(),
14693            market_filter_focused: false,
14694            market_category_filter: None,
14695            market_buy_confirm: None,
14696            market_ui_mode: MarketUiMode::Browse,
14697            storage_menu_index: 0,
14698            storage_ui_mode: StorageUiMode::Menu,
14699            shop_tab: ShopTab::default(),
14700            shop_menu_index: 0,
14701            shop_quantity: 1,
14702            shop_trade_log: VecDeque::new(),
14703            show_npc_verb_menu: false,
14704            npc_verb_target: None,
14705            npc_verb_index: 0,
14706            player_verbs: crate::social::PlayerVerbState::default(),
14707            social_chat: crate::social::SocialChatState::default(),
14708            trade_ui: crate::social::TradeUiState::default(),
14709            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14710            show_npc_chat: false,
14711            npc_chat: None,
14712            show_inventory_menu: false,
14713            inventory_menu_index: 0,
14714            inventory_tab: InventoryTab::OnPerson,
14715            inventory_filter: String::new(),
14716            inventory_filter_focused: false,
14717            show_move_picker: false,
14718            show_rename_prompt: false,
14719            show_worker_rename: false,
14720            rename_buffer: String::new(),
14721            move_picker_index: 0,
14722            move_picker: None,
14723            show_grant_picker: false,
14724            grant_picker_index: 0,
14725            grant_picker: None,
14726            show_destroy_picker: false,
14727            destroy_confirm_pending: false,
14728            destroy_picker: None,
14729            combat_target: None,
14730            combat_target_label: None,
14731            ground_target: None,
14732            combat_fx: Vec::new(),
14733            property_zones: Vec::new(),
14734            tax_zones: Vec::new(),
14735            growth_zones: Vec::new(),
14736            biome_zones: Vec::new(),
14737            property_plots: Vec::new(),
14738            property_plot_settings: None,
14739            claim_mode: None,
14740            relocate_mode: None,
14741            sell_plot_confirm: None,
14742            sell_plot_armed_at: None,
14743            show_plant_menu: false,
14744            plant_menu_index: 0,
14745            show_farm_access: false,
14746            farm_access_name_draft: String::new(),
14747            farm_access_discount_bps: 0,
14748            farm_access_index: 0,
14749            plant_quantity: 1,
14750            in_combat: false,
14751            auto_attack: true,
14752            combat_has_los: false,
14753            attack_cd_ticks: 0,
14754            gcd_ticks: 0,
14755            weapon_ability_id: "unarmed".into(),
14756            mainhand_template_id: None,
14757            mainhand_label: None,
14758            offhand_template_id: None,
14759            offhand_label: None,
14760            mainhand_hand_slots: 1,
14761            defense: None,
14762            worn: BTreeMap::new(),
14763            carry_mass: 0.0,
14764            carry_mass_max: 0.0,
14765            encumbrance: flatland_protocol::EncumbranceState::Light,
14766            inventory_stacks: Vec::new(),
14767            keychain_stacks: Vec::new(),
14768            whisper_pouch_stacks: Vec::new(),
14769            combat_target_detail: None,
14770            statuses: Vec::new(),
14771            cast_progress: None,
14772            timed_channel: None,
14773            ability_cooldowns: Vec::new(),
14774            blocking_active: false,
14775            max_target_slots: 1,
14776            combat_slots: Vec::new(),
14777            rotation_presets: Vec::new(),
14778            known_abilities: Vec::new(),
14779            ability_meta: std::collections::HashMap::new(),
14780            ability_mastery: std::collections::HashMap::new(),
14781            hotbar: vec![None; 9],
14782            max_abilities_per_rotation: 0,
14783            show_loadout_menu: false,
14784            show_keychain_menu: false,
14785            keychain_menu_index: 0,
14786            show_rotation_editor: false,
14787            loadout_menu_index: 0,
14788            loadout_hotbar_slot: 1,
14789            loadout_ability_index: 0,
14790            loadout_focus_presets: false,
14791            rotation_editor: RotationEditorState::default(),
14792            harvest_in_progress: false,
14793            harvest_started_at: None,
14794            pending_craft_ack: None,
14795            pending_worker_job_ack: None,
14796            attending_worker_instance_id: None,
14797            quest_log: Vec::new(),
14798            interactables: Vec::new(),
14799            ledger: None,
14800            career: None,
14801            character_sheet_tab: CharacterSheetTab::Character,
14802            ledger_period: LedgerPeriod::Day,
14803            show_quest_offer: false,
14804            pending_quest_offer: None,
14805            show_quest_menu: false,
14806            quest_menu_index: 0,
14807            quest_withdraw_confirm: false,
14808            hired_workers: Vec::new(),
14809            show_workers_menu: false,
14810            workers_menu_index: 0,
14811            workers_menu_compact: false,
14812            worker_step_display: BTreeMap::new(),
14813            worker_error_display: BTreeMap::new(),
14814            show_worker_give_picker: false,
14815            worker_give_picker_index: 0,
14816            worker_give_picker: None,
14817            show_worker_give_target_picker: false,
14818            worker_give_target_picker_index: 0,
14819            worker_give_target_picker: None,
14820            show_worker_take_picker: false,
14821            worker_take_picker_index: 0,
14822            worker_take_picker: None,
14823            show_worker_teach_picker: false,
14824            worker_teach_picker_index: 0,
14825            worker_teach_picker: None,
14826            worker_route_editor: None,
14827            progression_curve: None,
14828        };
14829        state.player = state.entities.first().cloned();
14830        assert_eq!(
14831            state.nearest_interact_target().as_deref(),
14832            Some("ada_broker")
14833        );
14834    }
14835
14836    #[test]
14837    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
14838        let mut state = sample_state();
14839        // Player is at (128, 128) per sample_state(). One chest just inside
14840        // CONTAINER_RANGE_M, one clearly beyond it.
14841        state.placed_containers = vec![
14842            flatland_protocol::PlacedContainerView {
14843                id: "near".into(),
14844                template_id: "wooden_chest_small".into(),
14845                display_name: "Wooden Chest".into(),
14846                x: 130.0,
14847                y: 128.0,
14848                z: 0.0,
14849                locked: true,
14850                accessible: true,
14851                owner_character_id: None,
14852                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
14853                lock_id: None,
14854                capacity_volume: None,
14855                item_instance_id: Some(uuid::Uuid::from_u128(1)),
14856                tile_id: None,
14857                worker_lodging_capacity: None,
14858                blocking: false,
14859                blocking_radius_m: 0.0,
14860            },
14861            flatland_protocol::PlacedContainerView {
14862                id: "far".into(),
14863                template_id: "wooden_chest_small".into(),
14864                display_name: "Distant Chest".into(),
14865                x: 128.0 + CONTAINER_RANGE_M + 5.0,
14866                y: 128.0,
14867                z: 0.0,
14868                locked: false,
14869                accessible: true,
14870                owner_character_id: None,
14871                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
14872                lock_id: None,
14873                capacity_volume: None,
14874                item_instance_id: Some(uuid::Uuid::from_u128(2)),
14875                tile_id: None,
14876                worker_lodging_capacity: None,
14877                blocking: false,
14878                blocking_radius_m: 0.0,
14879            },
14880        ];
14881
14882        let nearby = state.nearby_containers();
14883        assert_eq!(
14884            nearby.len(),
14885            1,
14886            "far chest must not appear once out of range"
14887        );
14888        assert_eq!(nearby[0].view.id, "near");
14889        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
14890        assert!(nearby[0].rows[0].is_chest_shell);
14891
14892        // The same chest, but locked and inaccessible (no key held), must hide
14893        // contents but still show the selectable chest shell row.
14894        state.placed_containers[0].accessible = false;
14895        let nearby = state.nearby_containers();
14896        assert_eq!(nearby.len(), 1);
14897        assert_eq!(nearby[0].rows.len(), 1);
14898        assert!(nearby[0].rows[0].is_chest_shell);
14899    }
14900
14901    #[test]
14902    fn chest_pickup_destinations_offer_person_and_worn_bag() {
14903        let mut state = sample_state();
14904        let back_id = uuid::Uuid::from_u128(42);
14905        state.worn.insert(
14906            BodySlot::Back,
14907            flatland_protocol::ItemStack {
14908                template_id: "travel_backpack".into(),
14909                quantity: 1,
14910                item_instance_id: Some(back_id),
14911                props: Default::default(),
14912                status_bindings: Vec::new(),
14913                contents: Vec::new(),
14914                display_name: Some("Travel Backpack".into()),
14915                category: Some("container".into()),
14916                base_mass: Some(2.5),
14917                base_volume: Some(12.0),
14918                capacity_volume: Some(80.0),
14919                stackable: Some(false),
14920                world_placeable: Some(false),
14921                worker_lodging_capacity: None,
14922                equip_slot: None,
14923                armor_physical: None,
14924                resists: vec![],
14925                hand_slots: None,
14926            listable: None,
14927        },
14928        );
14929        let opts = state.chest_pickup_destinations("chest-1");
14930        assert!(matches!(
14931            opts.first().map(|o| &o.kind),
14932            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
14933        ));
14934        assert!(opts.iter().any(|o| matches!(
14935            &o.kind,
14936            MoveOptionKind::PickupPlaced {
14937                nest_parent_instance_id: None,
14938                ..
14939            }
14940        )));
14941        assert!(opts.iter().any(|o| matches!(
14942            &o.kind,
14943            MoveOptionKind::PickupPlaced {
14944                nest_parent_instance_id: Some(id),
14945                ..
14946            } if *id == back_id
14947        )));
14948        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14949    }
14950
14951    #[test]
14952    fn placed_container_public_label_hides_owner_custom_name() {
14953        let owner = uuid::Uuid::from_u128(99);
14954        let mut state = sample_state();
14955        state.character_id = Some(uuid::Uuid::from_u128(1));
14956        state.inventory_hints.insert(
14957            "wooden_chest_medium".into(),
14958            InventoryHint {
14959                display_name: "Medium Wooden Chest".into(),
14960                category: "container".into(),
14961                base_mass: None,
14962                base_volume: None,
14963                capacity_volume: None,
14964                stackable: false,
14965                listable: true,
14966            },
14967        );
14968        let chest = flatland_protocol::PlacedContainerView {
14969            id: "c1".into(),
14970            template_id: "wooden_chest_medium".into(),
14971            display_name: "Barry's Loot #a3f2".into(),
14972            x: 128.0,
14973            y: 128.0,
14974            z: 0.0,
14975            locked: false,
14976            accessible: true,
14977            owner_character_id: Some(owner),
14978            contents: vec![],
14979            lock_id: None,
14980            capacity_volume: None,
14981            item_instance_id: None,
14982            tile_id: None,
14983            worker_lodging_capacity: None,
14984            blocking: false,
14985            blocking_radius_m: 0.0,
14986        };
14987        assert_eq!(
14988            state.placed_container_public_label(&chest),
14989            "Medium Wooden Chest"
14990        );
14991        state.character_id = Some(owner);
14992        assert_eq!(
14993            state.placed_container_public_label(&chest),
14994            "Barry's Loot #a3f2"
14995        );
14996    }
14997
14998    #[test]
14999    fn location_context_shows_crop_growth_percent_not_depleted() {
15000        let mut state = sample_state();
15001        state.player = state.entities.first().cloned();
15002        state.resource_nodes[0].label = "Carrot (growing)".into();
15003        state.resource_nodes[0].x = 128.2;
15004        state.resource_nodes[0].y = 128.0;
15005        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
15006        state.resource_nodes[0].growth_progress = Some(0.47);
15007        let lines = state.location_context_lines();
15008        let line = lines
15009            .iter()
15010            .find(|l| l.text.contains("Carrot"))
15011            .map(|l| l.text.as_str())
15012            .unwrap_or("");
15013        assert!(
15014            line.contains("(growing, 47%)"),
15015            "expected growth percent, got: {line}"
15016        );
15017        assert!(
15018            !line.contains("depleted"),
15019            "growing crop should not show depleted: {line}"
15020        );
15021    }
15022
15023    #[test]
15024    fn resource_node_near_action_suffix_prefers_growth() {
15025        let node = ResourceNodeView {
15026            id: "crop".into(),
15027            label: "Wheat".into(),
15028            x: 0.0,
15029            y: 0.0,
15030            z: 0.0,
15031            item_template: "wheat".into(),
15032            state: ResourceNodeState::Cooldown,
15033            blocking: false,
15034            blocking_radius_m: 0.0,
15035            tile_id: None,
15036                        yaw: 0.0,
15037            pitch: 0.0,
15038            roll: 0.0,
15039            draw_scale: 1.0,
15040            sprite_mode: None,
15041            growth_progress: Some(0.12),
15042            presentation_state: None,
15043            channel_start_tick: None,
15044            channel_end_tick: None,
15045            harvest_drop_templates: vec![],
15046        };
15047        assert_eq!(
15048            resource_node_near_action_suffix(&node),
15049            " (growing, 12%)"
15050        );
15051    }
15052
15053    #[test]
15054    fn location_context_lists_nearby_resource_node() {
15055        let mut state = sample_state();
15056        state.player = state.entities.first().cloned();
15057        state.resource_nodes[0].x = 128.2;
15058        state.resource_nodes[0].y = 128.0;
15059        let lines = state.location_context_lines();
15060        assert!(
15061            lines
15062                .iter()
15063                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
15064            "expected resource node in context: {:?}",
15065            lines
15066        );
15067    }
15068
15069    #[test]
15070    fn quest_board_usable_within_board_radius() {
15071        let mut state = sample_state();
15072        state.player = state.entities.first().cloned();
15073        state.interactables = vec![flatland_protocol::InteractableView {
15074            id: "board-1".into(),
15075            kind: "quest_board".into(),
15076            label: "Town Quest Board".into(),
15077            x: 130.5,
15078            y: 128.0,
15079            z: 0.0,
15080            board_id: Some("starter_town_board".into()),
15081        }];
15082        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
15083        assert_eq!(
15084            state.nearest_interact_target().as_deref(),
15085            Some("board-1"),
15086            "quest board should be selectable at ~2.5m"
15087        );
15088        let lines = state.location_context_lines();
15089        assert!(
15090            lines
15091                .iter()
15092                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
15093            "HUD should advertise f when board is in range: {:?}",
15094            lines
15095        );
15096    }
15097
15098    #[test]
15099    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
15100        let mut state = sample_state();
15101        state.worn.insert(
15102            BodySlot::Back,
15103            flatland_protocol::ItemStack {
15104                template_id: "travel_backpack".into(),
15105                quantity: 1,
15106                item_instance_id: Some(uuid::Uuid::from_u128(3)),
15107                props: Default::default(),
15108                status_bindings: Vec::new(),
15109                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
15110                display_name: None,
15111                category: None,
15112                base_mass: None,
15113                base_volume: None,
15114                capacity_volume: None,
15115                stackable: None,
15116                world_placeable: None,
15117                worker_lodging_capacity: None,
15118                equip_slot: None,
15119                armor_physical: None,
15120                resists: vec![],
15121                hand_slots: None,
15122            listable: None,
15123        },
15124        );
15125        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
15126        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15127            id: "chest-1".into(),
15128            template_id: "wooden_chest_small".into(),
15129            display_name: "Wooden Chest".into(),
15130            x: 129.0,
15131            y: 128.0,
15132            z: 0.0,
15133            locked: false,
15134            accessible: true,
15135            owner_character_id: None,
15136            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
15137            lock_id: None,
15138            capacity_volume: None,
15139            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15140            tile_id: None,
15141            worker_lodging_capacity: None,
15142            blocking: false,
15143            blocking_radius_m: 0.0,
15144        }];
15145
15146        state.inventory_tab = InventoryTab::OnPerson;
15147        let rows = state.inventory_selectable_rows();
15148        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15149        assert_eq!(
15150            sections,
15151            vec![
15152                InventorySection::Worn,   // backpack shell
15153                InventorySection::Worn,   // iron_ore nested in backpack
15154                InventorySection::Person, // lumber
15155            ]
15156        );
15157        assert_eq!(rows[0].stack.template_id, "travel_backpack");
15158        assert!(rows[0].is_equip_shell);
15159        assert_eq!(rows[1].stack.template_id, "iron_ore");
15160        assert_eq!(rows[1].depth, 1);
15161        assert_eq!(rows[2].stack.template_id, "lumber");
15162
15163        let lines = state.inventory_browser_lines();
15164        assert!(lines.iter().any(|l| matches!(
15165            l,
15166            InventoryBrowserLine::Section(s) if s.contains("Worn")
15167        )));
15168        assert!(lines.iter().any(|l| matches!(
15169            l,
15170            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15171                || text.contains("backpack")
15172        )));
15173        assert!(!lines.iter().any(|l| matches!(
15174            l,
15175            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15176        )));
15177
15178        state.inventory_tab = InventoryTab::Nearby;
15179        let nearby_rows = state.inventory_selectable_rows();
15180        assert_eq!(nearby_rows.len(), 2);
15181        assert!(nearby_rows[0].is_chest_shell);
15182        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15183        let nearby_lines = state.inventory_browser_lines();
15184        assert!(nearby_lines.iter().any(|l| matches!(
15185            l,
15186            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15187        )));
15188    }
15189
15190    #[test]
15191    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
15192        let mut state = sample_state();
15193        let back_id = uuid::Uuid::from_u128(5);
15194        state.worn.insert(
15195            BodySlot::Back,
15196            flatland_protocol::ItemStack {
15197                template_id: "travel_backpack".into(),
15198                quantity: 1,
15199                item_instance_id: Some(back_id),
15200                props: Default::default(),
15201                status_bindings: Vec::new(),
15202                contents: Vec::new(),
15203                display_name: None,
15204                category: Some("container".into()),
15205                base_mass: None,
15206                base_volume: None,
15207                capacity_volume: Some(80.0),
15208                stackable: None,
15209                world_placeable: None,
15210                worker_lodging_capacity: None,
15211                equip_slot: None,
15212                armor_physical: None,
15213                resists: vec![],
15214                hand_slots: None,
15215            listable: None,
15216        },
15217        );
15218        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15219            id: "chest-1".into(),
15220            template_id: "wooden_chest_small".into(),
15221            display_name: "Wooden Chest".into(),
15222            x: 129.0,
15223            y: 128.0,
15224            z: 0.0,
15225            locked: false,
15226            accessible: true,
15227            owner_character_id: None,
15228            contents: Vec::new(),
15229            lock_id: None,
15230            capacity_volume: None,
15231            item_instance_id: Some(uuid::Uuid::from_u128(6)),
15232            tile_id: None,
15233            worker_lodging_capacity: None,
15234            blocking: false,
15235            blocking_radius_m: 0.0,
15236        }];
15237
15238        // Item currently sitting loose on the person (Root): backpack + nearby
15239        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
15240        let opts = state.move_destinations_for(
15241            &flatland_protocol::InventoryLocation::Root,
15242            None,
15243            None,
15244            "lumber",
15245        );
15246        assert!(!opts.iter().any(|o| matches!(
15247            &o.kind,
15248            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15249        )));
15250        assert!(opts.iter().any(|o| matches!(
15251            &o.kind,
15252            MoveOptionKind::Move { location, parent_instance_id, .. }
15253                if *location == flatland_protocol::InventoryLocation::Worn {
15254                    slot: BodySlot::Back,
15255                } && *parent_instance_id == Some(back_id)
15256        )));
15257        assert!(opts.iter().any(|o| matches!(
15258            &o.kind,
15259            MoveOptionKind::Move { location, .. }
15260                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
15261        )));
15262        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15263        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
15264
15265        // Item currently inside the worn backpack: the backpack itself must be
15266        // excluded from its own destination list (can't move an item into the
15267        // container it's already in).
15268        let from_backpack = flatland_protocol::InventoryLocation::Worn {
15269            slot: BodySlot::Back,
15270        };
15271        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
15272        assert!(!opts.iter().any(|o| matches!(
15273            &o.kind,
15274            MoveOptionKind::Move { location, parent_instance_id, .. }
15275                if *location == from_backpack && *parent_instance_id == Some(back_id)
15276        )));
15277        assert!(opts.iter().any(|o| matches!(
15278            &o.kind,
15279            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15280        )));
15281    }
15282
15283    #[test]
15284    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
15285        let mut state = sample_state();
15286        // Insert out of display order — BTreeMap iteration must still yield the
15287        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
15288        state.worn.insert(
15289            BodySlot::Waist,
15290            flatland_protocol::ItemStack {
15291                template_id: "simple_belt".into(),
15292                quantity: 1,
15293                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15294                props: Default::default(),
15295                status_bindings: Vec::new(),
15296                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
15297                display_name: None,
15298                category: Some("container".into()),
15299                base_mass: None,
15300                base_volume: None,
15301                capacity_volume: None,
15302                stackable: None,
15303                world_placeable: None,
15304                worker_lodging_capacity: None,
15305                equip_slot: None,
15306                armor_physical: None,
15307                resists: vec![],
15308                hand_slots: None,
15309            listable: None,
15310        },
15311        );
15312        state.worn.insert(
15313            BodySlot::Head,
15314            flatland_protocol::ItemStack {
15315                template_id: "cloth_cap".into(),
15316                quantity: 1,
15317                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15318                props: Default::default(),
15319                status_bindings: Vec::new(),
15320                contents: Vec::new(),
15321                display_name: None,
15322                category: Some("armor".into()),
15323                base_mass: None,
15324                base_volume: None,
15325                capacity_volume: None,
15326                stackable: None,
15327                world_placeable: None,
15328                worker_lodging_capacity: None,
15329                equip_slot: None,
15330                armor_physical: None,
15331                resists: vec![],
15332                hand_slots: None,
15333            listable: None,
15334        },
15335        );
15336        state.worn.insert(
15337            BodySlot::Back,
15338            flatland_protocol::ItemStack {
15339                template_id: "travel_backpack".into(),
15340                quantity: 1,
15341                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15342                props: Default::default(),
15343                status_bindings: Vec::new(),
15344                contents: Vec::new(),
15345                display_name: None,
15346                category: Some("container".into()),
15347                base_mass: None,
15348                base_volume: None,
15349                capacity_volume: None,
15350                stackable: None,
15351                world_placeable: None,
15352                worker_lodging_capacity: None,
15353                equip_slot: None,
15354                armor_physical: None,
15355                resists: vec![],
15356                hand_slots: None,
15357            listable: None,
15358        },
15359        );
15360
15361        let rows = state.worn_rows();
15362        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
15363        assert_eq!(rows.len(), 4);
15364        assert_eq!(rows[0].stack.template_id, "cloth_cap");
15365        assert!(rows[0].is_equip_shell);
15366        assert_eq!(rows[1].stack.template_id, "travel_backpack");
15367        assert!(rows[1].is_equip_shell);
15368        assert_eq!(rows[2].stack.template_id, "simple_belt");
15369        assert!(rows[2].is_equip_shell);
15370        assert_eq!(rows[3].stack.template_id, "leather_pouch");
15371        assert_eq!(rows[3].depth, 1);
15372        assert!(!rows[3].is_equip_shell);
15373    }
15374
15375    #[test]
15376    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
15377        let mut state = sample_state();
15378        state.worn.insert(
15379            BodySlot::Waist,
15380            flatland_protocol::ItemStack {
15381                template_id: "simple_belt".into(),
15382                quantity: 1,
15383                item_instance_id: Some(uuid::Uuid::from_u128(20)),
15384                props: Default::default(),
15385                status_bindings: Vec::new(),
15386                contents: Vec::new(),
15387                display_name: Some("Simple Belt".into()),
15388                category: Some("container".into()),
15389                base_mass: None,
15390                base_volume: None,
15391                capacity_volume: None,
15392                stackable: None,
15393                world_placeable: None,
15394                worker_lodging_capacity: None,
15395                equip_slot: None,
15396                armor_physical: None,
15397                resists: vec![],
15398                hand_slots: None,
15399            listable: None,
15400        },
15401        );
15402        state.worn.insert(
15403            BodySlot::Head,
15404            flatland_protocol::ItemStack {
15405                template_id: "cloth_cap".into(),
15406                quantity: 1,
15407                item_instance_id: Some(uuid::Uuid::from_u128(21)),
15408                props: Default::default(),
15409                status_bindings: Vec::new(),
15410                contents: Vec::new(),
15411                display_name: Some("Cloth Cap".into()),
15412                category: Some("armor".into()),
15413                base_mass: None,
15414                base_volume: None,
15415                capacity_volume: None,
15416                stackable: None,
15417                world_placeable: None,
15418                worker_lodging_capacity: None,
15419                equip_slot: None,
15420                armor_physical: None,
15421                resists: vec![],
15422                hand_slots: None,
15423            listable: None,
15424        },
15425        );
15426
15427        let opts = state.move_destinations_for(
15428            &flatland_protocol::InventoryLocation::Root,
15429            None,
15430            None,
15431            "leather_pouch",
15432        );
15433        assert!(
15434            opts.iter().any(|o| matches!(
15435                &o.kind,
15436                MoveOptionKind::Move { location, .. }
15437                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15438            )),
15439            "belt loop must be offered when moving a pouch"
15440        );
15441        assert!(
15442            !opts.iter().any(|o| matches!(
15443                &o.kind,
15444                MoveOptionKind::Move { location, .. }
15445                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
15446            )),
15447            "armor slots can't hold other items and must not appear as move destinations"
15448        );
15449        let belt_opt = opts
15450            .iter()
15451            .find(|o| matches!(
15452                &o.kind,
15453                MoveOptionKind::Move { location, .. }
15454                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15455            ))
15456            .unwrap();
15457        assert!(belt_opt.label.contains("belt loop"));
15458
15459        let opts = state.move_destinations_for(
15460            &flatland_protocol::InventoryLocation::Root,
15461            None,
15462            None,
15463            "lumber",
15464        );
15465        assert!(
15466            !opts.iter().any(|o| o.label.contains("belt loop")),
15467            "loose materials must not target the belt shell — only nested pouches"
15468        );
15469    }
15470
15471    #[test]
15472    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
15473        let mut state = sample_state();
15474        let belt_id = uuid::Uuid::from_u128(30);
15475        let pouch_id = uuid::Uuid::from_u128(31);
15476        state.worn.insert(
15477            BodySlot::Waist,
15478            flatland_protocol::ItemStack {
15479                template_id: "simple_belt".into(),
15480                quantity: 1,
15481                item_instance_id: Some(belt_id),
15482                props: Default::default(),
15483                status_bindings: Vec::new(),
15484                world_placeable: None,
15485                worker_lodging_capacity: None,
15486                equip_slot: None,
15487                armor_physical: None,
15488                resists: vec![],
15489                hand_slots: None,
15490                contents: vec![flatland_protocol::ItemStack {
15491                    template_id: "dimensional_pouch".into(),
15492                    quantity: 1,
15493                    item_instance_id: Some(pouch_id),
15494                    props: Default::default(),
15495                    status_bindings: Vec::new(),
15496                    contents: Vec::new(),
15497                    display_name: Some("Dimensional Pouch".into()),
15498                    category: Some("container".into()),
15499                    base_mass: None,
15500                    base_volume: None,
15501                    capacity_volume: Some(200.0),
15502                    stackable: None,
15503                    world_placeable: None,
15504                    worker_lodging_capacity: None,
15505                    equip_slot: None,
15506                    armor_physical: None,
15507                    resists: vec![],
15508                    hand_slots: None,
15509            listable: None,
15510        }],
15511                display_name: Some("Simple Belt".into()),
15512                category: Some("container".into()),
15513                base_mass: None,
15514                base_volume: None,
15515                capacity_volume: None,
15516                stackable: None,
15517            listable: None,
15518        },
15519        );
15520
15521        let opts = state.move_destinations_for(
15522            &flatland_protocol::InventoryLocation::Root,
15523            None,
15524            None,
15525            "iron_ore",
15526        );
15527        assert!(
15528            opts.iter().any(|o| matches!(
15529                &o.kind,
15530                MoveOptionKind::Move {
15531                    location,
15532                    parent_instance_id,
15533                    ..
15534                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15535                    && *parent_instance_id == Some(pouch_id)
15536            )),
15537            "dimensional pouch clipped on belt must accept loose items"
15538        );
15539        assert!(
15540            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
15541            "destination label should name the pouch"
15542        );
15543    }
15544
15545    #[test]
15546    fn container_volume_label_on_placed_chest_shell() {
15547        let mut state = sample_state();
15548        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15549            id: "chest-1".into(),
15550            template_id: "wooden_chest_small".into(),
15551            display_name: "Camp Chest".into(),
15552            x: 129.0,
15553            y: 128.0,
15554            z: 0.0,
15555            locked: false,
15556            accessible: true,
15557            owner_character_id: None,
15558            contents: vec![flatland_protocol::ItemStack {
15559                template_id: "iron_ore".into(),
15560                quantity: 2,
15561                item_instance_id: None,
15562                props: Default::default(),
15563                status_bindings: Vec::new(),
15564                contents: Vec::new(),
15565                display_name: None,
15566                category: None,
15567                base_mass: None,
15568                base_volume: Some(2.0),
15569                capacity_volume: None,
15570                stackable: None,
15571                world_placeable: None,
15572                worker_lodging_capacity: None,
15573                equip_slot: None,
15574                armor_physical: None,
15575                resists: vec![],
15576                hand_slots: None,
15577            listable: None,
15578        }],
15579            lock_id: None,
15580            capacity_volume: Some(60.0),
15581            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15582            tile_id: None,
15583            worker_lodging_capacity: None,
15584            blocking: false,
15585            blocking_radius_m: 0.0,
15586        }];
15587        let nearby = state.nearby_containers();
15588        let label = state.container_volume_label(&nearby[0].rows[0]);
15589        assert!(
15590            label.contains("vol 4/60"),
15591            "expected used/cap in label, got {label}"
15592        );
15593        assert!(
15594            label.contains("56 free"),
15595            "expected free space, got {label}"
15596        );
15597    }
15598
15599    #[test]
15600    fn key_pair_chest_label_from_placed_lock_id() {
15601        let mut state = sample_state();
15602        let owner = uuid::Uuid::from_u128(77);
15603        state.character_id = Some(owner);
15604        let lock = uuid::Uuid::from_u128(99).to_string();
15605        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15606            id: "chest-1".into(),
15607            template_id: "wooden_chest_small".into(),
15608            display_name: "Barry's Loot #a3f2".into(),
15609            x: 129.0,
15610            y: 128.0,
15611            z: 0.0,
15612            locked: true,
15613            accessible: true,
15614            owner_character_id: Some(owner),
15615            contents: Vec::new(),
15616            lock_id: Some(lock.clone()),
15617            capacity_volume: None,
15618            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15619            tile_id: None,
15620            worker_lodging_capacity: None,
15621            blocking: false,
15622            blocking_radius_m: 0.0,
15623        }];
15624        let key_id = uuid::Uuid::from_u128(5);
15625        let key = flatland_protocol::ItemStack {
15626            template_id: KEY_TEMPLATE.into(),
15627            quantity: 1,
15628            item_instance_id: Some(key_id),
15629            props: BTreeMap::from([
15630                (PROP_OPENS_LOCK_ID.into(), lock),
15631                (
15632                    PROP_OPENS_CONTAINER_NAME.into(),
15633                    "Barry's Loot #a3f2".into(),
15634                ),
15635            ]),
15636            status_bindings: Vec::new(),
15637            contents: Vec::new(),
15638            display_name: Some("Container Key".into()),
15639            category: Some("key".into()),
15640            base_mass: None,
15641            base_volume: None,
15642            capacity_volume: None,
15643            stackable: None,
15644            world_placeable: None,
15645            worker_lodging_capacity: None,
15646            equip_slot: None,
15647            armor_physical: None,
15648            resists: vec![],
15649            hand_slots: None,
15650            listable: None,
15651        };
15652        state.inventory_stacks = vec![key.clone()];
15653        assert_eq!(
15654            state.key_pair_chest_label(&key).as_deref(),
15655            Some("Barry's Loot #a3f2")
15656        );
15657        assert!(state.key_drop_blocked(&key));
15658    }
15659
15660    #[test]
15661    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
15662        let mut state = sample_state();
15663        let lock = uuid::Uuid::from_u128(101).to_string();
15664        let key = flatland_protocol::ItemStack {
15665            template_id: KEY_TEMPLATE.into(),
15666            quantity: 1,
15667            item_instance_id: Some(uuid::Uuid::from_u128(7)),
15668            props: BTreeMap::from([
15669                (PROP_OPENS_LOCK_ID.into(), lock),
15670                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
15671            ]),
15672            status_bindings: Vec::new(),
15673            contents: Vec::new(),
15674            display_name: None,
15675            category: Some("key".into()),
15676            base_mass: None,
15677            base_volume: None,
15678            capacity_volume: None,
15679            stackable: None,
15680            world_placeable: None,
15681            worker_lodging_capacity: None,
15682            equip_slot: None,
15683            armor_physical: None,
15684            resists: vec![],
15685            hand_slots: None,
15686            listable: None,
15687        };
15688        state.placed_containers.clear();
15689        assert_eq!(
15690            state.key_pair_chest_label(&key).as_deref(),
15691            Some("Camp Stash")
15692        );
15693    }
15694
15695    #[test]
15696    fn key_drop_allowed_when_paired_chest_unlocked() {
15697        let mut state = sample_state();
15698        let lock = uuid::Uuid::from_u128(100).to_string();
15699        let key_id = uuid::Uuid::from_u128(6);
15700        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15701            id: "chest-1".into(),
15702            template_id: "wooden_chest_small".into(),
15703            display_name: "Camp Chest".into(),
15704            x: 129.0,
15705            y: 128.0,
15706            z: 0.0,
15707            locked: false,
15708            accessible: true,
15709            owner_character_id: None,
15710            contents: Vec::new(),
15711            lock_id: Some(lock.clone()),
15712            capacity_volume: None,
15713            item_instance_id: None,
15714            tile_id: None,
15715            worker_lodging_capacity: None,
15716            blocking: false,
15717            blocking_radius_m: 0.0,
15718        }];
15719        let key = flatland_protocol::ItemStack {
15720            template_id: KEY_TEMPLATE.into(),
15721            quantity: 1,
15722            item_instance_id: Some(key_id),
15723            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
15724            status_bindings: Vec::new(),
15725            contents: Vec::new(),
15726            display_name: None,
15727            category: Some("key".into()),
15728            base_mass: None,
15729            base_volume: None,
15730            capacity_volume: None,
15731            stackable: None,
15732            world_placeable: None,
15733            worker_lodging_capacity: None,
15734                equip_slot: None,
15735                armor_physical: None,
15736                resists: vec![],
15737                hand_slots: None,
15738            listable: None,
15739        };
15740        state.inventory_stacks = vec![key.clone()];
15741        assert!(!state.key_drop_blocked(&key));
15742        let opts = state.move_destinations_for(
15743            &flatland_protocol::InventoryLocation::Root,
15744            None,
15745            Some(key_id),
15746            KEY_TEMPLATE,
15747        );
15748        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
15749    }
15750
15751    #[test]
15752    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
15753        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
15754
15755        let mut state = sample_state();
15756        let curve = ProgressionCurve::default();
15757        let bootstrap = ProgressionXp::bootstrap_new(
15758            curve.baseline_display,
15759            curve.xp_base,
15760            curve.xp_growth,
15761        );
15762        let mut fresh = bootstrap.clone();
15763        fresh.strength += 0.08;
15764        if let Some(player) = state.player.as_mut() {
15765            player.progression_xp = Some(bootstrap);
15766        }
15767
15768        let combat = CombatHud {
15769            progression_xp: Some(fresh.clone()),
15770            progression_baseline: curve.baseline_display,
15771            progression_xp_base: curve.xp_base,
15772            progression_xp_growth: curve.xp_growth,
15773            attributes: state.player.as_ref().and_then(|p| p.attributes),
15774            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
15775            ..CombatHud::default()
15776        };
15777        state.apply_combat_hud(&combat);
15778
15779        let xp = state
15780            .player
15781            .as_ref()
15782            .and_then(|p| p.progression_xp.as_ref())
15783            .expect("xp");
15784        assert!((xp.strength - fresh.strength).abs() < 0.001);
15785        assert!(state.progression_curve.is_some());
15786    }
15787
15788    #[test]
15789    fn combat_hud_syncs_known_abilities_and_hotbar() {
15790        use flatland_protocol::CombatHud;
15791
15792        let mut state = sample_state();
15793        let combat = CombatHud {
15794            known_abilities: vec!["unarmed".into(), "fireball".into()],
15795            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
15796            max_abilities_per_rotation: 4,
15797            ability_id: "short_sword_slash".into(),
15798            ..CombatHud::default()
15799        };
15800        state.apply_combat_hud(&combat);
15801
15802        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
15803        assert_eq!(state.hotbar_ability(1), Some("fireball"));
15804        assert_eq!(state.hotbar_ability(2), None);
15805        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
15806        assert_eq!(state.max_abilities_per_rotation, 4);
15807        let choices = state.loadout_ability_choices();
15808        assert!(choices.iter().any(|a| a == "short_sword_slash"));
15809        assert!(choices.iter().any(|a| a == "fireball"));
15810    }
15811
15812    #[test]
15813    fn loadout_hotbar_choices_include_inventory_consumables() {
15814        let mut state = sample_state();
15815        state.known_abilities = vec!["unarmed".into()];
15816        state.weapon_ability_id = "unarmed".into();
15817        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15818            template_id: "bottle_of_water".into(),
15819            quantity: 3,
15820            item_instance_id: Some(uuid::Uuid::from_u128(9)),
15821            display_name: Some("Bottle of Water".into()),
15822            category: Some("consumable".into()),
15823            ..Default::default()
15824        }];
15825        state.inventory.insert("bottle_of_water".into(), 3);
15826        state.inventory_hints.insert(
15827            "bottle_of_water".into(),
15828            InventoryHint {
15829                display_name: "Bottle of Water".into(),
15830                category: "consumable".into(),
15831                ..Default::default()
15832            },
15833        );
15834
15835        let choices = state.loadout_hotbar_choices();
15836        assert!(choices.iter().any(|c| c.binding == "unarmed"));
15837        let water = choices
15838            .iter()
15839            .find(|c| c.binding == "item:bottle_of_water")
15840            .expect("water binding");
15841        assert_eq!(water.meta.as_deref(), Some("use"));
15842        assert!(water.label.contains("Water"));
15843        assert_eq!(
15844            state.hotbar_slot_label(1),
15845            None,
15846            "unbound until set"
15847        );
15848        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
15849        assert_eq!(
15850            state.hotbar_slot_label(5).as_deref(),
15851            Some("Bottle of Water×3")
15852        );
15853    }
15854
15855    #[test]
15856    fn loose_consumable_move_picker_offers_use_and_storage() {
15857        let mut state = sample_state();
15858        let inst = uuid::Uuid::from_u128(77);
15859        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15860            template_id: "carrot".into(),
15861            quantity: 2,
15862            item_instance_id: Some(inst),
15863            props: Default::default(),
15864            status_bindings: Vec::new(),
15865            contents: Vec::new(),
15866            display_name: Some("Wild Carrot".into()),
15867            category: Some("consumable".into()),
15868            base_mass: None,
15869            base_volume: None,
15870            capacity_volume: None,
15871            stackable: Some(true),
15872            world_placeable: None,
15873            worker_lodging_capacity: None,
15874                equip_slot: None,
15875                armor_physical: None,
15876                resists: vec![],
15877                hand_slots: None,
15878            listable: None,
15879        }];
15880        state.inventory_hints.insert(
15881            "carrot".into(),
15882            InventoryHint {
15883                display_name: "Wild Carrot".into(),
15884                category: "consumable".into(),
15885                base_mass: Some(0.15),
15886                base_volume: Some(0.3),
15887                capacity_volume: None,
15888                stackable: true,
15889                listable: true,
15890            },
15891        );
15892        state.show_inventory_menu = true;
15893        state.inventory_menu_index = 0;
15894
15895        let row = state.inventory_selected_row().expect("carrot row");
15896        let mut options = state.move_destinations_for(
15897            &row.from,
15898            row.from_parent_instance_id,
15899            row.stack.item_instance_id,
15900            &row.stack.template_id,
15901        );
15902        if row.from == flatland_protocol::InventoryLocation::Root
15903            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
15904        {
15905            options.insert(
15906                0,
15907                MoveOption {
15908                    label: "Use (eat / drink)".into(),
15909                    kind: MoveOptionKind::Use,
15910                },
15911            );
15912        }
15913
15914        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
15915        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
15916        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
15917    }
15918
15919    #[test]
15920    fn inventory_category_group_order_is_stable() {
15921        assert_eq!(inventory_category_group("weapon").0, "Weapons");
15922        assert_eq!(inventory_category_group("armor").0, "Armor");
15923        assert_eq!(inventory_category_group("consumable").0, "Consumables");
15924        assert_eq!(inventory_category_group("resource").0, "Resources");
15925        assert_eq!(inventory_category_group("container").0, "Containers");
15926        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
15927        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
15928    }
15929
15930    #[test]
15931    fn page_list_index_clamps_without_wrap() {
15932        assert_eq!(page_list_index(0, -1, 25), 0);
15933        assert_eq!(page_list_index(0, 1, 25), 10);
15934        assert_eq!(page_list_index(12, 1, 25), 22);
15935        assert_eq!(page_list_index(22, 1, 25), 24);
15936        assert_eq!(page_list_index(5, 1, 0), 0);
15937        assert_eq!(page_list_index(3, -1, 8), 0);
15938    }
15939
15940    #[test]
15941    fn inventory_filter_hides_non_matching_person_items() {
15942        let mut state = sample_state();
15943        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15944        sword.display_name = Some("Iron Sword".into());
15945        sword.category = Some("weapon".into());
15946        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
15947        herb.display_name = Some("Wild Herb".into());
15948        herb.category = Some("consumable".into());
15949        state.inventory_stacks = vec![sword, herb];
15950        state.inventory_tab = InventoryTab::OnPerson;
15951        state.inventory_filter = "sword".into();
15952
15953        let rows = state.inventory_selectable_rows();
15954        assert_eq!(rows.len(), 1);
15955        assert_eq!(rows[0].stack.template_id, "iron_sword");
15956
15957        let lines = state.inventory_browser_lines();
15958        assert!(lines.iter().any(|l| matches!(
15959            l,
15960            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
15961        )));
15962        assert!(!lines.iter().any(|l| matches!(
15963            l,
15964            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
15965        )));
15966    }
15967
15968    #[test]
15969    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
15970        let mut state = sample_state();
15971        let id_a = uuid::Uuid::from_u128(0xa1);
15972        let id_b = uuid::Uuid::from_u128(0xb2);
15973        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
15974        sword_a.display_name = Some("Iron Sword".into());
15975        sword_a.category = Some("weapon".into());
15976        sword_a.item_instance_id = Some(id_a);
15977        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
15978        sword_b.display_name = Some("Iron Sword".into());
15979        sword_b.category = Some("weapon".into());
15980        sword_b.item_instance_id = Some(id_b);
15981        state.inventory_stacks = vec![sword_a, sword_b];
15982        state.inventory_tab = InventoryTab::OnPerson;
15983
15984        let lines = state.inventory_browser_lines();
15985        let items: Vec<_> = lines
15986            .iter()
15987            .filter_map(|l| match l {
15988                InventoryBrowserLine::Item {
15989                    title,
15990                    instance_tooltip,
15991                    ..
15992                } => Some((title.clone(), instance_tooltip.clone())),
15993                _ => None,
15994            })
15995            .collect();
15996        assert_eq!(items.len(), 2);
15997        for (title, tip) in &items {
15998            assert!(
15999                !title.contains('#'),
16000                "title should not show instance suffix: {title}"
16001            );
16002            assert!(
16003                tip.is_some(),
16004                "two identical rows should expose instance on hover"
16005            );
16006        }
16007
16008        state.inventory_stacks.pop();
16009        let lines = state.inventory_browser_lines();
16010        let one = lines.iter().find_map(|l| match l {
16011            InventoryBrowserLine::Item {
16012                title,
16013                instance_tooltip,
16014                ..
16015            } => Some((title.clone(), instance_tooltip.clone())),
16016            _ => None,
16017        });
16018        let (title, tip) = one.expect("one sword row");
16019        assert!(!title.contains('#'));
16020        assert!(tip.is_none(), "single row should not need instance tooltip");
16021    }
16022
16023    #[test]
16024    fn inventory_person_rows_group_by_category() {
16025        let mut state = sample_state();
16026        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16027        sword.category = Some("weapon".into());
16028        sword.display_name = Some("Iron Sword".into());
16029        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
16030        ore.category = Some("resource".into());
16031        ore.display_name = Some("Iron Ore".into());
16032        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
16033        potion.category = Some("consumable".into());
16034        potion.display_name = Some("Health Potion".into());
16035        state.inventory_stacks = vec![ore, potion, sword];
16036        state.inventory_tab = InventoryTab::OnPerson;
16037
16038        let lines = state.inventory_browser_lines();
16039        let labels: Vec<&str> = lines
16040            .iter()
16041            .filter_map(|l| match l {
16042                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
16043                _ => None,
16044            })
16045            .collect();
16046        assert!(
16047            labels.iter().any(|s| s.contains("Weapons")),
16048            "expected Weapons group: {labels:?}"
16049        );
16050        assert!(labels.iter().any(|s| s.contains("Consumables")));
16051        assert!(labels.iter().any(|s| s.contains("Resources")));
16052
16053        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
16054        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
16055        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
16056        assert!(weapon_pos < consumable_pos);
16057        assert!(consumable_pos < resource_pos);
16058    }
16059
16060    #[test]
16061    fn inventory_tab_cycle_resets_selection() {
16062        let mut state = sample_state();
16063        state.inventory_tab = InventoryTab::OnPerson;
16064        state.inventory_menu_index = 3;
16065        state.inventory_tab = state.inventory_tab.cycle(true);
16066        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
16067        // Client method resets index; enum cycle alone does not — verify cycle labels.
16068        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
16069        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
16070        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
16071        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
16072    }
16073
16074    #[test]
16075    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
16076        assert_eq!(parse_bank_copper_amount(""), Some(0));
16077        assert_eq!(parse_bank_copper_amount("  "), Some(0));
16078        assert_eq!(parse_bank_copper_amount("0"), Some(0));
16079        assert_eq!(parse_bank_copper_amount("250"), Some(250));
16080        assert_eq!(parse_bank_copper_amount("nope"), None);
16081    }
16082
16083    #[test]
16084    fn parse_storage_quantity_blank_and_zero_mean_all() {
16085        assert_eq!(parse_storage_quantity(""), Some(None));
16086        assert_eq!(parse_storage_quantity("  "), Some(None));
16087        assert_eq!(parse_storage_quantity("0"), Some(None));
16088        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
16089        assert_eq!(parse_storage_quantity("nope"), None);
16090    }
16091
16092    #[test]
16093    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
16094        assert!(worker_error_is_hud_noise("path stuck — repathing"));
16095        assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
16096        assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
16097        // Real problem: player may need to place lodging / fix assignment.
16098        assert!(!worker_error_is_hud_noise(
16099            "path stuck — no lodging to reset to"
16100        ));
16101    }
16102
16103    #[test]
16104    fn leaving_building_restores_outdoor_z_bands() {
16105        use flatland_protocol::{InteriorMapView, ZPlatformView};
16106
16107        let mut state = sample_state();
16108        state.z_platforms.clear();
16109        state.z_transitions.clear();
16110        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
16111        state.interior_map = Some(InteriorMapView {
16112            building_id: "broker_hut".into(),
16113            blueprint_id: "broker_hut".into(),
16114            background_color: "#000".into(),
16115            default_floor_color: None,
16116            floor_height_m: 3.0,
16117            z_platforms: vec![ZPlatformView {
16118                id: "floor_0".into(),
16119                z: 0.0,
16120                x0: 0.0,
16121                y0: 0.0,
16122                x1: 8.0,
16123                y1: 8.0,
16124            }],
16125            z_transitions: vec![],
16126            rooms: vec![],
16127            room_doors: vec![],
16128        });
16129        state.sync_interior_map_context();
16130        assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
16131        assert!(state.z_bands_outdoor_backup.is_some());
16132
16133        state.player.as_mut().unwrap().inside_building = None;
16134        state.sync_interior_map_context();
16135        assert!(
16136            state.z_platforms.is_empty(),
16137            "leaving must restore outdoor bands (empty), not leave interior platforms"
16138        );
16139        assert!(state.z_bands_outdoor_backup.is_none());
16140        assert!(state.interior_map.is_none());
16141    }
16142}