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    pub world_clock: flatland_protocol::WorldClock,
1179    pub inventory: std::collections::HashMap<String, u32>,
1180    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1181    pub logs: VecDeque<String>,
1182    pub intents_sent: u64,
1183    pub ticks_received: u64,
1184    pub connected: bool,
1185    pub disconnect_reason: Option<String>,
1186    pub show_stats: bool,
1187    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1188    pub hud_log_hidden: bool,
1189    pub show_equip_menu: bool,
1190    pub equip_menu_index: usize,
1191    pub show_craft_menu: bool,
1192    pub craft_menu_index: usize,
1193    /// How many timed crafts to queue when confirming the craft menu.
1194    pub craft_batch_quantity: u32,
1195    pub show_shop_menu: bool,
1196    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1197    pub bank_panel: Option<flatland_protocol::BankPanel>,
1198    pub bank_menu_index: usize,
1199    pub bank_ui_mode: BankUiMode,
1200    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1201    pub market_panel: Option<flatland_protocol::MarketPanel>,
1202    /// Selected row among filtered market browse listings.
1203    pub market_menu_index: usize,
1204    /// Browse / list-pick text filter (`/` to focus).
1205    pub market_filter: String,
1206    pub market_filter_focused: bool,
1207    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1208    pub market_category_filter: Option<&'static str>,
1209    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1210    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1211    pub market_ui_mode: MarketUiMode,
1212    pub storage_menu_index: usize,
1213    pub storage_ui_mode: StorageUiMode,
1214    pub shop_tab: ShopTab,
1215    pub shop_menu_index: usize,
1216    pub shop_quantity: u32,
1217    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1218    pub shop_trade_log: VecDeque<String>,
1219    pub show_npc_verb_menu: bool,
1220    pub npc_verb_target: Option<String>,
1221    pub npc_verb_index: usize,
1222    /// Nearby player Speak / Whisper / Trade dock.
1223    pub player_verbs: crate::social::PlayerVerbState,
1224    pub social_chat: crate::social::SocialChatState,
1225    pub trade_ui: crate::social::TradeUiState,
1226    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1227    pub show_npc_chat: bool,
1228    pub npc_chat: Option<NpcChatState>,
1229    pub show_inventory_menu: bool,
1230    pub inventory_menu_index: usize,
1231    pub inventory_tab: InventoryTab,
1232    pub inventory_filter: String,
1233    pub inventory_filter_focused: bool,
1234    pub show_move_picker: bool,
1235    pub move_picker_index: usize,
1236    pub move_picker: Option<MovePicker>,
1237    pub show_grant_picker: bool,
1238    pub grant_picker_index: usize,
1239    pub grant_picker: Option<GrantTargetPicker>,
1240    pub show_destroy_picker: bool,
1241    pub destroy_confirm_pending: bool,
1242    pub destroy_picker: Option<DestroyPicker>,
1243    /// Rename prompt for a selected container (`n` in inventory).
1244    pub show_rename_prompt: bool,
1245    /// Rename prompt for a hired worker (`n` in workers menu).
1246    pub show_worker_rename: bool,
1247    pub rename_buffer: String,
1248    /// Slot-1 combat target (mirrors server after SetTarget).
1249    pub combat_target: Option<EntityId>,
1250    pub combat_target_label: Option<String>,
1251    /// Active combat footprints from the server (`plans/39`).
1252    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1253    /// Authored crown property zones (claimable land) — plan 40.
1254    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1255    /// Tax overlays for claim cost premium preview.
1256    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1257    /// Growth / fertility overlays (farming, respawn).
1258    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1259    /// Climate / biome overlays.
1260    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1261    /// Claimed property plots near the observer.
1262    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1263    /// Server knobs for client-side claim quotes.
1264    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1265    /// Local claim-footprint editor (not server state).
1266    pub claim_mode: Option<ClaimModeState>,
1267    /// Local relocate ghost for a placed chest/lodging (not server state).
1268    pub relocate_mode: Option<RelocateModeState>,
1269    /// Second `f` within a short window confirms selling this plot back to the crown.
1270    pub sell_plot_confirm: Option<uuid::Uuid>,
1271    /// When `sell_plot_confirm` was armed (for double-tap window).
1272    pub sell_plot_armed_at: Option<Instant>,
1273    /// Choose seed type when planting on owned tilled soil (`f`).
1274    pub show_plant_menu: bool,
1275    pub plant_menu_index: usize,
1276    /// Deed-holder farm access panel (public toggle + allow-list).
1277    pub show_farm_access: bool,
1278    /// Draft name for adding a tenant from the farm-access panel.
1279    pub farm_access_name_draft: String,
1280    /// Public / grant discount draft (bps) while editing farm access.
1281    pub farm_access_discount_bps: u32,
1282    /// Selected row in the farm-access panel (0 = public toggle).
1283    pub farm_access_index: usize,
1284    pub plant_quantity: u32,
1285    pub in_combat: bool,
1286    pub auto_attack: bool,
1287    pub combat_has_los: bool,
1288    pub attack_cd_ticks: u64,
1289    pub gcd_ticks: u64,
1290    pub weapon_ability_id: String,
1291    pub mainhand_template_id: Option<String>,
1292    pub mainhand_label: Option<String>,
1293    pub offhand_template_id: Option<String>,
1294    pub offhand_label: Option<String>,
1295    pub mainhand_hand_slots: u8,
1296    pub defense: Option<flatland_protocol::DefenseHud>,
1297    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1298    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1299    pub carry_mass: f32,
1300    pub carry_mass_max: f32,
1301    pub encumbrance: flatland_protocol::EncumbranceState,
1302    /// Full nested inventory stacks from the server (root only; worn are separate).
1303    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1304    /// Keys stowed on the virtual keychain (zero carry mass).
1305    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1306    /// Whisper stones in the pouch (zero carry mass).
1307    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1308    /// Active status effects on the local player (buff/debuff strip).
1309    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1310    pub combat_target_detail: Option<CombatTargetHud>,
1311    pub cast_progress: Option<CastProgressHud>,
1312    /// Till / plant / other non-combat timed channels on the local player.
1313    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1314    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1315    pub blocking_active: bool,
1316    pub max_target_slots: u8,
1317    pub combat_slots: Vec<CombatSlotHud>,
1318    pub rotation_presets: Vec<RotationPreset>,
1319    /// Learned abilities from combat HUD (usable casts).
1320    pub known_abilities: Vec<String>,
1321    /// Server-persisted hotbar bindings (index 0 = key 1).
1322    pub hotbar: Vec<Option<String>>,
1323    /// Max abilities allowed in one rotation (mind score).
1324    pub max_abilities_per_rotation: u8,
1325    pub show_loadout_menu: bool,
1326    pub show_keychain_menu: bool,
1327    pub keychain_menu_index: usize,
1328    pub show_rotation_editor: bool,
1329    /// Selected rotation preset in the loadout menu.
1330    pub loadout_menu_index: usize,
1331    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1332    pub loadout_hotbar_slot: u8,
1333    /// Selected known-ability row in the loadout menu.
1334    pub loadout_ability_index: usize,
1335    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1336    pub loadout_focus_presets: bool,
1337    pub rotation_editor: RotationEditorState,
1338    /// True after a harvest intent is accepted until result/reject/disconnect.
1339    pub harvest_in_progress: bool,
1340    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1341    pub harvest_started_at: Option<Instant>,
1342    /// Craft log deferred until the server acks the craft intent.
1343    pub pending_craft_ack: Option<(u32, String, u32)>,
1344    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1345    pub interactables: Vec<flatland_protocol::InteractableView>,
1346    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1347    pub career: Option<flatland_protocol::PlayerCareerView>,
1348    pub character_sheet_tab: CharacterSheetTab,
1349    pub ledger_period: LedgerPeriod,
1350    pub show_quest_offer: bool,
1351    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1352    pub show_quest_menu: bool,
1353    pub quest_menu_index: usize,
1354    pub quest_withdraw_confirm: bool,
1355    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1356    pub show_workers_menu: bool,
1357    pub workers_menu_index: usize,
1358    /// Workers panel: one-line rows instead of full cards (more on screen).
1359    pub workers_menu_compact: bool,
1360    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1361    /// Public so out-of-crate tests can construct [`GameState`].
1362    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1363    /// Held worker route errors (readable when server clears `last_error` each tick).
1364    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1365    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1366    pub show_worker_give_picker: bool,
1367    pub worker_give_picker_index: usize,
1368    pub worker_give_picker: Option<WorkerGivePicker>,
1369    /// Inventory `g` — pick which nearby worker receives the selected stack.
1370    pub show_worker_give_target_picker: bool,
1371    pub worker_give_target_picker_index: usize,
1372    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1373    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1374    pub show_worker_take_picker: bool,
1375    pub worker_take_picker_index: usize,
1376    pub worker_take_picker: Option<WorkerTakePicker>,
1377    /// Teach-blueprint sheet opened from the workers menu (`t`).
1378    pub show_worker_teach_picker: bool,
1379    pub worker_teach_picker_index: usize,
1380    pub worker_teach_picker: Option<WorkerTeachPicker>,
1381    /// Active harvest-route editor (`h` → `e` on a worker).
1382    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1383    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1384    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1385    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1386    pub attending_worker_instance_id: Option<String>,
1387    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1388    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1389}
1390
1391impl GameState {
1392    pub fn push_log(&mut self, line: impl Into<String>) {
1393        self.logs.push_back(line.into());
1394        while self.logs.len() > MAX_LOG_LINES {
1395            self.logs.pop_front();
1396        }
1397    }
1398
1399    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1400        self.shop_trade_log.push_back(line.into());
1401        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1402            self.shop_trade_log.pop_front();
1403        }
1404    }
1405
1406    pub fn clear_shop_trade_log(&mut self) {
1407        self.shop_trade_log.clear();
1408    }
1409
1410    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1411        if !self.show_shop_menu {
1412            return;
1413        }
1414        let msg = notice.message.trim();
1415        if msg.is_empty() {
1416            return;
1417        }
1418        if notice.coins_delta != 0
1419            || msg.starts_with("Bought ")
1420            || msg.starts_with("Sold ")
1421            || msg.contains("taught you how to craft")
1422            || msg.starts_with("need ")
1423        {
1424            self.push_shop_trade_log(msg);
1425        }
1426    }
1427
1428    pub fn is_alive(&self) -> bool {
1429        self.player
1430            .as_ref()
1431            .and_then(|p| p.vitals)
1432            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1433            .unwrap_or(true)
1434    }
1435
1436    /// Verb menu entries for the current `npc_verb_target`.
1437    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1438        let Some(ref id) = self.npc_verb_target else {
1439            return vec![];
1440        };
1441        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1442            return vec!["Talk"];
1443        };
1444        let role = npc.role.as_str();
1445        if Self::npc_role_is_bank(role) {
1446            return vec!["Bank", "Talk"];
1447        }
1448        if Self::npc_role_is_storage(role) {
1449            return vec!["Storage", "Talk"];
1450        }
1451        if Self::npc_role_is_market(role) {
1452            return vec!["Market", "Talk"];
1453        }
1454        if npc.can_trade || Self::npc_role_can_trade(role) {
1455            vec!["Talk", "Trade"]
1456        } else {
1457            vec!["Talk"]
1458        }
1459    }
1460
1461    fn npc_role_can_trade(role: &str) -> bool {
1462        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1463    }
1464
1465    fn npc_role_is_bank(role: &str) -> bool {
1466        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1467    }
1468
1469    fn npc_role_is_storage(role: &str) -> bool {
1470        role.eq_ignore_ascii_case("storage_manager")
1471    }
1472
1473    fn npc_role_is_market(role: &str) -> bool {
1474        role.eq_ignore_ascii_case("market_clerk")
1475    }
1476
1477    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1478        vec![
1479            "Deposit…",
1480            "Withdraw…",
1481            "Deposit all",
1482            "Withdraw all",
1483            "Transfer…",
1484        ]
1485    }
1486
1487    pub fn storage_menu_options(&self) -> Vec<String> {
1488        let mut opts = vec!["Store…".into(), "Take…".into()];
1489        if let Some(panel) = &self.storage_panel {
1490            for dest in &panel.ship_destinations {
1491                opts.push(format!(
1492                    "Ship → {} ({} cp / {} ticks)",
1493                    dest.label, dest.fee_copper, dest.travel_ticks
1494                ));
1495            }
1496        }
1497        opts
1498    }
1499
1500    /// Loose on-person stacks eligible for town-storage store.
1501    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1502        self.person_rows()
1503            .into_iter()
1504            .filter(|r| r.depth == 0)
1505            .filter_map(|r| {
1506                let id = r.stack.item_instance_id?;
1507                Some(StoragePickOption {
1508                    item_instance_id: id,
1509                    label: storage_stack_label(&r.stack),
1510                    quantity: r.stack.quantity,
1511                    category: r.stack.category.clone().unwrap_or_default(),
1512                })
1513            })
1514            .collect()
1515    }
1516
1517    /// Vault stacks eligible for take / ship.
1518    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1519        let Some(panel) = &self.storage_panel else {
1520            return Vec::new();
1521        };
1522        panel
1523            .contents
1524            .iter()
1525            .filter_map(|s| {
1526                let id = s.item_instance_id?;
1527                Some(StoragePickOption {
1528                    item_instance_id: id,
1529                    label: storage_stack_label(s),
1530                    quantity: s.quantity,
1531                    category: s.category.clone().unwrap_or_default(),
1532                })
1533            })
1534            .collect()
1535    }
1536
1537    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1538    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1539        let mut opts = Vec::new();
1540        if !self
1541            .market_list_item_options(&MarketListSourceKind::Person)
1542            .is_empty()
1543        {
1544            opts.push((MarketListSourceKind::Person, "On person".into()));
1545        }
1546        if let Some(panel) = &self.market_panel {
1547            for vault in &panel.list_vaults {
1548                let source = MarketListSourceKind::TownStorage {
1549                    building_id: vault.building_id.clone(),
1550                };
1551                if self.market_list_item_options(&source).is_empty() {
1552                    continue;
1553                }
1554                let label = if vault.building_label.is_empty() {
1555                    format!("Town storage ({})", vault.building_id)
1556                } else {
1557                    format!("Town storage — {}", vault.building_label)
1558                };
1559                opts.push((source, label));
1560            }
1561        }
1562        opts
1563    }
1564
1565    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1566    pub fn market_list_item_options(
1567        &self,
1568        source: &MarketListSourceKind,
1569    ) -> Vec<StoragePickOption> {
1570        let filter = self.market_filter.as_str();
1571        let cat_filter = self.market_category_filter;
1572        let mut opts: Vec<StoragePickOption> = match source {
1573            MarketListSourceKind::Person => self
1574                .person_rows()
1575                .into_iter()
1576                .filter(|r| r.depth == 0)
1577                .filter(|r| self.stack_is_market_listable(&r.stack))
1578                .filter_map(|r| {
1579                    let id = r.stack.item_instance_id?;
1580                    Some(StoragePickOption {
1581                        item_instance_id: id,
1582                        label: storage_stack_label(&r.stack),
1583                        quantity: r.stack.quantity,
1584                        category: r
1585                            .stack
1586                            .category
1587                            .clone()
1588                            .or_else(|| {
1589                                self.inventory_item_category(&r.stack.template_id)
1590                                    .map(str::to_string)
1591                            })
1592                            .unwrap_or_default(),
1593                    })
1594                })
1595                .collect(),
1596            MarketListSourceKind::TownStorage { building_id } => {
1597                let Some(panel) = &self.market_panel else {
1598                    return Vec::new();
1599                };
1600                let Some(vault) = panel
1601                    .list_vaults
1602                    .iter()
1603                    .find(|v| &v.building_id == building_id)
1604                else {
1605                    return Vec::new();
1606                };
1607                vault
1608                    .contents
1609                    .iter()
1610                    .filter(|s| self.stack_is_market_listable(s))
1611                    .filter_map(|s| {
1612                        let id = s.item_instance_id?;
1613                        Some(StoragePickOption {
1614                            item_instance_id: id,
1615                            label: storage_stack_label(s),
1616                            quantity: s.quantity,
1617                            category: s
1618                                .category
1619                                .clone()
1620                                .or_else(|| {
1621                                    self.inventory_item_category(&s.template_id)
1622                                        .map(str::to_string)
1623                                })
1624                                .unwrap_or_default(),
1625                        })
1626                    })
1627                    .collect()
1628            }
1629        };
1630        opts.retain(|o| {
1631            if !list_label_matches(&o.label, filter) {
1632                return false;
1633            }
1634            if let Some(group) = cat_filter {
1635                inventory_category_group(&o.category).0 == group
1636            } else {
1637                true
1638            }
1639        });
1640        opts
1641    }
1642
1643    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1644        if crate::currency::is_currency(&stack.template_id) {
1645            return false;
1646        }
1647        if let Some(flag) = stack.listable {
1648            return flag;
1649        }
1650        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1651            return hint.listable;
1652        }
1653        let cat = stack
1654            .category
1655            .as_deref()
1656            .or_else(|| self.inventory_item_category(&stack.template_id))
1657            .unwrap_or("");
1658        category_default_listable(cat)
1659    }
1660
1661    /// Category group labels present in the current browse book or list-pick source.
1662    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1663        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1664        match &self.market_ui_mode {
1665            MarketUiMode::ListPick { source, .. } => {
1666                let raw: Vec<_> = match source {
1667                    MarketListSourceKind::Person => self
1668                        .person_rows()
1669                        .into_iter()
1670                        .filter(|r| r.depth == 0)
1671                        .filter(|r| self.stack_is_market_listable(&r.stack))
1672                        .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1673                        .map(|r| {
1674                            r.stack
1675                                .category
1676                                .clone()
1677                                .or_else(|| {
1678                                    self.inventory_item_category(&r.stack.template_id)
1679                                        .map(str::to_string)
1680                                })
1681                                .unwrap_or_default()
1682                        })
1683                        .collect(),
1684                    MarketListSourceKind::TownStorage { building_id } => self
1685                        .market_panel
1686                        .as_ref()
1687                        .and_then(|p| {
1688                            p.list_vaults
1689                                .iter()
1690                                .find(|v| &v.building_id == building_id)
1691                        })
1692                        .map(|vault| {
1693                            vault
1694                                .contents
1695                                .iter()
1696                                .filter(|s| self.stack_is_market_listable(s))
1697                                .filter(|s| {
1698                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
1699                                })
1700                                .map(|s| {
1701                                    s.category
1702                                        .clone()
1703                                        .or_else(|| {
1704                                            self.inventory_item_category(&s.template_id)
1705                                                .map(str::to_string)
1706                                        })
1707                                        .unwrap_or_default()
1708                                })
1709                                .collect::<Vec<_>>()
1710                        })
1711                        .unwrap_or_default(),
1712                };
1713                for category in raw {
1714                    let (label, ord) = inventory_category_group(&category);
1715                    seen.insert(ord, label);
1716                }
1717            }
1718            _ => {
1719                if let Some(panel) = &self.market_panel {
1720                    for listing in &panel.listings {
1721                        if !list_label_matches(&listing.display_name, &self.market_filter)
1722                            && !list_label_matches(&listing.seller_label, &self.market_filter)
1723                        {
1724                            continue;
1725                        }
1726                        let (label, ord) = inventory_category_group(&listing.category);
1727                        seen.insert(ord, label);
1728                    }
1729                }
1730            }
1731        }
1732        seen.into_values().collect()
1733    }
1734
1735    /// Indices into `market_panel.listings` after category + text filter.
1736    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1737        let Some(panel) = &self.market_panel else {
1738            return Vec::new();
1739        };
1740        let filter = self.market_filter.as_str();
1741        let cat_filter = self.market_category_filter;
1742        panel
1743            .listings
1744            .iter()
1745            .enumerate()
1746            .filter(|(_, listing)| {
1747                if !list_label_matches(&listing.display_name, filter)
1748                    && !list_label_matches(&listing.seller_label, filter)
1749                    && !list_label_matches(&listing.template_id, filter)
1750                {
1751                    return false;
1752                }
1753                if let Some(group) = cat_filter {
1754                    inventory_category_group(&listing.category).0 == group
1755                } else {
1756                    true
1757                }
1758            })
1759            .map(|(i, _)| i)
1760            .collect()
1761    }
1762
1763    pub fn clear_harvest_state(&mut self) {
1764        self.harvest_in_progress = false;
1765        self.harvest_started_at = None;
1766    }
1767
1768    fn harvest_state_stale(&self) -> bool {
1769        match self.harvest_started_at {
1770            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1771            None => self.harvest_in_progress,
1772        }
1773    }
1774
1775    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1776        self.player.as_ref().and_then(|p| p.vitals)
1777    }
1778
1779    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1780        let materials_ok = blueprint.inputs.iter().all(|input| {
1781            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1782        });
1783        let tools_ok = blueprint
1784            .required_tools
1785            .iter()
1786            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1787        let station_ok = match blueprint.station.as_deref() {
1788            None | Some("hand") => true,
1789            Some(tag) => self.player_at_station_tag(tag),
1790        };
1791        materials_ok && tools_ok && station_ok
1792    }
1793
1794    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1795        if !self.can_craft_blueprint(blueprint) {
1796            return 0;
1797        }
1798        let mut limit = u32::MAX;
1799        for input in &blueprint.inputs {
1800            if input.quantity == 0 {
1801                continue;
1802            }
1803            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1804            limit = limit.min(have / input.quantity);
1805        }
1806        for tool in &blueprint.required_tools {
1807            if tool.consumed {
1808                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1809                limit = limit.min(have);
1810            }
1811        }
1812        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1813        if CRAFT_STAMINA_COST > 0.0 {
1814            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1815        }
1816        limit
1817    }
1818
1819    pub fn clamp_craft_batch_quantity(&mut self) {
1820        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1821            self.craft_batch_quantity = 1;
1822            return;
1823        };
1824        let max = self.max_craft_batches(bp).max(1);
1825        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1826    }
1827
1828    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1829        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1830            return;
1831        };
1832        let max = self.max_craft_batches(&bp).max(1);
1833        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1834        self.craft_batch_quantity = next as u32;
1835    }
1836
1837    pub fn craft_batch_set_max(&mut self) {
1838        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1839            return;
1840        };
1841        let max = self.max_craft_batches(&bp);
1842        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1843    }
1844
1845    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1846        let preserve_ui = self.show_shop_menu;
1847        let tab = self.shop_tab;
1848        let index = self.shop_menu_index;
1849        let qty = self.shop_quantity;
1850
1851        self.show_shop_menu = true;
1852        self.bank_panel = None;
1853        self.show_craft_menu = false;
1854        self.show_inventory_menu = false;
1855        self.show_stats = false;
1856        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1857            self.npc_verb_target = Some(catalog.npc_id.clone());
1858        }
1859        self.shop_catalog = Some(catalog);
1860
1861        if preserve_ui {
1862            self.shop_tab = tab;
1863            self.shop_menu_index = index;
1864            self.shop_quantity = qty;
1865        } else {
1866            self.shop_tab = ShopTab::Buy;
1867            self.shop_menu_index = 0;
1868            self.shop_quantity = 1;
1869            self.clear_shop_trade_log();
1870        }
1871        self.show_npc_verb_menu = false;
1872        self.clamp_shop_selection();
1873    }
1874
1875    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1876        let same_teller = self
1877            .bank_panel
1878            .as_ref()
1879            .is_some_and(|p| p.npc_id == panel.npc_id);
1880        self.bank_panel = Some(panel);
1881        self.storage_panel = None;
1882        self.market_panel = None;
1883        self.shop_catalog = None;
1884        self.show_shop_menu = false;
1885        self.show_craft_menu = false;
1886        self.show_inventory_menu = false;
1887        self.show_stats = false;
1888        self.show_npc_verb_menu = false;
1889        self.show_npc_chat = false;
1890        self.npc_chat = None;
1891        if !same_teller {
1892            self.bank_menu_index = 0;
1893            self.bank_ui_mode = BankUiMode::Menu;
1894        }
1895        if let Some(panel) = &self.bank_panel {
1896            if self.npc_verb_target.is_none() {
1897                self.npc_verb_target = Some(panel.npc_id.clone());
1898            }
1899        }
1900    }
1901
1902    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1903        let same_manager = self
1904            .storage_panel
1905            .as_ref()
1906            .is_some_and(|p| p.npc_id == panel.npc_id);
1907        self.storage_panel = Some(panel);
1908        self.bank_panel = None;
1909        self.market_panel = None;
1910        self.bank_ui_mode = BankUiMode::Menu;
1911        self.shop_catalog = None;
1912        self.show_shop_menu = false;
1913        self.show_craft_menu = false;
1914        self.show_inventory_menu = false;
1915        self.show_stats = false;
1916        self.show_npc_verb_menu = false;
1917        self.show_npc_chat = false;
1918        self.npc_chat = None;
1919        if !same_manager {
1920            self.storage_menu_index = 0;
1921            self.storage_ui_mode = StorageUiMode::Menu;
1922        } else {
1923            self.clamp_storage_pick_index();
1924        }
1925        if let Some(panel) = &self.storage_panel {
1926            if self.npc_verb_target.is_none() {
1927                self.npc_verb_target = Some(panel.npc_id.clone());
1928            }
1929        }
1930    }
1931
1932    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
1933        self.market_panel = Some(panel);
1934        self.bank_panel = None;
1935        self.storage_panel = None;
1936        self.shop_catalog = None;
1937        self.show_shop_menu = false;
1938        self.show_craft_menu = false;
1939        self.show_inventory_menu = false;
1940        self.show_stats = false;
1941        self.show_npc_verb_menu = false;
1942        self.show_npc_chat = false;
1943        self.npc_chat = None;
1944        self.market_menu_index = 0;
1945        self.market_buy_confirm = None;
1946        self.market_ui_mode = MarketUiMode::Browse;
1947        self.market_filter.clear();
1948        self.market_filter_focused = false;
1949        self.market_category_filter = None;
1950        if let Some(panel) = &self.market_panel {
1951            if self.npc_verb_target.is_none() {
1952                self.npc_verb_target = Some(panel.npc_id.clone());
1953            }
1954        }
1955    }
1956
1957    pub fn clear_market_panel(&mut self) {
1958        self.market_panel = None;
1959        self.market_menu_index = 0;
1960        self.market_buy_confirm = None;
1961        self.market_ui_mode = MarketUiMode::Browse;
1962        self.market_filter.clear();
1963        self.market_filter_focused = false;
1964        self.market_category_filter = None;
1965    }
1966
1967    pub fn clear_bank_panel(&mut self) {
1968        self.bank_panel = None;
1969        self.bank_menu_index = 0;
1970        self.bank_ui_mode = BankUiMode::Menu;
1971    }
1972
1973    pub fn clear_storage_panel(&mut self) {
1974        self.storage_panel = None;
1975        self.storage_menu_index = 0;
1976        self.storage_ui_mode = StorageUiMode::Menu;
1977    }
1978
1979    fn clamp_storage_pick_index(&mut self) {
1980        match &self.storage_ui_mode {
1981            StorageUiMode::StorePick { index } => {
1982                let n = self.storage_store_options().len();
1983                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1984                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1985            }
1986            StorageUiMode::TakePick { index } => {
1987                let n = self.storage_vault_options().len();
1988                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1989                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1990            }
1991            StorageUiMode::ShipPick {
1992                dest_building_id,
1993                dest_label,
1994                index,
1995            } => {
1996                let n = self.storage_vault_options().len();
1997                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1998                self.storage_ui_mode = StorageUiMode::ShipPick {
1999                    dest_building_id: dest_building_id.clone(),
2000                    dest_label: dest_label.clone(),
2001                    index: next,
2002                };
2003            }
2004            StorageUiMode::Menu
2005            | StorageUiMode::StoreAmount { .. }
2006            | StorageUiMode::TakeAmount { .. }
2007            | StorageUiMode::ShipAmount { .. } => {}
2008        }
2009    }
2010
2011    pub fn shop_list_len(&self) -> usize {
2012        let Some(catalog) = &self.shop_catalog else {
2013            return 0;
2014        };
2015        match self.shop_tab {
2016            ShopTab::Buy => catalog.sells.len(),
2017            ShopTab::Sell => catalog.buys.len(),
2018        }
2019    }
2020
2021    pub fn shop_menu_move(&mut self, delta: i32) {
2022        let n = self.shop_list_len();
2023        if n == 0 {
2024            return;
2025        }
2026        let idx = self.shop_menu_index as i32;
2027        let next = (idx + delta).rem_euclid(n as i32);
2028        self.shop_menu_index = next as usize;
2029        self.clamp_shop_quantity();
2030    }
2031
2032    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2033        let max = self.shop_quantity_max();
2034        if max == 0 {
2035            self.shop_quantity = 0;
2036            return;
2037        }
2038        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2039        self.shop_quantity = next as u32;
2040    }
2041
2042    pub(crate) fn clamp_shop_selection(&mut self) {
2043        let n = self.shop_list_len();
2044        if n == 0 {
2045            self.shop_menu_index = 0;
2046        } else {
2047            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2048        }
2049        self.clamp_shop_quantity();
2050    }
2051
2052    fn shop_quantity_max(&self) -> u32 {
2053        let Some(catalog) = &self.shop_catalog else {
2054            return 1;
2055        };
2056        match self.shop_tab {
2057            ShopTab::Buy => {
2058                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2059                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2060                        return 1;
2061                    }
2062                }
2063                99
2064            }
2065            ShopTab::Sell => catalog
2066                .buys
2067                .get(self.shop_menu_index)
2068                .map(|l| l.quantity)
2069                .unwrap_or(0),
2070        }
2071    }
2072
2073    pub fn shop_quantity_set_max(&mut self) {
2074        self.shop_quantity = self.shop_quantity_max();
2075    }
2076
2077    fn clamp_shop_quantity(&mut self) {
2078        let max = self.shop_quantity_max();
2079        if max == 0 {
2080            self.shop_quantity = 0;
2081        } else {
2082            self.shop_quantity = self.shop_quantity.max(1).min(max);
2083        }
2084    }
2085
2086    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2087        let Some(id) = self.effective_inside_building() else {
2088            return false;
2089        };
2090        self.buildings
2091            .iter()
2092            .find(|b| b.id == id)
2093            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2094    }
2095
2096    /// Short hint for UI when a recipe cannot be started.
2097    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2098        if self.can_craft_blueprint(blueprint) {
2099            return None;
2100        }
2101        let mut missing = Vec::new();
2102        for input in &blueprint.inputs {
2103            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2104            if have < input.quantity {
2105                missing.push(format!(
2106                    "{}×{} (have {have})",
2107                    input.quantity, input.template_id
2108                ));
2109            }
2110        }
2111        for tool in &blueprint.required_tools {
2112            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2113            if have < 1 {
2114                missing.push(format!("tool: {}", tool.item));
2115            }
2116        }
2117        if let Some(station) = blueprint.station.as_deref() {
2118            if station != "hand" && !self.player_at_station_tag(station) {
2119                missing.push(format!("station: {station} (enter building)"));
2120            }
2121        }
2122        if missing.is_empty() {
2123            None
2124        } else {
2125            Some(missing.join(", "))
2126        }
2127    }
2128
2129    pub fn player_entity(&self) -> Option<&EntityState> {
2130        self.player
2131            .as_ref()
2132            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2133    }
2134
2135    /// Apply persisted HUD / workers UI prefs from `client.json`.
2136    pub fn apply_client_ui_prefs(&mut self) {
2137        let cfg = crate::client_config::ClientConfig::load();
2138        if let Some(hidden) = cfg.hud_log_hidden {
2139            self.hud_log_hidden = hidden;
2140        }
2141        if let Some(compact) = cfg.workers_menu_compact {
2142            self.workers_menu_compact = compact;
2143        }
2144    }
2145
2146    pub fn player_position(&self) -> (f32, f32) {
2147        let (x, y, _) = self.player_position_with_z();
2148        (x, y)
2149    }
2150
2151    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2152        if let Some(p) = self.player_entity() {
2153            (
2154                p.transform.position.x,
2155                p.transform.position.y,
2156                p.transform.position.z,
2157            )
2158        } else {
2159            (0.0, 0.0, 0.0)
2160        }
2161    }
2162
2163    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2164        let mut rows: Vec<(String, u32, String)> = self
2165            .inventory
2166            .iter()
2167            .filter(|(_, q)| **q > 0)
2168            .map(|(id, qty)| {
2169                let label = self
2170                    .inventory_hints
2171                    .get(id)
2172                    .map(|h| h.display_name.clone())
2173                    .unwrap_or_else(|| id.clone());
2174                (id.clone(), *qty, label)
2175            })
2176            .collect();
2177        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2178        rows
2179    }
2180
2181    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2182        self.inventory_hints
2183            .get(template_id)
2184            .map(|h| h.category.as_str())
2185            .filter(|c| !c.is_empty())
2186    }
2187
2188    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2189        stack
2190            .props
2191            .get("grants_item_status_effect")
2192            .map(|s| !s.is_empty())
2193            .unwrap_or(false)
2194    }
2195
2196    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2197        stack
2198            .props
2199            .get("grants_item_status_effect")
2200            .map(String::as_str)
2201            .filter(|s| !s.is_empty())
2202    }
2203
2204    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2205        stack
2206            .props
2207            .get("grants_item_status_mode")
2208            .map(String::as_str)
2209            .unwrap_or("on_hit")
2210    }
2211
2212    /// Candidate gear for a grant consumable (inventory + worn).
2213    pub fn grant_target_options(
2214        &self,
2215        grant: &flatland_protocol::ItemStack,
2216    ) -> Vec<GrantTargetOption> {
2217        let mode = Self::grant_mode(grant);
2218        let grant_tags: Vec<&str> = grant
2219            .props
2220            .get("grants_item_status_tags")
2221            .map(|s| {
2222                s.split(',')
2223                    .map(str::trim)
2224                    .filter(|t| !t.is_empty())
2225                    .collect()
2226            })
2227            .unwrap_or_default();
2228        let grant_id = grant.item_instance_id;
2229        let mut out = Vec::new();
2230        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2231            let Some(iid) = stack.item_instance_id else {
2232                return;
2233            };
2234            if Some(iid) == grant_id {
2235                return;
2236            }
2237            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2238                return;
2239            }
2240            if !grant_target_matches_mode(stack, mode) {
2241                return;
2242            }
2243            if !grant_tags_match(stack, &grant_tags) {
2244                return;
2245            }
2246            let name = stack
2247                .display_name
2248                .clone()
2249                .unwrap_or_else(|| stack.template_id.clone());
2250            let bindings = if stack.status_bindings.is_empty() {
2251                String::new()
2252            } else {
2253                format!(
2254                    " · {}",
2255                    stack
2256                        .status_bindings
2257                        .iter()
2258                        .map(|b| b.effect_id.as_str())
2259                        .collect::<Vec<_>>()
2260                        .join(", ")
2261                )
2262            };
2263            out.push(GrantTargetOption {
2264                label: format!("{where_label}: {name}{bindings}"),
2265                target_instance_id: iid,
2266            });
2267        };
2268        fn walk(
2269            stacks: &[flatland_protocol::ItemStack],
2270            where_label: &str,
2271            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2272        ) {
2273            for s in stacks {
2274                push(s, where_label);
2275                if !s.contents.is_empty() {
2276                    let nested = format!(
2277                        "{where_label}/{}",
2278                        s.display_name
2279                            .as_deref()
2280                            .unwrap_or(s.template_id.as_str())
2281                    );
2282                    walk(&s.contents, &nested, push);
2283                }
2284            }
2285        }
2286        walk(&self.inventory_stacks, "Bag", &mut push);
2287        for (slot, stack) in &self.worn {
2288            push(stack, body_slot_label(*slot));
2289            let nest = format!(
2290                "{}/{}",
2291                body_slot_label(*slot),
2292                stack
2293                    .display_name
2294                    .as_deref()
2295                    .unwrap_or(stack.template_id.as_str())
2296            );
2297            walk(&stack.contents, &nest, &mut push);
2298        }
2299        out
2300    }
2301
2302    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2303        self.inventory_hints
2304            .get(template_id)
2305            .and_then(|h| h.base_mass)
2306            .unwrap_or(0.5)
2307    }
2308
2309    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2310        self.inventory_hints
2311            .get(template_id)
2312            .and_then(|h| h.base_volume)
2313            .unwrap_or(1.0)
2314    }
2315
2316    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2317        let unit = stack
2318            .base_mass
2319            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2320        unit * stack.quantity as f32
2321    }
2322
2323    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2324        let unit = stack.base_volume.unwrap_or(1.0);
2325        unit * stack.quantity as f32
2326            + stack
2327                .contents
2328                .iter()
2329                .map(Self::stack_tree_volume)
2330                .sum::<f32>()
2331    }
2332
2333    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2334        contents.iter().map(Self::stack_tree_volume).sum()
2335    }
2336
2337    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2338        self.inventory_hints
2339            .get(template_id)
2340            .and_then(|h| h.capacity_volume)
2341            .filter(|c| *c > 0.0)
2342    }
2343
2344    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2345        stack
2346            .capacity_volume
2347            .filter(|c| *c > 0.0)
2348            .or_else(|| self.template_capacity_volume(&stack.template_id))
2349    }
2350
2351    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2352    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2353        let Some((used, cap)) = self.container_volume_stats(row) else {
2354            return String::new();
2355        };
2356        let free = (cap - used).max(0.0);
2357        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2358    }
2359
2360    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2361        if row.is_chest_shell {
2362            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2363                return None;
2364            };
2365            let chest = self
2366                .placed_containers
2367                .iter()
2368                .find(|c| c.id == *container_id)?;
2369            let cap = self
2370                .stack_capacity_volume(&row.stack)
2371                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2372            let used = if chest.accessible {
2373                Self::contents_used_volume(&chest.contents)
2374            } else {
2375                0.0
2376            };
2377            return Some((used, cap));
2378        }
2379
2380        let cap = self.stack_capacity_volume(&row.stack)?;
2381        let used = Self::contents_used_volume(&row.stack.contents);
2382        Some((used, cap))
2383    }
2384
2385    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2386        if row.is_chest_shell {
2387            return true;
2388        }
2389        if row.is_equip_shell {
2390            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2391        }
2392        self.inventory_item_category(&row.stack.template_id) == Some("container")
2393            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2394    }
2395
2396    fn container_stack_for(
2397        &self,
2398        location: &flatland_protocol::InventoryLocation,
2399        parent_instance_id: Option<uuid::Uuid>,
2400    ) -> Option<flatland_protocol::ItemStack> {
2401        match location {
2402            flatland_protocol::InventoryLocation::Root => {
2403                let pid = parent_instance_id?;
2404                self.find_stack_by_instance(&self.inventory_stacks, pid)
2405            }
2406            flatland_protocol::InventoryLocation::Worn { slot } => {
2407                let worn = self.worn.get(slot)?;
2408                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2409                    Some(worn.clone())
2410                } else {
2411                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2412                }
2413            }
2414            flatland_protocol::InventoryLocation::Placed { container_id } => {
2415                let chest = self
2416                    .placed_containers
2417                    .iter()
2418                    .find(|c| c.id == *container_id)?;
2419                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2420                    Some(flatland_protocol::ItemStack {
2421                        template_id: chest.template_id.clone(),
2422                        quantity: 1,
2423                        item_instance_id: chest.item_instance_id,
2424                        props: Default::default(),
2425                        status_bindings: Vec::new(),
2426                        contents: chest.contents.clone(),
2427                        display_name: Some(chest.display_name.clone()),
2428                        category: Some("container".into()),
2429                        capacity_volume: self
2430                            .inventory_hints
2431                            .get(&chest.template_id)
2432                            .and_then(|h| h.capacity_volume),
2433                        worker_lodging_capacity: chest.worker_lodging_capacity,
2434                        ..Default::default()
2435                    })
2436                } else {
2437                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2438                }
2439            }
2440            flatland_protocol::InventoryLocation::Keychain => None,
2441            flatland_protocol::InventoryLocation::WhisperPouch => None,
2442        }
2443    }
2444
2445    fn find_stack_by_instance(
2446        &self,
2447        stacks: &[flatland_protocol::ItemStack],
2448        instance_id: uuid::Uuid,
2449    ) -> Option<flatland_protocol::ItemStack> {
2450        for stack in stacks {
2451            if stack.item_instance_id == Some(instance_id) {
2452                return Some(stack.clone());
2453            }
2454            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2455                return Some(found);
2456            }
2457        }
2458        None
2459    }
2460
2461    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2462    pub fn max_movable_to(
2463        &self,
2464        template_id: &str,
2465        stack_qty: u32,
2466        from: &flatland_protocol::InventoryLocation,
2467        to: &flatland_protocol::InventoryLocation,
2468        parent_instance_id: Option<uuid::Uuid>,
2469    ) -> u32 {
2470        let unit_vol = self.item_base_volume(template_id);
2471        let unit_mass = self.item_base_mass(template_id);
2472        let mut limit = stack_qty;
2473
2474        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2475            let cap = parent
2476                .capacity_volume
2477                .or_else(|| {
2478                    self.inventory_hints
2479                        .get(&parent.template_id)
2480                        .and_then(|h| h.capacity_volume)
2481                })
2482                .unwrap_or(0.0);
2483            if cap > 0.0 && unit_vol > 0.0 {
2484                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2485                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2486            }
2487        }
2488
2489        let to_person = matches!(
2490            to,
2491            flatland_protocol::InventoryLocation::Root
2492                | flatland_protocol::InventoryLocation::Worn { .. }
2493        );
2494        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2495        if to_person && from_placed && unit_mass > 0.0 {
2496            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2497            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2498                limit = 0;
2499            } else {
2500                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2501            }
2502        }
2503
2504        limit.max(0).min(stack_qty)
2505    }
2506
2507    pub fn move_picker_max_at_selection(&self) -> u32 {
2508        let Some(picker) = &self.move_picker else {
2509            return 1;
2510        };
2511        let Some(opt) = picker.options.get(self.move_picker_index) else {
2512            return picker.stack_quantity;
2513        };
2514        match &opt.kind {
2515            MoveOptionKind::Cancel
2516            | MoveOptionKind::Drop
2517            | MoveOptionKind::Use
2518            | MoveOptionKind::GrantApply
2519            | MoveOptionKind::SellPlotToCrown { .. }
2520            | MoveOptionKind::PickupPlaced { .. }
2521            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2522            MoveOptionKind::Move {
2523                location,
2524                parent_instance_id,
2525            } => self.max_movable_to(
2526                &picker.template_id,
2527                picker.stack_quantity,
2528                &picker.from,
2529                location,
2530                *parent_instance_id,
2531            ),
2532        }
2533    }
2534
2535    pub fn clamp_move_picker_quantity(&mut self) {
2536        let max = self.move_picker_max_at_selection();
2537        if let Some(picker) = &mut self.move_picker {
2538            if max == 0 {
2539                picker.quantity = 1;
2540            } else {
2541                picker.quantity = picker.quantity.clamp(1, max);
2542            }
2543        }
2544    }
2545
2546    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2547        let max = self.move_picker_max_at_selection().max(1);
2548        if let Some(picker) = &mut self.move_picker {
2549            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2550            picker.quantity = next as u32;
2551        }
2552    }
2553
2554    pub fn move_picker_set_quantity_max(&mut self) {
2555        let max = self.move_picker_max_at_selection();
2556        if let Some(picker) = &mut self.move_picker {
2557            picker.quantity = if max == 0 {
2558                1
2559            } else {
2560                max.min(picker.stack_quantity)
2561            };
2562        }
2563    }
2564
2565    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2566        if let Some(picker) = &mut self.destroy_picker {
2567            let max = picker.stack_quantity.max(1);
2568            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2569            picker.quantity = next as u32;
2570        }
2571    }
2572
2573    pub fn destroy_picker_set_quantity_max(&mut self) {
2574        if let Some(picker) = &mut self.destroy_picker {
2575            picker.quantity = picker.stack_quantity.max(1);
2576        }
2577    }
2578
2579    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2580        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2581        (have, have >= need)
2582    }
2583
2584    pub fn currency_display(&self) -> String {
2585        crate::currency::currency_line(&self.inventory)
2586    }
2587
2588    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2589    pub fn in_shallow_water(&self) -> bool {
2590        let (px, py) = self.player_position();
2591        self.terrain_at(px, py)
2592            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2593    }
2594
2595    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2596        self.terrain_zone_at(x, y).map(|z| z.kind)
2597    }
2598
2599    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2600    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2601        use std::cell::RefCell;
2602
2603        const CHUNK: i32 = 8;
2604        thread_local! {
2605            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2606                RefCell::new(None);
2607        }
2608
2609        let zones = &self.terrain_zones;
2610        if zones.is_empty() {
2611            return None;
2612        }
2613        if zones.len() <= 48 {
2614            return zones
2615                .iter()
2616                .enumerate()
2617                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2618                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2619                .map(|(_, z)| z);
2620        }
2621
2622        let ptr = zones.as_ptr();
2623        let len = zones.len();
2624        INDEX.with(|cell| {
2625            let mut slot = cell.borrow_mut();
2626            let stale = match slot.as_ref() {
2627                Some((p, l, _)) => *p != ptr || *l != len,
2628                None => true,
2629            };
2630            if stale {
2631                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2632                    std::collections::HashMap::new();
2633                for (zi, z) in zones.iter().enumerate() {
2634                    let x0 = z.x0.min(z.x1).floor() as i32;
2635                    let y0 = z.y0.min(z.y1).floor() as i32;
2636                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2637                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2638                    let cx0 = x0.div_euclid(CHUNK);
2639                    let cy0 = y0.div_euclid(CHUNK);
2640                    let cx1 = x1.div_euclid(CHUNK);
2641                    let cy1 = y1.div_euclid(CHUNK);
2642                    for cy in cy0..=cy1 {
2643                        for cx in cx0..=cx1 {
2644                            chunks.entry((cx, cy)).or_default().push(zi);
2645                        }
2646                    }
2647                }
2648                *slot = Some((ptr, len, chunks));
2649            }
2650            let chunks = &slot.as_ref().expect("index").2;
2651            let cx = (x.floor() as i32).div_euclid(CHUNK);
2652            let cy = (y.floor() as i32).div_euclid(CHUNK);
2653            let mut best: Option<(usize, &TerrainZoneView)> = None;
2654            if let Some(list) = chunks.get(&(cx, cy)) {
2655                for &zi in list {
2656                    let Some(z) = zones.get(zi) else { continue };
2657                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2658                        continue;
2659                    }
2660                    best = match best {
2661                        None => Some((zi, z)),
2662                        Some((bi, bz)) => {
2663                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2664                                Some((zi, z))
2665                            } else {
2666                                Some((bi, bz))
2667                            }
2668                        }
2669                    };
2670                }
2671            }
2672            best.map(|(_, z)| z)
2673        })
2674    }
2675
2676    /// Ground elevation from terrain zones (m).
2677    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2678        self.terrain_zone_at(x, y)
2679            .map(|z| z.elevation)
2680            .unwrap_or(0.0)
2681    }
2682
2683    /// Walkable z levels at a map column (terrain + platforms).
2684    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2685        const TOL: f32 = 0.35;
2686        let mut levels = vec![self.elevation_at(x, y)];
2687        for p in &self.z_platforms {
2688            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2689                levels.push(p.z);
2690            }
2691        }
2692        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2693        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2694        levels
2695    }
2696
2697    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2698        const TOL: f32 = 0.35;
2699        self.walkable_levels_at(x, y)
2700            .iter()
2701            .any(|&l| (l - z).abs() <= TOL)
2702    }
2703
2704    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2705        let mut top = self.elevation_at(x, y);
2706        for p in &self.z_platforms {
2707            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2708                top = top.max(p.z);
2709            }
2710        }
2711        top
2712    }
2713
2714    /// Authoritative interior context from the server (`inside_building` flag).
2715    pub fn effective_inside_building(&self) -> Option<String> {
2716        self.player_entity().and_then(|p| p.inside_building.clone())
2717    }
2718
2719    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2720        self.inventory_stacks = stacks.to_vec();
2721        self.inventory.clear();
2722        self.inventory_hints.clear();
2723        fn walk(
2724            stacks: &[flatland_protocol::ItemStack],
2725            inventory: &mut std::collections::HashMap<String, u32>,
2726            hints: &mut std::collections::HashMap<String, InventoryHint>,
2727        ) {
2728            for stack in stacks {
2729                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2730                if stack.display_name.is_some()
2731                    || stack.category.is_some()
2732                    || stack.base_mass.is_some()
2733                    || stack.base_volume.is_some()
2734                {
2735                    hints.insert(
2736                        stack.template_id.clone(),
2737                        InventoryHint {
2738                            display_name: stack
2739                                .display_name
2740                                .clone()
2741                                .unwrap_or_else(|| stack.template_id.clone()),
2742                            category: stack.category.clone().unwrap_or_default(),
2743                            base_mass: stack.base_mass,
2744                            base_volume: stack.base_volume,
2745                            capacity_volume: stack.capacity_volume,
2746                            stackable: stack.stackable.unwrap_or(true),
2747                            listable: stack.listable.unwrap_or_else(|| {
2748                                category_default_listable(
2749                                    stack.category.as_deref().unwrap_or(""),
2750                                )
2751                            }),
2752                        },
2753                    );
2754                }
2755                walk(&stack.contents, inventory, hints);
2756            }
2757        }
2758        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2759        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
2760        for item in self.worn.values() {
2761            walk(
2762                std::slice::from_ref(item),
2763                &mut self.inventory,
2764                &mut self.inventory_hints,
2765            );
2766        }
2767    }
2768
2769    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
2770    /// inventory UI updates before the next tick snapshot arrives.
2771    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2772        let subtract_items =
2773            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2774        for stack in &notice.inventory_delta {
2775            if stack.quantity == 0 {
2776                continue;
2777            }
2778            if subtract_items {
2779                crate::currency::drain_template_stacks(
2780                    &mut self.inventory_stacks,
2781                    &stack.template_id,
2782                    stack.quantity,
2783                );
2784                continue;
2785            }
2786            let stackable = self
2787                .inventory_hints
2788                .get(&stack.template_id)
2789                .map(|h| h.stackable)
2790                .or(stack.stackable)
2791                .unwrap_or(true);
2792            if stackable {
2793                if let Some(existing) = self
2794                    .inventory_stacks
2795                    .iter_mut()
2796                    .find(|s| s.template_id == stack.template_id)
2797                {
2798                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
2799                    if stack.display_name.is_some() {
2800                        existing.display_name = stack.display_name.clone();
2801                    }
2802                    if stack.category.is_some() {
2803                        existing.category = stack.category.clone();
2804                    }
2805                    continue;
2806                }
2807            }
2808            self.inventory_stacks.push(stack.clone());
2809        }
2810        if notice.coins_delta != 0 {
2811            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2812        }
2813        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2814            let stacks = self.inventory_stacks.clone();
2815            self.sync_inventory_from_stacks(&stacks);
2816        }
2817        self.record_shop_trade_notice(notice);
2818    }
2819
2820    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
2821    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
2822    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
2823    /// iteration alone gives a stable row order.
2824    pub fn worn_rows(&self) -> Vec<InventoryRow> {
2825        let mut rows = Vec::new();
2826        for (slot, item) in &self.worn {
2827            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2828            rows.push(InventoryRow {
2829                depth: 0,
2830                stack: item.clone(),
2831                from: from.clone(),
2832                from_parent_instance_id: None,
2833                is_equip_shell: true,
2834                is_chest_shell: false,
2835                section: InventorySection::Worn,
2836            });
2837            for child in &item.contents {
2838                push_inventory_rows(
2839                    &mut rows,
2840                    1,
2841                    child,
2842                    &from,
2843                    item.item_instance_id,
2844                    InventorySection::Worn,
2845                );
2846            }
2847        }
2848        rows
2849    }
2850
2851    /// Root on-person stacks that can be handed to a hired worker.
2852    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2853        self.inventory_stacks
2854            .iter()
2855            .filter_map(|stack| {
2856                let item_instance_id = stack.item_instance_id?;
2857                let label = stack
2858                    .display_name
2859                    .clone()
2860                    .unwrap_or_else(|| stack.template_id.clone());
2861                let label = if stack.quantity > 1 {
2862                    format!("{label} ×{}", stack.quantity)
2863                } else {
2864                    label
2865                };
2866                Some(WorkerGiveOption {
2867                    item_instance_id,
2868                    label,
2869                    quantity: stack.quantity,
2870                    template_id: stack.template_id.clone(),
2871                })
2872            })
2873            .collect()
2874    }
2875
2876    /// Employer-known blueprints the selected worker does not yet know.
2877    pub fn teachable_blueprint_options(
2878        &self,
2879        worker: &flatland_protocol::HiredWorkerView,
2880    ) -> Vec<WorkerTeachOption> {
2881        let copper = crate::currency::copper_from_counts(&self.inventory);
2882        let mut options: Vec<WorkerTeachOption> = self
2883            .blueprints
2884            .iter()
2885            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2886            .map(|bp| {
2887                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2888                let cost = bp.worker_train_copper;
2889                WorkerTeachOption {
2890                    blueprint_id: bp.id.clone(),
2891                    label: if bp.label.is_empty() {
2892                        bp.id.clone()
2893                    } else {
2894                        bp.label.clone()
2895                    },
2896                    cost_copper: cost,
2897                    min_level,
2898                    worker_level: worker.level,
2899                    can_afford: copper >= cost,
2900                    level_ok: worker.level >= min_level,
2901                }
2902            })
2903            .collect();
2904        options.sort_by(|a, b| a.label.cmp(&b.label));
2905        options
2906    }
2907
2908    /// Loose on-person inventory (not worn, not inside a placed chest).
2909    /// Root stacks are ordered by category group; nested contents stay under their parent.
2910    pub fn person_rows(&self) -> Vec<InventoryRow> {
2911        self.person_rows_filtered("")
2912    }
2913
2914    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2915        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2916        roots.sort_by(|a, b| {
2917            let ca = a
2918                .category
2919                .as_deref()
2920                .or_else(|| self.inventory_item_category(&a.template_id))
2921                .unwrap_or("");
2922            let cb = b
2923                .category
2924                .as_deref()
2925                .or_else(|| self.inventory_item_category(&b.template_id))
2926                .unwrap_or("");
2927            let ga = inventory_category_group(ca).1;
2928            let gb = inventory_category_group(cb).1;
2929            ga.cmp(&gb).then_with(|| {
2930                let na = a
2931                    .display_name
2932                    .as_deref()
2933                    .unwrap_or(a.template_id.as_str());
2934                let nb = b
2935                    .display_name
2936                    .as_deref()
2937                    .unwrap_or(b.template_id.as_str());
2938                na.cmp(nb)
2939            })
2940        });
2941        let mut rows = Vec::new();
2942        for stack in roots {
2943            push_inventory_rows_filtered(
2944                &mut rows,
2945                0,
2946                stack,
2947                &flatland_protocol::InventoryLocation::Root,
2948                None,
2949                InventorySection::Person,
2950                filter,
2951            );
2952        }
2953        rows
2954    }
2955
2956    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2957        if filter.is_empty() {
2958            return self.worn_rows();
2959        }
2960        let mut rows = Vec::new();
2961        for (slot, item) in &self.worn {
2962            if !stack_matches_filter(item, filter) {
2963                continue;
2964            }
2965            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2966            let self_hit = {
2967                let f = filter.to_ascii_lowercase();
2968                let name = item
2969                    .display_name
2970                    .as_deref()
2971                    .unwrap_or("")
2972                    .to_ascii_lowercase();
2973                let tid = item.template_id.to_ascii_lowercase();
2974                name.contains(&f) || tid.contains(&f)
2975            };
2976            rows.push(InventoryRow {
2977                depth: 0,
2978                stack: item.clone(),
2979                from: from.clone(),
2980                from_parent_instance_id: None,
2981                is_equip_shell: true,
2982                is_chest_shell: false,
2983                section: InventorySection::Worn,
2984            });
2985            for child in &item.contents {
2986                if self_hit || stack_matches_filter(child, filter) {
2987                    push_inventory_rows_filtered(
2988                        &mut rows,
2989                        1,
2990                        child,
2991                        &from,
2992                        item.item_instance_id,
2993                        InventorySection::Worn,
2994                        if self_hit { "" } else { filter },
2995                    );
2996                }
2997            }
2998        }
2999        rows
3000    }
3001
3002    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3003    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3004        let mut rows = self.worn_rows();
3005        rows.extend(self.person_rows());
3006        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3007    }
3008
3009    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3010    /// populated when `accessible` — this is what makes a chest's contents
3011    /// disappear the moment you walk away or it's locked without your key.
3012    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3013        let (px, py) = self.player_position();
3014        let mut list: Vec<NearbyContainer> = self
3015            .placed_containers
3016            .iter()
3017            .filter_map(|c| {
3018                let distance_m = (c.x - px).hypot(c.y - py);
3019                if distance_m > CONTAINER_RANGE_M {
3020                    return None;
3021                }
3022                let mut rows = Vec::new();
3023                let from = flatland_protocol::InventoryLocation::Placed {
3024                    container_id: c.id.clone(),
3025                };
3026                rows.push(InventoryRow {
3027                    depth: 0,
3028                    stack: flatland_protocol::ItemStack {
3029                        template_id: c.template_id.clone(),
3030                        quantity: 1,
3031                        item_instance_id: c.item_instance_id,
3032                        props: Default::default(),
3033                        status_bindings: Vec::new(),
3034                        contents: Vec::new(),
3035                        display_name: Some(c.display_name.clone()),
3036                        category: Some("container".into()),
3037                        capacity_volume: c.capacity_volume,
3038                        worker_lodging_capacity: c.worker_lodging_capacity,
3039                        ..Default::default()
3040                    },
3041                    from: from.clone(),
3042                    from_parent_instance_id: None,
3043                    is_equip_shell: false,
3044                    is_chest_shell: true,
3045                    section: InventorySection::Nearby,
3046                });
3047                if c.accessible {
3048                    for child in &c.contents {
3049                        push_inventory_rows(
3050                            &mut rows,
3051                            1,
3052                            child,
3053                            &from,
3054                            c.item_instance_id,
3055                            InventorySection::Nearby,
3056                        );
3057                    }
3058                }
3059                Some(NearbyContainer {
3060                    view: c.clone(),
3061                    distance_m,
3062                    rows,
3063                })
3064            })
3065            .collect();
3066        list.sort_by(|a, b| {
3067            a.distance_m
3068                .partial_cmp(&b.distance_m)
3069                .unwrap_or(std::cmp::Ordering::Equal)
3070        });
3071        list
3072    }
3073
3074    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3075    pub fn nearest_placed_container(
3076        &self,
3077        max_dist: f32,
3078    ) -> Option<flatland_protocol::PlacedContainerView> {
3079        let (px, py) = self.player_position();
3080        self.placed_containers
3081            .iter()
3082            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3083            .min_by(|a, b| {
3084                let da = (a.x - px).hypot(a.y - py);
3085                let db = (b.x - px).hypot(b.y - py);
3086                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3087            })
3088            .cloned()
3089    }
3090
3091    /// Selectable rows for the **active inventory tab** (and current filter).
3092    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3093    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3094        let filter = self.inventory_filter.as_str();
3095        match self.inventory_tab {
3096            InventoryTab::OnPerson => {
3097                let mut rows = self.worn_rows_filtered(filter);
3098                rows.extend(self.person_rows_filtered(filter));
3099                rows
3100            }
3101            InventoryTab::Nearby => {
3102                let mut rows = Vec::new();
3103                for nc in self.nearby_containers() {
3104                    if filter.is_empty() {
3105                        rows.extend(nc.rows);
3106                        continue;
3107                    }
3108                    let shell = nc.rows.first().cloned();
3109                    let contents: Vec<_> = nc
3110                        .rows
3111                        .iter()
3112                        .skip(1)
3113                        .filter(|r| stack_matches_filter(&r.stack, filter))
3114                        .cloned()
3115                        .collect();
3116                    let shell_hit = shell
3117                        .as_ref()
3118                        .map(|s| stack_matches_filter(&s.stack, filter))
3119                        .unwrap_or(false);
3120                    if shell_hit || !contents.is_empty() {
3121                        if let Some(s) = shell {
3122                            rows.push(s);
3123                        }
3124                        if shell_hit {
3125                            rows.extend(nc.rows.into_iter().skip(1));
3126                        } else {
3127                            rows.extend(contents);
3128                        }
3129                    }
3130                }
3131                rows
3132            }
3133        }
3134    }
3135
3136    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3137        self.inventory_selectable_rows()
3138            .into_iter()
3139            .nth(self.inventory_menu_index)
3140    }
3141
3142    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3143        let cat = self
3144            .inventory_item_category(&row.stack.template_id)
3145            .unwrap_or("");
3146        if cat == "key" {
3147            self.key_inventory_label(&row.stack)
3148        } else {
3149            row.stack
3150                .display_name
3151                .clone()
3152                .unwrap_or_else(|| row.stack.template_id.clone())
3153        }
3154    }
3155
3156    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3157    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3158        let bindings = format_status_bindings_suffix(
3159            &row.stack.status_bindings,
3160            self.tick,
3161            DEFAULT_TICK_HZ,
3162        );
3163        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3164            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3165            let mode = Self::grant_mode(&row.stack);
3166            format!(" [grant {effect} · {mode} — e apply]")
3167        } else {
3168            String::new()
3169        };
3170        let qty = if row.stack.quantity > 1 {
3171            format!(" ×{}", row.stack.quantity)
3172        } else {
3173            String::new()
3174        };
3175        let worn_slot = if row.is_equip_shell {
3176            match row.from {
3177                flatland_protocol::InventoryLocation::Worn { slot } => {
3178                    format!(" ({})", body_slot_label(slot))
3179                }
3180                _ => String::new(),
3181            }
3182        } else {
3183            String::new()
3184        };
3185        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3186    }
3187
3188    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3189        (
3190            row.stack.template_id.clone(),
3191            self.inventory_row_base_label(row),
3192            self.inventory_row_visible_mod_signature(row),
3193        )
3194    }
3195
3196    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3197    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3198        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3199        for row in self.inventory_selectable_rows() {
3200            if row.stack.item_instance_id.is_none() {
3201                continue;
3202            }
3203            let key = self.inventory_row_instance_identity_key(&row);
3204            *counts.entry(key).or_default() += 1;
3205        }
3206        counts
3207            .into_iter()
3208            .filter(|(_, n)| *n > 1)
3209            .map(|(k, _)| k)
3210            .collect()
3211    }
3212
3213    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3214        let hex: String = id
3215            .as_simple()
3216            .to_string()
3217            .chars()
3218            .filter(|c| c.is_ascii_hexdigit())
3219            .collect();
3220        let short = if hex.len() >= 4 {
3221            &hex[hex.len() - 4..]
3222        } else {
3223            hex.as_str()
3224        };
3225        format!("Instance {id} (#{short})")
3226    }
3227
3228    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3229    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3230        let cat = self
3231            .inventory_item_category(&row.stack.template_id)
3232            .unwrap_or("");
3233        let label = self.inventory_row_base_label(row);
3234        let hint: String = if row.is_equip_shell {
3235            " [worn — Enter to unequip]".into()
3236        } else if row.is_chest_shell {
3237            let (locked, lodging_note) = match &row.from {
3238                flatland_protocol::InventoryLocation::Placed { container_id } => {
3239                    let locked = self
3240                        .placed_containers
3241                        .iter()
3242                        .find(|c| c.id == *container_id)
3243                        .map(|c| c.locked)
3244                        .unwrap_or(false);
3245                    let lodging_note = self
3246                        .lodging_occupancy_label(container_id)
3247                        .map(|who| format!(" [lodging: {who}]"))
3248                        .unwrap_or_default();
3249                    (locked, lodging_note)
3250                }
3251                _ => (false, String::new()),
3252            };
3253            if locked {
3254                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3255            } else {
3256                format!(" [Enter pick up · l lock]{lodging_note}")
3257            }
3258        } else if cat == "key" {
3259            self.key_inventory_hint(&row.stack)
3260        } else {
3261            match cat {
3262                "weapon" => " [weapon]".into(),
3263                "container" => " [bag/chest/belt]".into(),
3264                "lodging" => " [worker lodging]".into(),
3265                "armor" => " [armor]".into(),
3266                _ => String::new(),
3267            }
3268        };
3269        let qty = if row.stack.quantity > 1 {
3270            format!(" ×{}", row.stack.quantity)
3271        } else {
3272            String::new()
3273        };
3274        let bindings = format_status_bindings_suffix(
3275            &row.stack.status_bindings,
3276            self.tick,
3277            DEFAULT_TICK_HZ,
3278        );
3279        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3280            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3281            let mode = Self::grant_mode(&row.stack);
3282            format!(" [grant {effect} · {mode} — e apply]")
3283        } else {
3284            String::new()
3285        };
3286        let mass = self.stack_mass(&row.stack);
3287        let mass_kg = (mass >= 0.05).then_some(mass);
3288        let mass_str = mass_kg
3289            .map(|m| format!("  {m:.1} kg"))
3290            .unwrap_or_default();
3291        let volume = self.container_volume_stats(row);
3292        let vol_str = self.container_volume_label(row);
3293
3294        let mut title = label.clone();
3295        title.push_str(&qty);
3296        if row.is_equip_shell {
3297            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3298                title.push_str(&format!(" ({})", body_slot_label(slot)));
3299            }
3300        }
3301
3302        InventoryRowView {
3303            depth: row.depth,
3304            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3305            title: format!("{title}{grant_hint}{bindings}"),
3306            mass_kg,
3307            volume,
3308            instance_tooltip: None,
3309        }
3310    }
3311
3312    fn push_browser_item(
3313        &self,
3314        lines: &mut Vec<InventoryBrowserLine>,
3315        row: &InventoryRow,
3316        global_idx: &mut usize,
3317        target: usize,
3318        highlight: bool,
3319        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3320    ) {
3321        let mut view = self.format_inventory_row(row);
3322        if let Some(id) = row.stack.item_instance_id {
3323            let key = self.inventory_row_instance_identity_key(row);
3324            if ambiguous_instance_keys.contains(&key) {
3325                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3326            }
3327        }
3328        lines.push(InventoryBrowserLine::Item {
3329            selectable_index: *global_idx,
3330            selected: highlight && *global_idx == target,
3331            depth: view.depth,
3332            text: view.text,
3333            title: view.title,
3334            mass_kg: view.mass_kg,
3335            volume: view.volume,
3336            instance_tooltip: view.instance_tooltip,
3337        });
3338        *global_idx += 1;
3339    }
3340
3341    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3342    /// `selectable_index` matching `inventory_menu_index`.
3343    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3344        let mut lines = Vec::new();
3345        let target = self.inventory_menu_index;
3346        let highlight = !self.show_move_picker && !self.show_grant_picker;
3347        let filter = self.inventory_filter.as_str();
3348        let mut global_idx = 0usize;
3349        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3350
3351        match self.inventory_tab {
3352            InventoryTab::OnPerson => {
3353                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3354                let worn = self.worn_rows_filtered(filter);
3355                if worn.is_empty() {
3356                    lines.push(InventoryBrowserLine::Hint(
3357                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3358                    ));
3359                } else {
3360                    for row in &worn {
3361                        if row.is_equip_shell {
3362                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3363                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3364                                    "  {}:",
3365                                    body_slot_label(slot)
3366                                )));
3367                            }
3368                        }
3369                        self.push_browser_item(
3370                            &mut lines,
3371                            row,
3372                            &mut global_idx,
3373                            target,
3374                            highlight,
3375                            &ambiguous_instance_keys,
3376                        );
3377                    }
3378                }
3379
3380                lines.push(InventoryBrowserLine::Blank);
3381                lines.push(InventoryBrowserLine::Section(
3382                    "— On you (loose, not worn) —".into(),
3383                ));
3384                let person = self.person_rows_filtered(filter);
3385                if person.is_empty() {
3386                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3387                } else {
3388                    let mut last_group: Option<&'static str> = None;
3389                    for row in &person {
3390                        if row.depth == 0 {
3391                            let cat = row
3392                                .stack
3393                                .category
3394                                .as_deref()
3395                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3396                                .unwrap_or("");
3397                            let (group, _) = inventory_category_group(cat);
3398                            if last_group != Some(group) {
3399                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3400                                    "  {group}"
3401                                )));
3402                                last_group = Some(group);
3403                            }
3404                        }
3405                        self.push_browser_item(
3406                            &mut lines,
3407                            row,
3408                            &mut global_idx,
3409                            target,
3410                            highlight,
3411                            &ambiguous_instance_keys,
3412                        );
3413                    }
3414                }
3415            }
3416            InventoryTab::Nearby => {
3417                let nearby = self.nearby_containers();
3418                if nearby.is_empty() {
3419                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3420                    lines.push(InventoryBrowserLine::Hint(
3421                        "  (none within reach — walk up to a chest)".into(),
3422                    ));
3423                    lines.push(InventoryBrowserLine::Hint(
3424                        "  Select an on-person item, then m / Enter → move into chest.".into(),
3425                    ));
3426                } else {
3427                    let mut any_visible = false;
3428                    for nc in &nearby {
3429                        let shell = nc.rows.first();
3430                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
3431                            nc.rows.iter().skip(1).collect()
3432                        } else {
3433                            let shell_hit = shell
3434                                .map(|s| {
3435                                    let f = filter.to_ascii_lowercase();
3436                                    let name = s
3437                                        .stack
3438                                        .display_name
3439                                        .as_deref()
3440                                        .unwrap_or("")
3441                                        .to_ascii_lowercase();
3442                                    let tid = s.stack.template_id.to_ascii_lowercase();
3443                                    name.contains(&f) || tid.contains(&f)
3444                                })
3445                                .unwrap_or(false);
3446                            if shell_hit {
3447                                nc.rows.iter().skip(1).collect()
3448                            } else {
3449                                nc.rows
3450                                    .iter()
3451                                    .skip(1)
3452                                    .filter(|r| stack_matches_filter(&r.stack, filter))
3453                                    .collect()
3454                            }
3455                        };
3456                        let shell_visible = filter.is_empty()
3457                            || shell
3458                                .map(|s| stack_matches_filter(&s.stack, filter))
3459                                .unwrap_or(false)
3460                            || !contents.is_empty();
3461                        if !shell_visible && shell.is_some() {
3462                            continue;
3463                        }
3464                        any_visible = true;
3465                        lines.push(InventoryBrowserLine::Blank);
3466                        let lock_note = if nc.view.locked && nc.view.accessible {
3467                            "  unlocked with your key"
3468                        } else if nc.view.locked {
3469                            "  locked"
3470                        } else {
3471                            ""
3472                        };
3473                        lines.push(InventoryBrowserLine::Section(format!(
3474                            "— {} ({:.0}m away){lock_note} —",
3475                            nc.view.display_name, nc.distance_m
3476                        )));
3477                        if !nc.view.accessible {
3478                            lines.push(InventoryBrowserLine::Hint(
3479                                "  locked — need the matching key (l to try)".into(),
3480                            ));
3481                        } else if nc.rows.is_empty() {
3482                            lines.push(InventoryBrowserLine::Hint(
3483                                "  (empty — switch to On person, select an item, m to move in)"
3484                                    .into(),
3485                            ));
3486                        } else if let Some(shell_row) = shell {
3487                            self.push_browser_item(
3488                                &mut lines,
3489                                shell_row,
3490                                &mut global_idx,
3491                                target,
3492                                highlight,
3493                                &ambiguous_instance_keys,
3494                            );
3495                            for row in contents {
3496                                self.push_browser_item(
3497                                    &mut lines,
3498                                    row,
3499                                    &mut global_idx,
3500                                    target,
3501                                    highlight,
3502                                    &ambiguous_instance_keys,
3503                                );
3504                            }
3505                        }
3506                    }
3507                    if !any_visible {
3508                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3509                        lines.push(InventoryBrowserLine::Hint(
3510                            "  (no matching items — clear filter with Esc)".into(),
3511                        ));
3512                    }
3513                }
3514            }
3515        }
3516        lines
3517    }
3518
3519    /// Destinations for picking up a placed chest/crate into inventory.
3520    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3521        let mut opts = Vec::new();
3522        opts.push(MoveOption {
3523            label: "Relocate…".into(),
3524            kind: MoveOptionKind::RelocatePlaced {
3525                container_id: container_id.to_string(),
3526            },
3527        });
3528        opts.push(MoveOption {
3529            label: "On your person (loose)".into(),
3530            kind: MoveOptionKind::PickupPlaced {
3531                container_id: container_id.to_string(),
3532                nest_location: flatland_protocol::InventoryLocation::Root,
3533                nest_parent_instance_id: None,
3534            },
3535        });
3536        for (slot, item) in &self.worn {
3537            if item.category.as_deref() != Some("container") {
3538                continue;
3539            }
3540            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3541                continue;
3542            }
3543            let Some(parent_id) = item.item_instance_id else {
3544                continue;
3545            };
3546            let shell_name = item
3547                .display_name
3548                .clone()
3549                .unwrap_or_else(|| item.template_id.clone());
3550            opts.push(MoveOption {
3551                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3552                kind: MoveOptionKind::PickupPlaced {
3553                    container_id: container_id.to_string(),
3554                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3555                    nest_parent_instance_id: Some(parent_id),
3556                },
3557            });
3558            // Nested pouches inside the worn bag.
3559            Self::append_chest_pickup_nested(
3560                &mut opts,
3561                container_id,
3562                flatland_protocol::InventoryLocation::Worn { slot: *slot },
3563                item,
3564                &format!("in {shell_name}"),
3565            );
3566        }
3567        opts.push(MoveOption {
3568            label: "Cancel".into(),
3569            kind: MoveOptionKind::Cancel,
3570        });
3571        opts
3572    }
3573
3574    fn append_chest_pickup_nested(
3575        opts: &mut Vec<MoveOption>,
3576        container_id: &str,
3577        location: flatland_protocol::InventoryLocation,
3578        parent: &flatland_protocol::ItemStack,
3579        context: &str,
3580    ) {
3581        for child in &parent.contents {
3582            if child.category.as_deref() != Some("container") {
3583                continue;
3584            }
3585            if !Self::is_volume_container_stack(child) {
3586                continue;
3587            }
3588            // Skip placeable nested chests — relocating into another chest is not useful.
3589            if child.world_placeable == Some(true) {
3590                continue;
3591            }
3592            let Some(child_id) = child.item_instance_id else {
3593                continue;
3594            };
3595            let name = child
3596                .display_name
3597                .clone()
3598                .unwrap_or_else(|| child.template_id.clone());
3599            opts.push(MoveOption {
3600                label: format!("{name} ({context})"),
3601                kind: MoveOptionKind::PickupPlaced {
3602                    container_id: container_id.to_string(),
3603                    nest_location: location.clone(),
3604                    nest_parent_instance_id: Some(child_id),
3605                },
3606            });
3607            Self::append_chest_pickup_nested(
3608                opts,
3609                container_id,
3610                location.clone(),
3611                child,
3612                &format!("in {name}"),
3613            );
3614        }
3615    }
3616
3617    /// Build the "move to…" destination list for an item currently at `from`.
3618    pub fn move_destinations_for(
3619        &self,
3620        from: &flatland_protocol::InventoryLocation,
3621        from_parent_instance_id: Option<uuid::Uuid>,
3622        moving_instance_id: Option<uuid::Uuid>,
3623        moving_template_id: &str,
3624    ) -> Vec<MoveOption> {
3625        let mut opts = Vec::new();
3626        if *from != flatland_protocol::InventoryLocation::Root {
3627            opts.push(MoveOption {
3628                label: "On your person (loose)".into(),
3629                kind: MoveOptionKind::Move {
3630                    location: flatland_protocol::InventoryLocation::Root,
3631                    parent_instance_id: None,
3632                },
3633            });
3634        }
3635        for (slot, item) in &self.worn {
3636            if item.category.as_deref() != Some("container") {
3637                continue;
3638            }
3639            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3640            let shell_name = item
3641                .display_name
3642                .clone()
3643                .unwrap_or_else(|| item.template_id.clone());
3644
3645            // Backpack and other worn volume containers — store directly inside the shell.
3646            if *slot != BodySlot::Waist
3647                && item.item_instance_id != moving_instance_id
3648                && Self::is_volume_container_stack(item)
3649            {
3650                Self::push_move_destination(
3651                    &mut opts,
3652                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3653                    location.clone(),
3654                    item.item_instance_id,
3655                    from,
3656                    from_parent_instance_id,
3657                );
3658            }
3659
3660            // Belt loops only accept pouch attachments — not loose materials.
3661            if *slot == BodySlot::Waist
3662                && Self::attaches_to_belt_loop(moving_template_id)
3663                && item.item_instance_id != moving_instance_id
3664            {
3665                Self::push_move_destination(
3666                    &mut opts,
3667                    format!("{shell_name} (belt loop)"),
3668                    location.clone(),
3669                    item.item_instance_id,
3670                    from,
3671                    from_parent_instance_id,
3672                );
3673            }
3674
3675            let context = if *slot == BodySlot::Waist {
3676                format!("on {shell_name}")
3677            } else {
3678                format!("in {shell_name}")
3679            };
3680            Self::append_nested_container_destinations(
3681                &mut opts,
3682                location,
3683                item,
3684                &context,
3685                from,
3686                from_parent_instance_id,
3687                moving_instance_id,
3688            );
3689        }
3690        for nc in self.nearby_containers() {
3691            if !nc.view.accessible {
3692                continue;
3693            }
3694            let location = flatland_protocol::InventoryLocation::Placed {
3695                container_id: nc.view.id.clone(),
3696            };
3697            Self::push_move_destination(
3698                &mut opts,
3699                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3700                location,
3701                nc.view.item_instance_id,
3702                from,
3703                from_parent_instance_id,
3704            );
3705        }
3706        let allow_drop = moving_instance_id
3707            .and_then(|id| self.stack_for_instance(id))
3708            .map(|stack| {
3709                !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3710            })
3711            .unwrap_or(
3712                moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_TEMPLATE,
3713            );
3714        if allow_drop {
3715            opts.push(MoveOption {
3716                label: "Drop on the ground".into(),
3717                kind: MoveOptionKind::Drop,
3718            });
3719        }
3720        opts.push(MoveOption {
3721            label: "Cancel".into(),
3722            kind: MoveOptionKind::Cancel,
3723        });
3724        opts
3725    }
3726
3727    fn is_same_container_dest(
3728        dest_location: &flatland_protocol::InventoryLocation,
3729        dest_parent: Option<uuid::Uuid>,
3730        from: &flatland_protocol::InventoryLocation,
3731        from_parent: Option<uuid::Uuid>,
3732    ) -> bool {
3733        dest_location == from && dest_parent == from_parent
3734    }
3735
3736    fn push_move_destination(
3737        opts: &mut Vec<MoveOption>,
3738        label: String,
3739        location: flatland_protocol::InventoryLocation,
3740        parent_instance_id: Option<uuid::Uuid>,
3741        from: &flatland_protocol::InventoryLocation,
3742        from_parent_instance_id: Option<uuid::Uuid>,
3743    ) {
3744        if Self::is_same_container_dest(
3745            &location,
3746            parent_instance_id,
3747            from,
3748            from_parent_instance_id,
3749        ) {
3750            return;
3751        }
3752        opts.push(MoveOption {
3753            label,
3754            kind: MoveOptionKind::Move {
3755                location,
3756                parent_instance_id,
3757            },
3758        });
3759    }
3760
3761    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3762        stack.capacity_volume.is_some_and(|c| c > 0.0)
3763    }
3764
3765    fn attaches_to_belt_loop(template_id: &str) -> bool {
3766        matches!(template_id, "leather_pouch" | "dimensional_pouch")
3767    }
3768
3769    fn append_nested_container_destinations(
3770        opts: &mut Vec<MoveOption>,
3771        location: flatland_protocol::InventoryLocation,
3772        container: &flatland_protocol::ItemStack,
3773        context: &str,
3774        from: &flatland_protocol::InventoryLocation,
3775        from_parent_instance_id: Option<uuid::Uuid>,
3776        moving_instance_id: Option<uuid::Uuid>,
3777    ) {
3778        for child in &container.contents {
3779            if Self::is_volume_container_stack(child)
3780                && child.item_instance_id != moving_instance_id
3781            {
3782                let name = child
3783                    .display_name
3784                    .clone()
3785                    .unwrap_or_else(|| child.template_id.clone());
3786                Self::push_move_destination(
3787                    opts,
3788                    format!("{name} ({context})"),
3789                    location.clone(),
3790                    child.item_instance_id,
3791                    from,
3792                    from_parent_instance_id,
3793                );
3794            }
3795            let nested_context = format!(
3796                "in {}",
3797                child.display_name.as_deref().unwrap_or(&child.template_id)
3798            );
3799            Self::append_nested_container_destinations(
3800                opts,
3801                location.clone(),
3802                child,
3803                &nested_context,
3804                from,
3805                from_parent_instance_id,
3806                moving_instance_id,
3807            );
3808        }
3809    }
3810
3811    fn clamp_inventory_indices(&mut self) {
3812        let n = self.inventory_selectable_rows().len();
3813        self.inventory_menu_index = if n == 0 {
3814            0
3815        } else {
3816            self.inventory_menu_index.min(n - 1)
3817        };
3818        if let Some(picker) = &self.move_picker {
3819            let pn = picker.options.len();
3820            self.move_picker_index = if pn == 0 {
3821                0
3822            } else {
3823                self.move_picker_index.min(pn - 1)
3824            };
3825        }
3826    }
3827
3828    /// Drop interior map layers whenever the player is outdoors.
3829    fn sync_interior_map_context(&mut self) {
3830        if self.effective_inside_building().is_none() {
3831            self.interior_map = None;
3832        }
3833        self.sync_interior_z_bands();
3834    }
3835
3836    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
3837    fn sync_interior_z_bands(&mut self) {
3838        if self.effective_inside_building().is_some() {
3839            if let Some(map) = &self.interior_map {
3840                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
3841                    self.z_platforms = map.z_platforms.clone();
3842                    self.z_transitions = map.z_transitions.clone();
3843                }
3844            }
3845        }
3846    }
3847
3848    fn apply_snapshot_fields(
3849        &mut self,
3850        snapshot: &flatland_protocol::Snapshot,
3851        entity_id: EntityId,
3852    ) {
3853        self.tick = snapshot.tick;
3854        self.chunk_rev = snapshot.chunk_rev;
3855        self.content_rev = snapshot.content_rev;
3856        self.publish_rev = snapshot.publish_rev;
3857        self.resource_nodes = snapshot.resource_nodes.clone();
3858        self.ground_drops = snapshot.ground_drops.clone();
3859        self.placed_containers = snapshot.placed_containers.clone();
3860        self.world_x0 = snapshot.world_x0;
3861        self.world_y0 = snapshot.world_y0;
3862        self.world_width_m = snapshot.world_width_m;
3863        self.world_height_m = snapshot.world_height_m;
3864        self.world_clock = snapshot.world_clock;
3865        self.terrain_zones = snapshot.terrain_zones.clone();
3866        self.z_platforms = snapshot.z_platforms.clone();
3867        self.z_transitions = snapshot.z_transitions.clone();
3868        self.buildings = snapshot.buildings.clone();
3869        self.doors = snapshot.doors.clone();
3870        self.interior_map = snapshot.interior_map.clone();
3871        self.npcs = snapshot.npcs.clone();
3872        self.blueprints = snapshot.blueprints.clone();
3873        self.sync_inventory_from_stacks(&snapshot.inventory);
3874        self.player = snapshot
3875            .entities
3876            .iter()
3877            .find(|e| e.id == entity_id)
3878            .cloned();
3879        self.entities = snapshot.entities.clone();
3880        self.quest_log = snapshot.quest_log.clone();
3881        self.apply_hired_workers(snapshot.hired_workers.clone());
3882        self.interactables = snapshot.interactables.clone();
3883        self.ledger = snapshot.ledger.clone();
3884        self.career = snapshot.career.clone();
3885        self.combat_fx = snapshot.combat_fx.clone();
3886        self.property_zones = snapshot.property_zones.clone();
3887        self.tax_zones = snapshot.tax_zones.clone();
3888        self.growth_zones = snapshot.growth_zones.clone();
3889        self.biome_zones = snapshot.biome_zones.clone();
3890        self.property_plots = snapshot.property_plots.clone();
3891        self.property_plot_settings = snapshot.property_plot_settings.clone();
3892        self.sync_interior_map_context();
3893        self.refresh_whisper_range();
3894    }
3895
3896    /// Re-derive inventory selection state after a snapshot/tick — chests that
3897    /// went out of range or got locked simply vanish from the row list, and the
3898    /// move picker (if any) closes once its item is no longer reachable.
3899    fn refresh_inventory_ui(&mut self) {
3900        if let Some(picker) = &self.move_picker {
3901            let instance_id = picker.item_instance_id;
3902            let still_exists = self
3903                .inventory_selectable_rows()
3904                .iter()
3905                .any(|r| r.stack.item_instance_id == Some(instance_id));
3906            if !still_exists {
3907                self.move_picker = None;
3908                self.show_move_picker = false;
3909            }
3910        }
3911        if let Some(picker) = &self.destroy_picker {
3912            let instance_id = picker.item_instance_id;
3913            let still_exists = self
3914                .inventory_selectable_rows()
3915                .iter()
3916                .any(|r| r.stack.item_instance_id == Some(instance_id));
3917            if !still_exists {
3918                self.destroy_picker = None;
3919                self.show_destroy_picker = false;
3920                self.destroy_confirm_pending = false;
3921            }
3922        }
3923        self.clamp_inventory_indices();
3924    }
3925
3926    /// Replace the hired-worker list from a snapshot/delta.
3927    ///
3928    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
3929    /// selected worker (and its step line) does not jump when the server rebuilds
3930    /// the list.
3931    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3932        let selected_id = self
3933            .hired_workers
3934            .get(self.workers_menu_index)
3935            .map(|w| w.instance_id.clone());
3936        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3937        let now = Instant::now();
3938        for w in &workers {
3939            let prev_err = self
3940                .hired_workers
3941                .iter()
3942                .find(|p| p.instance_id == w.instance_id)
3943                .and_then(|p| p.last_error.as_deref());
3944            let new_err = w.last_error.as_deref();
3945            if new_err != prev_err {
3946                if let Some(err) = new_err {
3947                    if !worker_error_is_transient(err) {
3948                        self.push_log(format!("Worker {}: {err}", w.label));
3949                    }
3950                }
3951            }
3952        }
3953        let mut next_display = BTreeMap::new();
3954        let mut next_errors = BTreeMap::new();
3955        for w in &workers {
3956            let mut sticky = self
3957                .worker_step_display
3958                .remove(&w.instance_id)
3959                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3960            sticky.observe(&w.step_label, now);
3961            next_display.insert(w.instance_id.clone(), sticky);
3962
3963            let mut err_sticky = self
3964                .worker_error_display
3965                .remove(&w.instance_id)
3966                .unwrap_or_default();
3967            err_sticky.observe(w.last_error.as_deref(), now);
3968            if err_sticky.shown(now).is_some() {
3969                next_errors.insert(w.instance_id.clone(), err_sticky);
3970            }
3971        }
3972        self.worker_step_display = next_display;
3973        self.worker_error_display = next_errors;
3974        self.hired_workers = workers;
3975        self.sync_worker_take_picker_from_hired();
3976        if let Some(id) = selected_id {
3977            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
3978                self.workers_menu_index = idx;
3979                return;
3980            }
3981        }
3982        if self.workers_menu_index >= self.hired_workers.len() {
3983            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
3984        }
3985    }
3986
3987    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
3988    fn sync_worker_take_picker_from_hired(&mut self) {
3989        if !self.show_worker_take_picker {
3990            return;
3991        }
3992        let Some(picker) = self.worker_take_picker.clone() else {
3993            return;
3994        };
3995        let Some(worker) = self
3996            .hired_workers
3997            .iter()
3998            .find(|w| w.instance_id == picker.worker_instance_id)
3999            .cloned()
4000        else {
4001            self.show_worker_take_picker = false;
4002            self.worker_take_picker = None;
4003            self.worker_take_picker_index = 0;
4004            return;
4005        };
4006        let options: Vec<WorkerGiveOption> = worker
4007            .inventory
4008            .iter()
4009            .filter_map(|stack| {
4010                let item_instance_id = stack.item_instance_id?;
4011                let label = stack
4012                    .display_name
4013                    .clone()
4014                    .unwrap_or_else(|| stack.template_id.clone());
4015                let label = if stack.quantity > 1 {
4016                    format!("{label} ×{}", stack.quantity)
4017                } else {
4018                    label
4019                };
4020                Some(WorkerGiveOption {
4021                    item_instance_id,
4022                    label,
4023                    quantity: stack.quantity,
4024                    template_id: stack.template_id.clone(),
4025                })
4026            })
4027            .collect();
4028        if options.is_empty() {
4029            self.show_worker_take_picker = false;
4030            self.worker_take_picker = None;
4031            self.worker_take_picker_index = 0;
4032            return;
4033        }
4034        let prev_id = picker
4035            .options
4036            .get(self.worker_take_picker_index)
4037            .map(|o| o.item_instance_id);
4038        let idx = prev_id
4039            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4040            .unwrap_or(0)
4041            .min(options.len().saturating_sub(1));
4042        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4043        let quantity = picker.quantity.clamp(1, max_qty);
4044        self.worker_take_picker_index = idx;
4045        self.worker_take_picker = Some(WorkerTakePicker {
4046            worker_instance_id: picker.worker_instance_id,
4047            worker_label: picker.worker_label,
4048            options,
4049            quantity,
4050        });
4051    }
4052
4053    /// Held coarse step label for the workers menu (`step:` line).
4054    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4055        self.worker_step_display
4056            .get(worker_instance_id)
4057            .map(|s| s.shown.as_str())
4058            .or_else(|| {
4059                self.hired_workers
4060                    .iter()
4061                    .find(|w| w.instance_id == worker_instance_id)
4062                    .map(|w| w.step_label.as_str())
4063            })
4064            .unwrap_or("")
4065    }
4066
4067    /// Held error line for workers UI (detail + compact).
4068    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4069        let now = Instant::now();
4070        self.worker_error_display
4071            .get(worker_instance_id)
4072            .and_then(|s| s.shown(now))
4073            .or_else(|| {
4074                self.hired_workers
4075                    .iter()
4076                    .find(|w| w.instance_id == worker_instance_id)
4077                    .and_then(|w| w.last_error.as_deref())
4078                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4079            })
4080            .filter(|e| !worker_error_is_hud_noise(e))
4081    }
4082
4083    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4084        self.in_combat = combat.in_combat;
4085        self.auto_attack = combat.auto_attack;
4086        self.combat_has_los = combat.has_los;
4087        self.attack_cd_ticks = combat.attack_cd_ticks;
4088        self.gcd_ticks = combat.gcd_ticks;
4089        self.weapon_ability_id = combat.ability_id.clone();
4090        self.mainhand_template_id = combat.mainhand_template_id.clone();
4091        self.mainhand_label = combat.mainhand_label.clone();
4092        self.offhand_template_id = combat.offhand_template_id.clone();
4093        self.offhand_label = combat.offhand_label.clone();
4094        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4095            1
4096        } else {
4097            combat.mainhand_hand_slots
4098        };
4099        self.defense = combat.defense.clone();
4100        self.worn = combat.worn.iter().cloned().collect();
4101        self.carry_mass = combat.carry_mass;
4102        self.carry_mass_max = combat.carry_mass_max;
4103        self.encumbrance = combat.encumbrance;
4104        self.cast_progress = combat.cast.clone();
4105        self.timed_channel = combat.timed_channel.clone();
4106        self.ability_cooldowns = combat.ability_cooldowns.clone();
4107        self.blocking_active = combat.blocking_active;
4108        self.max_target_slots = combat.max_target_slots.max(1);
4109        self.combat_slots = combat.slots.clone();
4110        self.rotation_presets = combat.rotation_presets.clone();
4111        self.known_abilities = combat.known_abilities.clone();
4112        self.hotbar = combat.hotbar.clone();
4113        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4114        self.keychain_stacks = combat.keychain.clone();
4115        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4116        self.combat_target_detail = combat.target.clone();
4117        self.statuses = combat.statuses.clone();
4118        self.combat_target = combat.target_entity_id;
4119        if combat.progression_xp_base > 0.0 {
4120            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4121                baseline_display: combat.progression_baseline,
4122                xp_base: combat.progression_xp_base,
4123                xp_growth: combat.progression_xp_growth,
4124            });
4125        }
4126        if let Some(xp) = &combat.progression_xp {
4127            if let Some(player) = &mut self.player {
4128                player.progression_xp = Some(xp.clone());
4129                if let Some(attrs) = combat.attributes {
4130                    player.attributes = Some(attrs);
4131                }
4132                if let Some(skills) = &combat.skills {
4133                    player.skills = Some(skills.clone());
4134                }
4135            }
4136        }
4137        if let Some(label) = &combat.target_label {
4138            self.combat_target_label = Some(label.clone());
4139        } else if let Some(id) = combat.target_entity_id {
4140            self.combat_target_label = self
4141                .entities
4142                .iter()
4143                .find(|e| e.id == id)
4144                .map(|e| e.label.clone())
4145                .or_else(|| self.combat_target_label.clone());
4146        }
4147        self.refresh_inventory_ui();
4148    }
4149
4150    /// Entity id assigned to a combat slot (from server HUD).
4151    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4152        self.combat_slots
4153            .iter()
4154            .find(|s| s.slot_index == slot)
4155            .and_then(|s| s.target_entity_id)
4156            .or_else(|| if slot == 1 { self.combat_target } else { None })
4157    }
4158
4159    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4160    /// May be an ability id or `item:<template_id>` consumable binding.
4161    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4162        if !(1..=9).contains(&slot_1_to_9) {
4163            return None;
4164        }
4165        self.hotbar
4166            .get((slot_1_to_9 - 1) as usize)
4167            .and_then(|a| a.as_deref())
4168            .filter(|id| !id.is_empty())
4169    }
4170
4171    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4172    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4173        let binding = self.hotbar_ability(slot_1_to_9)?;
4174        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4175            let name = self
4176                .inventory_hints
4177                .get(template_id)
4178                .map(|h| h.display_name.as_str())
4179                .unwrap_or(template_id);
4180            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4181            Some(format!("{name}×{qty}"))
4182        } else {
4183            Some(binding.to_string())
4184        }
4185    }
4186
4187    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4188    pub fn loadout_ability_choices(&self) -> Vec<String> {
4189        let mut out = self.known_abilities.clone();
4190        let weapon = self.weapon_ability_id.trim();
4191        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4192            out.push(weapon.to_string());
4193        }
4194        out
4195    }
4196
4197    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4198    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4199        let mut out = Vec::new();
4200        for ability in self.loadout_ability_choices() {
4201            let meta = if ability == self.weapon_ability_id {
4202                Some("weapon".into())
4203            } else {
4204                None
4205            };
4206            out.push(LoadoutHotbarChoice {
4207                binding: ability.clone(),
4208                label: ability,
4209                meta,
4210            });
4211        }
4212        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4213        for stack in &self.inventory_stacks {
4214            if Self::stack_is_item_grant(stack) {
4215                continue;
4216            }
4217            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4218                continue;
4219            }
4220            let qty = stack.quantity.max(1);
4221            if let Some((_, _, existing)) = consumables
4222                .iter_mut()
4223                .find(|(id, _, _)| id == &stack.template_id)
4224            {
4225                *existing = existing.saturating_add(qty);
4226            } else {
4227                let label = stack
4228                    .display_name
4229                    .clone()
4230                    .or_else(|| {
4231                        self.inventory_hints
4232                            .get(&stack.template_id)
4233                            .map(|h| h.display_name.clone())
4234                    })
4235                    .unwrap_or_else(|| stack.template_id.clone());
4236                consumables.push((stack.template_id.clone(), label, qty));
4237            }
4238        }
4239        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4240        for (template_id, label, qty) in consumables {
4241            out.push(LoadoutHotbarChoice {
4242                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4243                label: format!("{label} ×{qty}"),
4244                meta: Some("use".into()),
4245            });
4246        }
4247        out
4248    }
4249
4250    /// Hostile wildlife / monsters for T1 (`Tab`).
4251    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4252        self.combat_candidates()
4253    }
4254
4255    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4256    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4257        let (px, py) = self.player_position();
4258        let dist = |id: EntityId| {
4259            self.entities
4260                .iter()
4261                .find(|e| e.id == id)
4262                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4263                .unwrap_or(f32::MAX)
4264        };
4265
4266        let mut allies = Vec::new();
4267        // Self first — heal_touch on T2.
4268        if let Some(me) = self.player.as_ref() {
4269            let alive = me
4270                .vitals
4271                .as_ref()
4272                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4273                .unwrap_or(true);
4274            if alive {
4275                allies.push((self.entity_id, "Yourself".into()));
4276            }
4277        }
4278        for entity in &self.entities {
4279            if entity.id == self.entity_id {
4280                continue;
4281            }
4282            if entity.vitals.is_some() {
4283                let alive = entity
4284                    .vitals
4285                    .as_ref()
4286                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4287                    .unwrap_or(true);
4288                if alive {
4289                    allies.push((entity.id, entity.label.clone()));
4290                }
4291            }
4292        }
4293        allies.sort_by(|(a, _), (b, _)| {
4294            if *a == self.entity_id {
4295                return std::cmp::Ordering::Less;
4296            }
4297            if *b == self.entity_id {
4298                return std::cmp::Ordering::Greater;
4299            }
4300            dist(*a)
4301                .partial_cmp(&dist(*b))
4302                .unwrap_or(std::cmp::Ordering::Equal)
4303        });
4304
4305        let mut monsters = self.combat_candidates();
4306        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4307        allies.into_iter().chain(monsters).collect()
4308    }
4309
4310    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4311        match slot_index {
4312            2 => self.t2_candidates(),
4313            _ => self.t1_candidates(),
4314        }
4315    }
4316
4317    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
4318    pub fn pick_combat_target_at(
4319        &self,
4320        wx: f32,
4321        wy: f32,
4322        slot_index: u8,
4323        radius_m: f32,
4324    ) -> Option<(EntityId, String)> {
4325        let mut best: Option<(f32, EntityId, String)> = None;
4326        for (id, label) in self.candidates_for_slot(slot_index) {
4327            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4328                // NPCs may only appear in NpcView — fall back to npc list coords.
4329                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4330                    let d = distance(wx, wy, npc.x, npc.y);
4331                    if d <= radius_m {
4332                        best = match best {
4333                            Some((bd, _, _)) if bd <= d => best,
4334                            _ => Some((d, id, label)),
4335                        };
4336                    }
4337                }
4338                continue;
4339            };
4340            let d = distance(
4341                wx,
4342                wy,
4343                entity.transform.position.x,
4344                entity.transform.position.y,
4345            );
4346            if d <= radius_m {
4347                best = match best {
4348                    Some((bd, _, _)) if bd <= d => best,
4349                    _ => Some((d, id, label)),
4350                };
4351            }
4352        }
4353        best.map(|(_, id, label)| (id, label))
4354    }
4355
4356    /// Full client reset after Welcome (initial connect or reconnect).
4357    pub(crate) fn restore_from_welcome(
4358        &mut self,
4359        session_id: SessionId,
4360        entity_id: EntityId,
4361        snapshot: &flatland_protocol::Snapshot,
4362    ) {
4363        self.clear_harvest_state();
4364        self.disconnect_reason = None;
4365        self.show_stats = false;
4366        self.show_craft_menu = false;
4367        self.show_shop_menu = false;
4368        self.shop_catalog = None;
4369        self.show_inventory_menu = false;
4370        self.session_id = session_id;
4371        self.entity_id = entity_id;
4372        self.connected = true;
4373        self.apply_snapshot_fields(snapshot, entity_id);
4374        if let Some(combat) = &snapshot.combat {
4375            self.apply_combat_hud(combat);
4376            let stacks = self.inventory_stacks.clone();
4377            self.sync_inventory_from_stacks(&stacks);
4378        }
4379    }
4380
4381    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4382        self.tick = delta.tick;
4383        self.world_clock = delta.world_clock;
4384
4385        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
4386        if delta.entities.is_empty() {
4387            self.ground_drops = delta.ground_drops.clone();
4388            self.combat_fx = delta.combat_fx.clone();
4389            self.property_plots = delta.property_plots.clone();
4390            self.apply_terrain_overlays(&delta.terrain_overlays);
4391            if let Some(combat) = &delta.combat {
4392                self.apply_combat_hud(combat);
4393                let stacks = self.inventory_stacks.clone();
4394                self.sync_inventory_from_stacks(&stacks);
4395            }
4396            // Peer vanished from AOI — drop directed whisper.
4397            self.refresh_whisper_range();
4398            return;
4399        }
4400        if !delta.buildings.is_empty() {
4401            self.buildings = delta.buildings.clone();
4402        }
4403        if !delta.blueprints.is_empty() {
4404            self.blueprints = delta.blueprints.clone();
4405        }
4406        self.sync_inventory_from_stacks(&delta.inventory);
4407
4408        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4409            self.player = Some(updated.clone());
4410        }
4411        self.entities = delta.entities.clone();
4412        if self.player.is_none() {
4413            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4414        }
4415
4416        self.sync_interior_map_context();
4417
4418        // Static world layers: ticks often omit these (indoors, or unchanged).
4419        // Never wipe the welcome snapshot with an empty vec — except when indoors,
4420        // where empty means "no nodes in this building" (clear outdoor leftovers).
4421        if !delta.resource_nodes.is_empty() {
4422            self.resource_nodes = delta.resource_nodes.clone();
4423        } else if delta.interior_map.is_some()
4424            || self.effective_inside_building().is_some()
4425        {
4426            self.resource_nodes = delta.resource_nodes.clone();
4427        }
4428        self.ground_drops = delta.ground_drops.clone();
4429        if self
4430            .player
4431            .as_ref()
4432            .is_none_or(|p| p.inside_building.is_none())
4433        {
4434            self.placed_containers = delta.placed_containers.clone();
4435        }
4436        if !delta.doors.is_empty() {
4437            self.doors = delta.doors.clone();
4438        }
4439        if self.effective_inside_building().is_some() {
4440            if let Some(map) = &delta.interior_map {
4441                self.interior_map = Some(map.clone());
4442            }
4443        } else {
4444            self.interior_map = None;
4445        }
4446        self.sync_interior_z_bands();
4447        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
4448        self.npcs = delta.npcs.clone();
4449        if !delta.quest_log.is_empty() {
4450            self.quest_log = delta.quest_log.clone();
4451        }
4452        self.apply_hired_workers(delta.hired_workers.clone());
4453        if !delta.interactables.is_empty() {
4454            self.interactables = delta.interactables.clone();
4455        }
4456        if delta.ledger.is_some() {
4457            self.ledger = delta.ledger.clone();
4458        }
4459        if delta.career.is_some() {
4460            self.career = delta.career.clone();
4461        }
4462        self.combat_fx = delta.combat_fx.clone();
4463        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
4464        if !delta.property_plots.is_empty() {
4465            self.property_plots = delta.property_plots.clone();
4466        }
4467        self.apply_terrain_overlays(&delta.terrain_overlays);
4468        if let Some(combat) = &delta.combat {
4469            self.apply_combat_hud(combat);
4470            let stacks = self.inventory_stacks.clone();
4471            self.sync_inventory_from_stacks(&stacks);
4472        } else {
4473            self.refresh_inventory_ui();
4474        }
4475        self.refresh_whisper_range();
4476    }
4477
4478    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
4479    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
4480    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4481        self.terrain_zones
4482            .retain(|z| !z.id.starts_with("rt:"));
4483        self.terrain_zones.extend(overlays.iter().cloned());
4484    }
4485
4486    /// End directed whisper when the peer is farther than interact range (or missing).
4487    /// Each client runs this locally so both players drop whisper mode when either walks away.
4488    fn refresh_whisper_range(&mut self) {
4489        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4490            return;
4491        };
4492        let (px, py) = self.player_position();
4493        let in_range = self.entities.iter().any(|e| {
4494            e.id == peer
4495                && distance(
4496                    px,
4497                    py,
4498                    e.transform.position.x,
4499                    e.transform.position.y,
4500                ) <= INTERACTION_RADIUS_M
4501        });
4502        if !in_range {
4503            self.social_chat.cancel_whisper_out_of_range();
4504        }
4505    }
4506
4507    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
4508    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4509        let (px, py) = self.player_position();
4510        let mut out = Vec::new();
4511        for npc in &self.npcs {
4512            let Some(eid) = npc.entity_id else {
4513                continue;
4514            };
4515            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4516            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4517            if alive && has_hp {
4518                out.push((eid, npc.label.clone()));
4519            }
4520        }
4521        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4522            let dist = |id: EntityId| {
4523                self.entities
4524                    .iter()
4525                    .find(|e| e.id == id)
4526                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4527                    .unwrap_or(f32::MAX)
4528            };
4529            dist(*a_id)
4530                .partial_cmp(&dist(*b_id))
4531                .unwrap_or(std::cmp::Ordering::Equal)
4532                .then_with(|| a_label.cmp(b_label))
4533                .then_with(|| a_id.cmp(b_id))
4534        });
4535        out
4536    }
4537
4538    pub fn refresh_combat_target_label(&mut self) {
4539        let Some(id) = self.combat_target else {
4540            return;
4541        };
4542        if let Some((_, label)) = self
4543            .combat_candidates()
4544            .into_iter()
4545            .find(|(eid, _)| *eid == id)
4546        {
4547            self.combat_target_label = Some(label);
4548        } else if let Some(label) = self
4549            .entities
4550            .iter()
4551            .find(|e| e.id == id)
4552            .map(|e| e.label.clone())
4553        {
4554            self.combat_target_label = Some(label);
4555        }
4556    }
4557
4558    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4559        self.quest_log
4560            .iter()
4561            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4562            .collect()
4563    }
4564
4565    /// True when the observer has at least one free lodging slot (max workers = beds).
4566    pub fn has_worker_lodging(&self) -> bool {
4567        self.free_worker_lodging_slots() > 0
4568    }
4569
4570    /// Owned lodging capacity minus currently hired workers.
4571    pub fn free_worker_lodging_slots(&self) -> i64 {
4572        let slots: u32 = self
4573            .placed_containers
4574            .iter()
4575            .filter(|c| match (self.character_id, c.owner_character_id) {
4576                (Some(me), Some(owner)) => me == owner,
4577                (Some(_), None) => false,
4578                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4579            })
4580            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4581            .sum();
4582        let used = self.hired_workers.len() as u32;
4583        slots as i64 - used as i64
4584    }
4585
4586    /// Display names of hired workers assigned to this lodging container.
4587    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4588        let mut names: Vec<String> = self
4589            .hired_workers
4590            .iter()
4591            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4592            .map(|w| w.label.clone())
4593            .collect();
4594        names.sort();
4595        names
4596    }
4597
4598    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
4599    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4600        let is_lodging = self
4601            .placed_containers
4602            .iter()
4603            .find(|c| c.id == container_id)
4604            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4605        if !is_lodging {
4606            return None;
4607        }
4608        let names = self.lodging_occupant_labels(container_id);
4609        Some(if names.is_empty() {
4610            "vacant".into()
4611        } else {
4612            names.join(", ")
4613        })
4614    }
4615
4616    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4617        self.quest_log
4618            .iter()
4619            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4620            .or_else(|| {
4621                self.quest_log
4622                    .iter()
4623                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4624            })
4625    }
4626
4627    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
4628    pub fn nearest_interact_target(&self) -> Option<String> {
4629        let (px, py) = self.player_position();
4630        let inside = self.effective_inside_building();
4631
4632        #[derive(Clone, Copy, PartialEq, Eq)]
4633        enum Kind {
4634            Player,
4635            Npc,
4636            HiredWorker,
4637            QuestBoard,
4638            ExitDoor,
4639            EnterDoor,
4640            Well,
4641            Water,
4642        }
4643
4644        fn kind_priority(kind: Kind) -> u8 {
4645            match kind {
4646                Kind::Player => 0,
4647                Kind::Npc => 0,
4648                Kind::HiredWorker => 0,
4649                Kind::QuestBoard => 1,
4650                Kind::ExitDoor => 2,
4651                Kind::EnterDoor => 3,
4652                Kind::Well => 4,
4653                Kind::Water => 5,
4654            }
4655        }
4656
4657        let mut best: Option<(f32, Kind, String)> = None;
4658
4659        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
4660            if dist > max {
4661                return;
4662            }
4663            let replace = match best {
4664                None => true,
4665                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
4666                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
4667                    kind_priority(kind) < kind_priority(bk)
4668                }
4669                _ => false,
4670            };
4671            if replace {
4672                best = Some((dist, kind, id));
4673            }
4674        };
4675
4676        for npc in &self.npcs {
4677            consider(
4678                distance(px, py, npc.x, npc.y),
4679                INTERACTION_RADIUS_M,
4680                Kind::Npc,
4681                npc.id.clone(),
4682            );
4683        }
4684
4685        for worker in &self.hired_workers {
4686            consider(
4687                distance(px, py, worker.x, worker.y),
4688                INTERACTION_RADIUS_M,
4689                Kind::HiredWorker,
4690                worker.instance_id.clone(),
4691            );
4692        }
4693
4694        for entity in &self.entities {
4695            if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
4696            {
4697                continue;
4698            }
4699            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
4700            if self
4701                .hired_workers
4702                .iter()
4703                .any(|w| w.entity_id == entity.id)
4704            {
4705                continue;
4706            }
4707            consider(
4708                distance(
4709                    px,
4710                    py,
4711                    entity.transform.position.x,
4712                    entity.transform.position.y,
4713                ),
4714                INTERACTION_RADIUS_M,
4715                Kind::Player,
4716                entity.id.to_string(),
4717            );
4718        }
4719
4720        for door in &self.doors {
4721            if let Some(ref bid) = inside {
4722                if door.building_id != *bid {
4723                    continue;
4724                }
4725                let is_exit = door.portal.is_some();
4726                let max = if is_exit {
4727                    INTERACTION_RADIUS_M
4728                } else {
4729                    DOOR_INTERACTION_RADIUS_M
4730                };
4731                let kind = if is_exit {
4732                    Kind::ExitDoor
4733                } else {
4734                    Kind::EnterDoor
4735                };
4736                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4737                continue;
4738            }
4739            consider(
4740                distance(px, py, door.x, door.y),
4741                DOOR_INTERACTION_RADIUS_M,
4742                Kind::EnterDoor,
4743                door.id.clone(),
4744            );
4745        }
4746
4747        if inside.is_none() {
4748            for inter in &self.interactables {
4749                if inter.kind == "quest_board" {
4750                    consider(
4751                        distance(px, py, inter.x, inter.y),
4752                        QUEST_BOARD_INTERACTION_RADIUS_M,
4753                        Kind::QuestBoard,
4754                        inter.id.clone(),
4755                    );
4756                }
4757            }
4758            for building in &self.buildings {
4759                if !building.tags.iter().any(|t| t == "well") {
4760                    continue;
4761                }
4762                consider(
4763                    distance(px, py, building.x, building.y),
4764                    INTERACTION_RADIUS_M,
4765                    Kind::Well,
4766                    building.id.clone(),
4767                );
4768            }
4769            if self.in_shallow_water() {
4770                consider(
4771                    0.0,
4772                    INTERACTION_RADIUS_M,
4773                    Kind::Water,
4774                    "water_source".into(),
4775                );
4776            }
4777        }
4778
4779        best.map(|(_, _, id)| id)
4780    }
4781
4782    /// Nearest quest board and distance (any distance), for out-of-range feedback.
4783    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4784        if self.effective_inside_building().is_some() {
4785            return None;
4786        }
4787        let (px, py) = self.player_position();
4788        self.interactables
4789            .iter()
4790            .filter(|i| i.kind == "quest_board")
4791            .map(|i| {
4792                let label = if i.label.is_empty() {
4793                    "Quest board".to_string()
4794                } else {
4795                    i.label.clone()
4796                };
4797                (label, distance(px, py, i.x, i.y))
4798            })
4799            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4800    }
4801
4802    /// Human-readable template name from synced catalog hints.
4803    pub fn template_display_name(&self, template_id: &str) -> String {
4804        self.inventory_hints
4805            .get(template_id)
4806            .map(|h| h.display_name.clone())
4807            .filter(|n| !n.is_empty())
4808            .unwrap_or_else(|| humanize_template_id(template_id))
4809    }
4810
4811    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
4812    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
4813        use crate::worker_route_editor::{
4814            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
4815        };
4816        let lodging = self
4817            .worker_route_editor
4818            .as_ref()
4819            .and_then(|ed| ed.lodging_container_id.as_deref());
4820        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
4821            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
4822            None => node_candidates_stable(&self.resource_nodes),
4823        }
4824    }
4825
4826    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
4827        if dist_m.is_nan() {
4828            return "—".into();
4829        }
4830        let from_bed = self
4831            .worker_route_editor
4832            .as_ref()
4833            .and_then(|ed| ed.lodging_container_id.as_deref())
4834            .and_then(|id| {
4835                self.placed_containers
4836                    .iter()
4837                    .find(|c| c.id == id)
4838                    .map(|c| c.display_name.clone())
4839            });
4840        match from_bed {
4841            Some(bed) => format!("{dist_m:.0}m from {bed}"),
4842            None => format!("{dist_m:.0}m"),
4843        }
4844    }
4845
4846    /// Chest label for the location panel — generic type unless this player owns it.
4847    pub fn placed_container_public_label(
4848        &self,
4849        c: &flatland_protocol::PlacedContainerView,
4850    ) -> String {
4851        let is_owner = match (self.character_id, c.owner_character_id) {
4852            (Some(me), Some(owner)) => me == owner,
4853            _ => false,
4854        };
4855        if is_owner {
4856            c.display_name.clone()
4857        } else {
4858            self.template_display_name(&c.template_id)
4859        }
4860    }
4861
4862    /// Keys on person and stowed on the keychain for the keychain overlay.
4863    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4864        let mut out = Vec::new();
4865        for stack in &self.inventory_stacks {
4866            if stack.template_id == KEY_TEMPLATE {
4867                out.push(KeychainEntry {
4868                    stack: stack.clone(),
4869                    stowed: false,
4870                });
4871            }
4872        }
4873        for stack in &self.keychain_stacks {
4874            if stack.template_id == KEY_TEMPLATE {
4875                out.push(KeychainEntry {
4876                    stack: stack.clone(),
4877                    stowed: true,
4878                });
4879            }
4880        }
4881        out
4882    }
4883
4884    /// Display name of the chest a `container_key` opens, when known on the client.
4885    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4886        if stack.template_id != KEY_TEMPLATE {
4887            return None;
4888        }
4889        if let Some(name) = stack
4890            .props
4891            .get(PROP_OPENS_CONTAINER_NAME)
4892            .filter(|n| !n.is_empty())
4893        {
4894            return Some(name.clone());
4895        }
4896        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4897        self.container_name_for_lock_id(opens)
4898    }
4899
4900    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
4901    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4902        if stack.template_id == KEY_TEMPLATE {
4903            self.template_display_name(KEY_TEMPLATE)
4904        } else {
4905            stack
4906                .display_name
4907                .clone()
4908                .unwrap_or_else(|| stack.template_id.clone())
4909        }
4910    }
4911
4912    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
4913    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4914        if stack.template_id != KEY_TEMPLATE {
4915            return String::new();
4916        }
4917        match self.key_pair_chest_label(stack) {
4918            Some(chest) if self.key_drop_blocked(stack) => {
4919                format!(" [key for {chest} — can't drop while locked]")
4920            }
4921            Some(chest) => format!(" [key for {chest}]"),
4922            None => " [key — unpaired]".into(),
4923        }
4924    }
4925
4926    /// Resolve a lock id to a container label (placed chest or one still on your person).
4927    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4928        for c in &self.placed_containers {
4929            if c.lock_id.as_deref() == Some(lock) {
4930                return Some(c.display_name.clone());
4931            }
4932        }
4933        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4934            self.worn
4935                .values()
4936                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4937        })
4938    }
4939
4940    /// Keys cannot be dropped while their paired chest is locked.
4941    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4942        if stack.template_id != KEY_TEMPLATE {
4943            return false;
4944        }
4945        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4946            return false;
4947        };
4948        for c in &self.placed_containers {
4949            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4950                return true;
4951            }
4952        }
4953        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4954            return true;
4955        }
4956        self.worn
4957            .values()
4958            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4959    }
4960
4961    /// Property deeds cannot be dropped or destroyed (store or trade only).
4962    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
4963        stack.template_id == PROPERTY_DEED_TEMPLATE
4964    }
4965
4966    pub fn is_property_deed_template(template_id: &str) -> bool {
4967        template_id == PROPERTY_DEED_TEMPLATE
4968    }
4969
4970    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
4971        stack
4972            .props
4973            .get("plot_id")
4974            .and_then(|s| uuid::Uuid::parse_str(s).ok())
4975    }
4976
4977    /// World position (cell center) of the plot cell the player is standing on, if tillable.
4978    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
4979        let (px, py) = self.player_position();
4980        let (cx, cy) = self.farm_plot_cell_under_player()?;
4981        let tx = cx as f32 + 0.5;
4982        let ty = cy as f32 + 0.5;
4983        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
4984            return None;
4985        }
4986        let kind = self
4987            .terrain_at(tx, ty)
4988            .or_else(|| self.terrain_at(px, py));
4989        if kind == Some(TerrainKindView::Tilled) {
4990            return None;
4991        }
4992        if matches!(
4993            kind,
4994            Some(TerrainKindView::ShallowWater)
4995                | Some(TerrainKindView::DeepWater)
4996                | Some(TerrainKindView::Rock)
4997        ) {
4998            return None;
4999        }
5000        Some((tx, ty))
5001    }
5002
5003    fn container_name_in_stacks(
5004        stacks: &[flatland_protocol::ItemStack],
5005        lock: &str,
5006    ) -> Option<String> {
5007        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5008            for s in stacks {
5009                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5010                    return Some(GameState::stack_container_label(s));
5011                }
5012                if let Some(name) = walk(&s.contents, lock) {
5013                    return Some(name);
5014                }
5015            }
5016            None
5017        }
5018        walk(stacks, lock)
5019    }
5020
5021    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5022        stack
5023            .props
5024            .get(PROP_CUSTOM_NAME)
5025            .cloned()
5026            .or_else(|| stack.display_name.clone())
5027            .unwrap_or_else(|| stack.template_id.clone())
5028    }
5029
5030    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5031        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5032            for s in stacks {
5033                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5034                    return true;
5035                }
5036                if walk(&s.contents, lock) {
5037                    return true;
5038                }
5039            }
5040            false
5041        }
5042        walk(stacks, lock)
5043    }
5044
5045    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5046        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5047            return Some(stack.clone());
5048        }
5049        for worn in self.worn.values() {
5050            if worn.item_instance_id == Some(instance_id) {
5051                return Some(worn.clone());
5052            }
5053            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5054                return Some(stack.clone());
5055            }
5056        }
5057        None
5058    }
5059
5060    /// Highest-`z_order` property zone covering `(x, y)`.
5061    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5062        self.property_zones
5063            .iter()
5064            .enumerate()
5065            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5066            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5067            .map(|(_, z)| z)
5068    }
5069
5070    /// Highest-`z_order` tax zone covering `(x, y)`.
5071    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5072        self.tax_zones
5073            .iter()
5074            .enumerate()
5075            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5076            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5077            .map(|(_, z)| z)
5078    }
5079
5080    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5081    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5082        let mut max_bps = 0u32;
5083        let mut y = y0 + 0.5;
5084        while y < y1 {
5085            let mut x = x0 + 0.5;
5086            while x < x1 {
5087                if let Some(tz) = self.tax_zone_at(x, y) {
5088                    max_bps = max_bps.max(tz.rate_bps);
5089                }
5090                x += 1.0;
5091            }
5092            y += 1.0;
5093        }
5094        max_bps
5095    }
5096
5097    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5098    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5099        let mode = self.claim_mode.as_ref()?;
5100        let w = mode.width_m.max(1) as f32;
5101        let h = mode.height_m.max(1) as f32;
5102        Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5103    }
5104
5105    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5106    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5107        let mode = self.relocate_mode.as_ref()?;
5108        let x0 = mode.cursor_x.floor();
5109        let y0 = mode.cursor_y.floor();
5110        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5111    }
5112
5113    /// Client quote matching server `quote_plot`:
5114    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5115    pub fn claim_quote(
5116        &self,
5117    ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5118        let mode = self.claim_mode.as_ref()?;
5119        let zone = self
5120            .property_zones
5121            .iter()
5122            .find(|z| z.id == mode.zone_id)?;
5123        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5124        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5125        let zone_area = zone_view_area_m2(zone).max(1.0);
5126        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5127        let weight = self
5128            .property_plot_settings
5129            .as_ref()
5130            .map(|s| s.tax_premium_weight)
5131            .unwrap_or(0.5)
5132            .max(0.0);
5133        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5134        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5135        let purchase = ((zone.crown_price_copper as f64)
5136            * (area_frac as f64)
5137            * (premium as f64))
5138            .ceil()
5139            .max(0.0) as u64;
5140        let upkeep = if zone.upkeep_copper_per_day == 0 {
5141            0
5142        } else {
5143            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5144                .ceil()
5145                .max(1.0) as u64
5146        };
5147        let copper = crate::currency::copper_from_counts(&self.inventory);
5148        let can_afford = copper >= purchase;
5149        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5150        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5151    }
5152
5153    fn validate_claim_footprint(
5154        &self,
5155        zone: &flatland_protocol::PropertyZoneView,
5156        x0: f32,
5157        y0: f32,
5158        x1: f32,
5159        y1: f32,
5160        area: f32,
5161    ) -> (bool, String) {
5162        let min_area = self
5163            .property_plot_settings
5164            .as_ref()
5165            .map(|s| s.min_plot_area_m2)
5166            .unwrap_or(4.0);
5167        if area + f32::EPSILON < min_area {
5168            return (false, "plot too small".into());
5169        }
5170        if zone.max_area_m2.is_some_and(|m| area > m) {
5171            return (false, "plot exceeds max area".into());
5172        }
5173        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5174            return (false, "plot must lie inside the property zone".into());
5175        }
5176        if self.property_plots.iter().any(|p| {
5177            rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5178        }) {
5179            return (false, "plot overlaps an existing claim".into());
5180        }
5181        (true, String::new())
5182    }
5183
5184    /// Standing in a property zone on a free cell (not already claimed).
5185    pub fn free_property_zone_under_player(
5186        &self,
5187    ) -> Option<&flatland_protocol::PropertyZoneView> {
5188        let (px, py) = self.player_position();
5189        let zone = self.property_zone_at(px, py)?;
5190        if self
5191            .property_plots
5192            .iter()
5193            .any(|p| point_in_plot(px, py, p))
5194        {
5195            return None;
5196        }
5197        Some(zone)
5198    }
5199
5200    /// Own plot covering the player (`is_mine`).
5201    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5202        let (px, py) = self.player_position();
5203        self.property_plots
5204            .iter()
5205            .find(|p| p.is_mine && point_in_plot(px, py, p))
5206    }
5207
5208    /// Plot under the player that they may farm (deed, grant, or public).
5209    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5210        let (px, py) = self.player_position();
5211        self.property_plots
5212            .iter()
5213            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5214    }
5215
5216    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5217    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5218        if self.farmable_plot_under_player().is_none() {
5219            return None;
5220        }
5221        let (px, py) = self.player_position();
5222        Some((px.floor() as i32, py.floor() as i32))
5223    }
5224
5225    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5226        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5227        self.resource_nodes.iter().any(|n| {
5228            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5229            ncx == cx && ncy == cy
5230                || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5231        })
5232    }
5233
5234    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5235        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5236        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5237            || self
5238                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5239                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5240        if !tilled {
5241            return false;
5242        }
5243        !self.resource_node_occupies_farm_cell(cx, cy)
5244    }
5245
5246    /// Empty tilled soil on the cell the player is standing on (no crop node).
5247    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5248        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5249            return false;
5250        };
5251        self.free_tilled_plant_slot_at(cx, cy)
5252    }
5253
5254    /// True when a free tilled cell (no resource node) is within interact range.
5255    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5256        let (px, py) = self.player_position();
5257        for dy in -2..=2 {
5258            for dx in -2..=2 {
5259                let cx = px.floor() as i32 + dx;
5260                let cy = py.floor() as i32 + dy;
5261                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5262                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5263                    continue;
5264                }
5265                if self.free_tilled_plant_slot_at(cx, cy) {
5266                    return true;
5267                }
5268            }
5269        }
5270        false
5271    }
5272
5273    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5274        stack.quantity > 0
5275            && (stack.props.contains_key("seed_for")
5276                || stack.template_id.ends_with("_seed")
5277                || stack.template_id == "potato_seed"
5278                || stack.template_id == "carrot_seed")
5279    }
5280
5281    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
5282    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5283        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5284        fn walk(
5285            stacks: &[flatland_protocol::ItemStack],
5286            counts: &mut std::collections::HashMap<String, u32>,
5287        ) {
5288            for s in stacks {
5289                if GameState::stack_is_farm_seed(s) {
5290                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5291                }
5292                walk(&s.contents, counts);
5293            }
5294        }
5295        walk(&self.inventory_stacks, &mut counts);
5296        for worn in self.worn.values() {
5297            walk(std::slice::from_ref(worn), &mut counts);
5298        }
5299        let mut out: Vec<_> = counts
5300            .into_iter()
5301            .map(|(template_id, quantity)| {
5302                let label = self
5303                    .inventory_hints
5304                    .get(&template_id)
5305                    .map(|h| h.display_name.clone())
5306                    .filter(|n| !n.trim().is_empty())
5307                    .unwrap_or_else(|| humanize_template_id(&template_id));
5308                (template_id, quantity, label)
5309            })
5310            .collect();
5311        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5312        out
5313    }
5314
5315    /// First farm seed template in inventory (root + nested bags).
5316    pub fn first_farm_seed_template(&self) -> Option<String> {
5317        self.farm_seed_entries()
5318            .into_iter()
5319            .next()
5320            .map(|(id, _, _)| id)
5321    }
5322
5323    pub fn clamp_plant_menu(&mut self) {
5324        let n = self.farm_seed_entries().len();
5325        if n == 0 {
5326            self.plant_menu_index = 0;
5327            self.plant_quantity = 1;
5328            return;
5329        }
5330        self.plant_menu_index = self.plant_menu_index.min(n - 1);
5331        let max_qty = self
5332            .farm_seed_entries()
5333            .get(self.plant_menu_index)
5334            .map(|(_, q, _)| *q)
5335            .unwrap_or(1)
5336            .max(1);
5337        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5338    }
5339
5340    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5341        let entries = self.farm_seed_entries();
5342        let (id, max, label) = entries.get(self.plant_menu_index)?;
5343        let qty = self.plant_quantity.min(*max).max(1);
5344        Some((id.clone(), qty, label.clone()))
5345    }
5346
5347    /// Terrain, interactables, and map objects near the player for the HUD location panel.
5348    pub fn location_context_lines(&self) -> Vec<ContextLine> {
5349        let (px, py) = self.player_position();
5350        let inside = self.effective_inside_building();
5351        let mut lines = Vec::new();
5352
5353        if let Some(kind) = self.terrain_at(px, py) {
5354            lines.push(ContextLine {
5355                on_top: true,
5356                text: format!("Terrain: {}", terrain_kind_label(kind)),
5357            });
5358        }
5359
5360        if let Some(id) = inside.as_ref() {
5361            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5362                lines.push(ContextLine {
5363                    on_top: true,
5364                    text: format!("Inside: {}", b.label),
5365                });
5366            }
5367        }
5368
5369        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5370
5371        for node in &self.resource_nodes {
5372            if node.id.starts_with("preview:") {
5373                continue;
5374            }
5375            let dist = distance(px, py, node.x, node.y);
5376            if dist > NEARBY_SCAN_M {
5377                continue;
5378            }
5379            let on_top = dist <= ON_TOP_RADIUS_M;
5380            let prefix = if on_top { "On" } else { "Near" };
5381            let name = resource_node_near_display_label(&node.label);
5382            let action = resource_node_near_action_suffix(node);
5383            nearby.push((
5384                dist,
5385                ContextLine {
5386                    on_top,
5387                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5388                },
5389            ));
5390        }
5391
5392        for drop in &self.ground_drops {
5393            let dist = distance(px, py, drop.x, drop.y);
5394            if dist > INTERACTION_RADIUS_M {
5395                continue;
5396            }
5397            let on_top = dist <= ON_TOP_RADIUS_M;
5398            let name = self.template_display_name(&drop.template_id);
5399            let prefix = if on_top { "On" } else { "Near" };
5400            let qty = if drop.quantity > 1 {
5401                format!(" ×{}", drop.quantity)
5402            } else {
5403                String::new()
5404            };
5405            nearby.push((
5406                dist,
5407                ContextLine {
5408                    on_top,
5409                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5410                },
5411            ));
5412        }
5413
5414        for c in &self.placed_containers {
5415            let dist = distance(px, py, c.x, c.y);
5416            if dist > CONTAINER_RANGE_M {
5417                continue;
5418            }
5419            let on_top = dist <= ON_TOP_RADIUS_M;
5420            let name = self.placed_container_public_label(c);
5421            let lock = if c.locked { " [locked]" } else { "" };
5422            let prefix = if on_top { "On" } else { "Near" };
5423            nearby.push((
5424                dist,
5425                ContextLine {
5426                    on_top,
5427                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5428                },
5429            ));
5430        }
5431
5432        for npc in &self.npcs {
5433            let dist = distance(px, py, npc.x, npc.y);
5434            if dist > NEARBY_SCAN_M {
5435                continue;
5436            }
5437            let on_top = dist <= ON_TOP_RADIUS_M;
5438            let prefix = if on_top { "On" } else { "Near" };
5439            nearby.push((
5440                dist,
5441                ContextLine {
5442                    on_top,
5443                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5444                },
5445            ));
5446        }
5447
5448        for door in &self.doors {
5449            let dist = distance(px, py, door.x, door.y);
5450            if dist > DOOR_INTERACTION_RADIUS_M {
5451                continue;
5452            }
5453            let building = self
5454                .buildings
5455                .iter()
5456                .find(|b| b.id == door.building_id)
5457                .map(|b| b.label.as_str())
5458                .unwrap_or(door.building_id.as_str());
5459            let action = if inside.is_some() && door.portal.is_some() {
5460                "exit"
5461            } else {
5462                "enter"
5463            };
5464            nearby.push((
5465                dist,
5466                ContextLine {
5467                    on_top: dist <= ON_TOP_RADIUS_M,
5468                    text: format!("{building} door ({dist:.1}m) — f {action}"),
5469                },
5470            ));
5471        }
5472
5473        if inside.is_none() {
5474            for inter in &self.interactables {
5475                if inter.kind != "quest_board" {
5476                    continue;
5477                }
5478                let dist = distance(px, py, inter.x, inter.y);
5479                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5480                    continue;
5481                }
5482                let on_top = dist <= ON_TOP_RADIUS_M;
5483                let prefix = if on_top { "On" } else { "Near" };
5484                let label = if inter.label.is_empty() {
5485                    "Quest board".to_string()
5486                } else {
5487                    inter.label.clone()
5488                };
5489                nearby.push((
5490                    dist,
5491                    ContextLine {
5492                        on_top,
5493                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5494                    },
5495                ));
5496            }
5497        }
5498
5499        if self.in_shallow_water() {
5500            let already = self
5501                .terrain_at(px, py)
5502                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5503            if !already {
5504                nearby.push((
5505                    0.0,
5506                    ContextLine {
5507                        on_top: true,
5508                        text: "Shallow water — f fill bottle".into(),
5509                    },
5510                ));
5511            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5512                line.text.push_str(" — f fill bottle");
5513            }
5514        }
5515
5516        if self.claim_mode.is_some() {
5517            nearby.push((
5518                0.0,
5519                ContextLine {
5520                    on_top: true,
5521                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5522                        .into(),
5523                },
5524            ));
5525        } else if let Some(plot) = self.my_plot_under_player() {
5526            let zone = plot
5527                .zone_label
5528                .as_deref()
5529                .filter(|s| !s.trim().is_empty())
5530                .or_else(|| {
5531                    self.property_zones
5532                        .iter()
5533                        .find(|z| z.id == plot.property_zone_id)
5534                        .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5535                })
5536                .unwrap_or(plot.property_zone_id.as_str());
5537            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5538                format!("Your plot ({zone}) — f again to sell to crown")
5539            } else {
5540                format!(
5541                    "Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
5542                )
5543            };
5544            nearby.push((
5545                0.0,
5546                ContextLine {
5547                    on_top: true,
5548                    text: prompt,
5549                },
5550            ));
5551        } else if let Some(plot) = self.farmable_plot_under_player() {
5552            let owner = plot
5553                .owner_label
5554                .as_deref()
5555                .filter(|s| !s.trim().is_empty())
5556                .unwrap_or("owner");
5557            let disc = if plot.farm_public {
5558                plot.public_tax_discount_bps / 100
5559            } else {
5560                plot.farm_allow
5561                    .iter()
5562                    .find(|g| Some(g.character_id) == self.character_id)
5563                    .map(|g| g.tax_discount_bps / 100)
5564                    .unwrap_or(0)
5565            };
5566            nearby.push((
5567                0.0,
5568                ContextLine {
5569                    on_top: true,
5570                    text: format!(
5571                        "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5572                    ),
5573                },
5574            ));
5575        } else if let Some(zone) = self.free_property_zone_under_player() {
5576            let label = zone
5577                .label
5578                .as_deref()
5579                .filter(|s| !s.trim().is_empty())
5580                .unwrap_or(zone.id.as_str());
5581            nearby.push((
5582                0.0,
5583                ContextLine {
5584                    on_top: true,
5585                    text: format!("Claimable land: {label} — k buy plot"),
5586                },
5587            ));
5588        }
5589
5590        for entity in &self.entities {
5591            if entity.id == self.entity_id {
5592                continue;
5593            }
5594            let dist = distance(
5595                px,
5596                py,
5597                entity.transform.position.x,
5598                entity.transform.position.y,
5599            );
5600            if dist > NEARBY_SCAN_M {
5601                continue;
5602            }
5603            let label = if entity.label.is_empty() {
5604                format!("entity {}", entity.id)
5605            } else {
5606                entity.label.clone()
5607            };
5608            nearby.push((
5609                dist,
5610                ContextLine {
5611                    on_top: dist <= ON_TOP_RADIUS_M,
5612                    text: format!("Near: {label} ({dist:.1}m)"),
5613                },
5614            ));
5615        }
5616
5617        nearby.sort_by(|a, b| {
5618            a.0.partial_cmp(&b.0)
5619                .unwrap_or(std::cmp::Ordering::Equal)
5620                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
5621        });
5622        lines.extend(nearby.into_iter().map(|(_, l)| l));
5623
5624        if lines.is_empty() {
5625            lines.push(ContextLine {
5626                on_top: false,
5627                text: "(nothing notable nearby)".into(),
5628            });
5629        }
5630
5631        lines
5632    }
5633}
5634
5635/// HUD line for the location / nearby panel.
5636#[derive(Debug, Clone)]
5637pub struct ContextLine {
5638    pub on_top: bool,
5639    pub text: String,
5640}
5641
5642const ON_TOP_RADIUS_M: f32 = 0.65;
5643const NEARBY_SCAN_M: f32 = 5.0;
5644
5645/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
5646pub fn resource_node_near_display_label(label: &str) -> String {
5647    label
5648        .strip_suffix(" (growing)")
5649        .unwrap_or(label)
5650        .to_string()
5651}
5652
5653/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
5654pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
5655    use flatland_protocol::ResourceNodeState;
5656    if let Some(p) = node.growth_progress {
5657        if p < 1.0 - f32::EPSILON {
5658            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
5659            return format!(" (growing, {pct}%)");
5660        }
5661        return " — f harvest".to_string();
5662    }
5663    match node.state {
5664        ResourceNodeState::Available => " — f harvest".to_string(),
5665        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
5666        ResourceNodeState::Cooldown => " (depleted)".to_string(),
5667    }
5668}
5669
5670fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
5671    use flatland_protocol::TerrainKindView;
5672    match kind {
5673        TerrainKindView::Grass => "Grass",
5674        TerrainKindView::Dirt => "Dirt",
5675        TerrainKindView::Tilled => "Tilled",
5676        TerrainKindView::Desert => "Desert",
5677        TerrainKindView::Hill => "Hills",
5678        TerrainKindView::Bog => "Bog",
5679        TerrainKindView::Beach => "Beach",
5680        TerrainKindView::ShallowWater => "Shallow water",
5681        TerrainKindView::DeepWater => "Deep water",
5682        TerrainKindView::Trail => "Trail",
5683        TerrainKindView::Road => "Road",
5684        TerrainKindView::Rock => "Rock",
5685    }
5686}
5687
5688fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
5689    crate::world_zones::zone_rects_contain(rects, x, y)
5690}
5691
5692fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
5693    zone.rects
5694        .iter()
5695        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
5696        .sum()
5697}
5698
5699fn claim_rect_fully_inside_zone(
5700    zone: &flatland_protocol::PropertyZoneView,
5701    x0: f32,
5702    y0: f32,
5703    x1: f32,
5704    y1: f32,
5705) -> bool {
5706    let mut y = y0 + 0.5;
5707    while y < y1 {
5708        let mut x = x0 + 0.5;
5709        while x < x1 {
5710            if !zone_rects_contain(&zone.rects, x, y) {
5711                return false;
5712            }
5713            x += 1.0;
5714        }
5715        y += 1.0;
5716    }
5717    true
5718}
5719
5720fn rects_overlap_half_open(
5721    ax0: f32,
5722    ay0: f32,
5723    ax1: f32,
5724    ay1: f32,
5725    bx0: f32,
5726    by0: f32,
5727    bx1: f32,
5728    by1: f32,
5729) -> bool {
5730    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
5731}
5732
5733fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
5734    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
5735}
5736
5737fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
5738    p.zone_label
5739        .as_deref()
5740        .filter(|s| !s.trim().is_empty())
5741        .map(|s| s.to_string())
5742        .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
5743}
5744
5745/// Match `flatland_sim::economy_zones::snap_claim_rect`.
5746fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
5747    let a = x0.min(x1).floor();
5748    let b = y0.min(y1).floor();
5749    let mut c = x0.max(x1).ceil();
5750    let mut d = y0.max(y1).ceil();
5751    if (c - a) < 1.0 {
5752        c = a + 1.0;
5753    }
5754    if (d - b) < 1.0 {
5755        d = b + 1.0;
5756    }
5757    (a, b, c, d)
5758}
5759
5760fn humanize_template_id(template_id: &str) -> String {
5761    template_id
5762        .split('_')
5763        .map(|word| {
5764            let mut chars = word.chars();
5765            match chars.next() {
5766                None => String::new(),
5767                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5768            }
5769        })
5770        .collect::<Vec<_>>()
5771        .join(" ")
5772}
5773
5774/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
5775const HARVEST_RANGE_M: f32 = 1.5;
5776
5777pub struct GameClient<S: PlayConnection> {
5778    session: S,
5779    seq: Seq,
5780    pub state: GameState,
5781    last_move_forward: f32,
5782    last_move_strafe: f32,
5783}
5784
5785impl<S: PlayConnection> GameClient<S> {
5786    pub fn new(session: S) -> Self {
5787        let session_id = session.session_id();
5788        let entity_id = session.entity_id();
5789        let mut client = Self {
5790            session,
5791            seq: 0,
5792            last_move_forward: 0.0,
5793            last_move_strafe: 0.0,
5794            state: GameState {
5795                session_id,
5796                entity_id,
5797                character_id: None,
5798                tick: 0,
5799                chunk_rev: 0,
5800                content_rev: 0,
5801                publish_rev: 0,
5802                entities: Vec::new(),
5803                player: None,
5804                resource_nodes: Vec::new(),
5805                ground_drops: Vec::new(),
5806                placed_containers: Vec::new(),
5807                buildings: Vec::new(),
5808                doors: Vec::new(),
5809                interior_map: None,
5810                npcs: Vec::new(),
5811                blueprints: Vec::new(),
5812                world_x0: 0.0,
5813                world_y0: 0.0,
5814                world_width_m: 0.0,
5815                world_height_m: 0.0,
5816                terrain_zones: Vec::new(),
5817                z_platforms: Vec::new(),
5818                z_transitions: Vec::new(),
5819                world_clock: flatland_protocol::WorldClock::default(),
5820                inventory: std::collections::HashMap::new(),
5821                inventory_hints: std::collections::HashMap::new(),
5822                logs: VecDeque::new(),
5823                intents_sent: 0,
5824                ticks_received: 0,
5825                connected: false,
5826                disconnect_reason: None,
5827                show_stats: false,
5828            hud_log_hidden: false,
5829                show_equip_menu: false,
5830                equip_menu_index: 0,
5831                show_craft_menu: false,
5832                craft_menu_index: 0,
5833                craft_batch_quantity: 1,
5834                show_shop_menu: false,
5835                shop_catalog: None,
5836                bank_panel: None,
5837                bank_menu_index: 0,
5838                bank_ui_mode: BankUiMode::Menu,
5839                storage_panel: None,
5840                market_panel: None,
5841                market_menu_index: 0,
5842                market_filter: String::new(),
5843                market_filter_focused: false,
5844                market_category_filter: None,
5845                market_buy_confirm: None,
5846                market_ui_mode: MarketUiMode::Browse,
5847                storage_menu_index: 0,
5848                storage_ui_mode: StorageUiMode::Menu,
5849                shop_tab: ShopTab::default(),
5850                shop_menu_index: 0,
5851                shop_quantity: 1,
5852                shop_trade_log: VecDeque::new(),
5853                show_npc_verb_menu: false,
5854                npc_verb_target: None,
5855                npc_verb_index: 0,
5856                player_verbs: crate::social::PlayerVerbState::default(),
5857                social_chat: crate::social::SocialChatState::default(),
5858                trade_ui: crate::social::TradeUiState::default(),
5859                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
5860                show_npc_chat: false,
5861                npc_chat: None,
5862                show_inventory_menu: false,
5863                inventory_menu_index: 0,
5864                inventory_tab: InventoryTab::OnPerson,
5865                inventory_filter: String::new(),
5866                inventory_filter_focused: false,
5867                show_move_picker: false,
5868                show_rename_prompt: false,
5869                show_worker_rename: false,
5870                rename_buffer: String::new(),
5871                move_picker_index: 0,
5872                move_picker: None,
5873                show_grant_picker: false,
5874                grant_picker_index: 0,
5875                grant_picker: None,
5876                show_destroy_picker: false,
5877                destroy_confirm_pending: false,
5878                destroy_picker: None,
5879                combat_target: None,
5880                combat_target_label: None,
5881                combat_fx: Vec::new(),
5882                property_zones: Vec::new(),
5883                tax_zones: Vec::new(),
5884                growth_zones: Vec::new(),
5885                biome_zones: Vec::new(),
5886                property_plots: Vec::new(),
5887                property_plot_settings: None,
5888                claim_mode: None,
5889                relocate_mode: None,
5890                sell_plot_confirm: None,
5891                sell_plot_armed_at: None,
5892                show_plant_menu: false,
5893                plant_menu_index: 0,
5894                show_farm_access: false,
5895                farm_access_name_draft: String::new(),
5896                farm_access_discount_bps: 0,
5897                farm_access_index: 0,
5898                plant_quantity: 1,
5899                in_combat: false,
5900                auto_attack: true,
5901                combat_has_los: false,
5902                attack_cd_ticks: 0,
5903                gcd_ticks: 0,
5904                weapon_ability_id: "unarmed".into(),
5905                mainhand_template_id: None,
5906                mainhand_label: None,
5907                offhand_template_id: None,
5908                offhand_label: None,
5909                mainhand_hand_slots: 1,
5910                defense: None,
5911                worn: BTreeMap::new(),
5912                carry_mass: 0.0,
5913                carry_mass_max: 0.0,
5914                encumbrance: flatland_protocol::EncumbranceState::Light,
5915                inventory_stacks: Vec::new(),
5916                keychain_stacks: Vec::new(),
5917            whisper_pouch_stacks: Vec::new(),
5918                combat_target_detail: None,
5919                statuses: Vec::new(),
5920                cast_progress: None,
5921                timed_channel: None,
5922                ability_cooldowns: Vec::new(),
5923                blocking_active: false,
5924                max_target_slots: 1,
5925                combat_slots: Vec::new(),
5926                rotation_presets: Vec::new(),
5927                known_abilities: Vec::new(),
5928                hotbar: vec![None; 9],
5929                max_abilities_per_rotation: 0,
5930                show_loadout_menu: false,
5931                show_keychain_menu: false,
5932                keychain_menu_index: 0,
5933                show_rotation_editor: false,
5934                loadout_menu_index: 0,
5935                loadout_hotbar_slot: 1,
5936                loadout_ability_index: 0,
5937                loadout_focus_presets: false,
5938                rotation_editor: RotationEditorState::default(),
5939                harvest_in_progress: false,
5940                harvest_started_at: None,
5941                pending_craft_ack: None,
5942                pending_worker_job_ack: None,
5943                attending_worker_instance_id: None,
5944                quest_log: Vec::new(),
5945                interactables: Vec::new(),
5946                ledger: None,
5947                career: None,
5948                character_sheet_tab: CharacterSheetTab::Character,
5949                ledger_period: LedgerPeriod::Day,
5950                show_quest_offer: false,
5951                pending_quest_offer: None,
5952                show_quest_menu: false,
5953                quest_menu_index: 0,
5954                quest_withdraw_confirm: false,
5955                hired_workers: Vec::new(),
5956                show_workers_menu: false,
5957                workers_menu_index: 0,
5958                workers_menu_compact: false,
5959                worker_step_display: BTreeMap::new(),
5960                worker_error_display: BTreeMap::new(),
5961                show_worker_give_picker: false,
5962                worker_give_picker_index: 0,
5963                worker_give_picker: None,
5964                show_worker_give_target_picker: false,
5965                worker_give_target_picker_index: 0,
5966                worker_give_target_picker: None,
5967                show_worker_take_picker: false,
5968                worker_take_picker_index: 0,
5969                worker_take_picker: None,
5970                show_worker_teach_picker: false,
5971                worker_teach_picker_index: 0,
5972                worker_teach_picker: None,
5973                worker_route_editor: None,
5974                progression_curve: None,
5975            },
5976        };
5977        client.state.apply_client_ui_prefs();
5978        client
5979    }
5980
5981    pub fn entity_id(&self) -> EntityId {
5982        self.state.entity_id
5983    }
5984
5985    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
5986        if self.state.connected {
5987            return Ok(());
5988        }
5989
5990        loop {
5991            match self.session.next_event().await {
5992                Some(SessionEvent::Welcome {
5993                    session_id,
5994                    entity_id,
5995                    snapshot,
5996                }) => {
5997                    self.state
5998                        .restore_from_welcome(session_id, entity_id, &snapshot);
5999                    self.state.apply_client_ui_prefs();
6000                    self.state.push_log(format!(
6001                        "Connected — session {session_id}, entity {entity_id}"
6002                    ));
6003                    return Ok(());
6004                }
6005                Some(SessionEvent::Disconnected { .. }) => {
6006                    anyhow::bail!("disconnected before welcome");
6007                }
6008                Some(_) => continue,
6009                None => anyhow::bail!("session closed before welcome"),
6010            }
6011        }
6012    }
6013
6014    /// Drain all pending server events (non-blocking).
6015    pub fn drain_events(&mut self) {
6016        while let Some(event) = self.session.try_next_event() {
6017            if self.handle_event_sync(event).is_err() {
6018                break;
6019            }
6020        }
6021    }
6022
6023    /// Wait for the next server event.
6024    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6025        self.session.next_event().await
6026    }
6027
6028    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6029        self.handle_event_sync(event)
6030    }
6031
6032    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6033        match event {
6034            SessionEvent::Welcome {
6035                session_id,
6036                entity_id,
6037                snapshot,
6038            } => {
6039                let resumed = self.state.connected;
6040                self.state
6041                    .restore_from_welcome(session_id, entity_id, &snapshot);
6042                if resumed {
6043                    self.state.push_log(format!(
6044                        "Session restored — session {session_id}, entity {entity_id}"
6045                    ));
6046                }
6047            }
6048            SessionEvent::ContentUpdated { snapshot } => {
6049                self.state
6050                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6051                self.state.push_log(format!(
6052                    "World updated (content rev {})",
6053                    snapshot.content_rev
6054                ));
6055            }
6056            SessionEvent::Tick(delta) => {
6057                self.state.apply_tick_fields(&delta, self.state.entity_id);
6058                self.state.ticks_received += 1;
6059            }
6060            SessionEvent::IntentAck {
6061                entity_id,
6062                seq,
6063                tick,
6064            } => {
6065                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6066                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6067                    if *craft_seq == seq {
6068                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6069                        if batches > 1 {
6070                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6071                        } else {
6072                            self.state.push_log(format!("Crafting {label}…"));
6073                        }
6074                    }
6075                }
6076                if self
6077                    .state
6078                    .pending_worker_job_ack
6079                    .as_ref()
6080                    .is_some_and(|p| p.seq == seq)
6081                {
6082                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6083                    if pending.idle {
6084                        self.state.push_log(format!(
6085                            "Route cleared for {} — worker idle",
6086                            pending.worker_label
6087                        ));
6088                    } else {
6089                        self.state.push_log(format!(
6090                            "Route saved for {} — {} stop(s), job loop active",
6091                            pending.worker_label, pending.stop_count
6092                        ));
6093                    }
6094                    if self
6095                        .state
6096                        .worker_route_editor
6097                        .as_ref()
6098                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6099                    {
6100                        self.close_worker_route_editor();
6101                    }
6102                }
6103            }
6104            SessionEvent::Chat(msg) => {
6105                let label = match msg.channel {
6106                    flatland_protocol::ChatChannel::Nearby => "nearby",
6107                    flatland_protocol::ChatChannel::Direct => "speak",
6108                    flatland_protocol::ChatChannel::Whisper => "whisper",
6109                    flatland_protocol::ChatChannel::WhisperStone => "stone",
6110                };
6111                let clarity = match msg.clarity {
6112                    flatland_protocol::ChatClarity::Clear => "",
6113                    flatland_protocol::ChatClarity::Partial => "~",
6114                    flatland_protocol::ChatClarity::Heavy => "…",
6115                };
6116                self.state.push_log(format!(
6117                    "[{label}{clarity}] {}: {}",
6118                    msg.from_name, msg.text
6119                ));
6120                let now_ms = std::time::SystemTime::now()
6121                    .duration_since(std::time::UNIX_EPOCH)
6122                    .map(|d| d.as_millis() as u64)
6123                    .unwrap_or(0);
6124                self.state
6125                    .social_chat
6126                    .note_speech(&msg, self.state.entity_id, now_ms);
6127                self.state
6128                    .social_chat
6129                    .push(crate::social::ChatLogEntry::from_message(
6130                        msg,
6131                        self.state.entity_id,
6132                    ));
6133            }
6134            SessionEvent::TradeOpened(panel) => {
6135                self.state.social_chat.pending_trade = None;
6136                let peer = panel.peer_name.clone();
6137                self.state.trade_ui.open(panel);
6138                self.state
6139                    .social_chat
6140                    .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6141                self.state
6142                    .social_chat
6143                    .push_cue(crate::social::AudioCue::TradeOpened);
6144            }
6145            SessionEvent::TradeClosed { reason } => {
6146                self.state.push_log(reason.clone());
6147                self.state.social_chat.push_system(reason);
6148                self.state.trade_ui.close();
6149            }
6150            SessionEvent::HarvestResult(result) => {
6151                self.state.clear_harvest_state();
6152                crate::harvest_trace!(
6153                    entity_id = self.state.entity_id,
6154                    node_id = %result.node_id,
6155                    template = %result.item_template,
6156                    quantity = result.quantity,
6157                    client_tick = self.state.tick,
6158                    "client applied harvest result"
6159                );
6160                let msg = if result.quantity == 0 {
6161                    format!(
6162                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6163                        result.item_template
6164                    )
6165                } else {
6166                    format!(
6167                        "Harvested {} x{} (on the ground — press P to pick up)",
6168                        result.item_template, result.quantity
6169                    )
6170                };
6171                self.state.push_log(msg);
6172            }
6173            SessionEvent::CraftResult(result) => {
6174                for stack in &result.consumed {
6175                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6176                        *qty = qty.saturating_sub(stack.quantity);
6177                        if *qty == 0 {
6178                            self.state.inventory.remove(&stack.template_id);
6179                        }
6180                    }
6181                }
6182                for stack in &result.outputs {
6183                    *self
6184                        .state
6185                        .inventory
6186                        .entry(stack.template_id.clone())
6187                        .or_insert(0) += stack.quantity;
6188                }
6189                if let Some(output) = result.outputs.first() {
6190                    if result.batch_total > 1 {
6191                        self.state.push_log(format!(
6192                            "Crafted {} x{} ({}/{})",
6193                            output.template_id,
6194                            output.quantity,
6195                            result.batch_index,
6196                            result.batch_total
6197                        ));
6198                    } else {
6199                        self.state.push_log(format!(
6200                            "Crafted {} x{}",
6201                            output.template_id, output.quantity
6202                        ));
6203                    }
6204                } else {
6205                    self.state
6206                        .push_log(format!("Craft finished: {}", result.blueprint_id));
6207                }
6208            }
6209            SessionEvent::Death(notice) => {
6210                self.state.clear_harvest_state();
6211                self.state.push_log(notice.message.clone());
6212                self.state.push_log(format!(
6213                    "Respawned at ({:.1}, {:.1})",
6214                    notice.respawn_x, notice.respawn_y
6215                ));
6216            }
6217            SessionEvent::Interaction(notice) => {
6218                if notice.message.starts_with("Harvest failed:") {
6219                    self.state.clear_harvest_state();
6220                }
6221                if notice.message.starts_with("Can't do that:") {
6222                    self.state.pending_craft_ack = None;
6223                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
6224                        if let Some(w) = self
6225                            .state
6226                            .hired_workers
6227                            .iter_mut()
6228                            .find(|w| w.instance_id == pending.worker_instance_id)
6229                        {
6230                            w.route = pending.prev_route;
6231                            w.mode = pending.prev_mode;
6232                            w.step_label = pending.prev_step_label;
6233                            w.last_error = pending.prev_last_error;
6234                        }
6235                        let reason = notice
6236                            .message
6237                            .strip_prefix("Can't do that:")
6238                            .unwrap_or(&notice.message)
6239                            .trim();
6240                        self.state.push_log(format!(
6241                            "Route save failed for {}: {reason}",
6242                            pending.worker_label
6243                        ));
6244                    }
6245                    let reason = notice
6246                        .message
6247                        .strip_prefix("Can't do that:")
6248                        .unwrap_or(&notice.message)
6249                        .trim();
6250                    if reason.contains("already tilled") {
6251                        if let Some(plot) = self.state.my_plot_under_player() {
6252                            self.state.sell_plot_confirm = Some(plot.plot_id);
6253                            self.state.sell_plot_armed_at = Some(Instant::now());
6254                        }
6255                    }
6256                }
6257                if notice.message.starts_with("Cast failed:") {
6258                    self.state.cast_progress = None;
6259                }
6260                if notice.message.contains("slain the") {
6261                    self.state.combat_target = None;
6262                    self.state.combat_target_label = None;
6263                }
6264                // Inbound trade request → inline Y/N in the CHAT column (no popup).
6265                if notice.message.contains("wants to trade") {
6266                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6267                        let from_name = notice
6268                            .message
6269                            .split(" wants to trade")
6270                            .next()
6271                            .unwrap_or("Player")
6272                            .to_string();
6273                        self.state.social_chat.pending_trade =
6274                            Some(crate::social::PendingTradeRequest {
6275                                from_entity,
6276                                from_name: from_name.clone(),
6277                            });
6278                        self.state.social_chat.push_system(format!(
6279                            "{from_name} wants to trade — [Y] accept · [N] decline"
6280                        ));
6281                        self.state
6282                            .social_chat
6283                            .push_cue(crate::social::AudioCue::TradeOffer);
6284                    }
6285                }
6286                if notice.message.starts_with("trade request declined") {
6287                    self.state
6288                        .social_chat
6289                        .push_system(notice.message.clone());
6290                    self.state
6291                        .social_chat
6292                        .push_cue(crate::social::AudioCue::TradeDeclined);
6293                }
6294                self.state.apply_interaction_notice(&notice);
6295                self.state.push_log(notice.message.clone());
6296            }
6297            SessionEvent::ShopOpened(catalog) => {
6298                self.state.apply_shop_catalog(catalog);
6299            }
6300            SessionEvent::BankOpened(panel) => {
6301                self.state.apply_bank_panel(panel);
6302            }
6303            SessionEvent::StorageOpened(panel) => {
6304                self.state.apply_storage_panel(panel);
6305            }
6306            SessionEvent::MarketOpened(panel) => {
6307                self.state.apply_market_panel(panel);
6308            }
6309            SessionEvent::NpcTalkOpened(opened) => {
6310                self.state.show_npc_verb_menu = false;
6311                if self.state.npc_verb_target.is_none() {
6312                    self.state.npc_verb_target = Some(opened.npc_id.clone());
6313                }
6314                let label = opened.npc_label.clone();
6315                let banner = if !opened.trade_allowed {
6316                    Some("Trade is unavailable right now.".to_string())
6317                } else {
6318                    None
6319                };
6320                self.state.show_npc_chat = true;
6321                self.state.npc_chat = Some(NpcChatState {
6322                    npc_id: opened.npc_id,
6323                    npc_label: opened.npc_label,
6324                    lines: if opened.greeting.is_empty() {
6325                        vec![]
6326                    } else {
6327                        vec![format!("{label}: {}", opened.greeting)]
6328                    },
6329                    input: String::new(),
6330                    pending: opened.greeting.is_empty(),
6331                    talk_depth: opened.talk_depth,
6332                    trade_allowed: opened.trade_allowed,
6333                    banner,
6334                });
6335            }
6336            SessionEvent::NpcTalkPending(_) => {
6337                if let Some(chat) = self.state.npc_chat.as_mut() {
6338                    chat.pending = true;
6339                }
6340            }
6341            SessionEvent::NpcTalkReply(reply) => {
6342                if let Some(chat) = self.state.npc_chat.as_mut() {
6343                    if chat.npc_id == reply.npc_id {
6344                        chat.pending = false;
6345                        if reply.trade_disabled {
6346                            chat.trade_allowed = false;
6347                            chat.banner = Some("Trade is unavailable right now.".to_string());
6348                        }
6349                        if reply.wind_down {
6350                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6351                            if chat.banner.is_none() {
6352                                chat.banner =
6353                                    Some("They're wrapping up — keep it brief.".to_string());
6354                            }
6355                        }
6356                        chat.lines
6357                            .push(format!("{}: {}", chat.npc_label, reply.line));
6358                    }
6359                }
6360            }
6361            SessionEvent::NpcTalkClosed(closed) => {
6362                if self
6363                    .state
6364                    .npc_chat
6365                    .as_ref()
6366                    .is_some_and(|c| c.npc_id == closed.npc_id)
6367                {
6368                    self.state.show_npc_chat = false;
6369                    self.state.npc_chat = None;
6370                }
6371            }
6372            SessionEvent::NpcTalkError(err) => {
6373                self.state.push_log(format!("Talk failed: {}", err.reason));
6374                if let Some(chat) = self.state.npc_chat.as_mut() {
6375                    chat.pending = false;
6376                }
6377            }
6378            SessionEvent::UseResult(result) => {
6379                // Inventory count hint only — the Interaction notice already logs
6380                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
6381                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6382                    *qty = qty.saturating_sub(1);
6383                    if *qty == 0 {
6384                        self.state.inventory.remove(&result.template_id);
6385                    }
6386                }
6387            }
6388            SessionEvent::QuestOffer(offer) => {
6389                self.state.pending_quest_offer = Some(offer.clone());
6390                self.state.show_quest_offer = true;
6391                self.state
6392                    .push_log(format!("Quest offered: {}", offer.title));
6393            }
6394            SessionEvent::QuestAccepted(notice) => {
6395                self.state.show_quest_offer = false;
6396                self.state.pending_quest_offer = None;
6397                self.state.push_log(notice.message);
6398            }
6399            SessionEvent::QuestWithdrawn(notice) => {
6400                self.state.show_quest_menu = false;
6401                self.state.quest_withdraw_confirm = false;
6402                self.state.push_log(notice.message);
6403            }
6404            SessionEvent::QuestStepCompleted(notice) => {
6405                self.state.push_log(notice.message);
6406            }
6407            SessionEvent::QuestCompleted(notice) => {
6408                self.state.push_log(notice.message);
6409            }
6410            SessionEvent::Disconnected { reason } => {
6411                self.state.clear_harvest_state();
6412                self.state.connected = false;
6413                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6414                if let Some(r) = &self.state.disconnect_reason {
6415                    self.state.push_log(format!("Disconnected: {r}"));
6416                } else {
6417                    self.state.push_log("Disconnected from server");
6418                }
6419            }
6420        }
6421        Ok(())
6422    }
6423
6424    pub fn is_connected(&self) -> bool {
6425        self.state.connected
6426    }
6427
6428    pub fn close_overlays(&mut self) {
6429        self.state.show_stats = false;
6430        self.state.show_craft_menu = false;
6431        self.state.show_shop_menu = false;
6432        self.state.shop_catalog = None;
6433        self.state.show_npc_verb_menu = false;
6434        self.state.npc_verb_target = None;
6435        self.state.show_npc_chat = false;
6436        self.state.npc_chat = None;
6437        self.state.show_inventory_menu = false;
6438        self.state.show_loadout_menu = false;
6439        self.state.show_rotation_editor = false;
6440        self.state.rotation_editor.reset();
6441        self.state.show_rename_prompt = false;
6442        self.state.show_worker_rename = false;
6443        self.state.rename_buffer.clear();
6444        self.state.show_move_picker = false;
6445        self.state.move_picker = None;
6446        self.state.show_destroy_picker = false;
6447        self.state.destroy_confirm_pending = false;
6448        self.state.destroy_picker = None;
6449        self.state.show_quest_offer = false;
6450        self.state.pending_quest_offer = None;
6451        self.state.show_quest_menu = false;
6452        self.state.quest_withdraw_confirm = false;
6453        self.state.show_workers_menu = false;
6454        self.close_worker_give_picker();
6455        self.close_worker_give_target_picker();
6456        self.close_worker_take_picker();
6457        self.close_worker_teach_picker();
6458        self.state.worker_route_editor = None;
6459        self.state.claim_mode = None;
6460        self.state.relocate_mode = None;
6461        self.state.sell_plot_confirm = None;
6462        self.state.sell_plot_armed_at = None;
6463        self.close_farm_access_panel();
6464        if self.state.show_plant_menu {
6465            self.close_plant_menu();
6466        }
6467    }
6468
6469    /// Esc / back — pop one UI layer instead of closing every overlay at once.
6470    pub fn back_on_esc(&mut self) -> bool {
6471        if self.state.social_chat.composer_open() {
6472            self.state.social_chat.close_composer();
6473            return true;
6474        }
6475        if self.state.player_verbs.open {
6476            self.state.player_verbs.close();
6477            return true;
6478        }
6479        if self.state.whisper_pouch_ui.open {
6480            self.state.whisper_pouch_ui.open = false;
6481            return true;
6482        }
6483        if self.state.trade_ui.panel.is_some() {
6484            // async cancel — caller should prefer trade_cancel; close UI optimistically
6485            self.state.trade_ui.close();
6486            return true;
6487        }
6488        if self.state.show_rename_prompt {
6489            self.cancel_rename_prompt();
6490            return true;
6491        }
6492        if self.state.show_worker_rename {
6493            self.cancel_worker_rename();
6494            return true;
6495        }
6496        if self.state.show_destroy_picker {
6497            if self.state.destroy_confirm_pending {
6498                self.cancel_destroy_confirm();
6499            } else {
6500                self.close_destroy_picker();
6501            }
6502            return true;
6503        }
6504        if self.state.claim_mode.is_some() {
6505            self.cancel_claim_mode();
6506            return true;
6507        }
6508        if self.state.relocate_mode.is_some() {
6509            self.cancel_relocate_mode();
6510            return true;
6511        }
6512        if self.state.show_plant_menu {
6513            self.close_plant_menu();
6514            return true;
6515        }
6516        if self.state.show_farm_access {
6517            self.close_farm_access_panel();
6518            return true;
6519        }
6520        if self.state.sell_plot_confirm.is_some() {
6521            self.state.sell_plot_confirm = None;
6522            self.state.sell_plot_armed_at = None;
6523            self.state.push_log("Sell cancelled");
6524            return true;
6525        }
6526        if self.state.show_move_picker {
6527            self.close_move_picker();
6528            return true;
6529        }
6530        if self.state.show_rotation_editor {
6531            match self.state.rotation_editor.mode {
6532                RotationEditorMode::List => {
6533                    self.state.show_rotation_editor = false;
6534                    self.state.rotation_editor.reset();
6535                }
6536                RotationEditorMode::EditLabel => {
6537                    self.state.rotation_editor.label_buffer.clear();
6538                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6539                }
6540                RotationEditorMode::PickAbility => {
6541                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6542                }
6543                RotationEditorMode::EditSequence => {
6544                    self.state.rotation_editor.draft = None;
6545                    self.state.rotation_editor.mode = RotationEditorMode::List;
6546                }
6547            }
6548            return true;
6549        }
6550        if self.state.show_inventory_menu {
6551            self.close_inventory_menu();
6552            return true;
6553        }
6554        if self.state.show_craft_menu {
6555            self.close_craft_menu();
6556            return true;
6557        }
6558        if self.state.show_keychain_menu {
6559            self.close_keychain_menu();
6560            return true;
6561        }
6562        if self.state.show_quest_offer {
6563            self.quest_offer_decline();
6564            return true;
6565        }
6566        if self.state.show_shop_menu {
6567            // Caller must await shop close (npc_interaction_back / play-loop).
6568            return false;
6569        }
6570        if self.state.bank_panel.is_some() {
6571            return false;
6572        }
6573        if self.state.storage_panel.is_some() {
6574            return false;
6575        }
6576        if self.state.market_panel.is_some() {
6577            return false;
6578        }
6579        if self.state.show_npc_chat {
6580            // play.rs calls npc_interaction_back().await on Esc
6581            return false;
6582        }
6583        if self.state.show_npc_verb_menu {
6584            self.state.show_npc_verb_menu = false;
6585            self.state.npc_verb_target = None;
6586            return true;
6587        }
6588        if self.state.show_quest_menu {
6589            if self.state.quest_withdraw_confirm {
6590                self.state.quest_withdraw_confirm = false;
6591            } else {
6592                self.state.show_quest_menu = false;
6593            }
6594            return true;
6595        }
6596        if self.state.worker_route_editor.is_some() {
6597            // Pop one sheet level; only close the editor at the root stop list.
6598            if self.re_at_root_sheet() {
6599                let reopen = self.state.attending_worker_instance_id.clone();
6600                self.close_worker_route_editor();
6601                if let Some(id) = reopen {
6602                    if let Some(idx) = self
6603                        .state
6604                        .hired_workers
6605                        .iter()
6606                        .position(|w| w.instance_id == id)
6607                    {
6608                        self.state.workers_menu_index = idx;
6609                        self.state.show_workers_menu = true;
6610                    }
6611                }
6612            } else {
6613                self.re_sheet_back();
6614            }
6615            return true;
6616        }
6617        if self.state.show_worker_give_picker {
6618            self.close_worker_give_picker();
6619            return true;
6620        }
6621        if self.state.show_worker_give_target_picker {
6622            self.close_worker_give_target_picker();
6623            return true;
6624        }
6625        if self.state.show_worker_take_picker {
6626            self.close_worker_take_picker();
6627            return true;
6628        }
6629        if self.state.show_worker_teach_picker {
6630            self.close_worker_teach_picker();
6631            return true;
6632        }
6633        if self.state.show_workers_menu {
6634            self.close_workers_menu_ui();
6635            return true;
6636        }
6637        if self.state.show_loadout_menu {
6638            self.state.show_loadout_menu = false;
6639            return true;
6640        }
6641        if self.state.show_stats {
6642            self.state.show_stats = false;
6643            return true;
6644        }
6645        if self.state.show_equip_menu {
6646            self.state.show_equip_menu = false;
6647            return true;
6648        }
6649        false
6650    }
6651
6652    pub fn toggle_stats(&mut self) {
6653        self.state.show_stats = !self.state.show_stats;
6654        if self.state.show_stats {
6655            self.state.character_sheet_tab = CharacterSheetTab::Character;
6656            self.state.show_craft_menu = false;
6657            self.state.show_shop_menu = false;
6658            self.state.shop_catalog = None;
6659            self.state.show_inventory_menu = false;
6660            self.state.show_equip_menu = false;
6661        }
6662    }
6663
6664    pub fn toggle_equip_menu(&mut self) {
6665        self.state.show_equip_menu = !self.state.show_equip_menu;
6666        if self.state.show_equip_menu {
6667            self.state.show_stats = false;
6668            self.state.show_craft_menu = false;
6669            self.state.show_shop_menu = false;
6670            self.state.shop_catalog = None;
6671            self.state.show_inventory_menu = false;
6672            self.state.show_loadout_menu = false;
6673        }
6674    }
6675
6676    pub fn cycle_character_sheet_tab(&mut self) {
6677        if self.state.show_stats {
6678            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
6679        }
6680    }
6681
6682    pub fn set_ledger_period_digit(&mut self, c: char) {
6683        if self.state.show_stats {
6684            if let Some(p) = LedgerPeriod::from_digit(c) {
6685                self.state.ledger_period = p;
6686                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
6687            }
6688        }
6689    }
6690
6691    pub fn cycle_ledger_period(&mut self) {
6692        if self.state.show_stats
6693            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
6694        {
6695            self.state.ledger_period = self.state.ledger_period.cycle();
6696        }
6697    }
6698
6699    pub fn open_inventory_menu(&mut self) {
6700        self.state.show_inventory_menu = true;
6701        self.state.show_craft_menu = false;
6702        self.state.show_shop_menu = false;
6703        self.state.shop_catalog = None;
6704        self.state.show_stats = false;
6705        self.state.show_move_picker = false;
6706        self.state.move_picker = None;
6707        self.state.show_destroy_picker = false;
6708        self.state.destroy_confirm_pending = false;
6709        self.state.destroy_picker = None;
6710        self.state.show_rename_prompt = false;
6711        self.state.rename_buffer.clear();
6712        self.state.inventory_filter_focused = false;
6713        self.state.clamp_inventory_indices();
6714    }
6715
6716    pub fn close_inventory_menu(&mut self) {
6717        self.state.show_inventory_menu = false;
6718        self.state.show_move_picker = false;
6719        self.state.move_picker = None;
6720        self.close_grant_picker();
6721        self.state.show_destroy_picker = false;
6722        self.state.destroy_confirm_pending = false;
6723        self.state.destroy_picker = None;
6724        self.state.show_rename_prompt = false;
6725        self.state.rename_buffer.clear();
6726        self.state.inventory_filter_focused = false;
6727    }
6728
6729    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
6730        let Some(row) = self.state.inventory_selected_row() else {
6731            anyhow::bail!("inventory empty");
6732        };
6733        if !self.state.row_is_renameable_container(&row) {
6734            anyhow::bail!("only storage containers can be renamed");
6735        }
6736        let current = row
6737            .stack
6738            .display_name
6739            .clone()
6740            .unwrap_or_else(|| row.stack.template_id.clone());
6741        self.state.rename_buffer = current;
6742        self.state.show_rename_prompt = true;
6743        self.state.show_worker_rename = false;
6744        self.state.show_move_picker = false;
6745        self.state.show_destroy_picker = false;
6746        self.state.destroy_confirm_pending = false;
6747        Ok(())
6748    }
6749
6750    pub fn cancel_rename_prompt(&mut self) {
6751        self.state.show_rename_prompt = false;
6752        self.state.rename_buffer.clear();
6753    }
6754
6755    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
6756        let name = self.state.rename_buffer.trim().to_string();
6757        if name.is_empty() {
6758            anyhow::bail!("name cannot be empty");
6759        }
6760        let Some(row) = self.state.inventory_selected_row() else {
6761            anyhow::bail!("inventory empty");
6762        };
6763        let Some(instance_id) = row.stack.item_instance_id else {
6764            anyhow::bail!("item has no instance id");
6765        };
6766        self.seq += 1;
6767        self.session
6768            .submit_intent(Intent::RenameContainer {
6769                entity_id: self.state.entity_id,
6770                item_instance_id: instance_id,
6771                location: row.from.clone(),
6772                name,
6773                seq: self.seq,
6774            })
6775            .await?;
6776        self.state.intents_sent += 1;
6777        self.state.show_rename_prompt = false;
6778        self.state.rename_buffer.clear();
6779        Ok(())
6780    }
6781
6782    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
6783        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6784            anyhow::bail!("no worker selected");
6785        };
6786        self.state.rename_buffer = worker.label.clone();
6787        self.state.show_worker_rename = true;
6788        self.state.show_rename_prompt = false;
6789        Ok(())
6790    }
6791
6792    pub fn cancel_worker_rename(&mut self) {
6793        self.state.show_worker_rename = false;
6794        self.state.rename_buffer.clear();
6795    }
6796
6797    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
6798        let name = self.state.rename_buffer.trim().to_string();
6799        if name.is_empty() {
6800            anyhow::bail!("name cannot be empty");
6801        }
6802        if name.chars().count() > 32 {
6803            anyhow::bail!("name must be 1–32 characters");
6804        }
6805        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6806            anyhow::bail!("no worker selected");
6807        };
6808        let worker_instance_id = worker.instance_id.clone();
6809        self.seq += 1;
6810        self.session
6811            .submit_intent(Intent::RenameHiredWorker {
6812                entity_id: self.state.entity_id,
6813                worker_instance_id: worker_instance_id.clone(),
6814                name: name.clone(),
6815                seq: self.seq,
6816            })
6817            .await?;
6818        self.state.intents_sent += 1;
6819        if let Some(w) = self
6820            .state
6821            .hired_workers
6822            .iter_mut()
6823            .find(|w| w.instance_id == worker_instance_id)
6824        {
6825            w.label = name.clone();
6826        }
6827        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6828            if ed.worker_instance_id == worker_instance_id {
6829                ed.worker_label = name.clone();
6830            }
6831        }
6832        self.state.show_worker_rename = false;
6833        self.state.rename_buffer.clear();
6834        self.state.push_log(format!("Renamed worker to \"{name}\""));
6835        Ok(())
6836    }
6837
6838    pub fn toggle_inventory_menu(&mut self) {
6839        if self.state.show_inventory_menu {
6840            self.close_inventory_menu();
6841        } else {
6842            self.open_inventory_menu();
6843        }
6844    }
6845
6846    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
6847    pub fn inventory_menu_move(&mut self, delta: i32) {
6848        if self.state.show_grant_picker {
6849            let Some(picker) = self.state.grant_picker.as_ref() else {
6850                return;
6851            };
6852            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6853            let filter = picker.filter.clone();
6854            let n = labels.len();
6855            if n == 0 {
6856                return;
6857            }
6858            self.state.grant_picker_index = step_filtered_index(
6859                self.state.grant_picker_index,
6860                delta,
6861                n,
6862                |i| list_label_matches(&labels[i], &filter),
6863            );
6864            return;
6865        }
6866        if self.state.show_move_picker {
6867            let Some(picker) = self.state.move_picker.as_ref() else {
6868                return;
6869            };
6870            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6871            let filter = picker.filter.clone();
6872            let n = labels.len();
6873            if n == 0 {
6874                return;
6875            }
6876            self.state.move_picker_index = step_filtered_index(
6877                self.state.move_picker_index,
6878                delta,
6879                n,
6880                |i| list_label_matches(&labels[i], &filter),
6881            );
6882            self.state.clamp_move_picker_quantity();
6883            return;
6884        }
6885        let n = self.state.inventory_selectable_rows().len();
6886        if n == 0 {
6887            return;
6888        }
6889        let idx = self.state.inventory_menu_index as i32;
6890        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6891    }
6892
6893    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
6894    pub fn inventory_menu_page(&mut self, pages: i32) {
6895        if self.state.show_grant_picker {
6896            let Some(picker) = self.state.grant_picker.as_ref() else {
6897                return;
6898            };
6899            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6900            let filter = picker.filter.clone();
6901            let n = labels.len();
6902            self.state.grant_picker_index = page_filtered_index(
6903                self.state.grant_picker_index,
6904                pages,
6905                n,
6906                |i| list_label_matches(&labels[i], &filter),
6907            );
6908            return;
6909        }
6910        if self.state.show_move_picker {
6911            let Some(picker) = self.state.move_picker.as_ref() else {
6912                return;
6913            };
6914            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6915            let filter = picker.filter.clone();
6916            let n = labels.len();
6917            self.state.move_picker_index = page_filtered_index(
6918                self.state.move_picker_index,
6919                pages,
6920                n,
6921                |i| list_label_matches(&labels[i], &filter),
6922            );
6923            self.state.clamp_move_picker_quantity();
6924            return;
6925        }
6926        let n = self.state.inventory_selectable_rows().len();
6927        self.state.inventory_menu_index =
6928            page_list_index(self.state.inventory_menu_index, pages, n);
6929    }
6930
6931    pub fn cycle_inventory_tab(&mut self, forward: bool) {
6932        if self.state.show_move_picker
6933            || self.state.show_grant_picker
6934            || self.state.show_destroy_picker
6935            || self.state.show_rename_prompt
6936            || self.state.inventory_filter_focused
6937        {
6938            return;
6939        }
6940        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
6941        self.state.inventory_menu_index = 0;
6942        self.state.clamp_inventory_indices();
6943    }
6944
6945    pub fn focus_inventory_filter(&mut self) {
6946        if self.state.show_grant_picker {
6947            if let Some(p) = self.state.grant_picker.as_mut() {
6948                p.filter_focused = true;
6949            }
6950            return;
6951        }
6952        if self.state.show_move_picker {
6953            if let Some(p) = self.state.move_picker.as_mut() {
6954                p.filter_focused = true;
6955            }
6956            return;
6957        }
6958        self.state.inventory_filter_focused = true;
6959    }
6960
6961    pub fn set_inventory_filter(&mut self, filter: String) {
6962        self.state.inventory_filter = filter;
6963        self.state.inventory_menu_index = 0;
6964        self.state.clamp_inventory_indices();
6965    }
6966
6967    pub fn append_inventory_filter_char(&mut self, ch: char) {
6968        if ch.is_control() {
6969            return;
6970        }
6971        if self.state.show_grant_picker {
6972            if let Some(p) = self.state.grant_picker.as_mut() {
6973                if p.filter_focused {
6974                    p.filter.push(ch);
6975                    self.state.grant_picker_index = 0;
6976                }
6977            }
6978            return;
6979        }
6980        if self.state.show_move_picker {
6981            if let Some(p) = self.state.move_picker.as_mut() {
6982                if p.filter_focused {
6983                    p.filter.push(ch);
6984                    self.state.move_picker_index = 0;
6985                    self.state.clamp_move_picker_quantity();
6986                }
6987            }
6988            return;
6989        }
6990        if !self.state.inventory_filter_focused {
6991            return;
6992        }
6993        self.state.inventory_filter.push(ch);
6994        self.state.inventory_menu_index = 0;
6995        self.state.clamp_inventory_indices();
6996    }
6997
6998    pub fn inventory_filter_backspace(&mut self) {
6999        if self.state.show_grant_picker {
7000            if let Some(p) = self.state.grant_picker.as_mut() {
7001                if p.filter_focused {
7002                    p.filter.pop();
7003                    self.state.grant_picker_index = 0;
7004                }
7005            }
7006            return;
7007        }
7008        if self.state.show_move_picker {
7009            if let Some(p) = self.state.move_picker.as_mut() {
7010                if p.filter_focused {
7011                    p.filter.pop();
7012                    self.state.move_picker_index = 0;
7013                    self.state.clamp_move_picker_quantity();
7014                }
7015            }
7016            return;
7017        }
7018        if !self.state.inventory_filter_focused {
7019            return;
7020        }
7021        self.state.inventory_filter.pop();
7022        self.state.inventory_menu_index = 0;
7023        self.state.clamp_inventory_indices();
7024    }
7025
7026    /// Esc while filter focused: clear filter then blur. Returns true if handled.
7027    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7028        if self.state.show_grant_picker {
7029            if let Some(p) = self.state.grant_picker.as_mut() {
7030                if p.filter_focused {
7031                    if !p.filter.is_empty() {
7032                        p.filter.clear();
7033                        self.state.grant_picker_index = 0;
7034                    } else {
7035                        p.filter_focused = false;
7036                    }
7037                    return true;
7038                }
7039                if !p.filter.is_empty() {
7040                    p.filter.clear();
7041                    self.state.grant_picker_index = 0;
7042                    return true;
7043                }
7044            }
7045            return false;
7046        }
7047        if self.state.show_move_picker {
7048            if let Some(p) = self.state.move_picker.as_mut() {
7049                if p.filter_focused {
7050                    if !p.filter.is_empty() {
7051                        p.filter.clear();
7052                        self.state.move_picker_index = 0;
7053                        self.state.clamp_move_picker_quantity();
7054                    } else {
7055                        p.filter_focused = false;
7056                    }
7057                    return true;
7058                }
7059                if !p.filter.is_empty() {
7060                    p.filter.clear();
7061                    self.state.move_picker_index = 0;
7062                    self.state.clamp_move_picker_quantity();
7063                    return true;
7064                }
7065            }
7066            return false;
7067        }
7068        if self.state.inventory_filter_focused {
7069            if !self.state.inventory_filter.is_empty() {
7070                self.state.inventory_filter.clear();
7071                self.state.inventory_menu_index = 0;
7072                self.state.clamp_inventory_indices();
7073            } else {
7074                self.state.inventory_filter_focused = false;
7075            }
7076            return true;
7077        }
7078        if !self.state.inventory_filter.is_empty() {
7079            self.state.inventory_filter.clear();
7080            self.state.inventory_menu_index = 0;
7081            self.state.clamp_inventory_indices();
7082            return true;
7083        }
7084        false
7085    }
7086
7087    pub fn craft_menu_page(&mut self, pages: i32) {
7088        let n = self.state.blueprints.len();
7089        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7090        self.state.clamp_craft_batch_quantity();
7091    }
7092
7093    pub fn shop_menu_page(&mut self, pages: i32) {
7094        let n = self.state.shop_list_len();
7095        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7096        self.state.clamp_shop_quantity();
7097    }
7098
7099    pub fn workers_menu_page(&mut self, pages: i32) {
7100        let n = self.state.hired_workers.len();
7101        self.state.workers_menu_index =
7102            page_list_index(self.state.workers_menu_index, pages, n);
7103    }
7104
7105    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
7106    /// chest — or fall back to the "move to…" destination picker (including
7107    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
7108    /// Placed chest shells open a pick-up destination picker (`l` still locks).
7109    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7110        if self.state.show_destroy_picker {
7111            if self.state.destroy_confirm_pending {
7112                return self.confirm_destroy_item().await;
7113            }
7114            return self.request_destroy_confirm();
7115        }
7116        if self.state.show_grant_picker {
7117            return self.confirm_grant_picker().await;
7118        }
7119        if self.state.show_move_picker {
7120            return self.confirm_move_picker().await;
7121        }
7122        let Some(row) = self.state.inventory_selected_row() else {
7123            anyhow::bail!("inventory empty");
7124        };
7125        if row.is_equip_shell {
7126            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7127                anyhow::bail!("not a worn item");
7128            };
7129            return self.equip_worn(slot, None).await;
7130        }
7131        if row.is_chest_shell {
7132            return self.open_chest_pickup_picker();
7133        }
7134        let template_id = row.stack.template_id.clone();
7135        let instance_id = row.stack.item_instance_id;
7136        let category = self.state.inventory_item_category(&template_id);
7137        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7138
7139        if category == Some("weapon") {
7140            return self.equip_mainhand(Some(template_id)).await;
7141        }
7142        if category == Some("lodging") && on_person {
7143            if let Some(inst) = instance_id {
7144                return self.place_container(inst).await;
7145            }
7146        }
7147        if (category == Some("container") || category == Some("armor")) && on_person {
7148            if let Some(inst) = instance_id {
7149                let world_placeable = row.stack.world_placeable == Some(true)
7150                    || template_id.contains("chest");
7151                if world_placeable {
7152                    return self.place_container(inst).await;
7153                }
7154                // Pouches no longer equip directly — they clip onto a worn belt's loops
7155                // instead, so fall through to the move picker (offers "belt loop" when a
7156                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
7157                if let Some(slot) = guess_body_slot(&template_id) {
7158                    return self.equip_worn(slot, Some(inst)).await;
7159                }
7160            }
7161        }
7162        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
7163        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
7164        // instead of guessing.
7165        self.open_move_picker()
7166    }
7167
7168    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
7169    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7170        let Some(row) = self.state.inventory_selected_row() else {
7171            anyhow::bail!("inventory empty");
7172        };
7173        if row.from != flatland_protocol::InventoryLocation::Root {
7174            anyhow::bail!("select a consumable on your person");
7175        }
7176        if GameState::stack_is_item_grant(&row.stack) {
7177            return self.open_grant_target_picker();
7178        }
7179        if GameState::is_property_deed_template(&row.stack.template_id) {
7180            return self.open_move_picker();
7181        }
7182        let category = self
7183            .state
7184            .inventory_item_category(&row.stack.template_id);
7185        if category != Some("consumable") {
7186            anyhow::bail!("selected item is not consumable");
7187        }
7188        self.use_item(&row.stack.template_id).await
7189    }
7190
7191    /// Open picker: apply selected grant item onto inventory / worn gear.
7192    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7193        let Some(row) = self.state.inventory_selected_row() else {
7194            anyhow::bail!("inventory empty");
7195        };
7196        if row.from != flatland_protocol::InventoryLocation::Root {
7197            anyhow::bail!("select a grant item on your person");
7198        }
7199        if !GameState::stack_is_item_grant(&row.stack) {
7200            anyhow::bail!("selected item does not grant onto gear");
7201        }
7202        let Some(grant_instance_id) = row.stack.item_instance_id else {
7203            anyhow::bail!("grant has no instance id");
7204        };
7205        let effect_id = GameState::grant_effect_id(&row.stack)
7206            .unwrap_or("?")
7207            .to_string();
7208        let mode = GameState::grant_mode(&row.stack).to_string();
7209        let options = self.state.grant_target_options(&row.stack);
7210        if options.is_empty() {
7211            anyhow::bail!("no valid gear to apply {effect_id} to");
7212        }
7213        let grant_label = row
7214            .stack
7215            .display_name
7216            .clone()
7217            .unwrap_or_else(|| row.stack.template_id.clone());
7218        self.state.show_grant_picker = true;
7219        self.state.grant_picker_index = 0;
7220        self.state.grant_picker = Some(GrantTargetPicker {
7221            grant_instance_id,
7222            grant_label,
7223            effect_id,
7224            mode,
7225            options,
7226            filter: String::new(),
7227            filter_focused: false,
7228        });
7229        Ok(())
7230    }
7231
7232    pub fn close_grant_picker(&mut self) {
7233        self.state.show_grant_picker = false;
7234        self.state.grant_picker = None;
7235        self.state.grant_picker_index = 0;
7236    }
7237
7238    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7239        let Some(picker) = self.state.grant_picker.clone() else {
7240            self.close_grant_picker();
7241            return Ok(());
7242        };
7243        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7244            self.close_grant_picker();
7245            return Ok(());
7246        };
7247        self.close_grant_picker();
7248        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7249            .await?;
7250        self.state.push_log(format!(
7251            "Applying {} onto {}…",
7252            picker.effect_id, opt.label
7253        ));
7254        Ok(())
7255    }
7256
7257    /// `m`: always open the "move to…" picker for the selected item, even for
7258    /// weapons/wearables that Enter would otherwise equip/wear directly.
7259    /// Placed chests open the pick-up destination picker instead.
7260    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7261        let Some(row) = self.state.inventory_selected_row() else {
7262            anyhow::bail!("inventory empty");
7263        };
7264        if row.is_equip_shell {
7265            anyhow::bail!("this is a worn bag — press Enter to unequip it");
7266        }
7267        if row.is_chest_shell {
7268            return self.open_chest_pickup_picker();
7269        }
7270        let Some(instance_id) = row.stack.item_instance_id else {
7271            anyhow::bail!("item has no instance id");
7272        };
7273        let mut options = self.state.move_destinations_for(
7274            &row.from,
7275            row.from_parent_instance_id,
7276            row.stack.item_instance_id,
7277            &row.stack.template_id,
7278        );
7279        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7280        let category = self.state.inventory_item_category(&row.stack.template_id);
7281        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7282            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7283                options.insert(
7284                    0,
7285                    MoveOption {
7286                        label: "Sell plot to crown…".into(),
7287                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
7288                    },
7289                );
7290            }
7291        }
7292        if on_person && category == Some("consumable") {
7293            if GameState::stack_is_item_grant(&row.stack) {
7294                options.insert(
7295                    0,
7296                    MoveOption {
7297                        label: "Apply onto gear…".into(),
7298                        kind: MoveOptionKind::GrantApply,
7299                    },
7300                );
7301            } else {
7302                options.insert(
7303                    0,
7304                    MoveOption {
7305                        label: "Use (eat / drink)".into(),
7306                        kind: MoveOptionKind::Use,
7307                    },
7308                );
7309            }
7310        }
7311        let item_label = row
7312            .stack
7313            .display_name
7314            .clone()
7315            .unwrap_or_else(|| row.stack.template_id.clone());
7316        // Default to 1 so withdrawing from storage is partial unless the player
7317        // presses `a` for max fit (full stack when the destination allows it).
7318        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7319        self.state.move_picker = Some(MovePicker {
7320            item_instance_id: instance_id,
7321            from: row.from,
7322            item_label,
7323            template_id: row.stack.template_id.clone(),
7324            stack_quantity: row.stack.quantity,
7325            quantity: initial_qty.max(1),
7326            options,
7327            filter: String::new(),
7328            filter_focused: false,
7329        });
7330        self.state.move_picker_index = 0;
7331        self.state.show_move_picker = true;
7332        self.state.show_destroy_picker = false;
7333        self.state.destroy_confirm_pending = false;
7334        self.state.destroy_picker = None;
7335        self.state.clamp_move_picker_quantity();
7336        Ok(())
7337    }
7338
7339    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
7340    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7341        let Some(row) = self.state.inventory_selected_row() else {
7342            anyhow::bail!("inventory empty");
7343        };
7344        if !row.is_chest_shell {
7345            anyhow::bail!("not a placed chest");
7346        }
7347        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7348            anyhow::bail!("not a placed chest");
7349        };
7350        let Some(instance_id) = row.stack.item_instance_id else {
7351            anyhow::bail!("chest has no instance id");
7352        };
7353        let chest = self
7354            .state
7355            .placed_containers
7356            .iter()
7357            .find(|c| c.id == *container_id)
7358            .cloned()
7359            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7360        let (px, py) = self.state.player_position();
7361        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7362            anyhow::bail!("too far from {}", chest.display_name);
7363        }
7364        if chest.locked && !chest.accessible {
7365            anyhow::bail!(
7366                "need the matching key for {} before picking it up",
7367                chest.display_name
7368            );
7369        }
7370        let options = self.state.chest_pickup_destinations(container_id);
7371        let item_label = row
7372            .stack
7373            .display_name
7374            .clone()
7375            .unwrap_or_else(|| row.stack.template_id.clone());
7376        self.state.move_picker = Some(MovePicker {
7377            item_instance_id: instance_id,
7378            from: row.from.clone(),
7379            item_label,
7380            template_id: row.stack.template_id.clone(),
7381            stack_quantity: 1,
7382            quantity: 1,
7383            options,
7384            filter: String::new(),
7385            filter_focused: false,
7386        });
7387        self.state.move_picker_index = 0;
7388        self.state.show_move_picker = true;
7389        self.state.show_destroy_picker = false;
7390        self.state.destroy_confirm_pending = false;
7391        self.state.destroy_picker = None;
7392        Ok(())
7393    }
7394
7395    pub fn close_move_picker(&mut self) {
7396        self.state.show_move_picker = false;
7397        self.state.move_picker = None;
7398    }
7399
7400    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7401        self.state.move_picker_adjust_quantity(delta);
7402    }
7403
7404    pub fn move_picker_set_quantity_max(&mut self) {
7405        self.state.move_picker_set_quantity_max();
7406    }
7407
7408    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7409        self.state.destroy_picker_adjust_quantity(delta);
7410    }
7411
7412    pub fn destroy_picker_set_quantity_max(&mut self) {
7413        self.state.destroy_picker_set_quantity_max();
7414    }
7415
7416    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7417        let Some(picker) = self.state.move_picker.clone() else {
7418            self.close_move_picker();
7419            return Ok(());
7420        };
7421        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7422            self.close_move_picker();
7423            return Ok(());
7424        };
7425        match option.kind {
7426            MoveOptionKind::Cancel => {
7427                self.close_move_picker();
7428            }
7429            MoveOptionKind::Use => {
7430                self.close_move_picker();
7431                self.use_item(&picker.template_id).await?;
7432            }
7433            MoveOptionKind::GrantApply => {
7434                self.close_move_picker();
7435                self.open_grant_target_picker()?;
7436            }
7437            MoveOptionKind::SellPlotToCrown { plot_id } => {
7438                self.close_move_picker();
7439                self.confirm_sell_plot_to_crown(plot_id).await?;
7440            }
7441            MoveOptionKind::RelocatePlaced { container_id } => {
7442                self.close_move_picker();
7443                self.state.show_inventory_menu = false;
7444                self.begin_relocate_container(&container_id)?;
7445            }
7446            MoveOptionKind::Drop => {
7447                self.close_move_picker();
7448                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7449                    if self.state.deed_bound(&stack) {
7450                        anyhow::bail!(
7451                            "cannot drop a property deed — store it or trade it to another player"
7452                        );
7453                    }
7454                    if self.state.key_drop_blocked(&stack) {
7455                        anyhow::bail!("cannot drop the key while its chest is locked");
7456                    }
7457                }
7458                self.drop_item(picker.item_instance_id, picker.from).await?;
7459                self.state
7460                    .push_log(format!("Dropped {}", picker.item_label));
7461            }
7462            MoveOptionKind::PickupPlaced {
7463                container_id,
7464                nest_location,
7465                nest_parent_instance_id,
7466            } => {
7467                self.close_move_picker();
7468                self.pickup_container(container_id.clone()).await?;
7469                let nest_into_bag = nest_parent_instance_id.is_some()
7470                    || !matches!(
7471                        nest_location,
7472                        flatland_protocol::InventoryLocation::Root
7473                    );
7474                if nest_into_bag {
7475                    self.move_item(
7476                        picker.item_instance_id,
7477                        flatland_protocol::InventoryLocation::Root,
7478                        nest_location,
7479                        nest_parent_instance_id,
7480                        None,
7481                    )
7482                    .await?;
7483                    self.state
7484                        .push_log(format!("Picked up {} into bag", picker.item_label));
7485                } else {
7486                    self.state
7487                        .push_log(format!("Picked up {}", picker.item_label));
7488                }
7489            }
7490            MoveOptionKind::Move {
7491                location,
7492                parent_instance_id,
7493            } => {
7494                self.close_move_picker();
7495                let qty = if picker.quantity >= picker.stack_quantity {
7496                    None
7497                } else {
7498                    Some(picker.quantity)
7499                };
7500                self.move_item(
7501                    picker.item_instance_id,
7502                    picker.from,
7503                    location,
7504                    parent_instance_id,
7505                    qty,
7506                )
7507                .await?;
7508                let moved = qty.unwrap_or(picker.stack_quantity);
7509                if moved >= picker.stack_quantity {
7510                    self.state.push_log(format!("Moved {}", picker.item_label));
7511                } else {
7512                    self.state.push_log(format!(
7513                        "Moved {} ×{} of {}",
7514                        picker.item_label, moved, picker.stack_quantity
7515                    ));
7516                }
7517            }
7518        }
7519        Ok(())
7520    }
7521
7522    /// `d`: drop the selected item on the ground immediately (no picker).
7523    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7524        let Some(row) = self.state.inventory_selected_row() else {
7525            anyhow::bail!("inventory empty");
7526        };
7527        if row.is_equip_shell {
7528            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7529        }
7530        if row.is_chest_shell {
7531            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
7532        }
7533        let Some(inst) = row.stack.item_instance_id else {
7534            anyhow::bail!("item has no instance id");
7535        };
7536        if self.state.deed_bound(&row.stack) {
7537            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
7538        }
7539        if self.state.key_drop_blocked(&row.stack) {
7540            anyhow::bail!("cannot drop the key while its chest is locked");
7541        }
7542        let label = row
7543            .stack
7544            .display_name
7545            .clone()
7546            .unwrap_or_else(|| row.stack.template_id.clone());
7547        self.drop_item(inst, row.from).await?;
7548        self.state.push_log(format!("Dropped {label}"));
7549        Ok(())
7550    }
7551
7552    pub async fn drop_item(
7553        &mut self,
7554        item_instance_id: uuid::Uuid,
7555        from: flatland_protocol::InventoryLocation,
7556    ) -> anyhow::Result<()> {
7557        self.seq += 1;
7558        self.session
7559            .submit_intent(Intent::DropItem {
7560                entity_id: self.state.entity_id,
7561                item_instance_id,
7562                from,
7563                seq: self.seq,
7564            })
7565            .await?;
7566        self.state.intents_sent += 1;
7567        Ok(())
7568    }
7569
7570    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
7571    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
7572        let Some(row) = self.state.inventory_selected_row() else {
7573            anyhow::bail!("inventory empty");
7574        };
7575        if row.is_equip_shell {
7576            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
7577        }
7578        if row.is_chest_shell {
7579            anyhow::bail!("can't destroy a placed chest from the inventory list");
7580        }
7581        let Some(instance_id) = row.stack.item_instance_id else {
7582            anyhow::bail!("item has no instance id");
7583        };
7584        if self.state.deed_bound(&row.stack) {
7585            anyhow::bail!(
7586                "cannot destroy a property deed — store it or trade it to another player"
7587            );
7588        }
7589        if self.state.key_drop_blocked(&row.stack) {
7590            anyhow::bail!("cannot destroy the key while its chest is locked");
7591        }
7592        let item_label = row
7593            .stack
7594            .display_name
7595            .clone()
7596            .unwrap_or_else(|| row.stack.template_id.clone());
7597        self.state.destroy_picker = Some(DestroyPicker {
7598            item_instance_id: instance_id,
7599            from: row.from,
7600            item_label,
7601            stack_quantity: row.stack.quantity,
7602            quantity: row.stack.quantity,
7603        });
7604        self.state.destroy_confirm_pending = false;
7605        self.state.show_destroy_picker = true;
7606        self.state.show_move_picker = false;
7607        self.state.move_picker = None;
7608        Ok(())
7609    }
7610
7611    pub fn close_destroy_picker(&mut self) {
7612        self.state.show_destroy_picker = false;
7613        self.state.destroy_confirm_pending = false;
7614        self.state.destroy_picker = None;
7615    }
7616
7617    pub fn cancel_destroy_confirm(&mut self) {
7618        self.state.destroy_confirm_pending = false;
7619    }
7620
7621    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
7622        if self.state.destroy_picker.is_none() {
7623            self.close_destroy_picker();
7624            return Ok(());
7625        }
7626        self.state.destroy_confirm_pending = true;
7627        Ok(())
7628    }
7629
7630    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
7631        let Some(picker) = self.state.destroy_picker.clone() else {
7632            self.close_destroy_picker();
7633            return Ok(());
7634        };
7635        let qty = if picker.quantity >= picker.stack_quantity {
7636            None
7637        } else {
7638            Some(picker.quantity)
7639        };
7640        self.destroy_item(picker.item_instance_id, picker.from, qty)
7641            .await?;
7642        let destroyed = qty.unwrap_or(picker.stack_quantity);
7643        if destroyed >= picker.stack_quantity {
7644            self.state
7645                .push_log(format!("Destroyed {}", picker.item_label));
7646        } else {
7647            self.state.push_log(format!(
7648                "Destroyed {} ×{} of {}",
7649                picker.item_label, destroyed, picker.stack_quantity
7650            ));
7651        }
7652        self.close_destroy_picker();
7653        Ok(())
7654    }
7655
7656    pub async fn destroy_item(
7657        &mut self,
7658        item_instance_id: uuid::Uuid,
7659        from: flatland_protocol::InventoryLocation,
7660        quantity: Option<u32>,
7661    ) -> anyhow::Result<()> {
7662        self.seq += 1;
7663        self.session
7664            .submit_intent(Intent::DestroyItem {
7665                entity_id: self.state.entity_id,
7666                item_instance_id,
7667                from,
7668                quantity,
7669                seq: self.seq,
7670            })
7671            .await?;
7672        self.state.intents_sent += 1;
7673        Ok(())
7674    }
7675
7676    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
7677    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
7678        if let Some(row) = self.state.inventory_selected_row() {
7679            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
7680                return self.toggle_placed_chest_lock(container_id).await;
7681            }
7682        }
7683        self.toggle_nearby_chest_lock().await
7684    }
7685
7686    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
7687        let chest = self
7688            .state
7689            .placed_containers
7690            .iter()
7691            .find(|c| c.id == container_id)
7692            .cloned()
7693            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7694        let (px, py) = self.state.player_position();
7695        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7696            anyhow::bail!("too far from {}", chest.display_name);
7697        }
7698        if !chest.accessible && chest.locked {
7699            anyhow::bail!(
7700                "need the matching key for {} (each crafted chest has its own key)",
7701                chest.display_name
7702            );
7703        }
7704        let lock = !chest.locked;
7705        self.set_container_locked(
7706            flatland_protocol::InventoryLocation::Placed {
7707                container_id: chest.id.clone(),
7708            },
7709            lock,
7710        )
7711        .await?;
7712        self.state.push_log(if lock {
7713            format!("Locked {}", chest.display_name)
7714        } else {
7715            format!("Unlocked {}", chest.display_name)
7716        });
7717        Ok(())
7718    }
7719
7720    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
7721    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
7722        let chest = self
7723            .state
7724            .nearest_placed_container(CONTAINER_RANGE_M)
7725            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
7726        self.toggle_placed_chest_lock(&chest.id).await
7727    }
7728
7729    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
7730        self.equip_mainhand(None).await
7731    }
7732
7733    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7734        if !self.state.is_alive() {
7735            anyhow::bail!("you are dead");
7736        }
7737        self.seq += 1;
7738        self.session
7739            .submit_intent(Intent::EquipOffhand {
7740                entity_id: self.state.entity_id,
7741                template_id,
7742                instance_id: None,
7743                seq: self.seq,
7744            })
7745            .await?;
7746        self.state.intents_sent += 1;
7747        Ok(())
7748    }
7749
7750    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
7751        self.equip_offhand(None).await
7752    }
7753
7754    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
7755        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
7756        for slot in slots {
7757            self.equip_worn(slot, None).await?;
7758        }
7759        Ok(())
7760    }
7761
7762    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
7763        let (px, py) = self.state.player_position();
7764        let nearest = self
7765            .state
7766            .placed_containers
7767            .iter()
7768            .min_by(|a, b| {
7769                let da = (a.x - px).hypot(a.y - py);
7770                let db = (b.x - px).hypot(b.y - py);
7771                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7772            })
7773            .cloned();
7774        let Some(chest) = nearest else {
7775            anyhow::bail!("no chest nearby");
7776        };
7777        if (chest.x - px).hypot(chest.y - py) > 2.0 {
7778            anyhow::bail!("too far from chest");
7779        }
7780        self.pickup_container(chest.id).await
7781    }
7782
7783    pub async fn equip_worn(
7784        &mut self,
7785        slot: BodySlot,
7786        instance_id: Option<uuid::Uuid>,
7787    ) -> anyhow::Result<()> {
7788        self.seq += 1;
7789        self.session
7790            .submit_intent(Intent::EquipWorn {
7791                entity_id: self.state.entity_id,
7792                slot,
7793                instance_id,
7794                seq: self.seq,
7795            })
7796            .await?;
7797        self.state.intents_sent += 1;
7798        Ok(())
7799    }
7800
7801    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
7802        self.seq += 1;
7803        self.session
7804            .submit_intent(Intent::PlaceContainer {
7805                entity_id: self.state.entity_id,
7806                item_instance_id,
7807                seq: self.seq,
7808            })
7809            .await?;
7810        self.state.intents_sent += 1;
7811        Ok(())
7812    }
7813
7814    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
7815        self.seq += 1;
7816        self.session
7817            .submit_intent(Intent::PickupContainer {
7818                entity_id: self.state.entity_id,
7819                container_id,
7820                seq: self.seq,
7821            })
7822            .await?;
7823        self.state.intents_sent += 1;
7824        Ok(())
7825    }
7826
7827    pub async fn move_item(
7828        &mut self,
7829        item_instance_id: uuid::Uuid,
7830        from: flatland_protocol::InventoryLocation,
7831        to: flatland_protocol::InventoryLocation,
7832        to_parent_instance_id: Option<uuid::Uuid>,
7833        quantity: Option<u32>,
7834    ) -> anyhow::Result<()> {
7835        self.seq += 1;
7836        self.session
7837            .submit_intent(Intent::MoveItem {
7838                entity_id: self.state.entity_id,
7839                item_instance_id,
7840                from,
7841                to,
7842                to_parent_instance_id,
7843                quantity,
7844                seq: self.seq,
7845            })
7846            .await?;
7847        self.state.intents_sent += 1;
7848        Ok(())
7849    }
7850
7851    pub async fn set_container_locked(
7852        &mut self,
7853        location: flatland_protocol::InventoryLocation,
7854        locked: bool,
7855    ) -> anyhow::Result<()> {
7856        self.seq += 1;
7857        self.session
7858            .submit_intent(Intent::SetContainerLocked {
7859                entity_id: self.state.entity_id,
7860                location,
7861                locked,
7862                seq: self.seq,
7863            })
7864            .await?;
7865        self.state.intents_sent += 1;
7866        Ok(())
7867    }
7868
7869    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
7870        if !self.state.is_alive() {
7871            anyhow::bail!("you are dead");
7872        }
7873        self.seq += 1;
7874        self.session
7875            .submit_intent(Intent::Use {
7876                entity_id: self.state.entity_id,
7877                template_id: template_id.to_string(),
7878                seq: self.seq,
7879            })
7880            .await?;
7881        self.state.intents_sent += 1;
7882        Ok(())
7883    }
7884
7885    /// Bind a grant consumable onto a specific item instance (unique gear status).
7886    pub async fn use_grant(
7887        &mut self,
7888        grant_instance_id: uuid::Uuid,
7889        target_instance_id: uuid::Uuid,
7890    ) -> anyhow::Result<()> {
7891        if !self.state.is_alive() {
7892            anyhow::bail!("you are dead");
7893        }
7894        self.seq += 1;
7895        self.session
7896            .submit_intent(Intent::UseGrant {
7897                entity_id: self.state.entity_id,
7898                grant_instance_id,
7899                target_instance_id,
7900                seq: self.seq,
7901            })
7902            .await?;
7903        self.state.intents_sent += 1;
7904        Ok(())
7905    }
7906
7907    pub fn open_craft_menu(&mut self) {
7908        self.state.show_craft_menu = true;
7909        self.state.show_shop_menu = false;
7910        self.state.shop_catalog = None;
7911        self.state.show_stats = false;
7912        self.state.show_inventory_menu = false;
7913        if self.state.blueprints.is_empty() {
7914            self.state.craft_menu_index = 0;
7915            self.state.craft_batch_quantity = 1;
7916            return;
7917        }
7918        self.state.craft_menu_index = self
7919            .state
7920            .craft_menu_index
7921            .min(self.state.blueprints.len() - 1);
7922        if let Some(idx) = self
7923            .state
7924            .blueprints
7925            .iter()
7926            .position(|bp| self.state.can_craft_blueprint(bp))
7927        {
7928            self.state.craft_menu_index = idx;
7929        }
7930        self.state.clamp_craft_batch_quantity();
7931    }
7932
7933    pub fn close_craft_menu(&mut self) {
7934        self.state.show_craft_menu = false;
7935    }
7936
7937    pub fn toggle_keychain_menu(&mut self) {
7938        if self.state.show_keychain_menu {
7939            self.close_keychain_menu();
7940        } else {
7941            self.state.show_keychain_menu = true;
7942            self.state.show_craft_menu = false;
7943            self.state.show_shop_menu = false;
7944            self.state.show_inventory_menu = false;
7945            let n = self.state.keychain_entries().len();
7946            if n == 0 {
7947                self.state.keychain_menu_index = 0;
7948            } else {
7949                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
7950            }
7951        }
7952    }
7953
7954    pub fn close_keychain_menu(&mut self) {
7955        self.state.show_keychain_menu = false;
7956    }
7957
7958    pub fn keychain_menu_move(&mut self, delta: i32) {
7959        let n = self.state.keychain_entries().len();
7960        if n == 0 {
7961            self.state.keychain_menu_index = 0;
7962            return;
7963        }
7964        let idx = self.state.keychain_menu_index as i32 + delta;
7965        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
7966    }
7967
7968    pub fn keychain_menu_page(&mut self, pages: i32) {
7969        let n = self.state.keychain_entries().len();
7970        self.state.keychain_menu_index =
7971            page_list_index(self.state.keychain_menu_index, pages, n);
7972    }
7973
7974    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
7975        if !self.state.is_alive() {
7976            anyhow::bail!("you are dead");
7977        }
7978        let entries = self.state.keychain_entries();
7979        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
7980            anyhow::bail!("nothing selected");
7981        };
7982        let Some(instance_id) = entry.stack.item_instance_id else {
7983            anyhow::bail!("key has no instance id");
7984        };
7985        if entry.stowed {
7986            self.move_item(
7987                instance_id,
7988                flatland_protocol::InventoryLocation::Keychain,
7989                flatland_protocol::InventoryLocation::Root,
7990                None,
7991                Some(1),
7992            )
7993            .await
7994        } else {
7995            self.move_item(
7996                instance_id,
7997                flatland_protocol::InventoryLocation::Root,
7998                flatland_protocol::InventoryLocation::Keychain,
7999                None,
8000                Some(1),
8001            )
8002            .await
8003        }
8004    }
8005
8006    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8007        let npc_id = self
8008            .state
8009            .shop_catalog
8010            .as_ref()
8011            .map(|c| c.npc_id.clone());
8012        self.state.show_shop_menu = false;
8013        self.state.shop_catalog = None;
8014        self.state.clear_shop_trade_log();
8015        if let Some(npc_id) = npc_id {
8016            self.seq += 1;
8017            self.session
8018                .submit_intent(Intent::ShopClose {
8019                    entity_id: self.state.entity_id,
8020                    npc_id,
8021                    seq: self.seq,
8022                })
8023                .await?;
8024            self.state.intents_sent += 1;
8025        }
8026        Ok(())
8027    }
8028
8029    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8030        let Some(panel) = self.state.bank_panel.clone() else {
8031            return Ok(());
8032        };
8033        self.seq += 1;
8034        self.session
8035            .submit_intent(Intent::BankDeposit {
8036                entity_id: self.state.entity_id,
8037                npc_id: panel.npc_id,
8038                amount_copper,
8039                seq: self.seq,
8040            })
8041            .await?;
8042        self.state.intents_sent += 1;
8043        Ok(())
8044    }
8045
8046    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8047        let Some(panel) = self.state.bank_panel.clone() else {
8048            return Ok(());
8049        };
8050        self.seq += 1;
8051        self.session
8052            .submit_intent(Intent::BankWithdraw {
8053                entity_id: self.state.entity_id,
8054                npc_id: panel.npc_id,
8055                amount_copper,
8056                seq: self.seq,
8057            })
8058            .await?;
8059        self.state.intents_sent += 1;
8060        Ok(())
8061    }
8062
8063    pub async fn bank_transfer(
8064        &mut self,
8065        to_character_id: Option<uuid::Uuid>,
8066        to_name: String,
8067        amount_copper: u64,
8068    ) -> anyhow::Result<()> {
8069        let Some(panel) = self.state.bank_panel.clone() else {
8070            return Ok(());
8071        };
8072        self.seq += 1;
8073        self.session
8074            .submit_intent(Intent::BankTransfer {
8075                entity_id: self.state.entity_id,
8076                npc_id: panel.npc_id,
8077                to_character_id,
8078                to_name,
8079                amount_copper,
8080                seq: self.seq,
8081            })
8082            .await?;
8083        self.state.intents_sent += 1;
8084        Ok(())
8085    }
8086
8087    pub fn bank_menu_move(&mut self, delta: i32) {
8088        let n = self.state.bank_menu_options().len();
8089        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8090            return;
8091        }
8092        let idx = self.state.bank_menu_index as i32;
8093        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8094    }
8095
8096    pub fn storage_menu_move(&mut self, delta: i32) {
8097        let n = self.state.storage_menu_options().len();
8098        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8099            return;
8100        }
8101        let idx = self.state.storage_menu_index as i32;
8102        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8103    }
8104
8105    pub fn storage_pick_move(&mut self, delta: i32) {
8106        let n = match &self.state.storage_ui_mode {
8107            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8108            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8109                self.state.storage_vault_options().len()
8110            }
8111            StorageUiMode::Menu
8112            | StorageUiMode::StoreAmount { .. }
8113            | StorageUiMode::TakeAmount { .. }
8114            | StorageUiMode::ShipAmount { .. } => 0,
8115        };
8116        if n == 0 {
8117            return;
8118        }
8119        match &mut self.state.storage_ui_mode {
8120            StorageUiMode::StorePick { index }
8121            | StorageUiMode::TakePick { index }
8122            | StorageUiMode::ShipPick { index, .. } => {
8123                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8124            }
8125            StorageUiMode::Menu
8126            | StorageUiMode::StoreAmount { .. }
8127            | StorageUiMode::TakeAmount { .. }
8128            | StorageUiMode::ShipAmount { .. } => {}
8129        }
8130    }
8131
8132    pub fn storage_ui_back(&mut self) {
8133        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8134            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8135                index: *pick_index,
8136            },
8137            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8138                index: *pick_index,
8139            },
8140            StorageUiMode::ShipAmount {
8141                dest_building_id,
8142                dest_label,
8143                pick_index,
8144                ..
8145            } => StorageUiMode::ShipPick {
8146                dest_building_id: dest_building_id.clone(),
8147                dest_label: dest_label.clone(),
8148                index: *pick_index,
8149            },
8150            StorageUiMode::StorePick { .. }
8151            | StorageUiMode::TakePick { .. }
8152            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8153            StorageUiMode::Menu => StorageUiMode::Menu,
8154        };
8155    }
8156
8157    pub fn storage_amount_append_char(&mut self, c: char) {
8158        match &mut self.state.storage_ui_mode {
8159            StorageUiMode::StoreAmount { input, .. }
8160            | StorageUiMode::TakeAmount { input, .. }
8161            | StorageUiMode::ShipAmount { input, .. } => {
8162                if c.is_ascii_digit() && input.len() < 8 {
8163                    input.push(c);
8164                }
8165            }
8166            _ => {}
8167        }
8168    }
8169
8170    pub fn storage_amount_backspace(&mut self) {
8171        match &mut self.state.storage_ui_mode {
8172            StorageUiMode::StoreAmount { input, .. }
8173            | StorageUiMode::TakeAmount { input, .. }
8174            | StorageUiMode::ShipAmount { input, .. } => {
8175                input.pop();
8176            }
8177            _ => {}
8178        }
8179    }
8180
8181    pub fn storage_ui_typing(&self) -> bool {
8182        matches!(
8183            self.state.storage_ui_mode,
8184            StorageUiMode::StoreAmount { .. }
8185                | StorageUiMode::TakeAmount { .. }
8186                | StorageUiMode::ShipAmount { .. }
8187        )
8188    }
8189
8190    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8191        match self.state.storage_ui_mode.clone() {
8192            StorageUiMode::Menu => {
8193                let index = self.state.storage_menu_index;
8194                match index {
8195                    0 => {
8196                        let opts = self.state.storage_store_options();
8197                        if opts.is_empty() {
8198                            self.state.push_log("Nothing loose to store.");
8199                            return Ok(());
8200                        }
8201                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8202                    }
8203                    1 => {
8204                        let opts = self.state.storage_vault_options();
8205                        if opts.is_empty() {
8206                            self.state.push_log("Vault is empty.");
8207                            return Ok(());
8208                        }
8209                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8210                    }
8211                    n => {
8212                        let dest = self
8213                            .state
8214                            .storage_panel
8215                            .as_ref()
8216                            .and_then(|p| p.ship_destinations.get(n - 2))
8217                            .cloned();
8218                        let Some(dest) = dest else {
8219                            return Ok(());
8220                        };
8221                        let opts = self.state.storage_vault_options();
8222                        if opts.is_empty() {
8223                            self.state
8224                                .push_log("Vault is empty — nothing to ship.");
8225                            return Ok(());
8226                        }
8227                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
8228                            dest_building_id: dest.building_id,
8229                            dest_label: dest.label,
8230                            index: 0,
8231                        };
8232                    }
8233                }
8234            }
8235            StorageUiMode::StorePick { index } => {
8236                let opts = self.state.storage_store_options();
8237                let Some(opt) = opts.get(index) else {
8238                    self.state.push_log("Nothing loose to store.");
8239                    self.state.storage_ui_mode = StorageUiMode::Menu;
8240                    return Ok(());
8241                };
8242                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8243                    pick_index: index,
8244                    item_instance_id: opt.item_instance_id,
8245                    label: opt.label.clone(),
8246                    max_qty: opt.quantity.max(1),
8247                    input: String::new(),
8248                };
8249            }
8250            StorageUiMode::TakePick { index } => {
8251                let opts = self.state.storage_vault_options();
8252                let Some(opt) = opts.get(index) else {
8253                    self.state.push_log("Vault is empty.");
8254                    self.state.storage_ui_mode = StorageUiMode::Menu;
8255                    return Ok(());
8256                };
8257                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8258                    pick_index: index,
8259                    item_instance_id: opt.item_instance_id,
8260                    label: opt.label.clone(),
8261                    max_qty: opt.quantity.max(1),
8262                    input: String::new(),
8263                };
8264            }
8265            StorageUiMode::ShipPick {
8266                dest_building_id,
8267                dest_label,
8268                index,
8269            } => {
8270                let opts = self.state.storage_vault_options();
8271                let Some(opt) = opts.get(index) else {
8272                    self.state
8273                        .push_log("Vault is empty — nothing to ship.");
8274                    self.state.storage_ui_mode = StorageUiMode::Menu;
8275                    return Ok(());
8276                };
8277                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8278                    dest_building_id,
8279                    dest_label,
8280                    pick_index: index,
8281                    item_instance_id: opt.item_instance_id,
8282                    label: opt.label.clone(),
8283                    max_qty: opt.quantity.max(1),
8284                    input: String::new(),
8285                };
8286            }
8287            StorageUiMode::StoreAmount {
8288                item_instance_id,
8289                max_qty,
8290                input,
8291                ..
8292            } => {
8293                let Some(qty) = parse_storage_quantity(&input) else {
8294                    self.state
8295                        .push_log("Enter a quantity (blank or 0 = all).");
8296                    return Ok(());
8297                };
8298                let qty = qty.map(|n| n.min(max_qty).max(1));
8299                self.storage_store(item_instance_id, qty).await?;
8300                self.state.storage_ui_mode = StorageUiMode::Menu;
8301            }
8302            StorageUiMode::TakeAmount {
8303                item_instance_id,
8304                max_qty,
8305                input,
8306                ..
8307            } => {
8308                let Some(qty) = parse_storage_quantity(&input) else {
8309                    self.state
8310                        .push_log("Enter a quantity (blank or 0 = all).");
8311                    return Ok(());
8312                };
8313                let qty = qty.map(|n| n.min(max_qty).max(1));
8314                self.storage_take(item_instance_id, qty).await?;
8315                self.state.storage_ui_mode = StorageUiMode::Menu;
8316            }
8317            StorageUiMode::ShipAmount {
8318                dest_building_id,
8319                item_instance_id,
8320                max_qty,
8321                input,
8322                ..
8323            } => {
8324                let Some(qty) = parse_storage_quantity(&input) else {
8325                    self.state
8326                        .push_log("Enter a quantity (blank or 0 = all).");
8327                    return Ok(());
8328                };
8329                let qty = qty.map(|n| n.min(max_qty).max(1));
8330                self.storage_ship(dest_building_id, item_instance_id, qty)
8331                    .await?;
8332                self.state.storage_ui_mode = StorageUiMode::Menu;
8333            }
8334        }
8335        Ok(())
8336    }
8337
8338    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8339        match self.state.bank_ui_mode.clone() {
8340            BankUiMode::Menu => {
8341                let choice = self
8342                    .state
8343                    .bank_menu_options()
8344                    .get(self.state.bank_menu_index)
8345                    .copied()
8346                    .unwrap_or("Deposit…");
8347                match choice {
8348                    "Withdraw…" => {
8349                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8350                            input: String::new(),
8351                        };
8352                    }
8353                    "Deposit all" => self.bank_deposit(0).await?,
8354                    "Withdraw all" => self.bank_withdraw(0).await?,
8355                    "Transfer…" => {
8356                        self.state.bank_ui_mode = BankUiMode::TransferName {
8357                            input: String::new(),
8358                        };
8359                    }
8360                    _ => {
8361                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
8362                            input: String::new(),
8363                        };
8364                    }
8365                }
8366            }
8367            BankUiMode::DepositAmount { input } => {
8368                let Some(amount) = parse_bank_copper_amount(&input) else {
8369                    self.state
8370                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
8371                    return Ok(());
8372                };
8373                self.bank_deposit(amount).await?;
8374                self.state.bank_ui_mode = BankUiMode::Menu;
8375            }
8376            BankUiMode::WithdrawAmount { input } => {
8377                let Some(amount) = parse_bank_copper_amount(&input) else {
8378                    self.state
8379                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
8380                    return Ok(());
8381                };
8382                self.bank_withdraw(amount).await?;
8383                self.state.bank_ui_mode = BankUiMode::Menu;
8384            }
8385            BankUiMode::TransferName { input } => {
8386                let name = input.trim().to_string();
8387                if name.is_empty() {
8388                    self.state.push_log("Enter the recipient character name.");
8389                    return Ok(());
8390                }
8391                self.state.bank_ui_mode = BankUiMode::TransferAmount {
8392                    to_name: name,
8393                    input: String::new(),
8394                };
8395            }
8396            BankUiMode::TransferAmount { to_name, input } => {
8397                let amount: u64 = match input.trim().parse() {
8398                    Ok(v) if v > 0 => v,
8399                    _ => {
8400                        self.state
8401                            .push_log("Enter a positive copper amount to transfer.");
8402                        return Ok(());
8403                    }
8404                };
8405                self.bank_transfer(None, to_name, amount).await?;
8406                self.state.bank_ui_mode = BankUiMode::Menu;
8407            }
8408        }
8409        Ok(())
8410    }
8411
8412    pub fn bank_transfer_back(&mut self) {
8413        match &self.state.bank_ui_mode {
8414            BankUiMode::TransferAmount { to_name, .. } => {
8415                self.state.bank_ui_mode = BankUiMode::TransferName {
8416                    input: to_name.clone(),
8417                };
8418            }
8419            BankUiMode::TransferName { .. }
8420            | BankUiMode::DepositAmount { .. }
8421            | BankUiMode::WithdrawAmount { .. } => {
8422                self.state.bank_ui_mode = BankUiMode::Menu;
8423            }
8424            BankUiMode::Menu => {}
8425        }
8426    }
8427
8428    pub fn bank_transfer_append_char(&mut self, c: char) {
8429        match &mut self.state.bank_ui_mode {
8430            BankUiMode::TransferName { input } => {
8431                if input.len() < 32 && !c.is_control() {
8432                    input.push(c);
8433                }
8434            }
8435            BankUiMode::DepositAmount { input }
8436            | BankUiMode::WithdrawAmount { input }
8437            | BankUiMode::TransferAmount { input, .. } => {
8438                if c.is_ascii_digit() && input.len() < 12 {
8439                    input.push(c);
8440                }
8441            }
8442            BankUiMode::Menu => {}
8443        }
8444    }
8445
8446    pub fn bank_transfer_backspace(&mut self) {
8447        match &mut self.state.bank_ui_mode {
8448            BankUiMode::TransferName { input }
8449            | BankUiMode::DepositAmount { input }
8450            | BankUiMode::WithdrawAmount { input }
8451            | BankUiMode::TransferAmount { input, .. } => {
8452                input.pop();
8453            }
8454            BankUiMode::Menu => {}
8455        }
8456    }
8457
8458    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8459        let npc_id = self
8460            .state
8461            .bank_panel
8462            .as_ref()
8463            .map(|p| p.npc_id.clone());
8464        self.state.clear_bank_panel();
8465        if let Some(npc_id) = npc_id {
8466            self.seq += 1;
8467            self.session
8468                .submit_intent(Intent::BankClose {
8469                    entity_id: self.state.entity_id,
8470                    npc_id,
8471                    seq: self.seq,
8472                })
8473                .await?;
8474            self.state.intents_sent += 1;
8475        }
8476        Ok(())
8477    }
8478
8479    pub async fn storage_store(
8480        &mut self,
8481        item_instance_id: uuid::Uuid,
8482        quantity: Option<u32>,
8483    ) -> anyhow::Result<()> {
8484        let Some(panel) = self.state.storage_panel.clone() else {
8485            return Ok(());
8486        };
8487        self.seq += 1;
8488        self.session
8489            .submit_intent(Intent::StorageStore {
8490                entity_id: self.state.entity_id,
8491                npc_id: panel.npc_id,
8492                item_instance_id,
8493                quantity,
8494                seq: self.seq,
8495            })
8496            .await?;
8497        self.state.intents_sent += 1;
8498        Ok(())
8499    }
8500
8501    pub async fn storage_take(
8502        &mut self,
8503        item_instance_id: uuid::Uuid,
8504        quantity: Option<u32>,
8505    ) -> anyhow::Result<()> {
8506        let Some(panel) = self.state.storage_panel.clone() else {
8507            return Ok(());
8508        };
8509        self.seq += 1;
8510        self.session
8511            .submit_intent(Intent::StorageTake {
8512                entity_id: self.state.entity_id,
8513                npc_id: panel.npc_id,
8514                item_instance_id,
8515                quantity,
8516                seq: self.seq,
8517            })
8518            .await?;
8519        self.state.intents_sent += 1;
8520        Ok(())
8521    }
8522
8523    pub async fn storage_ship(
8524        &mut self,
8525        dest_building_id: String,
8526        item_instance_id: uuid::Uuid,
8527        quantity: Option<u32>,
8528    ) -> anyhow::Result<()> {
8529        let Some(panel) = self.state.storage_panel.clone() else {
8530            return Ok(());
8531        };
8532        self.seq += 1;
8533        self.session
8534            .submit_intent(Intent::StorageShip {
8535                entity_id: self.state.entity_id,
8536                npc_id: panel.npc_id,
8537                dest_building_id,
8538                item_instance_id,
8539                quantity,
8540                seq: self.seq,
8541            })
8542            .await?;
8543        self.state.intents_sent += 1;
8544        Ok(())
8545    }
8546
8547    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
8548        let npc_id = self
8549            .state
8550            .storage_panel
8551            .as_ref()
8552            .map(|p| p.npc_id.clone());
8553        self.state.clear_storage_panel();
8554        if let Some(npc_id) = npc_id {
8555            self.seq += 1;
8556            self.session
8557                .submit_intent(Intent::StorageClose {
8558                    entity_id: self.state.entity_id,
8559                    npc_id,
8560                    seq: self.seq,
8561                })
8562                .await?;
8563            self.state.intents_sent += 1;
8564        }
8565        Ok(())
8566    }
8567
8568    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
8569        let npc_id = self
8570            .state
8571            .market_panel
8572            .as_ref()
8573            .map(|p| p.npc_id.clone());
8574        self.state.clear_market_panel();
8575        if let Some(npc_id) = npc_id {
8576            self.seq += 1;
8577            self.session
8578                .submit_intent(Intent::MarketClose {
8579                    entity_id: self.state.entity_id,
8580                    npc_id,
8581                    seq: self.seq,
8582                })
8583                .await?;
8584            self.state.intents_sent += 1;
8585        }
8586        Ok(())
8587    }
8588
8589    pub fn market_move_selection(&mut self, delta: i32) {
8590        let indices = self.state.market_filtered_listing_indices();
8591        let n = indices.len();
8592        if n == 0 {
8593            self.state.market_menu_index = 0;
8594            return;
8595        }
8596        let cur = self.state.market_menu_index as i32;
8597        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
8598    }
8599
8600    pub fn market_page_selection(&mut self, pages: i32) {
8601        let indices = self.state.market_filtered_listing_indices();
8602        let n = indices.len();
8603        if n == 0 {
8604            self.state.market_menu_index = 0;
8605            return;
8606        }
8607        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
8608    }
8609
8610    pub fn market_list_page(&mut self, pages: i32) {
8611        match &self.state.market_ui_mode {
8612            MarketUiMode::ListSource { index } => {
8613                let n = self.state.market_list_source_options().len();
8614                if n == 0 {
8615                    return;
8616                }
8617                let next = page_list_index(*index, pages, n);
8618                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8619            }
8620            MarketUiMode::ListPick { source, index } => {
8621                let opts = self.state.market_list_item_options(source);
8622                let n = opts.len();
8623                if n == 0 {
8624                    return;
8625                }
8626                let next = page_list_index(*index, pages, n);
8627                self.state.market_ui_mode = MarketUiMode::ListPick {
8628                    source: source.clone(),
8629                    index: next,
8630                };
8631            }
8632            _ => {}
8633        }
8634    }
8635
8636    pub fn market_cycle_category(&mut self, delta: i32) {
8637        let groups = self.state.market_available_category_groups();
8638        // All + groups
8639        let mut labels: Vec<Option<&'static str>> = vec![None];
8640        labels.extend(groups.into_iter().map(Some));
8641        let n = labels.len() as i32;
8642        let cur = labels
8643            .iter()
8644            .position(|g| *g == self.state.market_category_filter)
8645            .unwrap_or(0) as i32;
8646        let next = (cur + delta).rem_euclid(n) as usize;
8647        self.state.market_category_filter = labels[next];
8648        self.state.market_menu_index = 0;
8649        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8650            let source = source.clone();
8651            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8652        }
8653    }
8654
8655    pub fn focus_market_filter(&mut self) {
8656        self.state.market_filter_focused = true;
8657    }
8658
8659    pub fn append_market_filter_char(&mut self, ch: char) {
8660        if !self.state.market_filter_focused {
8661            return;
8662        }
8663        if ch.is_control() {
8664            return;
8665        }
8666        if self.state.market_filter.len() < 48 {
8667            self.state.market_filter.push(ch);
8668            self.state.market_menu_index = 0;
8669            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8670                let source = source.clone();
8671                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8672            }
8673        }
8674    }
8675
8676    pub fn market_filter_backspace(&mut self) {
8677        if !self.state.market_filter_focused {
8678            return;
8679        }
8680        self.state.market_filter.pop();
8681        self.state.market_menu_index = 0;
8682        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8683            let source = source.clone();
8684            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8685        }
8686    }
8687
8688    /// Clears filter text, blurs search, or returns false if already idle.
8689    pub fn clear_or_blur_market_filter(&mut self) -> bool {
8690        if self.state.market_filter_focused {
8691            if !self.state.market_filter.is_empty() {
8692                self.state.market_filter.clear();
8693                self.state.market_menu_index = 0;
8694                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8695                    let source = source.clone();
8696                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8697                }
8698                return true;
8699            }
8700            self.state.market_filter_focused = false;
8701            return true;
8702        }
8703        if !self.state.market_filter.is_empty() {
8704            self.state.market_filter.clear();
8705            self.state.market_menu_index = 0;
8706            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8707                let source = source.clone();
8708                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8709            }
8710            return true;
8711        }
8712        false
8713    }
8714
8715    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
8716        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
8717            return self.market_confirm_buy(listing_id, qty).await;
8718        }
8719        let Some(panel) = self.state.market_panel.clone() else {
8720            return Ok(());
8721        };
8722        let indices = self.state.market_filtered_listing_indices();
8723        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
8724            return Ok(());
8725        };
8726        let Some(listing) = panel.listings.get(raw_idx) else {
8727            return Ok(());
8728        };
8729        if listing.mine {
8730            self.seq += 1;
8731            self.session
8732                .submit_intent(Intent::MarketDelist {
8733                    entity_id: self.state.entity_id,
8734                    npc_id: panel.npc_id.clone(),
8735                    listing_id: listing.listing_id,
8736                    dest: flatland_protocol::GoodsLocation::Person,
8737                    seq: self.seq,
8738                })
8739                .await?;
8740            self.state.intents_sent += 1;
8741            return Ok(());
8742        }
8743        let qty = 1u32.min(listing.quantity).max(1);
8744        let line = listing.unit_price_copper.saturating_mul(qty as u64);
8745        self.state.market_buy_confirm = Some((
8746            listing.listing_id,
8747            qty,
8748            listing.unit_price_copper,
8749            line,
8750            listing.display_name.clone(),
8751        ));
8752        Ok(())
8753    }
8754
8755    pub async fn market_confirm_buy(
8756        &mut self,
8757        listing_id: uuid::Uuid,
8758        quantity: u32,
8759    ) -> anyhow::Result<()> {
8760        let Some(panel) = self.state.market_panel.clone() else {
8761            self.state.market_buy_confirm = None;
8762            return Ok(());
8763        };
8764        self.state.market_buy_confirm = None;
8765        self.seq += 1;
8766        self.session
8767            .submit_intent(Intent::MarketBuy {
8768                entity_id: self.state.entity_id,
8769                npc_id: panel.npc_id,
8770                listing_id,
8771                quantity,
8772                dest: flatland_protocol::GoodsLocation::Person,
8773                seq: self.seq,
8774            })
8775            .await?;
8776        self.state.intents_sent += 1;
8777        Ok(())
8778    }
8779
8780    /// Begin the list wizard (`l` in market browse).
8781    pub fn market_begin_list(&mut self) {
8782        if self.state.market_panel.is_none() {
8783            return;
8784        }
8785        let sources = self.state.market_list_source_options();
8786        if sources.is_empty() {
8787            self.state.push_log("Nothing to list from.");
8788            return;
8789        }
8790        // One source (person only) → skip straight to item pick when it has stacks.
8791        if sources.len() == 1 {
8792            let (source, _) = sources[0].clone();
8793            let opts = self.state.market_list_item_options(&source);
8794            if opts.is_empty() {
8795                self.state.push_log("Nothing loose to list.");
8796                return;
8797            }
8798            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8799            self.state.market_buy_confirm = None;
8800            return;
8801        }
8802        self.state.market_buy_confirm = None;
8803        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
8804    }
8805
8806    pub fn market_ui_back(&mut self) {
8807        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
8808            MarketUiMode::Browse => MarketUiMode::Browse,
8809            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
8810            MarketUiMode::ListPick { .. } => {
8811                if self.state.market_list_source_options().len() <= 1 {
8812                    MarketUiMode::Browse
8813                } else {
8814                    MarketUiMode::ListSource { index: 0 }
8815                }
8816            }
8817            MarketUiMode::ListAmount {
8818                source,
8819                pick_index,
8820                ..
8821            } => MarketUiMode::ListPick {
8822                source,
8823                index: pick_index,
8824            },
8825            MarketUiMode::ListPrice {
8826                source,
8827                item_instance_id,
8828                label,
8829                max_qty,
8830                quantity,
8831                ..
8832            } => {
8833                let input = quantity
8834                    .map(|q| q.to_string())
8835                    .unwrap_or_default();
8836                MarketUiMode::ListAmount {
8837                    source,
8838                    pick_index: 0,
8839                    item_instance_id,
8840                    label,
8841                    max_qty,
8842                    input,
8843                }
8844            }
8845        };
8846    }
8847
8848    pub fn market_list_move(&mut self, delta: i32) {
8849        match &self.state.market_ui_mode {
8850            MarketUiMode::ListSource { index } => {
8851                let n = self.state.market_list_source_options().len();
8852                if n == 0 {
8853                    return;
8854                }
8855                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8856                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8857            }
8858            MarketUiMode::ListPick { source, index } => {
8859                let opts = self.state.market_list_item_options(source);
8860                let n = opts.len();
8861                if n == 0 {
8862                    return;
8863                }
8864                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8865                self.state.market_ui_mode = MarketUiMode::ListPick {
8866                    source: source.clone(),
8867                    index: next,
8868                };
8869            }
8870            _ => {}
8871        }
8872    }
8873
8874    pub fn market_list_amount_append_char(&mut self, c: char) {
8875        if !c.is_ascii_digit() {
8876            return;
8877        }
8878        match &mut self.state.market_ui_mode {
8879            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8880                if input.len() < 12 {
8881                    input.push(c);
8882                }
8883            }
8884            _ => {}
8885        }
8886    }
8887
8888    pub fn market_list_amount_backspace(&mut self) {
8889        match &mut self.state.market_ui_mode {
8890            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8891                input.pop();
8892            }
8893            _ => {}
8894        }
8895    }
8896
8897    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
8898        match self.state.market_ui_mode.clone() {
8899            MarketUiMode::Browse => Ok(()),
8900            MarketUiMode::ListSource { index } => {
8901                let sources = self.state.market_list_source_options();
8902                let Some((source, _)) = sources.get(index).cloned() else {
8903                    return Ok(());
8904                };
8905                let opts = self.state.market_list_item_options(&source);
8906                if opts.is_empty() {
8907                    self.state.push_log("Nothing to list from that source.");
8908                    return Ok(());
8909                }
8910                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8911                Ok(())
8912            }
8913            MarketUiMode::ListPick { source, index } => {
8914                let opts = self.state.market_list_item_options(&source);
8915                let Some(opt) = opts.get(index) else {
8916                    self.state.push_log("Nothing to list.");
8917                    self.state.market_ui_mode = MarketUiMode::Browse;
8918                    return Ok(());
8919                };
8920                self.state.market_ui_mode = MarketUiMode::ListAmount {
8921                    source,
8922                    pick_index: index,
8923                    item_instance_id: opt.item_instance_id,
8924                    label: opt.label.clone(),
8925                    max_qty: opt.quantity.max(1),
8926                    input: String::new(),
8927                };
8928                Ok(())
8929            }
8930            MarketUiMode::ListAmount {
8931                source,
8932                item_instance_id,
8933                label,
8934                max_qty,
8935                input,
8936                ..
8937            } => {
8938                let Some(qty_opt) = parse_storage_quantity(&input) else {
8939                    self.state.push_log("Enter a quantity (blank = all).");
8940                    return Ok(());
8941                };
8942                if let Some(q) = qty_opt {
8943                    if q > max_qty {
8944                        self.state
8945                            .push_log(format!("Only {max_qty} available."));
8946                        return Ok(());
8947                    }
8948                }
8949                self.state.market_ui_mode = MarketUiMode::ListPrice {
8950                    source,
8951                    item_instance_id,
8952                    label,
8953                    quantity: qty_opt,
8954                    max_qty,
8955                    input: String::new(),
8956                };
8957                Ok(())
8958            }
8959            MarketUiMode::ListPrice {
8960                source,
8961                item_instance_id,
8962                label,
8963                quantity,
8964                input,
8965                ..
8966            } => {
8967                let price = input.trim().parse::<u64>().unwrap_or(0);
8968                if price == 0 {
8969                    self.state.push_log("Enter a unit price of at least 1 copper.");
8970                    return Ok(());
8971                }
8972                let Some(panel) = self.state.market_panel.clone() else {
8973                    self.state.market_ui_mode = MarketUiMode::Browse;
8974                    return Ok(());
8975                };
8976                let goods = match source {
8977                    MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
8978                    MarketListSourceKind::TownStorage { building_id } => {
8979                        flatland_protocol::GoodsLocation::TownStorage { building_id }
8980                    }
8981                };
8982                self.seq += 1;
8983                self.session
8984                    .submit_intent(Intent::MarketList {
8985                        entity_id: self.state.entity_id,
8986                        npc_id: panel.npc_id,
8987                        source: goods,
8988                        item_instance_id,
8989                        quantity,
8990                        unit_price_copper: price,
8991                        seq: self.seq,
8992                    })
8993                    .await?;
8994                self.state.intents_sent += 1;
8995                self.state
8996                    .push_log(format!("Listing {label} @ {price} cp…"));
8997                self.state.market_ui_mode = MarketUiMode::Browse;
8998                Ok(())
8999            }
9000        }
9001    }
9002
9003    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
9004    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9005        let return_to_verbs = self.state.npc_verb_target.is_some();
9006        self.close_shop_menu().await?;
9007        if return_to_verbs {
9008            self.state.show_npc_verb_menu = true;
9009        }
9010        Ok(())
9011    }
9012
9013    pub fn shop_tab_toggle(&mut self) {
9014        self.state.shop_tab = match self.state.shop_tab {
9015            ShopTab::Buy => ShopTab::Sell,
9016            ShopTab::Sell => ShopTab::Buy,
9017        };
9018        self.state.shop_menu_index = 0;
9019        if self.state.shop_tab == ShopTab::Sell {
9020            self.state.shop_quantity_set_max();
9021        }
9022        self.state.clamp_shop_selection();
9023    }
9024
9025    pub fn shop_menu_move(&mut self, delta: i32) {
9026        self.state.shop_menu_move(delta);
9027    }
9028
9029    pub fn shop_quantity_adjust(&mut self, delta: i32) {
9030        self.state.shop_quantity_adjust(delta);
9031    }
9032
9033    pub fn shop_quantity_set_max(&mut self) {
9034        self.state.shop_quantity_set_max();
9035    }
9036
9037    pub fn toggle_quest_menu(&mut self) {
9038        self.state.show_quest_menu = !self.state.show_quest_menu;
9039        if self.state.show_quest_menu {
9040            self.state.quest_menu_index = 0;
9041            self.state.quest_withdraw_confirm = false;
9042            self.state.show_workers_menu = false;
9043        }
9044    }
9045
9046    pub fn toggle_workers_menu(&mut self) {
9047        if self.state.show_workers_menu {
9048            self.close_workers_menu_ui();
9049        } else {
9050            self.state.show_workers_menu = true;
9051            self.state.workers_menu_index = 0;
9052            self.state.show_quest_menu = false;
9053            self.close_worker_give_picker();
9054            self.close_worker_give_target_picker();
9055            self.close_worker_take_picker();
9056            self.close_worker_teach_picker();
9057            self.cancel_worker_rename();
9058        }
9059    }
9060
9061    /// Close workers UI layers without network (caller should release attend).
9062    pub fn close_workers_menu_ui(&mut self) {
9063        self.state.show_workers_menu = false;
9064        self.close_worker_give_picker();
9065        self.close_worker_give_target_picker();
9066        self.close_worker_take_picker();
9067        self.close_worker_teach_picker();
9068        self.cancel_worker_rename();
9069    }
9070
9071    /// Open the workers menu focused on `instance_id` and pause that worker's job.
9072    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9073        let Some(idx) = self
9074            .state
9075            .hired_workers
9076            .iter()
9077            .position(|w| w.instance_id == instance_id)
9078        else {
9079            anyhow::bail!("worker not found");
9080        };
9081        let label = self.state.hired_workers[idx].label.clone();
9082        self.state.show_workers_menu = true;
9083        self.state.workers_menu_index = idx;
9084        self.state.show_quest_menu = false;
9085        self.close_worker_give_picker();
9086        self.close_worker_give_target_picker();
9087        self.close_worker_take_picker();
9088        self.close_worker_teach_picker();
9089        self.cancel_worker_rename();
9090        self.set_worker_attending(instance_id, true).await?;
9091        self.state
9092            .push_log(format!("Managing {label} — job paused while menu is open"));
9093        Ok(())
9094    }
9095
9096    /// Close workers menu and release any attend pause.
9097    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9098        self.close_workers_menu_ui();
9099        self.release_worker_attend().await
9100    }
9101
9102    async fn set_worker_attending(
9103        &mut self,
9104        instance_id: &str,
9105        attending: bool,
9106    ) -> anyhow::Result<()> {
9107        if attending {
9108            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9109                return Ok(());
9110            }
9111            // Switch target: release previous first.
9112            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9113                if prev != instance_id {
9114                    self.send_attend_hired_worker(&prev, false).await?;
9115                }
9116            }
9117            self.send_attend_hired_worker(instance_id, true).await?;
9118            self.state.attending_worker_instance_id = Some(instance_id.to_string());
9119        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9120            self.send_attend_hired_worker(instance_id, false).await?;
9121            self.state.attending_worker_instance_id = None;
9122        }
9123        Ok(())
9124    }
9125
9126    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9127        let Some(id) = self.state.attending_worker_instance_id.take() else {
9128            return Ok(());
9129        };
9130        self.send_attend_hired_worker(&id, false).await
9131    }
9132
9133    async fn send_attend_hired_worker(
9134        &mut self,
9135        worker_instance_id: &str,
9136        attending: bool,
9137    ) -> anyhow::Result<()> {
9138        self.seq += 1;
9139        self.session
9140            .submit_intent(Intent::AttendHiredWorker {
9141                entity_id: self.state.entity_id,
9142                worker_instance_id: worker_instance_id.to_string(),
9143                attending,
9144                seq: self.seq,
9145            })
9146            .await?;
9147        self.state.intents_sent += 1;
9148        Ok(())
9149    }
9150
9151    pub fn workers_menu_move(&mut self, delta: i32) {
9152        let n = self.state.hired_workers.len();
9153        if n == 0 {
9154            return;
9155        }
9156        let idx = self.state.workers_menu_index as i32;
9157        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9158    }
9159
9160    pub fn toggle_workers_menu_compact(&mut self) {
9161        self.state.workers_menu_compact = !self.state.workers_menu_compact;
9162        let mut cfg = crate::client_config::ClientConfig::load();
9163        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9164    }
9165
9166    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9167        let Some(worker) = self
9168            .state
9169            .hired_workers
9170            .get(self.state.workers_menu_index)
9171            .cloned()
9172        else {
9173            anyhow::bail!("no worker selected");
9174        };
9175        self.seq += 1;
9176        self.session
9177            .submit_intent(Intent::DismissWorker {
9178                entity_id: self.state.entity_id,
9179                worker_instance_id: worker.instance_id.clone(),
9180                seq: self.seq,
9181            })
9182            .await?;
9183        self.state.intents_sent += 1;
9184        self.state
9185            .hired_workers
9186            .retain(|w| w.instance_id != worker.instance_id);
9187        if self.state.workers_menu_index >= self.state.hired_workers.len() {
9188            self.state.workers_menu_index = self
9189                .state
9190                .hired_workers
9191                .len()
9192                .saturating_sub(1);
9193        }
9194        self.state.push_log(format!("Dismissed {}", worker.label));
9195        Ok(())
9196    }
9197
9198    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9199        let Some(worker) = self
9200            .state
9201            .hired_workers
9202            .get(self.state.workers_menu_index)
9203            .cloned()
9204        else {
9205            anyhow::bail!("no worker selected");
9206        };
9207        let mode = match worker.mode {
9208            flatland_protocol::WorkerModeView::Companion => "job_loop",
9209            flatland_protocol::WorkerModeView::JobLoop => "idle",
9210            flatland_protocol::WorkerModeView::Idle => "companion",
9211        };
9212        self.seq += 1;
9213        self.session
9214            .submit_intent(Intent::SetWorkerMode {
9215                entity_id: self.state.entity_id,
9216                worker_instance_id: worker.instance_id,
9217                mode: mode.into(),
9218                seq: self.seq,
9219            })
9220            .await?;
9221        self.state.intents_sent += 1;
9222        Ok(())
9223    }
9224
9225    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9226        if self.state.hired_workers.is_empty() {
9227            return self.hire_worker_laborer().await;
9228        }
9229        self.workers_toggle_mode_selected().await
9230    }
9231
9232    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
9233    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
9234    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9235        let row = self
9236            .state
9237            .inventory_selected_row()
9238            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9239            .clone();
9240        if row.from != flatland_protocol::InventoryLocation::Root {
9241            anyhow::bail!("select a carried item to give");
9242        }
9243        let Some(instance_id) = row.stack.item_instance_id else {
9244            anyhow::bail!("that stack can't be given");
9245        };
9246        let options = self.nearby_worker_give_targets();
9247        if options.is_empty() {
9248            anyhow::bail!(
9249                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9250            );
9251        }
9252        let item_label = row
9253            .stack
9254            .display_name
9255            .as_deref()
9256            .unwrap_or(&row.stack.template_id)
9257            .to_string();
9258        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9259            item_instance_id: instance_id,
9260            item_label,
9261            quantity: None,
9262            options,
9263        });
9264        self.state.worker_give_target_picker_index = 0;
9265        self.state.show_worker_give_target_picker = true;
9266        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
9267        self.state.show_inventory_menu = false;
9268        Ok(())
9269    }
9270
9271    /// Hired workers within give/take range, nearest first.
9272    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9273        let (px, py, _) = self.state.player_position_with_z();
9274        let mut options: Vec<WorkerGiveTargetOption> = self
9275            .state
9276            .hired_workers
9277            .iter()
9278            .filter_map(|w| {
9279                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9280                if dist > WORKER_GIVE_RANGE_M {
9281                    return None;
9282                }
9283                Some(WorkerGiveTargetOption {
9284                    instance_id: w.instance_id.clone(),
9285                    label: w.label.clone(),
9286                    distance_m: dist,
9287                })
9288            })
9289            .collect();
9290        options.sort_by(|a, b| {
9291            a.distance_m
9292                .partial_cmp(&b.distance_m)
9293                .unwrap_or(std::cmp::Ordering::Equal)
9294        });
9295        options
9296    }
9297
9298    pub fn close_worker_give_target_picker(&mut self) {
9299        self.state.show_worker_give_target_picker = false;
9300        self.state.worker_give_target_picker = None;
9301        self.state.worker_give_target_picker_index = 0;
9302    }
9303
9304    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9305        let Some(picker) = &self.state.worker_give_target_picker else {
9306            return;
9307        };
9308        let n = picker.options.len();
9309        if n == 0 {
9310            return;
9311        }
9312        let idx = self.state.worker_give_target_picker_index as i32;
9313        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9314    }
9315
9316    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9317        let Some(picker) = self.state.worker_give_target_picker.clone() else {
9318            anyhow::bail!("give target picker not open");
9319        };
9320        let Some(opt) = picker
9321            .options
9322            .get(self.state.worker_give_target_picker_index)
9323            .cloned()
9324        else {
9325            anyhow::bail!("no worker selected");
9326        };
9327        let Some(worker) = self
9328            .state
9329            .hired_workers
9330            .iter()
9331            .find(|w| w.instance_id == opt.instance_id)
9332            .cloned()
9333        else {
9334            self.close_worker_give_target_picker();
9335            anyhow::bail!("worker no longer hired");
9336        };
9337        self.give_item_to_worker(
9338            &worker.instance_id,
9339            &worker.label,
9340            worker.x,
9341            worker.y,
9342            picker.item_instance_id,
9343            &picker.item_label,
9344            picker.quantity,
9345        )
9346        .await?;
9347        self.close_worker_give_target_picker();
9348        Ok(())
9349    }
9350
9351    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
9352    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9353        self.open_worker_give_target_picker()
9354    }
9355
9356    /// Open the workers-menu give picker for the selected worker (must be in range).
9357    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9358        let Some(worker) = self
9359            .state
9360            .hired_workers
9361            .get(self.state.workers_menu_index)
9362            .cloned()
9363        else {
9364            anyhow::bail!("select a hired worker first");
9365        };
9366        let (px, py, _) = self.state.player_position_with_z();
9367        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9368        if dist > WORKER_GIVE_RANGE_M {
9369            anyhow::bail!(
9370                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9371                worker.label
9372            );
9373        }
9374        let options = self.state.giveable_inventory_options();
9375        if options.is_empty() {
9376            anyhow::bail!("nothing in inventory to give");
9377        }
9378        self.state.worker_give_picker = Some(WorkerGivePicker {
9379            worker_instance_id: worker.instance_id,
9380            worker_label: worker.label,
9381            options,
9382        });
9383        self.state.worker_give_picker_index = 0;
9384        self.state.show_worker_give_picker = true;
9385        Ok(())
9386    }
9387
9388    pub fn close_worker_give_picker(&mut self) {
9389        self.state.show_worker_give_picker = false;
9390        self.state.worker_give_picker = None;
9391        self.state.worker_give_picker_index = 0;
9392    }
9393
9394    pub fn worker_give_picker_move(&mut self, delta: i32) {
9395        let Some(picker) = &self.state.worker_give_picker else {
9396            return;
9397        };
9398        let n = picker.options.len();
9399        if n == 0 {
9400            return;
9401        }
9402        let idx = self.state.worker_give_picker_index as i32;
9403        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9404    }
9405
9406    /// Confirm the selected row in the workers-menu give picker.
9407    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9408        let Some(picker) = self.state.worker_give_picker.clone() else {
9409            anyhow::bail!("give picker not open");
9410        };
9411        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9412            anyhow::bail!("no item selected");
9413        };
9414        let Some(worker) = self
9415            .state
9416            .hired_workers
9417            .iter()
9418            .find(|w| w.instance_id == picker.worker_instance_id)
9419            .cloned()
9420        else {
9421            self.close_worker_give_picker();
9422            anyhow::bail!("worker no longer hired");
9423        };
9424        self.give_item_to_worker(
9425            &worker.instance_id,
9426            &worker.label,
9427            worker.x,
9428            worker.y,
9429            opt.item_instance_id,
9430            &opt.label,
9431            None,
9432        )
9433        .await?;
9434        // Refresh options (stack may be gone / reduced) or close when empty.
9435        let options = self.state.giveable_inventory_options();
9436        if options.is_empty() {
9437            self.close_worker_give_picker();
9438        } else {
9439            self.state.worker_give_picker = Some(WorkerGivePicker {
9440                worker_instance_id: picker.worker_instance_id,
9441                worker_label: picker.worker_label,
9442                options,
9443            });
9444            if self.state.worker_give_picker_index
9445                >= self
9446                    .state
9447                    .worker_give_picker
9448                    .as_ref()
9449                    .map(|p| p.options.len())
9450                    .unwrap_or(0)
9451            {
9452                self.state.worker_give_picker_index = self
9453                    .state
9454                    .worker_give_picker
9455                    .as_ref()
9456                    .map(|p| p.options.len().saturating_sub(1))
9457                    .unwrap_or(0);
9458            }
9459        }
9460        Ok(())
9461    }
9462
9463    /// Open the workers-menu teach picker for the selected worker (must be in range).
9464    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9465        let Some(worker) = self
9466            .state
9467            .hired_workers
9468            .get(self.state.workers_menu_index)
9469            .cloned()
9470        else {
9471            anyhow::bail!("select a hired worker first");
9472        };
9473        let (px, py, _) = self.state.player_position_with_z();
9474        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9475        if dist > WORKER_GIVE_RANGE_M {
9476            anyhow::bail!(
9477                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
9478                worker.label
9479            );
9480        }
9481        let options = self.state.teachable_blueprint_options(&worker);
9482        if options.is_empty() {
9483            anyhow::bail!("no recipes you know that {} still needs", worker.label);
9484        }
9485        self.state.worker_teach_picker = Some(WorkerTeachPicker {
9486            worker_instance_id: worker.instance_id,
9487            worker_label: worker.label,
9488            worker_level: worker.level,
9489            options,
9490        });
9491        self.state.worker_teach_picker_index = 0;
9492        self.state.show_worker_teach_picker = true;
9493        Ok(())
9494    }
9495
9496    pub fn close_worker_teach_picker(&mut self) {
9497        self.state.show_worker_teach_picker = false;
9498        self.state.worker_teach_picker = None;
9499        self.state.worker_teach_picker_index = 0;
9500    }
9501
9502    pub fn worker_teach_picker_move(&mut self, delta: i32) {
9503        let Some(picker) = &self.state.worker_teach_picker else {
9504            return;
9505        };
9506        let n = picker.options.len();
9507        if n == 0 {
9508            return;
9509        }
9510        let idx = self.state.worker_teach_picker_index as i32;
9511        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9512    }
9513
9514    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9515        let Some(picker) = self.state.worker_teach_picker.clone() else {
9516            anyhow::bail!("teach picker not open");
9517        };
9518        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
9519            anyhow::bail!("nothing selected");
9520        };
9521        if !opt.level_ok {
9522            anyhow::bail!(
9523                "{} needs level {} (is level {})",
9524                picker.worker_label,
9525                opt.min_level,
9526                opt.worker_level
9527            );
9528        }
9529        if !opt.can_afford {
9530            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
9531        }
9532        let Some(worker) = self
9533            .state
9534            .hired_workers
9535            .iter()
9536            .find(|w| w.instance_id == picker.worker_instance_id)
9537            .cloned()
9538        else {
9539            anyhow::bail!("worker gone");
9540        };
9541        let (px, py, _) = self.state.player_position_with_z();
9542        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9543        if dist > WORKER_GIVE_RANGE_M {
9544            anyhow::bail!("worker {} too far — stand next to them", worker.label);
9545        }
9546        self.seq += 1;
9547        self.session
9548            .submit_intent(Intent::TeachWorkerBlueprint {
9549                entity_id: self.state.entity_id,
9550                worker_instance_id: picker.worker_instance_id.clone(),
9551                blueprint_id: opt.blueprint_id.clone(),
9552                seq: self.seq,
9553            })
9554            .await?;
9555        self.state.intents_sent += 1;
9556        self.state.push_log(format!(
9557            "Teaching {} to {} ({} cp)",
9558            opt.label, picker.worker_label, opt.cost_copper
9559        ));
9560        self.close_worker_teach_picker();
9561        Ok(())
9562    }
9563
9564    async fn give_item_to_worker(
9565        &mut self,
9566        worker_instance_id: &str,
9567        worker_label: &str,
9568        worker_x: f32,
9569        worker_y: f32,
9570        item_instance_id: uuid::Uuid,
9571        item_label: &str,
9572        quantity: Option<u32>,
9573    ) -> anyhow::Result<()> {
9574        let (px, py, _) = self.state.player_position_with_z();
9575        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9576        if dist > WORKER_GIVE_RANGE_M {
9577            anyhow::bail!("worker {worker_label} too far — stand next to them");
9578        }
9579        self.seq += 1;
9580        self.session
9581            .submit_intent(Intent::GiveWorkerItem {
9582                entity_id: self.state.entity_id,
9583                worker_instance_id: worker_instance_id.to_string(),
9584                item_instance_id,
9585                quantity,
9586                seq: self.seq,
9587            })
9588            .await?;
9589        self.state.intents_sent += 1;
9590        self.state
9591            .push_log(format!("Gave {item_label} to {worker_label}"));
9592        Ok(())
9593    }
9594
9595    /// Open the workers-menu take picker for the selected worker (must be in range).
9596    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
9597        let Some(worker) = self
9598            .state
9599            .hired_workers
9600            .get(self.state.workers_menu_index)
9601            .cloned()
9602        else {
9603            anyhow::bail!("select a hired worker first");
9604        };
9605        let (px, py, _) = self.state.player_position_with_z();
9606        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9607        if dist > WORKER_GIVE_RANGE_M {
9608            anyhow::bail!(
9609                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
9610                worker.label
9611            );
9612        }
9613        let options = Self::worker_inventory_options(&worker);
9614        if options.is_empty() {
9615            anyhow::bail!("{} isn't carrying anything", worker.label);
9616        }
9617        let initial_qty = options
9618            .first()
9619            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
9620            .unwrap_or(1);
9621        self.state.worker_take_picker = Some(WorkerTakePicker {
9622            worker_instance_id: worker.instance_id,
9623            worker_label: worker.label,
9624            options,
9625            quantity: initial_qty,
9626        });
9627        self.state.worker_take_picker_index = 0;
9628        self.state.show_worker_take_picker = true;
9629        Ok(())
9630    }
9631
9632    fn worker_inventory_options(
9633        worker: &flatland_protocol::HiredWorkerView,
9634    ) -> Vec<WorkerGiveOption> {
9635        worker
9636            .inventory
9637            .iter()
9638            .filter_map(|stack| {
9639                let item_instance_id = stack.item_instance_id?;
9640                let label = stack
9641                    .display_name
9642                    .clone()
9643                    .unwrap_or_else(|| stack.template_id.clone());
9644                let label = if stack.quantity > 1 {
9645                    format!("{label} ×{}", stack.quantity)
9646                } else {
9647                    label
9648                };
9649                Some(WorkerGiveOption {
9650                    item_instance_id,
9651                    label,
9652                    quantity: stack.quantity,
9653                    template_id: stack.template_id.clone(),
9654                })
9655            })
9656            .collect()
9657    }
9658
9659    pub fn close_worker_take_picker(&mut self) {
9660        self.state.show_worker_take_picker = false;
9661        self.state.worker_take_picker = None;
9662        self.state.worker_take_picker_index = 0;
9663    }
9664
9665    pub fn worker_take_picker_move(&mut self, delta: i32) {
9666        let Some(picker) = &self.state.worker_take_picker else {
9667            return;
9668        };
9669        let n = picker.options.len();
9670        if n == 0 {
9671            return;
9672        }
9673        let idx = self.state.worker_take_picker_index as i32;
9674        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9675        self.clamp_worker_take_quantity();
9676    }
9677
9678    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
9679        let Some(picker) = &mut self.state.worker_take_picker else {
9680            return;
9681        };
9682        let max = picker
9683            .options
9684            .get(self.state.worker_take_picker_index)
9685            .map(|o| o.quantity.max(1))
9686            .unwrap_or(1);
9687        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
9688        picker.quantity = next as u32;
9689    }
9690
9691    pub fn worker_take_picker_set_quantity_max(&mut self) {
9692        let Some(picker) = &mut self.state.worker_take_picker else {
9693            return;
9694        };
9695        let max = picker
9696            .options
9697            .get(self.state.worker_take_picker_index)
9698            .map(|o| o.quantity.max(1))
9699            .unwrap_or(1);
9700        picker.quantity = max;
9701    }
9702
9703    fn clamp_worker_take_quantity(&mut self) {
9704        let Some(picker) = &mut self.state.worker_take_picker else {
9705            return;
9706        };
9707        let max = picker
9708            .options
9709            .get(self.state.worker_take_picker_index)
9710            .map(|o| o.quantity.max(1))
9711            .unwrap_or(1);
9712        if picker.quantity == 0 || picker.quantity > max {
9713            picker.quantity = if max > 1 { 1 } else { max };
9714        }
9715    }
9716
9717    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
9718        let Some(picker) = self.state.worker_take_picker.clone() else {
9719            anyhow::bail!("take picker not open");
9720        };
9721        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
9722            anyhow::bail!("no item selected");
9723        };
9724        let Some(worker) = self
9725            .state
9726            .hired_workers
9727            .iter()
9728            .find(|w| w.instance_id == picker.worker_instance_id)
9729            .cloned()
9730        else {
9731            self.close_worker_take_picker();
9732            anyhow::bail!("worker no longer hired");
9733        };
9734        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
9735        let intent_qty = if qty >= opt.quantity {
9736            None
9737        } else {
9738            Some(qty)
9739        };
9740        self.take_item_from_worker(
9741            &worker.instance_id,
9742            &worker.label,
9743            worker.x,
9744            worker.y,
9745            opt.item_instance_id,
9746            &opt.label,
9747            intent_qty,
9748        )
9749        .await?;
9750        // Leave the picker open — server Interaction / hired-workers sync refresh
9751        // options. Do not optimistic-strip stacks (rejects used to look like success).
9752        Ok(())
9753    }
9754
9755    async fn take_item_from_worker(
9756        &mut self,
9757        worker_instance_id: &str,
9758        worker_label: &str,
9759        worker_x: f32,
9760        worker_y: f32,
9761        item_instance_id: uuid::Uuid,
9762        item_label: &str,
9763        quantity: Option<u32>,
9764    ) -> anyhow::Result<()> {
9765        let (px, py, _) = self.state.player_position_with_z();
9766        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9767        if dist > WORKER_GIVE_RANGE_M {
9768            anyhow::bail!("worker {worker_label} too far — stand next to them");
9769        }
9770        self.seq += 1;
9771        self.session
9772            .submit_intent(Intent::TakeWorkerItem {
9773                entity_id: self.state.entity_id,
9774                worker_instance_id: worker_instance_id.to_string(),
9775                item_instance_id,
9776                quantity,
9777                seq: self.seq,
9778            })
9779            .await?;
9780        self.state.intents_sent += 1;
9781        let qty_note = quantity
9782            .map(|q| format!(" ×{q}"))
9783            .unwrap_or_default();
9784        self.state
9785            .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
9786        Ok(())
9787    }
9788
9789    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
9790        if !self.state.has_worker_lodging() {
9791            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
9792        }
9793        self.seq += 1;
9794        self.session
9795            .submit_intent(Intent::HireWorker {
9796                entity_id: self.state.entity_id,
9797                def_id: "worker_laborer".into(),
9798                wage_copper_per_interval: 8,
9799                lodging_container_id: None,
9800                job_yaml: None,
9801                seq: self.seq,
9802            })
9803            .await?;
9804        self.state.intents_sent += 1;
9805        Ok(())
9806    }
9807
9808    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
9809        let Some(worker) = self
9810            .state
9811            .hired_workers
9812            .get(self.state.workers_menu_index)
9813            .cloned()
9814        else {
9815            anyhow::bail!("select a hired worker first");
9816        };
9817        let lodging = worker.lodging_container_id.clone().or_else(|| {
9818            crate::worker_route_editor::owned_lodging_container_ids(
9819                &self.state.placed_containers,
9820                self.state.character_id,
9821            )
9822            .into_iter()
9823            .next()
9824            .map(|(id, _)| id)
9825        });
9826        let label = worker.label.clone();
9827        let editor = if let Some(route) = &worker.route {
9828            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
9829                worker.instance_id,
9830                worker.label,
9831                route,
9832                lodging,
9833            )
9834        } else {
9835            crate::worker_route_editor::WorkerRouteEditorState::new(
9836                worker.instance_id,
9837                worker.label,
9838                lodging,
9839            )
9840        };
9841        self.state.worker_route_editor = Some(editor);
9842        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9843            if let Some(collapsed) =
9844                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
9845            {
9846                ed.panel_collapsed = collapsed;
9847            }
9848        }
9849        self.state.show_workers_menu = false;
9850        self.state.push_log(format!(
9851            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
9852        ));
9853        Ok(())
9854    }
9855
9856    pub fn close_worker_route_editor(&mut self) {
9857        self.state.worker_route_editor = None;
9858    }
9859
9860    pub fn worker_route_editor_toggle_panel(&mut self) {
9861        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9862            ed.toggle_panel_collapsed();
9863            let collapsed = ed.panel_collapsed;
9864            let mut cfg = crate::client_config::ClientConfig::load();
9865            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
9866        }
9867    }
9868
9869    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
9870        let n = {
9871            let Some(ed) = self.state.worker_route_editor.as_mut() else {
9872                return;
9873            };
9874            ed.append_waypoint(x, y, z);
9875            ed.stop_count()
9876        };
9877        self.state
9878            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
9879    }
9880
9881    // ---- picker candidate snapshots ----------------------------------
9882
9883    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
9884        let (px, py, _) = self.state.player_position_with_z();
9885        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
9886            &self.state.placed_containers,
9887            &self.state.buildings,
9888            self.state.character_id,
9889            px,
9890            py,
9891            &self.state.hired_workers,
9892        )
9893    }
9894
9895    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
9896        self.state.route_editor_node_candidates()
9897    }
9898
9899    fn re_open_harvest_picker(
9900        &mut self,
9901        index: usize,
9902        picked: std::collections::BTreeSet<String>,
9903    ) {
9904        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
9905        let nodes = self.state.route_editor_node_candidates();
9906        let index = if nodes.is_empty() {
9907            ROUTE_PICKER_DONE_ROW
9908        } else {
9909            index.max(1).min(nodes.len())
9910        };
9911        self.re_open_sheet(S::HarvestPicker {
9912            index,
9913            picked,
9914            nodes,
9915        });
9916    }
9917
9918    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
9919        let (px, py, _) = self.state.player_position_with_z();
9920        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
9921    }
9922
9923    fn re_template_candidates(&self) -> Vec<String> {
9924        let mut extra = Vec::new();
9925        if let Some(ed) = self.state.worker_route_editor.as_ref() {
9926            for stop in &ed.stops {
9927                match stop {
9928                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
9929                        filter: Some(filter),
9930                        ..
9931                    } => extra.extend(filter.iter().cloned()),
9932                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
9933                        extra.push(template.clone());
9934                    }
9935                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
9936                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
9937                            extra.push(bp.output.clone());
9938                            for input in &bp.inputs {
9939                                extra.push(input.template_id.clone());
9940                            }
9941                        }
9942                    }
9943                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
9944                        for it in items {
9945                            extra.push(it.template.clone());
9946                        }
9947                    }
9948                    _ => {}
9949                }
9950            }
9951            // Worker-known recipe outputs even if the player hasn't learned them yet.
9952            if let Some(worker) = self
9953                .state
9954                .hired_workers
9955                .iter()
9956                .find(|w| w.instance_id == ed.worker_instance_id)
9957            {
9958                for recipe in &worker.known_blueprint_ids {
9959                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
9960                        extra.push(bp.output.clone());
9961                    }
9962                }
9963            }
9964        }
9965        crate::worker_route_editor::route_item_template_candidates(
9966            &self.state.placed_containers,
9967            self.state.character_id,
9968            &self.state.inventory,
9969            &self.state.blueprints,
9970            &self.state.resource_nodes,
9971            &extra,
9972        )
9973    }
9974
9975    fn re_blueprint_ids(&self) -> Vec<String> {
9976        let worker_known: Option<&[String]> = self
9977            .state
9978            .worker_route_editor
9979            .as_ref()
9980            .and_then(|ed| {
9981                self.state
9982                    .hired_workers
9983                    .iter()
9984                    .find(|w| w.instance_id == ed.worker_instance_id)
9985            })
9986            .map(|w| w.known_blueprint_ids.as_slice());
9987        crate::worker_route_editor::worker_craft_blueprint_ids(
9988            &self.state.blueprints,
9989            worker_known,
9990        )
9991    }
9992
9993    fn re_bed_candidates(&self) -> Vec<(String, String)> {
9994        crate::worker_route_editor::owned_lodging_container_ids(
9995            &self.state.placed_containers,
9996            self.state.character_id,
9997        )
9998    }
9999
10000    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10001        self.state
10002            .placed_containers
10003            .iter()
10004            .find(|c| c.id == container_id)
10005            .map(|c| c.contents.clone())
10006            .unwrap_or_default()
10007    }
10008
10009    // ---- sheet navigation (`plans/33`) --------------------------------
10010
10011    fn re_sheet_supports_filter(&self) -> bool {
10012        use crate::worker_route_editor::RouteEditorSheet as S;
10013        self.state
10014            .worker_route_editor
10015            .as_ref()
10016            .is_some_and(|ed| {
10017                matches!(
10018                    ed.sheet,
10019                    S::HarvestPicker { .. }
10020                        | S::SellItem { .. }
10021                        | S::DepositFilter { .. }
10022                        | S::WithdrawItems { .. }
10023                        | S::WithdrawContainers { .. }
10024                        | S::DepositContainers { .. }
10025                        | S::SellNpcs { .. }
10026                        | S::CraftBlueprint { .. }
10027                        | S::BedPicker { .. }
10028                )
10029            })
10030    }
10031
10032    /// Whether a sheet row is shown under the current `/` filter.
10033    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10034        use crate::worker_route_editor::{
10035            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10036            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10037        };
10038        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10039            return false;
10040        };
10041        let filter = &ed.sheet_filter;
10042        match &ed.sheet {
10043            S::HarvestPicker { nodes, .. } => {
10044                harvest_picker_row_matches(nodes, row, filter)
10045            }
10046            S::SellItem { templates, .. } => {
10047                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10048                    return true;
10049                }
10050                let slot = row.saturating_sub(2);
10051                templates.get(slot).is_some_and(|t| {
10052                    let label = self.state.template_display_name(t);
10053                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10054                })
10055            }
10056            S::DepositFilter { rows, .. } => {
10057                if row >= rows.len() {
10058                    return true;
10059                }
10060                rows.get(row).is_some_and(|(t, _)| {
10061                    let label = self.state.template_display_name(t);
10062                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10063                })
10064            }
10065            S::WithdrawItems { lines, .. } => {
10066                if row >= lines.len() {
10067                    return true;
10068                }
10069                lines.get(row).is_some_and(|l| {
10070                    let label = self.state.template_display_name(&l.template);
10071                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10072                })
10073            }
10074            S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10075                .re_container_candidates()
10076                .get(row)
10077                .is_some_and(|c| {
10078                    list_filter_row_matches(
10079                        filter,
10080                        Some(c.dist),
10081                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10082                    )
10083                }),
10084            S::SellNpcs { .. } => {
10085                if row == 0 {
10086                    return true;
10087                }
10088                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10089                    list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10090                })
10091            }
10092            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10093                let label = self
10094                    .state
10095                    .blueprints
10096                    .iter()
10097                    .find(|b| &b.id == id)
10098                    .map(|b| {
10099                        if b.label.is_empty() {
10100                            id.as_str()
10101                        } else {
10102                            b.label.as_str()
10103                        }
10104                    })
10105                    .unwrap_or(id.as_str());
10106                list_filter_row_matches(filter, None, &[id.as_str(), label])
10107            }),
10108            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10109                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10110            }),
10111            _ => true,
10112        }
10113    }
10114
10115    fn re_sheet_clamp_index(&mut self) {
10116        let count = self.re_sheet_row_count();
10117        if count == 0 {
10118            return;
10119        }
10120        let cur = self.re_sheet_index();
10121        if self.re_sheet_row_visible(cur) {
10122            return;
10123        }
10124        for offset in 1..count {
10125            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10126                self.re_sheet_set_index(cur + offset);
10127                return;
10128            }
10129            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10130                self.re_sheet_set_index(cur - offset);
10131                return;
10132            }
10133        }
10134    }
10135
10136    fn re_sheet_set_index(&mut self, index: usize) {
10137        use crate::worker_route_editor::RouteEditorSheet as S;
10138        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10139            return;
10140        };
10141        match &mut ed.sheet {
10142            S::AddMenu { index: slot }
10143            | S::WaypointMenu { index: slot }
10144            | S::HarvestPicker { index: slot, .. }
10145            | S::WithdrawContainers { index: slot }
10146            | S::DepositContainers { index: slot }
10147            | S::SellNpcs { index: slot }
10148            | S::CraftBlueprint { index: slot }
10149            | S::BedPicker { index: slot }
10150            | S::FarmPlotPicker { index: slot, .. }
10151            | S::FarmPlantSeed { index: slot, .. }
10152            | S::WithdrawItems { index: slot, .. }
10153            | S::DepositFilter { index: slot, .. }
10154            | S::SellItem { index: slot, .. } => *slot = index,
10155            _ => {}
10156        }
10157    }
10158
10159    pub fn re_focus_sheet_filter(&mut self) {
10160        if !self.re_sheet_supports_filter() {
10161            return;
10162        }
10163        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10164            ed.sheet_filter_focused = true;
10165        }
10166    }
10167
10168    pub fn re_blur_sheet_filter_keep_text(&mut self) {
10169        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10170            return;
10171        };
10172        if !ed.sheet_filter_focused {
10173            return;
10174        }
10175        ed.sheet_filter_focused = false;
10176        self.re_sheet_clamp_index();
10177    }
10178
10179    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10180        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10181            return false;
10182        };
10183        if ed.sheet_filter_focused {
10184            ed.sheet_filter_focused = false;
10185            self.re_sheet_clamp_index();
10186            return true;
10187        }
10188        if !ed.sheet_filter.is_empty() {
10189            ed.sheet_filter.clear();
10190            self.re_sheet_clamp_index();
10191            return true;
10192        }
10193        false
10194    }
10195
10196    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10197        if ch.is_control() {
10198            return;
10199        }
10200        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10201            return;
10202        };
10203        if !ed.sheet_filter_focused {
10204            return;
10205        }
10206        ed.sheet_filter.push(ch);
10207        self.re_sheet_set_index(0);
10208        self.re_sheet_clamp_index();
10209    }
10210
10211    pub fn re_sheet_filter_backspace(&mut self) {
10212        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10213            return;
10214        };
10215        if !ed.sheet_filter_focused {
10216            return;
10217        }
10218        ed.sheet_filter.pop();
10219        self.re_sheet_set_index(0);
10220        self.re_sheet_clamp_index();
10221    }
10222
10223    /// Row count of the current sheet (for cursor wrapping).
10224    pub fn re_sheet_row_count(&self) -> usize {
10225        use crate::worker_route_editor::{
10226            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10227        };
10228        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10229            return 0;
10230        };
10231        match &ed.sheet {
10232            S::Stops => ed.stops.len(),
10233            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10234            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10235            S::WaypointMapPick => 0,
10236            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10237            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10238                self.re_container_candidates().len()
10239            }
10240            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
10241            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
10242            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
10243            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
10244            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10245            S::WaitEntry { .. } => 1,
10246            S::BedPicker { .. } => self.re_bed_candidates().len(),
10247            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10248            S::FarmPlantSeed { seeds, .. } => seeds.len(),
10249        }
10250    }
10251
10252    /// Current sheet cursor index (0 for sheets without one).
10253    pub fn re_sheet_index(&self) -> usize {
10254        use crate::worker_route_editor::RouteEditorSheet as S;
10255        let Some(ed) = self.state.worker_route_editor.as_ref() else {
10256            return 0;
10257        };
10258        match &ed.sheet {
10259            S::AddMenu { index }
10260            | S::WaypointMenu { index }
10261            | S::HarvestPicker { index, .. }
10262            | S::WithdrawContainers { index }
10263            | S::DepositContainers { index }
10264            | S::SellNpcs { index }
10265            | S::CraftBlueprint { index }
10266            | S::BedPicker { index }
10267            | S::FarmPlotPicker { index, .. }
10268            | S::FarmPlantSeed { index, .. }
10269            | S::WithdrawItems { index, .. }
10270            | S::DepositFilter { index, .. }
10271            | S::SellItem { index, .. } => *index,
10272            _ => 0,
10273        }
10274    }
10275
10276    /// Move the current sheet's cursor, wrapping within its rows.
10277    pub fn re_sheet_move(&mut self, delta: i32) {
10278        let count = self.re_sheet_row_count();
10279        if count == 0 {
10280            return;
10281        }
10282        let cur = self.re_sheet_index();
10283        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10284        self.re_sheet_set_index(next);
10285    }
10286
10287    pub fn re_sheet_page(&mut self, pages: i32) {
10288        let count = self.re_sheet_row_count();
10289        if count == 0 {
10290            return;
10291        }
10292        let cur = self.re_sheet_index();
10293        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10294        self.re_sheet_set_index(next);
10295    }
10296
10297    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
10298    pub fn re_sheet_adjust(&mut self, delta: i32) {
10299        use crate::worker_route_editor::RouteEditorSheet as S;
10300        let index = self.re_sheet_index();
10301        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10302            return;
10303        };
10304        match &mut ed.sheet {
10305            S::WithdrawItems { lines, .. } => {
10306                if let Some(line) = lines.get_mut(index) {
10307                    line.adjust_qty(delta);
10308                }
10309            }
10310            S::WaitEntry { ticks } => {
10311                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10312            }
10313            _ => {}
10314        }
10315    }
10316
10317    pub fn re_sheet_back(&mut self) {
10318        let Some(ed) = self.state.worker_route_editor.as_mut() else {
10319            return;
10320        };
10321        use crate::worker_route_editor::RouteEditorSheet as S;
10322        let was_editing = ed.editing_index.is_some();
10323        let from_top_picker = matches!(
10324            ed.sheet,
10325            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10326        );
10327        ed.sheet_back();
10328        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10329            // Chest retarget commits on pick; Esc here just ends the edit sheet.
10330            self.state
10331                .push_log("Route: left edit sheet — press s to save current stops".to_string());
10332        }
10333    }
10334
10335    /// True when the editor is at its root sheet (Esc should close it).
10336    pub fn re_at_root_sheet(&self) -> bool {
10337        self.state
10338            .worker_route_editor
10339            .as_ref()
10340            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10341    }
10342
10343    pub fn re_open_add_menu(&mut self) {
10344        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10345            ed.open_add_menu();
10346        }
10347    }
10348
10349    pub fn re_open_bed_picker(&mut self) {
10350        let beds = self.re_bed_candidates();
10351        if beds.is_empty() {
10352            self.state
10353                .push_log("Route: place a camp bed first".to_string());
10354            return;
10355        }
10356        let current = self
10357            .state
10358            .worker_route_editor
10359            .as_ref()
10360            .and_then(|ed| ed.lodging_container_id.clone());
10361        let index = current
10362            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10363            .unwrap_or(0);
10364        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10365    }
10366
10367    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10368        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10369            ed.open_sheet(sheet);
10370        }
10371    }
10372
10373    /// Confirm a sheet's stop and log the outcome.
10374    fn re_confirm_stop(
10375        &mut self,
10376        stop: crate::worker_route_editor::WorkerRouteStop,
10377        what: String,
10378    ) {
10379        let appended = self
10380            .state
10381            .worker_route_editor
10382            .as_mut()
10383            .is_some_and(|ed| ed.confirm_stop(stop));
10384        if appended {
10385            self.state.push_log(format!("Route: + {what}"));
10386        } else {
10387            self.state
10388                .push_log(format!("Route: {what} already in route — selected it"));
10389        }
10390    }
10391
10392    fn re_open_withdraw_items(&mut self, container_id: String) {
10393        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10394        let contents = self.re_container_contents(&container_id);
10395        // When editing, keep the stop's item picks even if the player retargets
10396        // to a different (possibly empty) chest — otherwise Done has nothing to
10397        // confirm and the edit appears to "revert".
10398        let existing = self
10399            .state
10400            .worker_route_editor
10401            .as_ref()
10402            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10403            .and_then(|stop| match stop {
10404                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10405                _ => None,
10406            })
10407            .unwrap_or_default();
10408        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10409        // Commit the new chest immediately while editing so Esc-before-Done still
10410        // keeps the retarget (items update only when Done is pressed).
10411        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10412            let _ = ed.retarget_withdraw_container(container_id.clone());
10413        }
10414        self.re_open_sheet(S::WithdrawItems {
10415            container_id,
10416            lines,
10417            index: 0,
10418        });
10419    }
10420
10421    fn re_withdraw_items_activate(&mut self, index: usize) {
10422        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10423        enum Outcome {
10424            Cycled,
10425            Confirmed(String),
10426            Empty,
10427        }
10428        let outcome = {
10429            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10430                return;
10431            };
10432            let S::WithdrawItems {
10433                container_id,
10434                lines,
10435                index: sheet_index,
10436            } = &mut ed.sheet
10437            else {
10438                return;
10439            };
10440            *sheet_index = index;
10441            if index < lines.len() {
10442                lines[index].cycle();
10443                Outcome::Cycled
10444            } else {
10445                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
10446                if items.is_empty() {
10447                    Outcome::Empty
10448                } else {
10449                    let stop = WorkerRouteStop::WithdrawFrom {
10450                        container_id: container_id.clone(),
10451                        items,
10452                    };
10453                    let summary = stop.summary();
10454                    ed.confirm_stop(stop);
10455                    Outcome::Confirmed(summary)
10456                }
10457            }
10458        };
10459        match outcome {
10460            Outcome::Cycled => {}
10461            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
10462            Outcome::Empty => self
10463                .state
10464                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
10465        }
10466    }
10467
10468    fn re_open_deposit_filter(&mut self, container_id: String) {
10469        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10470        // Preserve filter when retargeting the deposit chest while editing.
10471        let existing_filter = self
10472            .state
10473            .worker_route_editor
10474            .as_ref()
10475            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10476            .and_then(|stop| match stop {
10477                WorkerRouteStop::DepositAt { filter, .. } => {
10478                    Some(filter.clone().unwrap_or_default())
10479                }
10480                _ => None,
10481            });
10482        let mut candidates = self.re_template_candidates();
10483        if let Some(ref chosen) = existing_filter {
10484            for t in chosen {
10485                if !candidates.iter().any(|c| c == t) {
10486                    candidates.push(t.clone());
10487                }
10488            }
10489            candidates.sort();
10490            candidates.dedup();
10491        }
10492        let rows: Vec<(String, bool)> = match existing_filter {
10493            Some(chosen) => candidates
10494                .iter()
10495                .map(|t| (t.clone(), chosen.contains(t)))
10496                .collect(),
10497            None => candidates.into_iter().map(|t| (t, false)).collect(),
10498        };
10499        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10500            let _ = ed.retarget_deposit_container(container_id.clone());
10501        }
10502        self.re_open_sheet(S::DepositFilter {
10503            container_id,
10504            rows,
10505            index: 0,
10506        });
10507    }
10508
10509    fn re_deposit_filter_activate(&mut self, index: usize) {
10510        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10511        let mut confirmed: Option<String> = None;
10512        {
10513            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10514                return;
10515            };
10516            let S::DepositFilter {
10517                container_id,
10518                rows,
10519                index: sheet_index,
10520            } = &mut ed.sheet
10521            else {
10522                return;
10523            };
10524            *sheet_index = index;
10525            if index < rows.len() {
10526                rows[index].1 = !rows[index].1;
10527            } else {
10528                // "Done" row.
10529                let chosen: Vec<String> = rows
10530                    .iter()
10531                    .filter(|(_, on)| *on)
10532                    .map(|(t, _)| t.clone())
10533                    .collect();
10534                let filter = if chosen.is_empty() { None } else { Some(chosen) };
10535                let stop = WorkerRouteStop::DepositAt {
10536                    container_id: container_id.clone(),
10537                    filter,
10538                };
10539                confirmed = Some(stop.summary());
10540                ed.confirm_stop(stop);
10541            }
10542        }
10543        if let Some(what) = confirmed {
10544            self.state.push_log(format!("Route: + {what}"));
10545        }
10546    }
10547
10548    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
10549        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10550        let templates = self.re_template_candidates();
10551        if templates.is_empty() {
10552            self.state.push_log(
10553                "Route: no item templates available — learn a craft recipe or place a harvest node first"
10554                    .to_string(),
10555            );
10556            return;
10557        }
10558        // Prefill when editing an existing sell stop.
10559        let (pre_npc, pre_template, pre_all) = self
10560            .state
10561            .worker_route_editor
10562            .as_ref()
10563            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10564            .and_then(|stop| match stop {
10565                WorkerRouteStop::TradeWith {
10566                    npc_id,
10567                    template,
10568                    sell_all,
10569                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
10570                _ => None,
10571            })
10572            .unwrap_or((None, None, true));
10573        let npc_id = npc_id.or(pre_npc);
10574        let mut picked = std::collections::BTreeSet::new();
10575        if let Some(t) = pre_template {
10576            picked.insert(t);
10577        }
10578        self.re_open_sheet(S::SellItem {
10579            npc_id,
10580            templates,
10581            index: if picked.is_empty() {
10582                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
10583            } else {
10584                2
10585            },
10586            sell_all: pre_all,
10587            picked,
10588        });
10589    }
10590
10591    fn re_sell_item_activate(&mut self, index: usize) {
10592        use crate::worker_route_editor::{
10593            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10594        };
10595        let mut batch_log: Option<String> = None;
10596        {
10597            let Some(ed) = self.state.worker_route_editor.as_mut() else {
10598                return;
10599            };
10600            let S::SellItem {
10601                npc_id,
10602                templates,
10603                index: sheet_index,
10604                sell_all,
10605                picked,
10606            } = &mut ed.sheet
10607            else {
10608                return;
10609            };
10610            *sheet_index = index;
10611            if index == ROUTE_PICKER_DONE_ROW {
10612                if picked.is_empty() {
10613                    batch_log = Some(
10614                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
10615                    );
10616                } else {
10617                    let picks: Vec<String> = picked.iter().cloned().collect();
10618                    let npc = npc_id.clone();
10619                    let all = *sell_all;
10620                    let added = ed.confirm_trade_picks(npc, &picks, all);
10621                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
10622                }
10623            } else if index == SELL_ITEM_TOGGLE_ROW {
10624                *sell_all = !*sell_all;
10625            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
10626                if picked.contains(template) {
10627                    picked.remove(template);
10628                } else {
10629                    picked.insert(template.clone());
10630                }
10631            }
10632        }
10633        if let Some(msg) = batch_log {
10634            self.state.push_log(msg);
10635        }
10636    }
10637
10638    /// Enter on the stop list: open the selected stop's sheet, prefilled.
10639    pub fn re_edit_selected_stop(&mut self) {
10640        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10641        let Some(stop) = self
10642            .state
10643            .worker_route_editor
10644            .as_ref()
10645            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
10646        else {
10647            self.state
10648                .push_log("Route: no stop selected — press a to add one".to_string());
10649            return;
10650        };
10651        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10652            ed.begin_edit_selected();
10653        }
10654        match stop {
10655            WorkerRouteStop::Waypoint { .. } => {
10656                self.re_open_sheet(S::WaypointMenu { index: 0 });
10657            }
10658            WorkerRouteStop::HarvestNode { node_id } => {
10659                let nodes = self.state.route_editor_node_candidates();
10660                if nodes.is_empty() {
10661                    self.re_cancel_edit();
10662                    self.state
10663                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
10664                } else {
10665                    let mut picked = std::collections::BTreeSet::new();
10666                    picked.insert(node_id.clone());
10667                    let index = nodes
10668                        .iter()
10669                        .position(|n| n.id == node_id)
10670                        .map(|i| i + 1)
10671                        .unwrap_or(1);
10672                    self.re_open_harvest_picker(index, picked);
10673                }
10674            }
10675            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
10676                // Open the container picker so the player can retarget storage
10677                // (previously jumped straight to items on the same chest).
10678                let containers = self.re_container_candidates();
10679                if containers.is_empty() {
10680                    self.re_cancel_edit();
10681                    self.state
10682                        .push_log("Route: place a storage chest first".to_string());
10683                } else {
10684                    let index = containers
10685                        .iter()
10686                        .position(|c| c.id == container_id)
10687                        .unwrap_or(0);
10688                    self.re_open_sheet(S::WithdrawContainers { index });
10689                }
10690            }
10691            WorkerRouteStop::DepositAt { container_id, .. } => {
10692                let containers = self.re_container_candidates();
10693                if containers.is_empty() {
10694                    self.re_cancel_edit();
10695                    self.state
10696                        .push_log("Route: place a storage chest first".to_string());
10697                } else {
10698                    let index = containers
10699                        .iter()
10700                        .position(|c| c.id == container_id)
10701                        .unwrap_or(0);
10702                    self.re_open_sheet(S::DepositContainers { index });
10703                }
10704            }
10705            WorkerRouteStop::TradeWith { npc_id, .. } => {
10706                let npcs = self.re_npc_candidates();
10707                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
10708                let index = npc_id
10709                    .as_ref()
10710                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
10711                    .unwrap_or(0);
10712                self.re_open_sheet(S::SellNpcs { index });
10713            }
10714            WorkerRouteStop::CraftAt { blueprint, .. } => {
10715                let bps = self.re_blueprint_ids();
10716                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
10717                if bps.is_empty() {
10718                    self.re_cancel_edit();
10719                    self.state
10720                        .push_log("Route: no known blueprints to retarget".to_string());
10721                } else {
10722                    self.re_open_sheet(S::CraftBlueprint { index });
10723                }
10724            }
10725            WorkerRouteStop::CultivatePlot { .. } => {
10726                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
10727            }
10728            WorkerRouteStop::PlantPlot { .. } => {
10729                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
10730            }
10731            WorkerRouteStop::HarvestPlot { .. } => {
10732                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
10733            }
10734            WorkerRouteStop::RestIfNeeded => {
10735                self.re_cancel_edit();
10736                self.state
10737                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
10738            }
10739            WorkerRouteStop::Wait { wait_ticks } => {
10740                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
10741            }
10742        }
10743    }
10744
10745    fn re_cancel_edit(&mut self) {
10746        if let Some(ed) = self.state.worker_route_editor.as_mut() {
10747            ed.editing_index = None;
10748        }
10749    }
10750
10751    // ---- mouse clicks inside the overlay ------------------------------
10752
10753    pub fn worker_route_editor_ui_click(
10754        &mut self,
10755        click: crate::worker_route_editor::RouteEditorClick,
10756    ) {
10757        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
10758        match click {
10759            RouteEditorClick::SelectStop(i) => {
10760                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10761                    ed.sheet = S::Stops;
10762                    ed.select_stop(i);
10763                }
10764            }
10765            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
10766            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
10767            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
10768        }
10769    }
10770
10771    /// Activate (Enter / click) a row of the current sheet.
10772    pub fn re_sheet_row_activate(&mut self, row: usize) {
10773        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10774        let Some(sheet) = self
10775            .state
10776            .worker_route_editor
10777            .as_ref()
10778            .map(|ed| ed.sheet.clone())
10779        else {
10780            return;
10781        };
10782        match sheet {
10783            S::Stops => {
10784                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10785                    ed.select_stop(row);
10786                }
10787            }
10788            S::AddMenu { .. } => match row {
10789                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
10790                1 => {
10791                    if self.re_node_candidates().is_empty() {
10792                        self.state
10793                            .push_log("Route: no harvestable nodes visible in this region".to_string());
10794                    } else {
10795                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
10796                    }
10797                }
10798                2 | 3 => {
10799                    if self.re_container_candidates().is_empty() {
10800                        self.state
10801                            .push_log("Route: place a storage chest first".to_string());
10802                    } else if row == 2 {
10803                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
10804                    } else {
10805                        self.re_open_sheet(S::DepositContainers { index: 0 });
10806                    }
10807                }
10808                4 => {
10809                    if self.re_template_candidates().is_empty() {
10810                        self.state.push_log(
10811                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
10812                                .to_string(),
10813                        );
10814                    } else {
10815                        self.re_open_sheet(S::SellNpcs { index: 0 });
10816                    }
10817                }
10818                5 => {
10819                    if self.re_blueprint_ids().is_empty() {
10820                        self.state.push_log(
10821                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
10822                                .to_string(),
10823                        );
10824                    } else {
10825                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
10826                    }
10827                }
10828                6 => self.re_confirm_stop(
10829                    WorkerRouteStop::RestIfNeeded,
10830                    "rest at lodging (if needed)".into(),
10831                ),
10832                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
10833                8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
10834                9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10835                10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
10836                _ => {}
10837            },
10838            S::WaypointMenu { .. } => match row {
10839                0 => {
10840                    let (x, y, z) = self.state.player_position_with_z();
10841                    let stop = WorkerRouteStop::Waypoint { x, y, z };
10842                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
10843                }
10844                1 => {
10845                    self.re_open_sheet(S::WaypointMapPick);
10846                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
10847                }
10848                _ => {}
10849            },
10850            S::HarvestPicker { .. } => {
10851                let mut log: Option<String> = None;
10852                if let Some(ed) = self.state.worker_route_editor.as_mut() {
10853                    let S::HarvestPicker {
10854                        index: sheet_index,
10855                        picked,
10856                        nodes,
10857                    } = &mut ed.sheet
10858                    else {
10859                        return;
10860                    };
10861                    *sheet_index = row;
10862                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
10863                        if picked.is_empty() {
10864                            log = Some(
10865                                "Route: pick at least one node (Space toggles, Done confirms)"
10866                                    .into(),
10867                            );
10868                        } else {
10869                            let ids: Vec<String> = picked.iter().cloned().collect();
10870                            let added = ed.confirm_harvest_picks(&ids);
10871                            log = Some(format!("Route: + {added} harvest stop(s)"));
10872                        }
10873                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
10874                        if picked.contains(&n.id) {
10875                            picked.remove(&n.id);
10876                        } else {
10877                            picked.insert(n.id.clone());
10878                        }
10879                    }
10880                }
10881                if let Some(msg) = log {
10882                    self.state.push_log(msg);
10883                }
10884            }
10885            S::WithdrawContainers { .. } => {
10886                let containers = self.re_container_candidates();
10887                if let Some(c) = containers.get(row) {
10888                    let id = c.id.clone();
10889                    self.re_open_withdraw_items(id);
10890                }
10891            }
10892            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
10893            S::DepositContainers { .. } => {
10894                let containers = self.re_container_candidates();
10895                if let Some(c) = containers.get(row) {
10896                    let id = c.id.clone();
10897                    self.re_open_deposit_filter(id);
10898                }
10899            }
10900            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
10901            S::SellNpcs { .. } => {
10902                let npcs = self.re_npc_candidates();
10903                let npc_id = if row == 0 {
10904                    None
10905                } else {
10906                    npcs.get(row - 1).map(|n| n.id.clone())
10907                };
10908                if row == 0 || npc_id.is_some() {
10909                    self.re_open_sell_item(npc_id);
10910                }
10911            }
10912            S::SellItem { .. } => self.re_sell_item_activate(row),
10913            S::CraftBlueprint { .. } => {
10914                let bps = self.re_blueprint_ids();
10915                if let Some(bp) = bps.get(row) {
10916                    let stop = WorkerRouteStop::CraftAt {
10917                        device: "hand".into(),
10918                        blueprint: bp.clone(),
10919                        qty: None,
10920                    };
10921                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
10922                }
10923            }
10924            S::WaitEntry { ticks } => {
10925                let stop = WorkerRouteStop::Wait {
10926                    wait_ticks: ticks,
10927                };
10928                self.re_confirm_stop(stop, format!("wait {ticks}t"));
10929            }
10930            S::BedPicker { .. } => {
10931                let beds = self.re_bed_candidates();
10932                if let Some((id, name)) = beds.get(row) {
10933                    let (id, name) = (id.clone(), name.clone());
10934                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
10935                        ed.lodging_container_id = Some(id.clone());
10936                        ed.sheet = S::Stops;
10937                    }
10938                    self.state
10939                        .push_log(format!("Route: rest bed set to {name}"));
10940                }
10941            }
10942            S::FarmPlotPicker { action, .. } => {
10943                let plots = self.re_farm_plot_candidates();
10944                let Some(plot) = plots.get(row).cloned() else {
10945                    return;
10946                };
10947                match action {
10948                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
10949                        let label = plot_route_label(&plot);
10950                        self.re_confirm_stop(
10951                            WorkerRouteStop::CultivatePlot {
10952                                plot_id: plot.plot_id,
10953                            },
10954                            format!("cultivate {label}"),
10955                        );
10956                    }
10957                    crate::worker_route_editor::FarmPlotAction::Harvest => {
10958                        let label = plot_route_label(&plot);
10959                        self.re_confirm_stop(
10960                            WorkerRouteStop::HarvestPlot {
10961                                plot_id: plot.plot_id,
10962                            },
10963                            format!("harvest {label}"),
10964                        );
10965                    }
10966                    crate::worker_route_editor::FarmPlotAction::Plant => {
10967                        let seeds = self.re_farm_seed_candidates();
10968                        if seeds.is_empty() {
10969                            self.state.push_log(
10970                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
10971                            );
10972                            return;
10973                        }
10974                        self.re_open_sheet(S::FarmPlantSeed {
10975                            plot_id: plot.plot_id,
10976                            seeds,
10977                            index: 0,
10978                        });
10979                    }
10980                }
10981            }
10982            S::FarmPlantSeed { plot_id, seeds, .. } => {
10983                if let Some(seed) = seeds.get(row).cloned() {
10984                    self.re_confirm_stop(
10985                        WorkerRouteStop::PlantPlot {
10986                            plot_id,
10987                            seed_template: seed.clone(),
10988                        },
10989                        format!("plant {seed}"),
10990                    );
10991                }
10992            }
10993            S::WaypointMapPick => {}
10994        }
10995    }
10996
10997    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
10998        use crate::worker_route_editor::RouteEditorSheet as S;
10999        if self.re_farm_plot_candidates().is_empty() {
11000            self.state.push_log(
11001                "Route: no farmable plots visible — claim land or get farm access first",
11002            );
11003            return;
11004        }
11005        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11006    }
11007
11008    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11009        self.state
11010            .property_plots
11011            .iter()
11012            .filter(|p| p.is_mine || p.may_farm)
11013            .cloned()
11014            .collect()
11015    }
11016
11017    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
11018    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
11019    /// and known crop seeds so routes can withdraw-then-plant.
11020    fn re_farm_seed_candidates(&self) -> Vec<String> {
11021        let mut set = std::collections::BTreeSet::new();
11022        let looks_like_seed = |id: &str| {
11023            id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11024        };
11025        for (id, _, _) in self.state.farm_seed_entries() {
11026            set.insert(id);
11027        }
11028        for c in &self.state.placed_containers {
11029            let mine = match (self.state.character_id, c.owner_character_id) {
11030                (Some(a), Some(b)) => a == b,
11031                _ => false,
11032            };
11033            if !mine {
11034                continue;
11035            }
11036            for s in &c.contents {
11037                if s.quantity > 0
11038                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11039                {
11040                    set.insert(s.template_id.clone());
11041                }
11042            }
11043        }
11044        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11045            for stop in &ed.stops {
11046                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11047                {
11048                    for it in items {
11049                        if looks_like_seed(&it.template) {
11050                            set.insert(it.template.clone());
11051                        }
11052                    }
11053                }
11054                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11055                    seed_template, ..
11056                } = stop
11057                {
11058                    if !seed_template.is_empty() {
11059                        set.insert(seed_template.clone());
11060                    }
11061                }
11062            }
11063        }
11064        for id in self.state.inventory_hints.keys() {
11065            if looks_like_seed(id) {
11066                set.insert(id.clone());
11067            }
11068        }
11069        // Authored crop seeds (always offer so withdraw-then-plant routes work).
11070        for id in ["potato_seed", "carrot_seed"] {
11071            set.insert(id.to_string());
11072        }
11073        set.into_iter().collect()
11074    }
11075
11076    // ---- map clicks (sheet-scoped accelerators) ------------------------
11077
11078    /// Map click while the route editor is open. When a picker sheet is open
11079    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
11080    /// container, NPC clicks the merchant, node clicks the harvest target);
11081    /// otherwise the click quick-adds the nearest target (context-aware).
11082    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11083        use crate::worker_route_editor as wre;
11084        use wre::RouteEditorSheet as S;
11085        if self.state.worker_route_editor.is_none() {
11086            return;
11087        }
11088        let sheet = self
11089            .state
11090            .worker_route_editor
11091            .as_ref()
11092            .map(|ed| ed.sheet.clone())
11093            .unwrap_or(S::Stops);
11094        match sheet {
11095            S::WaypointMapPick => {
11096                let (_, _, z) = self.state.player_position_with_z();
11097                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11098                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11099                // Stay in map-pick mode when adding (not editing) for fast pathing.
11100                let editing = self
11101                    .state
11102                    .worker_route_editor
11103                    .as_ref()
11104                    .is_some_and(|ed| ed.editing_index.is_some());
11105                if !editing {
11106                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11107                        ed.sheet = S::WaypointMapPick;
11108                    }
11109                }
11110            }
11111            S::HarvestPicker { .. } => {
11112                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11113                    let mut log: Option<String> = None;
11114                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11115                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11116                            return;
11117                        };
11118                        let selected = if picked.contains(&node.id) {
11119                            picked.remove(&node.id);
11120                            false
11121                        } else {
11122                            picked.insert(node.id.clone());
11123                            true
11124                        };
11125                        log = Some(format!(
11126                            "Route: {} {}",
11127                            if selected { "selected" } else { "deselected" },
11128                            node.label
11129                        ));
11130                    }
11131                    if let Some(msg) = log {
11132                        self.state.push_log(msg);
11133                    }
11134                }
11135            }
11136            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11137                // Chest click picks (or switches) the withdraw source.
11138                if let Some(cid) = wre::pick_storage_container_at(
11139                    &self.state.placed_containers,
11140                    self.state.character_id,
11141                    x,
11142                    y,
11143                ) {
11144                    self.re_open_withdraw_items(cid);
11145                }
11146            }
11147            S::DepositContainers { .. } | S::DepositFilter { .. } => {
11148                if let Some(cid) = wre::pick_storage_container_at(
11149                    &self.state.placed_containers,
11150                    self.state.character_id,
11151                    x,
11152                    y,
11153                ) {
11154                    self.re_open_deposit_filter(cid);
11155                }
11156            }
11157            S::SellNpcs { .. } => {
11158                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11159                    self.re_open_sell_item(Some(npc_id));
11160                }
11161            }
11162            S::SellItem { .. } => {
11163                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11164                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
11165                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11166                            *slot = Some(npc_id.clone());
11167                        }
11168                    }
11169                    self.state
11170                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11171                }
11172            }
11173            // Stop list / other sheets: context-aware quick add.
11174            _ => self.worker_route_editor_quick_add_click(x, y),
11175        }
11176    }
11177
11178    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
11179    /// node wins; duplicates select the existing stop; a click on a merchant
11180    /// while a sell stop is selected pins it.
11181    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11182        use crate::worker_route_editor as wre;
11183        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11184            let dx = ax - bx;
11185            let dy = ay - by;
11186            (dx * dx + dy * dy).sqrt()
11187        };
11188
11189        // 1. Retarget the selected stop when the click names its target:
11190        //    sell stop → pin the merchant; withdraw stop → set the source chest.
11191        let selected_stop_kind = self
11192            .state
11193            .worker_route_editor
11194            .as_ref()
11195            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11196            .map(|s| match s {
11197                wre::WorkerRouteStop::TradeWith { .. } => 1,
11198                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11199                _ => 0,
11200            })
11201            .unwrap_or(0);
11202        if selected_stop_kind == 1 {
11203            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11204                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11205                    ed.set_selected_trade_npc(npc_id.clone());
11206                }
11207                self.state
11208                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11209                return;
11210            }
11211        }
11212        if selected_stop_kind == 2 {
11213            if let Some(cid) = wre::pick_storage_container_at(
11214                &self.state.placed_containers,
11215                self.state.character_id,
11216                x,
11217                y,
11218            ) {
11219                let name = self
11220                    .state
11221                    .placed_containers
11222                    .iter()
11223                    .find(|c| c.id == cid)
11224                    .map(|c| c.display_name.clone())
11225                    .unwrap_or_else(|| "container".into());
11226                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11227                    ed.set_selected_withdraw_container(cid.clone());
11228                }
11229                self.state
11230                    .push_log(format!("Route: withdraw source → {name}"));
11231                return;
11232            }
11233        }
11234
11235        // 2. Nearest candidate of any kind wins. Category order is the
11236        //    tie-break for exact overlaps (bed over chest over merchant over node).
11237        enum Target {
11238            Bed(String),
11239            Container(String),
11240            Npc(String, String),
11241            Node(String, String),
11242        }
11243        let mut best: Option<(f32, u8, Target)> = None;
11244        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11245            let better = match best {
11246                None => true,
11247                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11248            };
11249            if better {
11250                *best = Some((d, rank, t));
11251            }
11252        };
11253        if let Some(bed_id) = wre::pick_lodging_container_at(
11254            &self.state.placed_containers,
11255            self.state.character_id,
11256            x,
11257            y,
11258        ) {
11259            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11260                // A bed that's already the rest bed doubles as a storage chest —
11261                // a second click on it means "deposit here", not "set bed again".
11262                let already_bed = self
11263                    .state
11264                    .worker_route_editor
11265                    .as_ref()
11266                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11267                if already_bed {
11268                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11269                } else {
11270                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11271                }
11272            }
11273        }
11274        if let Some(cid) = wre::pick_storage_container_at(
11275            &self.state.placed_containers,
11276            self.state.character_id,
11277            x,
11278            y,
11279        ) {
11280            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11281                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11282            }
11283        }
11284        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11285            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11286                consider(
11287                    dist(x, y, n.x, n.y),
11288                    2,
11289                    Target::Npc(npc_id, label),
11290                    &mut best,
11291                );
11292            }
11293        }
11294        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11295            let d = dist(x, y, node.x, node.y);
11296            consider(
11297                d,
11298                3,
11299                Target::Node(node.id.clone(), node.label.clone()),
11300                &mut best,
11301            );
11302        }
11303
11304        match best.map(|(_, _, t)| t) {
11305            Some(Target::Bed(bed_id)) => {
11306                let name = self
11307                    .state
11308                    .placed_containers
11309                    .iter()
11310                    .find(|c| c.id == bed_id)
11311                    .map(|c| c.display_name.clone())
11312                    .unwrap_or_else(|| "camp bed".into());
11313                if let Some(ed) = self.state.worker_route_editor.as_mut() {
11314                    ed.lodging_container_id = Some(bed_id.clone());
11315                }
11316                self.state
11317                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11318            }
11319            Some(Target::Container(cid)) => {
11320                let name = self
11321                    .state
11322                    .placed_containers
11323                    .iter()
11324                    .find(|c| c.id == cid)
11325                    .map(|c| c.display_name.clone())
11326                    .unwrap_or_else(|| "container".into());
11327                let added = self
11328                    .state
11329                    .worker_route_editor
11330                    .as_mut()
11331                    .is_some_and(|ed| ed.append_deposit_at(&cid));
11332                if added {
11333                    self.state
11334                        .push_log(format!("Route: + deposit at {name} ({cid})"));
11335                } else {
11336                    self.state.push_log(format!(
11337                        "Route: {name} already in route — selected it (d to remove)"
11338                    ));
11339                }
11340            }
11341            Some(Target::Npc(npc_id, label)) => {
11342                // Quick-add sell: first storage template (the full sell flow
11343                // lives in the a → Sell sheet).
11344                let template = self.re_template_candidates().into_iter().next();
11345                let Some(template) = template else {
11346                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11347                    return;
11348                };
11349                let added = self
11350                    .state
11351                    .worker_route_editor
11352                    .as_mut()
11353                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11354                if added {
11355                    self.state
11356                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11357                } else {
11358                    self.state.push_log(format!(
11359                        "Route: {label} already sells {template} — selected it (d to remove)"
11360                    ));
11361                }
11362            }
11363            Some(Target::Node(id, label)) => {
11364                let added = self
11365                    .state
11366                    .worker_route_editor
11367                    .as_mut()
11368                    .is_some_and(|ed| ed.append_harvest_node(&id));
11369                if added {
11370                    self.state
11371                        .push_log(format!("Route: + harvest node {label} ({id})"));
11372                } else {
11373                    self.state.push_log(format!(
11374                        "Route: {label} already in route — selected it (d to remove)"
11375                    ));
11376                }
11377            }
11378            None => {}
11379        }
11380    }
11381
11382    pub fn worker_route_editor_select(&mut self, delta: i32) {
11383        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11384            return;
11385        };
11386        if ed.stops.is_empty() {
11387            return;
11388        }
11389        let n = ed.stops.len() as i32;
11390        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11391        ed.selected_stop_index = next;
11392    }
11393
11394    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11395        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11396            return;
11397        };
11398        if delta < 0 {
11399            ed.move_selected_up();
11400        } else if delta > 0 {
11401            ed.move_selected_down();
11402        }
11403    }
11404
11405    pub fn worker_route_editor_delete_selected(&mut self) {
11406        let removed = self
11407            .state
11408            .worker_route_editor
11409            .as_mut()
11410            .is_some_and(|ed| {
11411                let before = ed.stop_count();
11412                ed.remove_selected_stop();
11413                ed.stop_count() < before
11414            });
11415        if removed {
11416            self.state.push_log("Route: removed selected stop");
11417        }
11418    }
11419
11420    /// Clear all stops. Saving afterwards parks the worker in idle mode
11421    /// instead of leaving it in a broken job loop.
11422    pub fn worker_route_editor_clear_stops(&mut self) {
11423        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11424            return;
11425        };
11426        if ed.stops.is_empty() {
11427            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
11428            return;
11429        }
11430        ed.stops.clear();
11431        ed.selected_stop_index = 0;
11432        self.state
11433            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
11434    }
11435
11436    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
11437        if self.state.pending_worker_job_ack.is_some() {
11438            anyhow::bail!("route save still pending — wait for server ack");
11439        }
11440        let Some(ed) = self.state.worker_route_editor.clone() else {
11441            anyhow::bail!("route editor not open");
11442        };
11443        // Empty route = stand down: park the worker in idle mode rather than
11444        // erroring out or leaving it marching a ghost loop.
11445        let (job_yaml, idle) = if ed.stops.is_empty() {
11446            (ed.build_idle_job_yaml(), true)
11447        } else {
11448            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
11449        };
11450        let worker_id = ed.worker_instance_id.clone();
11451        let route_view = if idle {
11452            None
11453        } else {
11454            Some(ed.to_route_view())
11455        };
11456        let mode = if idle {
11457            flatland_protocol::WorkerModeView::Idle
11458        } else {
11459            flatland_protocol::WorkerModeView::JobLoop
11460        };
11461        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
11462            .state
11463            .hired_workers
11464            .iter()
11465            .find(|w| w.instance_id == worker_id)
11466            .map(|w| {
11467                (
11468                    w.route.clone(),
11469                    w.mode,
11470                    w.step_label.clone(),
11471                    w.last_error.clone(),
11472                )
11473            })
11474            .unwrap_or((
11475                None,
11476                flatland_protocol::WorkerModeView::Idle,
11477                String::new(),
11478                None,
11479            ));
11480        self.seq += 1;
11481        let seq = self.seq;
11482        self.session
11483            .submit_intent(Intent::SetWorkerJob {
11484                entity_id: self.state.entity_id,
11485                worker_instance_id: worker_id.clone(),
11486                job_yaml,
11487                seq,
11488            })
11489            .await?;
11490        self.state.intents_sent += 1;
11491        if let Some(w) = self
11492            .state
11493            .hired_workers
11494            .iter_mut()
11495            .find(|w| w.instance_id == worker_id)
11496        {
11497            w.route = route_view;
11498            w.mode = mode;
11499            w.last_error = None;
11500            if idle {
11501                w.step_label.clear();
11502                w.route_stop_index = None;
11503            }
11504        }
11505        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
11506            seq,
11507            worker_instance_id: worker_id,
11508            worker_label: ed.worker_label.clone(),
11509            idle,
11510            stop_count: ed.stops.len(),
11511            prev_route,
11512            prev_mode,
11513            prev_step_label,
11514            prev_last_error,
11515        });
11516        self.state.push_log(format!(
11517            "Route: saving for {}… (waiting for server)",
11518            ed.worker_label
11519        ));
11520        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
11521        Ok(())
11522    }
11523    pub fn quest_menu_move(&mut self, delta: i32) {
11524        let n = self.state.active_quest_entries().len();
11525        if n == 0 {
11526            return;
11527        }
11528        let idx = self.state.quest_menu_index as i32;
11529        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11530    }
11531
11532    pub fn quest_menu_page(&mut self, pages: i32) {
11533        let n = self.state.active_quest_entries().len();
11534        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
11535    }
11536
11537    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
11538        let Some(offer) = self.state.pending_quest_offer.clone() else {
11539            anyhow::bail!("no quest offer");
11540        };
11541        self.seq += 1;
11542        let seq = self.seq;
11543        self.session
11544            .submit_intent(Intent::AcceptQuest {
11545                entity_id: self.state.entity_id,
11546                quest_id: offer.quest_id,
11547                seq,
11548            })
11549            .await?;
11550        self.state.intents_sent += 1;
11551        Ok(())
11552    }
11553
11554    pub fn quest_offer_decline(&mut self) {
11555        self.state.show_quest_offer = false;
11556        self.state.pending_quest_offer = None;
11557        if !self.state.show_npc_chat
11558            && !self.state.show_shop_menu
11559            && self.state.npc_verb_target.is_some()
11560        {
11561            self.state.show_npc_verb_menu = true;
11562        }
11563    }
11564
11565    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
11566        if !self.state.show_quest_menu {
11567            return Ok(());
11568        }
11569        let active: Vec<_> = self
11570            .state
11571            .active_quest_entries()
11572            .into_iter()
11573            .cloned()
11574            .collect();
11575        let Some(entry) = active.get(self.state.quest_menu_index) else {
11576            return Ok(());
11577        };
11578        if self.state.quest_withdraw_confirm {
11579            if !entry.can_withdraw {
11580                anyhow::bail!("quest cannot be withdrawn");
11581            }
11582            self.seq += 1;
11583            let seq = self.seq;
11584            self.session
11585                .submit_intent(Intent::WithdrawQuest {
11586                    entity_id: self.state.entity_id,
11587                    quest_id: entry.quest_id.clone(),
11588                    seq,
11589                })
11590                .await?;
11591            self.state.intents_sent += 1;
11592            self.state.quest_withdraw_confirm = false;
11593            return Ok(());
11594        }
11595        self.seq += 1;
11596        let seq = self.seq;
11597        self.session
11598            .submit_intent(Intent::TrackQuest {
11599                entity_id: self.state.entity_id,
11600                quest_id: entry.quest_id.clone(),
11601                seq,
11602            })
11603            .await?;
11604        self.state.intents_sent += 1;
11605        Ok(())
11606    }
11607
11608    pub fn quest_request_withdraw(&mut self) {
11609        if self.state.show_quest_menu {
11610            self.state.quest_withdraw_confirm = true;
11611        }
11612    }
11613
11614    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
11615        if !self.state.is_alive() {
11616            anyhow::bail!("you are dead");
11617        }
11618        let Some(catalog) = self.state.shop_catalog.clone() else {
11619            anyhow::bail!("no shop open");
11620        };
11621        self.seq += 1;
11622        let seq = self.seq;
11623        match self.state.shop_tab {
11624            ShopTab::Buy => {
11625                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
11626                    anyhow::bail!("nothing selected");
11627                };
11628                if offer.already_owned {
11629                    anyhow::bail!("already owned");
11630                }
11631                self.session
11632                    .submit_intent(Intent::ShopBuy {
11633                        entity_id: self.state.entity_id,
11634                        npc_id: catalog.npc_id.clone(),
11635                        offer_id: offer.offer_id.clone(),
11636                        quantity: self.state.shop_quantity,
11637                        seq,
11638                    })
11639                    .await?;
11640            }
11641            ShopTab::Sell => {
11642                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
11643                    anyhow::bail!("nothing to sell");
11644                };
11645                if line.quantity == 0 {
11646                    anyhow::bail!("you have no {}", line.label);
11647                }
11648                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
11649                self.session
11650                    .submit_intent(Intent::ShopSell {
11651                        entity_id: self.state.entity_id,
11652                        npc_id: catalog.npc_id.clone(),
11653                        template_id: line.template_id.clone(),
11654                        quantity,
11655                        seq,
11656                    })
11657                    .await?;
11658            }
11659        }
11660        self.state.intents_sent += 1;
11661        Ok(())
11662    }
11663
11664    pub fn craft_menu_move(&mut self, delta: i32) {
11665        let n = self.state.blueprints.len();
11666        if n == 0 {
11667            return;
11668        }
11669        let idx = self.state.craft_menu_index as i32;
11670        let next = (idx + delta).rem_euclid(n as i32);
11671        self.state.craft_menu_index = next as usize;
11672        self.state.clamp_craft_batch_quantity();
11673    }
11674
11675    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
11676        self.state.craft_batch_adjust_quantity(delta);
11677    }
11678
11679    pub fn craft_batch_set_max(&mut self) {
11680        self.state.craft_batch_set_max();
11681    }
11682
11683    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
11684        let Some(blueprint) = self
11685            .state
11686            .blueprints
11687            .get(self.state.craft_menu_index)
11688            .cloned()
11689        else {
11690            anyhow::bail!("no blueprints known");
11691        };
11692        if !self.state.can_craft_blueprint(&blueprint) {
11693            let hint = self
11694                .state
11695                .craft_missing_hint(&blueprint)
11696                .unwrap_or_else(|| "missing materials".into());
11697            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
11698        }
11699        let count = self.state.craft_batch_quantity;
11700        let max = self.state.max_craft_batches(&blueprint);
11701        if max == 0 {
11702            anyhow::bail!("cannot craft {}", blueprint.label);
11703        }
11704        let batches = count.min(max);
11705        self.craft(&blueprint.id, Some(batches)).await?;
11706        self.state.show_craft_menu = false;
11707        Ok(())
11708    }
11709
11710    pub async fn move_by(
11711        &mut self,
11712        forward: f32,
11713        strafe: f32,
11714        vertical: f32,
11715        sprint: bool,
11716    ) -> anyhow::Result<()> {
11717        if !self.state.is_alive() {
11718            anyhow::bail!("you are dead");
11719        }
11720        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
11721            self.last_move_forward = forward;
11722            self.last_move_strafe = strafe;
11723        }
11724        self.seq += 1;
11725        self.session
11726            .submit_intent(Intent::Move {
11727                entity_id: self.state.entity_id,
11728                forward,
11729                strafe,
11730                vertical,
11731                sprint,
11732                seq: self.seq,
11733            })
11734            .await?;
11735        self.state.intents_sent += 1;
11736        Ok(())
11737    }
11738
11739    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
11740        if !self.state.connected {
11741            crate::harvest_trace!("harvest_nearest rejected: not connected");
11742            anyhow::bail!("not connected");
11743        }
11744        if !self.state.is_alive() {
11745            crate::harvest_trace!("harvest_nearest rejected: player dead");
11746            anyhow::bail!("you are dead");
11747        }
11748        if self.state.harvest_in_progress {
11749            if self.state.harvest_state_stale() {
11750                self.state.clear_harvest_state();
11751            } else {
11752                anyhow::bail!("already harvesting");
11753            }
11754        }
11755        let (px, py) = self
11756            .state
11757            .player
11758            .as_ref()
11759            .map(|p| (p.transform.position.x, p.transform.position.y))
11760            .unwrap_or((0.0, 0.0));
11761
11762        let available = self
11763            .state
11764            .resource_nodes
11765            .iter()
11766            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11767            .count();
11768        let node_id = self
11769            .state
11770            .resource_nodes
11771            .iter()
11772            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11773            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
11774            .min_by(|a, b| {
11775                let da = distance(px, py, a.x, a.y);
11776                let db = distance(px, py, b.x, b.y);
11777                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
11778            })
11779            .map(|n| n.id.clone());
11780
11781        let Some(node_id) = node_id else {
11782            let has_loot = self
11783                .state
11784                .ground_drops
11785                .iter()
11786                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11787            if has_loot {
11788                return self.pickup_nearest().await;
11789            }
11790            anyhow::bail!(
11791                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
11792            );
11793        };
11794
11795        self.seq += 1;
11796        let seq = self.seq;
11797        crate::harvest_trace!(
11798            entity_id = self.state.entity_id,
11799            node_id = %node_id,
11800            seq,
11801            px,
11802            py,
11803            available_nodes = available,
11804            "submitting harvest intent"
11805        );
11806        self.session
11807            .submit_intent(Intent::Harvest {
11808                entity_id: self.state.entity_id,
11809                node_id,
11810                seq,
11811            })
11812            .await?;
11813        self.state.intents_sent += 1;
11814        self.state.harvest_in_progress = true;
11815        self.state.harvest_started_at = Some(Instant::now());
11816        self.state.push_log("Harvesting…");
11817        crate::harvest_trace!(
11818            entity_id = self.state.entity_id,
11819            seq,
11820            "harvest intent queued to session"
11821        );
11822        Ok(())
11823    }
11824
11825    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
11826        if !self.state.is_alive() {
11827            anyhow::bail!("you are dead");
11828        }
11829        let blueprint_id = self
11830            .state
11831            .blueprints
11832            .iter()
11833            .find(|bp| self.state.can_craft_blueprint(bp))
11834            .map(|bp| bp.id.clone())
11835            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
11836        self.craft(&blueprint_id, None).await
11837    }
11838
11839    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
11840        if !self.state.is_alive() {
11841            anyhow::bail!("you are dead");
11842        }
11843        self.seq += 1;
11844        self.session
11845            .submit_intent(Intent::Craft {
11846                entity_id: self.state.entity_id,
11847                blueprint_id: blueprint_id.to_string(),
11848                count,
11849                seq: self.seq,
11850            })
11851            .await?;
11852        self.state.intents_sent += 1;
11853        let (label, batches) = self
11854            .state
11855            .blueprints
11856            .iter()
11857            .find(|b| b.id == blueprint_id)
11858            .map(|b| {
11859                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
11860                (b.label.as_str(), n)
11861            })
11862            .unwrap_or((blueprint_id, count.unwrap_or(1)));
11863        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
11864        Ok(())
11865    }
11866
11867    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
11868        if !self.state.is_alive() {
11869            anyhow::bail!("you are dead");
11870        }
11871        let target_id = match self.state.nearest_interact_target() {
11872            Some(id) => id,
11873            None => {
11874                anyhow::bail!("nothing to interact with nearby");
11875            }
11876        };
11877        if self.state.npcs.iter().any(|n| n.id == target_id) {
11878            self.state.show_npc_verb_menu = true;
11879            self.state.npc_verb_target = Some(target_id);
11880            self.state.npc_verb_index = 0;
11881            return Ok(());
11882        }
11883        if self
11884            .state
11885            .hired_workers
11886            .iter()
11887            .any(|w| w.instance_id == target_id)
11888        {
11889            return self.open_workers_menu_for(&target_id).await;
11890        }
11891        if let Ok(peer_id) = target_id.parse::<EntityId>() {
11892            if self
11893                .state
11894                .hired_workers
11895                .iter()
11896                .any(|w| w.entity_id == peer_id)
11897            {
11898                if let Some(w) = self
11899                    .state
11900                    .hired_workers
11901                    .iter()
11902                    .find(|w| w.entity_id == peer_id)
11903                {
11904                    let id = w.instance_id.clone();
11905                    return self.open_workers_menu_for(&id).await;
11906                }
11907            }
11908            if let Some(entity) = self
11909                .state
11910                .entities
11911                .iter()
11912                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
11913            {
11914                self.state
11915                    .player_verbs
11916                    .open_for(peer_id, &entity.label);
11917                return Ok(());
11918            }
11919        }
11920        self.seq += 1;
11921        self.session
11922            .submit_intent(Intent::Interact {
11923                entity_id: self.state.entity_id,
11924                target_id: target_id.clone(),
11925                seq: self.seq,
11926            })
11927            .await?;
11928        self.state.intents_sent += 1;
11929        Ok(())
11930    }
11931
11932    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
11933    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
11934        if !self.state.is_alive() {
11935            anyhow::bail!("you are dead");
11936        }
11937        let (px, py) = self.state.player_position();
11938        let has_loot = self
11939            .state
11940            .ground_drops
11941            .iter()
11942            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11943        if has_loot {
11944            return self.pickup_nearest().await;
11945        }
11946        if self
11947            .state
11948            .placed_containers
11949            .iter()
11950            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
11951        {
11952            return self.pickup_nearest_container().await;
11953        }
11954
11955        if let Some(plot) = self.state.my_plot_under_player().cloned() {
11956            // Double-tap f within 1.2s sells; otherwise farm the plot.
11957            const SELL_WINDOW: Duration = Duration::from_millis(1200);
11958            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
11959                && self
11960                    .state
11961                    .sell_plot_armed_at
11962                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
11963            if sell_armed {
11964                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
11965            }
11966            self.state.sell_plot_confirm = None;
11967            self.state.sell_plot_armed_at = None;
11968
11969            // Prefer farm actions on your own land unless an NPC/player/door/board
11970            // is in interact range (those still win).
11971            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
11972                self.state.npcs.iter().any(|n| n.id == id)
11973                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
11974                    || self.state.doors.iter().any(|d| d.id == id)
11975                    || self.state.interactables.iter().any(|i| {
11976                        i.id == id
11977                            && matches!(
11978                                i.kind.as_str(),
11979                                "quest_board" | "well" | "exit" | "enter"
11980                            )
11981                    })
11982                    || id.parse::<EntityId>().is_ok_and(|eid| {
11983                        self.state
11984                            .entities
11985                            .iter()
11986                            .any(|e| e.id == eid && e.id != self.state.entity_id)
11987                    })
11988            });
11989            if !blocking_interact {
11990                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
11991                match self.harvest_nearest().await {
11992                    Ok(()) => return Ok(()),
11993                    Err(err) => {
11994                        let msg = err.to_string();
11995                        if !(msg.contains("no harvestable")
11996                            || msg.contains("press p")
11997                            || msg.contains("press f")
11998                            || msg.contains("nothing"))
11999                        {
12000                            return Err(err);
12001                        }
12002                    }
12003                }
12004                return Ok(());
12005            }
12006        }
12007        if self.state.nearest_interact_target().is_some() {
12008            return self.interact_nearest().await;
12009        }
12010        // Prefer a clear "move closer" when a board is visible but out of reach,
12011        // instead of silently falling through to harvest.
12012        if let Some((label, dist)) = self.state.nearest_quest_board() {
12013            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12014                anyhow::bail!(
12015                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12016                );
12017            }
12018        }
12019
12020        match self.harvest_nearest().await {
12021            Ok(()) => Ok(()),
12022            Err(err) => {
12023                let msg = err.to_string();
12024                if msg.contains("no harvestable")
12025                    || msg.contains("press p")
12026                    || msg.contains("press f")
12027                {
12028                    anyhow::bail!(
12029                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12030                    );
12031                }
12032                Err(err)
12033            }
12034        }
12035    }
12036
12037    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
12038    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12039        if !self.state.is_alive() {
12040            anyhow::bail!("you are dead");
12041        }
12042        if self.state.claim_mode.is_some() {
12043            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12044        }
12045        let zone = self
12046            .state
12047            .free_property_zone_under_player()
12048            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12049        let zone_id = zone.id.clone();
12050        let label = zone
12051            .label
12052            .as_deref()
12053            .filter(|s| !s.trim().is_empty())
12054            .unwrap_or(zone.id.as_str())
12055            .to_string();
12056        self.enter_claim_mode(&zone_id);
12057        self.state
12058            .push_log(format!(
12059                "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12060            ));
12061        Ok(())
12062    }
12063
12064    /// Enter claim mode over a property zone (default 4×4 or min size).
12065    pub fn enter_claim_mode(&mut self, zone_id: &str) {
12066        let Some(zone) = self
12067            .state
12068            .property_zones
12069            .iter()
12070            .find(|z| z.id == zone_id)
12071            .cloned()
12072        else {
12073            self.state.push_log("unknown property zone");
12074            return;
12075        };
12076        self.state.sell_plot_confirm = None;
12077        self.state.sell_plot_armed_at = None;
12078        let min_area = self
12079            .state
12080            .property_plot_settings
12081            .as_ref()
12082            .map(|s| s.min_plot_area_m2)
12083            .unwrap_or(4.0)
12084            .max(1.0);
12085        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12086        let side = 4u32.max(min_side);
12087        let (px, py) = self.state.player_position();
12088        let anchor_x = px.floor();
12089        let anchor_y = py.floor();
12090        self.state.claim_mode = Some(ClaimModeState {
12091            zone_id: zone.id.clone(),
12092            width_m: side,
12093            height_m: side,
12094            anchor_x,
12095            anchor_y,
12096        });
12097        let label = zone
12098            .label
12099            .as_deref()
12100            .filter(|s| !s.trim().is_empty())
12101            .unwrap_or(zone.id.as_str());
12102        self.state.push_log(format!(
12103            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12104        ));
12105    }
12106
12107    pub fn cancel_claim_mode(&mut self) {
12108        if self.state.claim_mode.take().is_some() {
12109            self.state.push_log("Claim cancelled");
12110        }
12111    }
12112
12113    /// Enter relocate mode for a placed container (1×1 ghost).
12114    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12115        if !self.state.is_alive() {
12116            anyhow::bail!("you are dead");
12117        }
12118        if self.state.relocate_mode.is_some() {
12119            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12120        }
12121        if self.state.claim_mode.is_some() {
12122            anyhow::bail!("finish or cancel claim mode first");
12123        }
12124        let chest = self
12125            .state
12126            .placed_containers
12127            .iter()
12128            .find(|c| c.id == container_id)
12129            .cloned()
12130            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12131        let (px, py) = self.state.player_position();
12132        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12133            anyhow::bail!("too far from {}", chest.display_name);
12134        }
12135        if chest.locked && !chest.accessible {
12136            anyhow::bail!(
12137                "need the matching key for {} before moving it",
12138                chest.display_name
12139            );
12140        }
12141        let label = if chest.display_name.trim().is_empty() {
12142            chest.template_id.clone()
12143        } else {
12144            chest.display_name.clone()
12145        };
12146        self.state.relocate_mode = Some(RelocateModeState {
12147            container_id: chest.id.clone(),
12148            label: label.clone(),
12149            cursor_x: chest.x.floor() + 0.5,
12150            cursor_y: chest.y.floor() + 0.5,
12151        });
12152        self.state.push_log(format!(
12153            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12154        ));
12155        Ok(())
12156    }
12157
12158    /// World `Shift+m`: relocate nearest owned/accessible placed container.
12159    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12160        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12161            anyhow::bail!("no chest nearby to relocate");
12162        };
12163        if chest.locked && !chest.accessible {
12164            anyhow::bail!(
12165                "need the matching key for {} before moving it",
12166                chest.display_name
12167            );
12168        }
12169        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
12170        // Server enforces ownership; client starts on nearest accessible chest in range.
12171        self.begin_relocate_container(&chest.id)
12172    }
12173
12174    pub fn cancel_relocate_mode(&mut self) {
12175        if self.state.relocate_mode.take().is_some() {
12176            self.state.push_log("Relocate cancelled");
12177        }
12178    }
12179
12180    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12181        let Some(mode) = self.state.relocate_mode.as_mut() else {
12182            return;
12183        };
12184        let max_x = self.state.world_width_m.max(1.0);
12185        let max_y = self.state.world_height_m.max(1.0);
12186        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12187        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12188        mode.cursor_x = nx.floor() + 0.5;
12189        mode.cursor_y = ny.floor() + 0.5;
12190    }
12191
12192    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12193        let Some(mode) = self.state.relocate_mode.as_mut() else {
12194            return;
12195        };
12196        let max_x = self.state.world_width_m.max(1.0);
12197        let max_y = self.state.world_height_m.max(1.0);
12198        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12199        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12200    }
12201
12202    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12203        if !self.state.is_alive() {
12204            anyhow::bail!("you are dead");
12205        }
12206        let Some(mode) = self.state.relocate_mode.clone() else {
12207            anyhow::bail!("not relocating");
12208        };
12209        let (px, py) = self.state.player_position();
12210        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12211        if dist > 8.0 {
12212            anyhow::bail!("destination too far (max 8 m)");
12213        }
12214        self.seq += 1;
12215        self.session
12216            .submit_intent(Intent::MovePlacedContainer {
12217                entity_id: self.state.entity_id,
12218                container_id: mode.container_id.clone(),
12219                x: mode.cursor_x,
12220                y: mode.cursor_y,
12221                seq: self.seq,
12222            })
12223            .await?;
12224        self.state.intents_sent += 1;
12225        self.state.relocate_mode = None;
12226        self.state
12227            .push_log(format!("Moving {}…", mode.label));
12228        Ok(())
12229    }
12230
12231    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12232        let Some(mode) = self.state.claim_mode.as_mut() else {
12233            return;
12234        };
12235        mode.width_m = w.max(1);
12236        mode.height_m = h.max(1);
12237    }
12238
12239    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12240        let Some(mode) = self.state.claim_mode.as_mut() else {
12241            return;
12242        };
12243        let w = (mode.width_m as i32 + dw).max(1) as u32;
12244        let h = (mode.height_m as i32 + dh).max(1) as u32;
12245        mode.width_m = w;
12246        mode.height_m = h;
12247    }
12248
12249    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
12250    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12251        let Some(mode) = self.state.claim_mode.as_mut() else {
12252            return;
12253        };
12254        let max_x = self.state.world_width_m.max(1.0);
12255        let max_y = self.state.world_height_m.max(1.0);
12256        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12257        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12258        mode.anchor_x = nx.floor();
12259        mode.anchor_y = ny.floor();
12260    }
12261
12262    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12263        if !self.state.is_alive() {
12264            anyhow::bail!("you are dead");
12265        }
12266        let Some(mode) = self.state.claim_mode.clone() else {
12267            anyhow::bail!("not in claim mode");
12268        };
12269        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12270            self.state.claim_quote()
12271        else {
12272            anyhow::bail!("cannot quote claim");
12273        };
12274        if !valid {
12275            anyhow::bail!(reason);
12276        }
12277        if !can_afford {
12278            anyhow::bail!(
12279                "not enough copper (need {})",
12280                crate::currency::format_copper(purchase)
12281            );
12282        }
12283        let (x0, y0, x1, y1) = self
12284            .state
12285            .claim_footprint_rect()
12286            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12287        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12288        self.seq += 1;
12289        self.session
12290            .submit_intent(Intent::BuyPlot {
12291                entity_id: self.state.entity_id,
12292                zone_id: mode.zone_id,
12293                x0,
12294                y0,
12295                x1,
12296                y1,
12297                seq: self.seq,
12298            })
12299            .await?;
12300        self.state.intents_sent += 1;
12301        self.state.claim_mode = None;
12302        self.state
12303            .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12304        Ok(())
12305    }
12306
12307    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12308        if !self.state.is_alive() {
12309            anyhow::bail!("you are dead");
12310        }
12311        let zone_id = self
12312            .state
12313            .claim_mode
12314            .as_ref()
12315            .map(|m| m.zone_id.clone())
12316            .or_else(|| {
12317                self.state
12318                    .free_property_zone_under_player()
12319                    .map(|z| z.id.clone())
12320            })
12321            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12322        self.seq += 1;
12323        self.session
12324            .submit_intent(Intent::BuyPlotAllFree {
12325                entity_id: self.state.entity_id,
12326                zone_id,
12327                seq: self.seq,
12328            })
12329            .await?;
12330        self.state.intents_sent += 1;
12331        self.state.claim_mode = None;
12332        self.state.push_log("Claiming largest free plot…");
12333        Ok(())
12334    }
12335
12336    pub async fn confirm_sell_plot_to_crown(
12337        &mut self,
12338        plot_id: uuid::Uuid,
12339    ) -> anyhow::Result<()> {
12340        if !self.state.is_alive() {
12341            anyhow::bail!("you are dead");
12342        }
12343        self.seq += 1;
12344        self.session
12345            .submit_intent(Intent::SellPlotToCrown {
12346                entity_id: self.state.entity_id,
12347                plot_id,
12348                seq: self.seq,
12349            })
12350            .await?;
12351        self.state.intents_sent += 1;
12352        self.state.sell_plot_confirm = None;
12353        self.state.sell_plot_armed_at = None;
12354        self.state.push_log("Selling plot to the crown…");
12355        Ok(())
12356    }
12357
12358    pub async fn set_plot_farm_public(
12359        &mut self,
12360        plot_id: uuid::Uuid,
12361        public: bool,
12362        public_tax_discount_bps: u32,
12363    ) -> anyhow::Result<()> {
12364        self.seq += 1;
12365        self.session
12366            .submit_intent(Intent::SetPlotFarmPublic {
12367                entity_id: self.state.entity_id,
12368                plot_id,
12369                public,
12370                public_tax_discount_bps,
12371                seq: self.seq,
12372            })
12373            .await?;
12374        self.state.intents_sent += 1;
12375        Ok(())
12376    }
12377
12378    pub async fn plot_farm_allow_upsert(
12379        &mut self,
12380        plot_id: uuid::Uuid,
12381        character_id: Option<uuid::Uuid>,
12382        character_name: String,
12383        tax_discount_bps: u32,
12384    ) -> anyhow::Result<()> {
12385        self.seq += 1;
12386        self.session
12387            .submit_intent(Intent::PlotFarmAllowUpsert {
12388                entity_id: self.state.entity_id,
12389                plot_id,
12390                character_id,
12391                character_name,
12392                tax_discount_bps,
12393                seq: self.seq,
12394            })
12395            .await?;
12396        self.state.intents_sent += 1;
12397        Ok(())
12398    }
12399
12400    pub async fn plot_farm_allow_remove(
12401        &mut self,
12402        plot_id: uuid::Uuid,
12403        character_id: uuid::Uuid,
12404    ) -> anyhow::Result<()> {
12405        self.seq += 1;
12406        self.session
12407            .submit_intent(Intent::PlotFarmAllowRemove {
12408                entity_id: self.state.entity_id,
12409                plot_id,
12410                character_id,
12411                seq: self.seq,
12412            })
12413            .await?;
12414        self.state.intents_sent += 1;
12415        Ok(())
12416    }
12417
12418    pub fn open_farm_access_panel(&mut self) {
12419        let Some(plot) = self.state.my_plot_under_player() else {
12420            self.state
12421                .push_log("Stand on your deed plot to manage farm access");
12422            return;
12423        };
12424        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
12425        self.state.farm_access_index = 0;
12426        self.state.show_farm_access = true;
12427    }
12428
12429    pub fn close_farm_access_panel(&mut self) {
12430        self.state.show_farm_access = false;
12431        self.state.farm_access_name_draft.clear();
12432        self.state.farm_access_index = 0;
12433    }
12434
12435    pub fn farm_access_move(&mut self, delta: i32) {
12436        let n = self.farm_access_row_count().max(1);
12437        let idx = self.state.farm_access_index as i32 + delta;
12438        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
12439    }
12440
12441    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
12442        let Some(plot) = self.state.my_plot_under_player() else {
12443            return vec![FarmAccessRow::PublicToggle];
12444        };
12445        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
12446        for g in &plot.farm_allow {
12447            rows.push(FarmAccessRow::AllowRemove {
12448                character_id: g.character_id,
12449                label: if g.character_label.trim().is_empty() {
12450                    g.character_id.to_string()[..8].to_string()
12451                } else {
12452                    g.character_label.clone()
12453                },
12454                tax_discount_bps: g.tax_discount_bps,
12455            });
12456        }
12457        for e in &self.state.entities {
12458            if e.id == self.state.entity_id || e.label.trim().is_empty() {
12459                continue;
12460            }
12461            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
12462                continue;
12463            }
12464            if self
12465                .state
12466                .npcs
12467                .iter()
12468                .any(|n| n.id == e.label || n.label == e.label)
12469            {
12470                continue;
12471            }
12472            if plot
12473                .farm_allow
12474                .iter()
12475                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
12476            {
12477                continue;
12478            }
12479            rows.push(FarmAccessRow::NearbyAdd {
12480                name: e.label.clone(),
12481            });
12482        }
12483        rows
12484    }
12485
12486    pub fn farm_access_row_count(&self) -> usize {
12487        self.farm_access_rows().len().max(1)
12488    }
12489
12490    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
12491        let Some(plot) = self.state.my_plot_under_player().cloned() else {
12492            self.close_farm_access_panel();
12493            return Ok(());
12494        };
12495        let rows = self.farm_access_rows();
12496        let Some(row) = rows.get(self.state.farm_access_index) else {
12497            return Ok(());
12498        };
12499        match row {
12500            FarmAccessRow::PublicToggle => {
12501                self.set_plot_farm_public(
12502                    plot.plot_id,
12503                    !plot.farm_public,
12504                    plot.public_tax_discount_bps,
12505                )
12506                .await
12507            }
12508            FarmAccessRow::PublicDiscount => Ok(()),
12509            FarmAccessRow::AllowRemove { character_id, .. } => {
12510                self.plot_farm_allow_remove(plot.plot_id, *character_id)
12511                    .await
12512            }
12513            FarmAccessRow::NearbyAdd { name } => {
12514                let disc = self
12515                    .state
12516                    .farm_access_discount_bps
12517                    .max(plot.public_tax_discount_bps);
12518                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
12519                    .await
12520            }
12521        }
12522    }
12523
12524    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
12525        let Some(plot) = self.state.my_plot_under_player().cloned() else {
12526            return Ok(());
12527        };
12528        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
12529        self.state.farm_access_discount_bps = next;
12530        self.state.farm_access_index = 1;
12531        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
12532            .await
12533    }
12534
12535    /// Till the cell under you on a farmable plot (`c`).
12536    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
12537        if self.state.farmable_plot_under_player().is_none() {
12538            anyhow::bail!("stand on a farmable plot to cultivate");
12539        }
12540        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
12541            let (px, py) = self.state.player_position();
12542            if self
12543                .state
12544                .terrain_at(px, py)
12545                .is_some_and(|k| k == TerrainKindView::Tilled)
12546            {
12547                anyhow::bail!("already tilled — stand on bare soil and press c");
12548            }
12549            anyhow::bail!("cannot till this cell — move onto soil on your plot");
12550        };
12551        self.cultivate_at(tx, ty).await
12552    }
12553
12554    /// Plant seeds on empty tilled soil under you (`p`).
12555    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
12556        if self.state.farmable_plot_under_player().is_none() {
12557            anyhow::bail!("stand on a farmable plot to plant");
12558        }
12559        if !self.state.underfoot_free_tilled_plant_slot() {
12560            anyhow::bail!("stand on empty tilled soil and press p");
12561        }
12562        let seeds = self.state.farm_seed_entries();
12563        if seeds.is_empty() {
12564            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
12565        }
12566        if seeds.len() == 1 {
12567            return self.plant_seeds(seeds[0].0.clone(), 1).await;
12568        }
12569        self.open_plant_menu();
12570        Ok(())
12571    }
12572
12573    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
12574        if !self.state.is_alive() {
12575            anyhow::bail!("you are dead");
12576        }
12577        self.seq += 1;
12578        self.session
12579            .submit_intent(Intent::Cultivate {
12580                entity_id: self.state.entity_id,
12581                x,
12582                y,
12583                seq: self.seq,
12584            })
12585            .await?;
12586        self.state.intents_sent += 1;
12587        Ok(())
12588    }
12589
12590    pub async fn plant_seeds(
12591        &mut self,
12592        seed_template_id: String,
12593        quantity: u32,
12594    ) -> anyhow::Result<()> {
12595        if !self.state.is_alive() {
12596            anyhow::bail!("you are dead");
12597        }
12598        self.seq += 1;
12599        self.session
12600            .submit_intent(Intent::PlantSeeds {
12601                entity_id: self.state.entity_id,
12602                seed_template_id: seed_template_id.clone(),
12603                quantity,
12604                seq: self.seq,
12605            })
12606            .await?;
12607        self.state.intents_sent += 1;
12608        self.state
12609            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
12610        Ok(())
12611    }
12612
12613    pub fn open_plant_menu(&mut self) {
12614        if self.state.farm_seed_entries().is_empty() {
12615            self.state.push_log("No seeds in inventory to plant");
12616            return;
12617        }
12618        self.state.show_plant_menu = true;
12619        self.state.plant_menu_index = 0;
12620        self.state.plant_quantity = 1;
12621        self.state.clamp_plant_menu();
12622    }
12623
12624    pub fn close_plant_menu(&mut self) {
12625        self.state.show_plant_menu = false;
12626    }
12627
12628    pub fn plant_menu_move(&mut self, delta: i32) {
12629        let n = self.state.farm_seed_entries().len();
12630        if n == 0 {
12631            return;
12632        }
12633        let idx = self.state.plant_menu_index as i32 + delta;
12634        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
12635        self.state.clamp_plant_menu();
12636    }
12637
12638    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
12639        let next = self.state.plant_quantity as i32 + delta;
12640        self.state.plant_quantity = next.max(1) as u32;
12641        self.state.clamp_plant_menu();
12642    }
12643
12644    pub fn plant_menu_set_quantity_max(&mut self) {
12645        if let Some((_, max, _)) = self.state.plant_menu_selection() {
12646            self.state.plant_quantity = max;
12647        }
12648        self.state.clamp_plant_menu();
12649    }
12650
12651    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
12652        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
12653            self.close_plant_menu();
12654            anyhow::bail!("no seeds to plant");
12655        };
12656        self.close_plant_menu();
12657        self.plant_seeds(seed, qty).await?;
12658        self.state.push_log(format!("Planted {qty}× {label}"));
12659        Ok(())
12660    }
12661
12662    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
12663    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
12664    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
12665        if !self.state.is_alive() {
12666            anyhow::bail!("you are dead");
12667        }
12668        let binding = self
12669            .state
12670            .hotbar_ability(slot)
12671            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
12672            .to_string();
12673        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
12674            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
12675            if qty == 0 {
12676                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
12677            }
12678            return self.use_item(template_id).await;
12679        }
12680        let ability_id = binding;
12681        let target = if ability_id == "heal_touch" {
12682            Some(
12683                self.state
12684                    .target_for_slot(2)
12685                    .unwrap_or(self.state.entity_id),
12686            )
12687        } else {
12688            self.state
12689                .target_for_slot(1)
12690                .or_else(|| self.state.target_for_slot(2))
12691        };
12692        let Some(target_id) = target else {
12693            anyhow::bail!("no target — Tab to select, then press the hotbar key");
12694        };
12695        self.cast_ability(&ability_id, Some(target_id)).await
12696    }
12697
12698    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
12699    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
12700    pub async fn set_hotbar_slot(
12701        &mut self,
12702        slot: u8,
12703        ability_id: Option<&str>,
12704    ) -> anyhow::Result<()> {
12705        if !self.state.is_alive() {
12706            anyhow::bail!("you are dead");
12707        }
12708        if !(1..=9).contains(&slot) {
12709            anyhow::bail!("hotbar slot must be 1–9");
12710        }
12711        let ability_id = ability_id
12712            .map(str::trim)
12713            .filter(|id| !id.is_empty())
12714            .map(str::to_string);
12715        self.seq += 1;
12716        self.session
12717            .submit_intent(Intent::SetHotbarSlot {
12718                entity_id: self.state.entity_id,
12719                slot,
12720                ability_id: ability_id.clone(),
12721                seq: self.seq,
12722            })
12723            .await?;
12724        self.state.intents_sent += 1;
12725        let idx = (slot - 1) as usize;
12726        if self.state.hotbar.len() < 9 {
12727            self.state.hotbar.resize(9, None);
12728        }
12729        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
12730            *slot_mut = ability_id.clone();
12731        }
12732        match ability_id {
12733            Some(id) => {
12734                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
12735                    format!("use {tid}")
12736                } else {
12737                    id
12738                };
12739                self.state.push_log(format!("Hotbar {slot} → {label}"))
12740            }
12741            None => self.state.push_log(format!("Hotbar {slot} cleared")),
12742        }
12743        Ok(())
12744    }
12745
12746    pub fn npc_verb_options(&self) -> Vec<&'static str> {
12747        self.state.npc_verb_options()
12748    }
12749
12750    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
12751        let Some(npc_id) = self.state.npc_verb_target.clone() else {
12752            return Ok(());
12753        };
12754        let options = self.npc_verb_options();
12755        let choice = options
12756            .get(self.state.npc_verb_index)
12757            .copied()
12758            .unwrap_or("Talk");
12759        self.seq += 1;
12760        match choice {
12761            "Trade" | "Bank" | "Storage" | "Market" => {
12762                self.session
12763                    .submit_intent(Intent::Interact {
12764                        entity_id: self.state.entity_id,
12765                        target_id: npc_id,
12766                        seq: self.seq,
12767                    })
12768                    .await?;
12769            }
12770            _ => {
12771                self.session
12772                    .submit_intent(Intent::NpcTalkOpen {
12773                        entity_id: self.state.entity_id,
12774                        npc_id,
12775                        seq: self.seq,
12776                    })
12777                    .await?;
12778            }
12779        }
12780        self.state.intents_sent += 1;
12781        Ok(())
12782    }
12783
12784    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
12785        let Some(chat) = self.state.npc_chat.clone() else {
12786            return Ok(());
12787        };
12788        let message = chat.input.trim().to_string();
12789        if message.is_empty() || chat.pending {
12790            return Ok(());
12791        }
12792        if let Some(c) = self.state.npc_chat.as_mut() {
12793            c.lines.push(format!("You: {message}"));
12794            c.input.clear();
12795            c.pending = true;
12796        }
12797        self.seq += 1;
12798        self.session
12799            .submit_intent(Intent::NpcTalkSay {
12800                entity_id: self.state.entity_id,
12801                npc_id: chat.npc_id,
12802                message,
12803                seq: self.seq,
12804            })
12805            .await?;
12806        self.state.intents_sent += 1;
12807        Ok(())
12808    }
12809
12810    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
12811        let return_to_verbs = self.state.npc_verb_target.is_some();
12812        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
12813            self.state.show_npc_chat = false;
12814            if return_to_verbs {
12815                self.state.show_npc_verb_menu = true;
12816            }
12817            return Ok(());
12818        };
12819        self.seq += 1;
12820        self.session
12821            .submit_intent(Intent::NpcTalkClose {
12822                entity_id: self.state.entity_id,
12823                npc_id,
12824                seq: self.seq,
12825            })
12826            .await?;
12827        self.state.intents_sent += 1;
12828        self.state.show_npc_chat = false;
12829        self.state.npc_chat = None;
12830        if return_to_verbs {
12831            self.state.show_npc_verb_menu = true;
12832        }
12833        Ok(())
12834    }
12835
12836    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
12837    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
12838        if self.state.show_quest_offer
12839            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
12840        {
12841            self.quest_offer_decline();
12842            return Ok(());
12843        }
12844        if self.state.show_npc_chat {
12845            return self.npc_talk_close().await;
12846        }
12847        if self.state.show_shop_menu {
12848            return self.back_from_shop_menu().await;
12849        }
12850        if self.state.bank_panel.is_some() {
12851            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
12852                self.bank_transfer_back();
12853                return Ok(());
12854            }
12855            return self.close_bank_panel().await;
12856        }
12857        if self.state.storage_panel.is_some() {
12858            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
12859                self.storage_ui_back();
12860                return Ok(());
12861            }
12862            return self.close_storage_panel().await;
12863        }
12864        if self.state.market_panel.is_some() {
12865            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
12866                self.market_ui_back();
12867                return Ok(());
12868            }
12869            if self.state.market_buy_confirm.is_some() {
12870                self.state.market_buy_confirm = None;
12871                return Ok(());
12872            }
12873            return self.close_market_panel().await;
12874        }
12875        if self.state.show_npc_verb_menu {
12876            self.state.show_npc_verb_menu = false;
12877            self.state.npc_verb_target = None;
12878        }
12879        Ok(())
12880    }
12881
12882    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
12883        self.seq += 1;
12884        self.session
12885            .submit_intent(Intent::TestDamage {
12886                entity_id: self.state.entity_id,
12887                amount,
12888                seq: self.seq,
12889            })
12890            .await?;
12891        self.state.intents_sent += 1;
12892        Ok(())
12893    }
12894
12895    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
12896        self.cycle_combat_target_slot(1, reverse).await
12897    }
12898
12899    pub async fn cycle_combat_target_slot(
12900        &mut self,
12901        slot_index: u8,
12902        reverse: bool,
12903    ) -> anyhow::Result<()> {
12904        if !self.state.is_alive() {
12905            anyhow::bail!("you are dead");
12906        }
12907        let candidates = self.state.candidates_for_slot(slot_index);
12908        if candidates.is_empty() {
12909            anyhow::bail!("no targets nearby");
12910        }
12911        let current = self.state.target_for_slot(slot_index);
12912        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
12913        let next_idx = match idx {
12914            None => 0,
12915            Some(i) if reverse => {
12916                if i == 0 {
12917                    candidates.len() - 1
12918                } else {
12919                    i - 1
12920                }
12921            }
12922            Some(i) => (i + 1) % candidates.len(),
12923        };
12924        if idx == Some(next_idx) && candidates.len() == 1 {
12925            self.clear_combat_target_slot(slot_index).await?;
12926            return Ok(());
12927        }
12928        let (target_id, label) = candidates[next_idx].clone();
12929        self.set_combat_target_slot(slot_index, target_id, &label)
12930            .await
12931    }
12932
12933    pub async fn set_combat_target_slot(
12934        &mut self,
12935        slot_index: u8,
12936        target_id: EntityId,
12937        label: &str,
12938    ) -> anyhow::Result<()> {
12939        if !self.state.is_alive() {
12940            anyhow::bail!("you are dead");
12941        }
12942        self.seq += 1;
12943        self.session
12944            .submit_intent(Intent::SetTargetSlot {
12945                entity_id: self.state.entity_id,
12946                slot_index,
12947                target_id,
12948                seq: self.seq,
12949            })
12950            .await?;
12951        self.state.intents_sent += 1;
12952        if slot_index == 1 {
12953            self.state.combat_target = Some(target_id);
12954            self.state.combat_target_label = Some(label.to_string());
12955        }
12956        self.state
12957            .push_log(format!("Slot {slot_index} target: {label}"));
12958        Ok(())
12959    }
12960
12961    pub async fn set_combat_target(
12962        &mut self,
12963        target_id: EntityId,
12964        label: &str,
12965    ) -> anyhow::Result<()> {
12966        self.set_combat_target_slot(1, target_id, label).await
12967    }
12968
12969    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
12970        if slot_index == 1 && self.state.combat_target.is_none() {
12971            return Ok(());
12972        }
12973        self.seq += 1;
12974        self.session
12975            .submit_intent(Intent::ClearTargetSlot {
12976                entity_id: self.state.entity_id,
12977                slot_index,
12978                seq: self.seq,
12979            })
12980            .await?;
12981        if slot_index == 1 {
12982            self.state.combat_target = None;
12983            self.state.combat_target_label = None;
12984        }
12985        self.state.intents_sent += 1;
12986        self.state
12987            .push_log(format!("Slot {slot_index} target cleared"));
12988        Ok(())
12989    }
12990
12991    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
12992        self.clear_combat_target_slot(1).await
12993    }
12994
12995    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
12996        if !self.state.is_alive() {
12997            anyhow::bail!("you are dead");
12998        }
12999        self.seq += 1;
13000        self.session
13001            .submit_intent(Intent::AdvanceRotation {
13002                entity_id: self.state.entity_id,
13003                slot_index,
13004                seq: self.seq,
13005            })
13006            .await?;
13007        self.state.intents_sent += 1;
13008        Ok(())
13009    }
13010
13011    pub async fn assign_slot_preset(
13012        &mut self,
13013        slot_index: u8,
13014        preset_id: &str,
13015    ) -> anyhow::Result<()> {
13016        if !self.state.is_alive() {
13017            anyhow::bail!("you are dead");
13018        }
13019        self.seq += 1;
13020        self.session
13021            .submit_intent(Intent::AssignSlotPreset {
13022                entity_id: self.state.entity_id,
13023                slot_index,
13024                preset_id: preset_id.to_string(),
13025                seq: self.seq,
13026            })
13027            .await?;
13028        self.state.intents_sent += 1;
13029        if let Some(slot) = self
13030            .state
13031            .combat_slots
13032            .iter_mut()
13033            .find(|s| s.slot_index == slot_index)
13034        {
13035            slot.preset_id = Some(preset_id.to_string());
13036            if let Some(preset) = self
13037                .state
13038                .rotation_presets
13039                .iter()
13040                .find(|p| p.id == preset_id)
13041            {
13042                slot.preset_label = Some(preset.label.clone());
13043                slot.rotation = preset.abilities.clone();
13044                slot.rotation_index = 0;
13045            }
13046        }
13047        self.state
13048            .push_log(format!("T{slot_index} loadout → {preset_id}"));
13049        Ok(())
13050    }
13051
13052    pub async fn cast_ability(
13053        &mut self,
13054        ability_id: &str,
13055        target_id: Option<EntityId>,
13056    ) -> anyhow::Result<()> {
13057        if !self.state.is_alive() {
13058            anyhow::bail!("you are dead");
13059        }
13060        let target_id = target_id
13061            .or_else(|| self.state.target_for_slot(2))
13062            .or_else(|| self.state.target_for_slot(1))
13063            .unwrap_or(self.state.entity_id);
13064        self.seq += 1;
13065        self.session
13066            .submit_intent(Intent::Cast {
13067                entity_id: self.state.entity_id,
13068                ability_id: ability_id.to_string(),
13069                target_id,
13070                seq: self.seq,
13071            })
13072            .await?;
13073        self.state.intents_sent += 1;
13074        self.state
13075            .push_log(format!("Cast {ability_id} → {target_id}"));
13076        Ok(())
13077    }
13078
13079    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13080        self.seq += 1;
13081        self.session
13082            .submit_intent(Intent::UpsertRotationPreset {
13083                entity_id: self.state.entity_id,
13084                preset: preset.clone(),
13085                seq: self.seq,
13086            })
13087            .await?;
13088        self.state.intents_sent += 1;
13089        if let Some(existing) = self
13090            .state
13091            .rotation_presets
13092            .iter_mut()
13093            .find(|p| p.id == preset.id)
13094        {
13095            *existing = preset.clone();
13096        } else {
13097            self.state.rotation_presets.push(preset.clone());
13098        }
13099        for slot in &mut self.state.combat_slots {
13100            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
13101                slot.preset_label = Some(preset.label.clone());
13102                slot.rotation = preset.abilities.clone();
13103            }
13104        }
13105        self.state
13106            .push_log(format!("Saved rotation: {}", preset.label));
13107        Ok(())
13108    }
13109
13110    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
13111        self.seq += 1;
13112        self.session
13113            .submit_intent(Intent::DeleteRotationPreset {
13114                entity_id: self.state.entity_id,
13115                preset_id: preset_id.to_string(),
13116                seq: self.seq,
13117            })
13118            .await?;
13119        self.state.intents_sent += 1;
13120        self.state.rotation_presets.retain(|p| p.id != preset_id);
13121        for slot in &mut self.state.combat_slots {
13122            if slot.preset_id.as_deref() == Some(preset_id) {
13123                slot.preset_id = None;
13124                slot.preset_label = None;
13125                slot.rotation.clear();
13126                slot.rotation_index = 0;
13127            }
13128        }
13129        self.state
13130            .push_log(format!("Deleted rotation: {preset_id}"));
13131        Ok(())
13132    }
13133
13134    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13135        if !self.state.is_alive() {
13136            anyhow::bail!("you are dead");
13137        }
13138        let enabled = !self
13139            .state
13140            .combat_slots
13141            .iter()
13142            .find(|s| s.slot_index == slot_index)
13143            .map(|s| s.auto_enabled)
13144            .unwrap_or(false);
13145        self.seq += 1;
13146        self.session
13147            .submit_intent(Intent::SetAutoAttack {
13148                entity_id: self.state.entity_id,
13149                slot_index,
13150                enabled,
13151                seq: self.seq,
13152            })
13153            .await?;
13154        if slot_index == 1 {
13155            self.state.auto_attack = enabled;
13156        }
13157        self.state.intents_sent += 1;
13158        self.state.push_log(format!(
13159            "T{slot_index} auto {}",
13160            if enabled { "ON" } else { "OFF" }
13161        ));
13162        Ok(())
13163    }
13164
13165    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
13166        if !self.state.connected {
13167            anyhow::bail!("not connected");
13168        }
13169        if !self.state.is_alive() {
13170            anyhow::bail!("you are dead");
13171        }
13172        let (px, py) = self.state.player_position();
13173        if self
13174            .state
13175            .ground_drops
13176            .iter()
13177            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
13178        {
13179            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
13180        }
13181        self.seq += 1;
13182        self.session
13183            .submit_intent(Intent::Pickup {
13184                entity_id: self.state.entity_id,
13185                drop_id: None,
13186                seq: self.seq,
13187            })
13188            .await?;
13189        self.state.intents_sent += 1;
13190        Ok(())
13191    }
13192
13193    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
13194        self.toggle_auto_attack_slot(1).await
13195    }
13196
13197    pub async fn dodge(&mut self) -> anyhow::Result<()> {
13198        if !self.state.is_alive() {
13199            anyhow::bail!("you are dead");
13200        }
13201        self.seq += 1;
13202        self.session
13203            .submit_intent(Intent::Dodge {
13204                entity_id: self.state.entity_id,
13205                seq: self.seq,
13206            })
13207            .await?;
13208        self.state.intents_sent += 1;
13209        self.state.push_log("Dodge!");
13210        Ok(())
13211    }
13212
13213    pub async fn lunge(&mut self) -> anyhow::Result<()> {
13214        if !self.state.is_alive() {
13215            anyhow::bail!("you are dead");
13216        }
13217        let (forward, strafe) = self.last_move_axes();
13218        self.seq += 1;
13219        self.session
13220            .submit_intent(Intent::Lunge {
13221                entity_id: self.state.entity_id,
13222                forward,
13223                strafe,
13224                seq: self.seq,
13225            })
13226            .await?;
13227        self.state.intents_sent += 1;
13228        self.state.push_log("Lunge!");
13229        Ok(())
13230    }
13231
13232    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
13233        if !self.state.is_alive() {
13234            anyhow::bail!("you are dead");
13235        }
13236        self.seq += 1;
13237        self.session
13238            .submit_intent(Intent::DirectionalJump {
13239                entity_id: self.state.entity_id,
13240                forward,
13241                strafe,
13242                seq: self.seq,
13243            })
13244            .await?;
13245        self.state.intents_sent += 1;
13246        self.state.push_log("Jump!");
13247        Ok(())
13248    }
13249
13250    /// Remembered WASD axes for lunge when not currently moving.
13251    pub fn last_move_axes(&self) -> (f32, f32) {
13252        (self.last_move_forward, self.last_move_strafe)
13253    }
13254
13255    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
13256        if !self.state.is_alive() {
13257            anyhow::bail!("you are dead");
13258        }
13259        self.seq += 1;
13260        self.session
13261            .submit_intent(Intent::Block {
13262                entity_id: self.state.entity_id,
13263                enabled,
13264                seq: self.seq,
13265            })
13266            .await?;
13267        self.state.intents_sent += 1;
13268        if enabled {
13269            self.state.push_log("Blocking");
13270        }
13271        Ok(())
13272    }
13273
13274    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
13275        if !self.state.is_alive() {
13276            anyhow::bail!("you are dead");
13277        }
13278        self.seq += 1;
13279        self.session
13280            .submit_intent(Intent::EquipMainhand {
13281                entity_id: self.state.entity_id,
13282                template_id,
13283                instance_id: None,
13284                seq: self.seq,
13285            })
13286            .await?;
13287        self.state.intents_sent += 1;
13288        Ok(())
13289    }
13290
13291    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
13292    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
13293        let idx = self.state.equip_menu_index;
13294        let slots = equip_paperdoll_rows(&self.state);
13295        let Some(row) = slots.get(idx) else {
13296            return Ok(());
13297        };
13298        match row {
13299            EquipPaperdollRow::Body { slot, filled } => {
13300                if *filled {
13301                    self.equip_worn(*slot, None).await
13302                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
13303                    self.equip_worn(*slot, Some(inst)).await
13304                } else {
13305                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
13306                    Ok(())
13307                }
13308            }
13309            EquipPaperdollRow::Mainhand { filled } => {
13310                if *filled {
13311                    self.unequip_mainhand().await
13312                } else if let Some(tid) = first_inventory_weapon(&self.state) {
13313                    self.equip_mainhand(Some(tid)).await
13314                } else {
13315                    self.state.push_log("No weapon in inventory".to_string());
13316                    Ok(())
13317                }
13318            }
13319            EquipPaperdollRow::Offhand { filled, locked } => {
13320                if *locked {
13321                    self.state
13322                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
13323                    Ok(())
13324                } else if *filled {
13325                    self.unequip_offhand().await
13326                } else if let Some(tid) = first_inventory_offhand(&self.state) {
13327                    self.equip_offhand(Some(tid)).await
13328                } else {
13329                    self.state
13330                        .push_log("No offhand item in inventory".to_string());
13331                    Ok(())
13332                }
13333            }
13334        }
13335    }
13336
13337    pub async fn say(
13338        &mut self,
13339        channel: flatland_protocol::ChatChannel,
13340        text: &str,
13341    ) -> anyhow::Result<()> {
13342        self.say_to(channel, text, None).await
13343    }
13344
13345    pub async fn say_to(
13346        &mut self,
13347        channel: flatland_protocol::ChatChannel,
13348        text: &str,
13349        to_entity: Option<EntityId>,
13350    ) -> anyhow::Result<()> {
13351        self.seq += 1;
13352        self.session
13353            .submit_intent(Intent::Say {
13354                entity_id: self.state.entity_id,
13355                channel,
13356                text: text.to_string(),
13357                to_entity,
13358                seq: self.seq,
13359            })
13360            .await?;
13361        self.state.intents_sent += 1;
13362        Ok(())
13363    }
13364
13365    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
13366        let Some(peer) = self.state.player_verbs.target_entity else {
13367            return Ok(());
13368        };
13369        let label = self.state.player_verbs.target_label.clone();
13370        let choice = crate::social::PlayerVerbState::options()
13371            .get(self.state.player_verbs.index)
13372            .copied()
13373            .unwrap_or("Whisper");
13374        self.state.player_verbs.close();
13375        match choice {
13376            "Trade" => {
13377                // Only request — peer must accept (Y in chat). Do not auto-respond;
13378                // TradeRespond requires a pending inbound request from the peer.
13379                self.seq += 1;
13380                self.session
13381                    .submit_intent(Intent::TradeRequest {
13382                        entity_id: self.state.entity_id,
13383                        peer_entity_id: peer,
13384                        seq: self.seq,
13385                    })
13386                    .await?;
13387                self.state.intents_sent += 1;
13388                self.state
13389                    .social_chat
13390                    .push_system(format!("Trade request sent to {label} — waiting for accept"));
13391            }
13392            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
13393            _ => self.state.social_chat.focus_nearby(),
13394        }
13395        Ok(())
13396    }
13397
13398    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
13399        let Some(pending) = self.state.social_chat.pending_trade.take() else {
13400            return Ok(());
13401        };
13402        self.seq += 1;
13403        self.session
13404            .submit_intent(Intent::TradeRespond {
13405                entity_id: self.state.entity_id,
13406                peer_entity_id: pending.from_entity,
13407                accept,
13408                seq: self.seq,
13409            })
13410            .await?;
13411        self.state.intents_sent += 1;
13412        if accept {
13413            self.state
13414                .social_chat
13415                .push_system(format!("Accepted trade with {}", pending.from_name));
13416        } else {
13417            self.state
13418                .social_chat
13419                .push_system(format!("Declined trade with {}", pending.from_name));
13420        }
13421        Ok(())
13422    }
13423
13424    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
13425        let text = self.state.social_chat.buffer.trim().to_string();
13426        if text.is_empty() {
13427            return Ok(());
13428        }
13429        self.state.social_chat.buffer.clear();
13430        if crate::social::is_chat_slash_line(&text) {
13431            match crate::social::parse_chat_slash(&text) {
13432                Some(cmd) => return self.apply_chat_slash(cmd).await,
13433                None => {
13434                    self.state.social_chat.push_system(format!(
13435                        "Unknown command — {}",
13436                        crate::social::chat_slash_help_text()
13437                    ));
13438                    return Ok(());
13439                }
13440            }
13441        }
13442        let thread = self.state.social_chat.thread;
13443        let channel = thread.channel();
13444        let to = thread.to_entity();
13445        if let Some(peer) = to {
13446            let label = self.state.social_chat.peer_label.clone();
13447            self.state
13448                .social_chat
13449                .remember_whisper_peer(peer, &label, channel);
13450        }
13451        self.say_to(channel, &text, to).await
13452    }
13453
13454    async fn apply_chat_slash(
13455        &mut self,
13456        cmd: crate::social::ChatSlashCommand,
13457    ) -> anyhow::Result<()> {
13458        use crate::social::{chat_slash_help_text, ChatSlashCommand};
13459        match cmd {
13460            ChatSlashCommand::Help => {
13461                self.state
13462                    .social_chat
13463                    .push_system(chat_slash_help_text().to_string());
13464                Ok(())
13465            }
13466            ChatSlashCommand::Nearby { message } => {
13467                self.state.social_chat.focus_nearby();
13468                self.state
13469                    .social_chat
13470                    .push_system("Nearby speech — everyone close can hear");
13471                if let Some(msg) = message {
13472                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
13473                        .await
13474                } else {
13475                    Ok(())
13476                }
13477            }
13478            ChatSlashCommand::Reply { message } => {
13479                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13480                    self.state.social_chat.push_system(
13481                        "No one to reply to — wait for a whisper, or /whisper Name",
13482                    );
13483                    return Ok(());
13484                };
13485                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
13486                self.state
13487                    .social_chat
13488                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
13489                self.state.social_chat.push_system(format!(
13490                    "Replying to {} — type and Enter · /nearby",
13491                    peer.label
13492                ));
13493                if let Some(msg) = message {
13494                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
13495                } else {
13496                    Ok(())
13497                }
13498            }
13499            ChatSlashCommand::Whisper { name, message } => {
13500                let (peer_id, label, stone) = if let Some(name) = name {
13501                    match self.resolve_whisper_target(&name) {
13502                        Ok(t) => t,
13503                        Err(err) => {
13504                            self.state.social_chat.push_system(err);
13505                            return Ok(());
13506                        }
13507                    }
13508                } else {
13509                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13510                        self.state.social_chat.push_system(
13511                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
13512                        );
13513                        return Ok(());
13514                    };
13515                    (
13516                        peer.entity_id,
13517                        peer.label,
13518                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
13519                    )
13520                };
13521                self.state
13522                    .social_chat
13523                    .set_whisper_thread(peer_id, &label, stone);
13524                let channel = if stone {
13525                    flatland_protocol::ChatChannel::WhisperStone
13526                } else {
13527                    flatland_protocol::ChatChannel::Whisper
13528                };
13529                if let Some(msg) = message {
13530                    self.state.social_chat.push_system(format!(
13531                        "Whisper → {label}"
13532                    ));
13533                    self.say_to(channel, &msg, Some(peer_id)).await
13534                } else {
13535                    self.state.social_chat.push_system(format!(
13536                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
13537                    ));
13538                    Ok(())
13539                }
13540            }
13541        }
13542    }
13543
13544    /// Resolve `/whisper Name` against AOI players (label match).
13545    fn resolve_whisper_target(
13546        &self,
13547        name: &str,
13548    ) -> Result<(EntityId, String, bool), String> {
13549        let needle = name.trim().to_ascii_lowercase();
13550        if needle.is_empty() {
13551            return Err("Usage: /whisper Name [message]".into());
13552        }
13553        let mut candidates: Vec<(EntityId, String)> = self
13554            .state
13555            .entities
13556            .iter()
13557            .filter(|e| e.id != self.state.entity_id)
13558            .filter(|e| !e.label.trim().is_empty())
13559            .filter(|e| e.vitals.is_some())
13560            .filter(|e| {
13561                !self
13562                    .state
13563                    .npcs
13564                    .iter()
13565                    .any(|n| n.id == e.id.to_string())
13566            })
13567            .filter(|e| {
13568                !self
13569                    .state
13570                    .hired_workers
13571                    .iter()
13572                    .any(|w| w.entity_id == e.id)
13573            })
13574            .map(|e| (e.id, e.label.clone()))
13575            .collect();
13576
13577        // Also allow matching the last whisper peer by name even if they left AOI briefly.
13578        if let Some(last) = &self.state.social_chat.last_whisper_peer {
13579            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
13580                candidates.push((last.entity_id, last.label.clone()));
13581            }
13582        }
13583
13584        let exact: Vec<_> = candidates
13585            .iter()
13586            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
13587            .cloned()
13588            .collect();
13589        let pool = if exact.len() == 1 {
13590            exact
13591        } else if exact.len() > 1 {
13592            return Err(format!(
13593                "Several players named '{name}' nearby — move closer and try again"
13594            ));
13595        } else {
13596            let starts: Vec<_> = candidates
13597                .iter()
13598                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
13599                .cloned()
13600                .collect();
13601            if starts.len() == 1 {
13602                starts
13603            } else if starts.len() > 1 {
13604                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
13605                return Err(format!(
13606                    "Ambiguous name '{name}' — matches: {}",
13607                    names.join(", ")
13608                ));
13609            } else {
13610                let contains: Vec<_> = candidates
13611                    .iter()
13612                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
13613                    .cloned()
13614                    .collect();
13615                if contains.len() == 1 {
13616                    contains
13617                } else if contains.is_empty() {
13618                    return Err(format!(
13619                        "No player matching '{name}' in range — get closer or check the spelling"
13620                    ));
13621                } else {
13622                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
13623                    return Err(format!(
13624                        "Ambiguous name '{name}' — matches: {}",
13625                        names.join(", ")
13626                    ));
13627                }
13628            }
13629        };
13630
13631        let (id, label) = pool.into_iter().next().unwrap();
13632        let stone = self
13633            .state
13634            .social_chat
13635            .last_whisper_peer
13636            .as_ref()
13637            .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
13638        Ok((id, label, stone))
13639    }
13640
13641    pub async fn trade_present_selected(
13642        &mut self,
13643        item_instance_id: uuid::Uuid,
13644    ) -> anyhow::Result<()> {
13645        self.trade_present_quantity(item_instance_id, None).await
13646    }
13647
13648    pub async fn trade_present_quantity(
13649        &mut self,
13650        item_instance_id: uuid::Uuid,
13651        quantity: Option<u32>,
13652    ) -> anyhow::Result<()> {
13653        self.seq += 1;
13654        self.session
13655            .submit_intent(Intent::TradePresent {
13656                entity_id: self.state.entity_id,
13657                item_instance_id,
13658                quantity,
13659                seq: self.seq,
13660            })
13661            .await?;
13662        self.state.intents_sent += 1;
13663        self.state.trade_ui.qty_entry = None;
13664        self.state.trade_ui.picking_inventory = false;
13665        Ok(())
13666    }
13667
13668    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
13669    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
13670        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
13671            let qty = self.state.trade_ui.present_quantity();
13672            return self
13673                .trade_present_quantity(entry.item_instance_id, qty)
13674                .await;
13675        }
13676        if !self.state.trade_ui.picking_inventory {
13677            return Ok(());
13678        }
13679        let Some(stack) = self
13680            .state
13681            .inventory_stacks
13682            .get(self.state.trade_ui.inventory_index)
13683            .cloned()
13684        else {
13685            return Ok(());
13686        };
13687        let Some(id) = stack.item_instance_id else {
13688            return Ok(());
13689        };
13690        let label = stack
13691            .display_name
13692            .clone()
13693            .unwrap_or_else(|| stack.template_id.clone());
13694        if stack.quantity <= 1 {
13695            self.trade_present_quantity(id, Some(1)).await
13696        } else {
13697            self.state
13698                .trade_ui
13699                .begin_qty_entry(id, label, stack.quantity);
13700            Ok(())
13701        }
13702    }
13703
13704    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
13705        self.seq += 1;
13706        self.session
13707            .submit_intent(Intent::TradeSetReady {
13708                entity_id: self.state.entity_id,
13709                ready,
13710                seq: self.seq,
13711            })
13712            .await?;
13713        self.state.intents_sent += 1;
13714        Ok(())
13715    }
13716
13717    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
13718        self.seq += 1;
13719        self.session
13720            .submit_intent(Intent::TradeCancel {
13721                entity_id: self.state.entity_id,
13722                seq: self.seq,
13723            })
13724            .await?;
13725        self.state.intents_sent += 1;
13726        self.state.trade_ui.close();
13727        Ok(())
13728    }
13729
13730    pub async fn destroy_whisper_stone(
13731        &mut self,
13732        item_instance_id: uuid::Uuid,
13733    ) -> anyhow::Result<()> {
13734        self.seq += 1;
13735        self.session
13736            .submit_intent(Intent::DestroyWhisperStone {
13737                entity_id: self.state.entity_id,
13738                item_instance_id,
13739                seq: self.seq,
13740            })
13741            .await?;
13742        self.state.intents_sent += 1;
13743        Ok(())
13744    }
13745
13746    pub async fn stop(&mut self) -> anyhow::Result<()> {
13747        self.seq += 1;
13748        self.session
13749            .submit_intent(Intent::Stop {
13750                entity_id: self.state.entity_id,
13751                seq: self.seq,
13752            })
13753            .await?;
13754        self.state.intents_sent += 1;
13755        Ok(())
13756    }
13757
13758    pub fn disconnect(&self) {
13759        self.session.disconnect();
13760    }
13761}
13762
13763fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
13764    let dx = ax - bx;
13765    let dy = ay - by;
13766    (dx * dx + dy * dy).sqrt()
13767}
13768
13769#[cfg(test)]
13770mod tests {
13771    use std::collections::BTreeMap;
13772
13773    use super::*;
13774    use flatland_protocol::{
13775        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
13776    };
13777
13778    fn sample_state() -> GameState {
13779        let mut state = GameState {
13780            session_id: 1,
13781            entity_id: 1,
13782            character_id: None,
13783            tick: 0,
13784            chunk_rev: 0,
13785            content_rev: 0,
13786            publish_rev: 0,
13787            entities: vec![EntityState {
13788                id: 1,
13789                label: "You".into(),
13790                transform: Transform {
13791                    position: WorldCoord::surface(128.0, 128.0),
13792                    yaw: 0.0,
13793                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
13794                },
13795                vitals: None,
13796                attributes: None,
13797                skills: None,
13798                inside_building: None,
13799                tile_id: None,
13800                paperdoll_ref: None,
13801                presentation_state: None,
13802                sprite_mode: None,
13803                progression_xp: None,
13804                combat_cues: vec![],
13805            }],
13806            player: None,
13807            resource_nodes: vec![ResourceNodeView {
13808                id: "oak-1".into(),
13809                label: "Oak".into(),
13810                x: 130.0,
13811                y: 128.0,
13812                z: 0.0,
13813                item_template: "oak_log".into(),
13814                state: ResourceNodeState::Available,
13815                blocking: true,
13816                blocking_radius_m: 0.8,
13817                tile_id: None,
13818                                yaw: 0.0,
13819                pitch: 0.0,
13820                roll: 0.0,
13821                draw_scale: 1.0,
13822                sprite_mode: None,
13823                growth_progress: None,
13824                presentation_state: None,
13825                channel_start_tick: None,
13826                channel_end_tick: None,
13827                harvest_drop_templates: vec![],
13828            }],
13829            ground_drops: vec![],
13830            placed_containers: vec![],
13831            buildings: vec![BuildingView {
13832                id: "broker-hut".into(),
13833                label: "Broker".into(),
13834                x: 148.0,
13835                y: 118.0,
13836                width_m: 8.0,
13837                depth_m: 6.0,
13838                interior_blueprint: Some("broker_hut".into()),
13839                tags: vec![],
13840                market_boundary_zone_ids: vec![],
13841                market_max_volume: None,
13842                wall_set: None,
13843                roof_set: None,
13844            }],
13845            doors: vec![flatland_protocol::DoorView {
13846                id: "door-1".into(),
13847                building_id: "broker-hut".into(),
13848                x: 148.0,
13849                y: 118.0,
13850                open: false,
13851                portal: Some("front".into()),
13852            }],
13853            interior_map: None,
13854            npcs: vec![],
13855            blueprints: vec![],
13856            world_x0: 0.0,
13857            world_y0: 0.0,
13858            world_width_m: 256.0,
13859            world_height_m: 256.0,
13860            terrain_zones: Vec::new(),
13861            z_platforms: Vec::new(),
13862            z_transitions: Vec::new(),
13863            world_clock: flatland_protocol::WorldClock::default(),
13864            inventory: std::collections::HashMap::new(),
13865            inventory_hints: std::collections::HashMap::new(),
13866            logs: VecDeque::new(),
13867            intents_sent: 0,
13868            ticks_received: 0,
13869            connected: true,
13870            disconnect_reason: None,
13871            show_stats: false,
13872            hud_log_hidden: false,
13873            show_equip_menu: false,
13874            equip_menu_index: 0,
13875            show_craft_menu: false,
13876            craft_menu_index: 0,
13877            craft_batch_quantity: 1,
13878            show_shop_menu: false,
13879            shop_catalog: None,
13880            bank_panel: None,
13881            bank_menu_index: 0,
13882            bank_ui_mode: BankUiMode::Menu,
13883            storage_panel: None,
13884                market_panel: None,
13885            market_menu_index: 0,
13886            market_filter: String::new(),
13887            market_filter_focused: false,
13888            market_category_filter: None,
13889            market_buy_confirm: None,
13890            market_ui_mode: MarketUiMode::Browse,
13891            storage_menu_index: 0,
13892            storage_ui_mode: StorageUiMode::Menu,
13893            shop_tab: ShopTab::default(),
13894            shop_menu_index: 0,
13895            shop_quantity: 1,
13896            shop_trade_log: VecDeque::new(),
13897            show_npc_verb_menu: false,
13898            npc_verb_target: None,
13899            npc_verb_index: 0,
13900            player_verbs: crate::social::PlayerVerbState::default(),
13901            social_chat: crate::social::SocialChatState::default(),
13902            trade_ui: crate::social::TradeUiState::default(),
13903            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
13904            show_npc_chat: false,
13905            npc_chat: None,
13906            show_inventory_menu: false,
13907            inventory_menu_index: 0,
13908            inventory_tab: InventoryTab::OnPerson,
13909            inventory_filter: String::new(),
13910            inventory_filter_focused: false,
13911            show_move_picker: false,
13912            show_rename_prompt: false,
13913            show_worker_rename: false,
13914            rename_buffer: String::new(),
13915            move_picker_index: 0,
13916            move_picker: None,
13917            show_grant_picker: false,
13918            grant_picker_index: 0,
13919            grant_picker: None,
13920            show_destroy_picker: false,
13921            destroy_confirm_pending: false,
13922            destroy_picker: None,
13923            combat_target: None,
13924            combat_target_label: None,
13925            combat_fx: Vec::new(),
13926            property_zones: Vec::new(),
13927            tax_zones: Vec::new(),
13928            growth_zones: Vec::new(),
13929            biome_zones: Vec::new(),
13930            property_plots: Vec::new(),
13931            property_plot_settings: None,
13932            claim_mode: None,
13933            relocate_mode: None,
13934            sell_plot_confirm: None,
13935            sell_plot_armed_at: None,
13936            show_plant_menu: false,
13937            plant_menu_index: 0,
13938            show_farm_access: false,
13939            farm_access_name_draft: String::new(),
13940            farm_access_discount_bps: 0,
13941            farm_access_index: 0,
13942            plant_quantity: 1,
13943            in_combat: false,
13944            auto_attack: true,
13945            combat_has_los: false,
13946            attack_cd_ticks: 0,
13947            gcd_ticks: 0,
13948            weapon_ability_id: "unarmed".into(),
13949            mainhand_template_id: None,
13950            mainhand_label: None,
13951            offhand_template_id: None,
13952            offhand_label: None,
13953            mainhand_hand_slots: 1,
13954            defense: None,
13955            worn: BTreeMap::new(),
13956            carry_mass: 0.0,
13957            carry_mass_max: 0.0,
13958            encumbrance: flatland_protocol::EncumbranceState::Light,
13959            inventory_stacks: Vec::new(),
13960            keychain_stacks: Vec::new(),
13961            whisper_pouch_stacks: Vec::new(),
13962            combat_target_detail: None,
13963            statuses: Vec::new(),
13964            cast_progress: None,
13965            timed_channel: None,
13966            ability_cooldowns: Vec::new(),
13967            blocking_active: false,
13968            max_target_slots: 1,
13969            combat_slots: Vec::new(),
13970            rotation_presets: Vec::new(),
13971            known_abilities: Vec::new(),
13972            hotbar: vec![None; 9],
13973            max_abilities_per_rotation: 0,
13974            show_loadout_menu: false,
13975            show_keychain_menu: false,
13976            keychain_menu_index: 0,
13977            show_rotation_editor: false,
13978            loadout_menu_index: 0,
13979            loadout_hotbar_slot: 1,
13980            loadout_ability_index: 0,
13981            loadout_focus_presets: false,
13982            rotation_editor: RotationEditorState::default(),
13983            harvest_in_progress: false,
13984            harvest_started_at: None,
13985            pending_craft_ack: None,
13986            pending_worker_job_ack: None,
13987            attending_worker_instance_id: None,
13988            quest_log: Vec::new(),
13989            interactables: Vec::new(),
13990            ledger: None,
13991            career: None,
13992            character_sheet_tab: CharacterSheetTab::Character,
13993            ledger_period: LedgerPeriod::Day,
13994            show_quest_offer: false,
13995            pending_quest_offer: None,
13996            show_quest_menu: false,
13997            quest_menu_index: 0,
13998            quest_withdraw_confirm: false,
13999            hired_workers: Vec::new(),
14000            show_workers_menu: false,
14001            workers_menu_index: 0,
14002            workers_menu_compact: false,
14003            worker_step_display: BTreeMap::new(),
14004            worker_error_display: BTreeMap::new(),
14005            show_worker_give_picker: false,
14006            worker_give_picker_index: 0,
14007            worker_give_picker: None,
14008            show_worker_give_target_picker: false,
14009            worker_give_target_picker_index: 0,
14010            worker_give_target_picker: None,
14011            show_worker_take_picker: false,
14012            worker_take_picker_index: 0,
14013            worker_take_picker: None,
14014            show_worker_teach_picker: false,
14015            worker_teach_picker_index: 0,
14016            worker_teach_picker: None,
14017            worker_route_editor: None,
14018            progression_curve: None,
14019        };
14020        state.player = state.entities.first().cloned();
14021        state
14022    }
14023
14024    #[test]
14025    fn whisper_cancels_when_peer_walks_out_of_range() {
14026        let mut state = sample_state();
14027        state.player = state.entities.first().cloned();
14028        let mut peer = state.entities[0].clone();
14029        peer.id = 2;
14030        peer.label = "Ada".into();
14031        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
14032        state.entities.push(peer.clone());
14033        state.social_chat.focus_whisper(2, "Ada");
14034        state.refresh_whisper_range();
14035        assert!(matches!(
14036            state.social_chat.thread,
14037            crate::social::ChatThreadKind::Whisper { peer: 2 }
14038        ));
14039
14040        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
14041        state.entities[1] = peer;
14042        state.refresh_whisper_range();
14043        assert_eq!(
14044            state.social_chat.thread,
14045            crate::social::ChatThreadKind::Nearby
14046        );
14047        assert!(!state.social_chat.input_focused);
14048    }
14049
14050    #[test]
14051    fn probe_use_world_hired_worker_manage() {
14052        let mut state = sample_state();
14053        state.hired_workers.push(flatland_protocol::HiredWorkerView {
14054            instance_id: "worker-1".into(),
14055            entity_id: 42,
14056            def_id: "worker_laborer".into(),
14057            label: "Sam".into(),
14058            x: 129.0,
14059            y: 128.0,
14060            z: 0.0,
14061            mode: flatland_protocol::WorkerModeView::JobLoop,
14062            state: flatland_protocol::WorkerStateView::Working,
14063            step_label: "cultivate".into(),
14064            vitals: flatland_protocol::WorkerVitalsSummary {
14065                health_pct: 100.0,
14066                stamina_pct: 100.0,
14067            },
14068            carry_pct: 0.0,
14069            last_error: None,
14070            wage_copper_per_interval: 1,
14071            effective_wage_copper: 1,
14072            wage_meters_walked: 0.0,
14073            lodging_container_id: None,
14074            route: None,
14075            route_stop_index: None,
14076            known_blueprint_ids: Vec::new(),
14077            level: 1,
14078            worker_xp: 0.0,
14079            inventory: Vec::new(),
14080        });
14081        let probe = state.probe_use_world();
14082        let primary = probe.primary.expect("primary");
14083        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
14084        assert_eq!(primary.id, "worker-1");
14085        assert!(primary.hint_line().contains("Manage"));
14086        assert!(primary.hint_line().contains("Sam"));
14087        assert_eq!(
14088            state.nearest_interact_target().as_deref(),
14089            Some("worker-1")
14090        );
14091    }
14092
14093    #[test]
14094    fn market_clerk_verb_options_include_market() {
14095        let mut state = sample_state();
14096        state.npcs.push(flatland_protocol::NpcView {
14097            id: "mira_market".into(),
14098            label: "Mira".into(),
14099            role: "market_clerk".into(),
14100            x: 129.0,
14101            y: 128.0,
14102            building_id: Some("town_market".into()),
14103            entity_id: None,
14104            life_state: None,
14105            hp_pct: None,
14106            can_trade: false,
14107            tile_id: None,
14108            behavior_state: None,
14109            presentation_state: None,
14110            sprite_mode: None,
14111            paperdoll_ref: None,
14112        });
14113        state.npc_verb_target = Some("mira_market".into());
14114        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
14115    }
14116
14117    #[test]
14118    fn market_list_excludes_currency_stacks() {
14119        let mut state = sample_state();
14120        state.inventory_stacks = vec![
14121            flatland_protocol::ItemStack {
14122                template_id: "copper_coin".into(),
14123                quantity: 50,
14124                item_instance_id: Some(uuid::Uuid::from_u128(10)),
14125                display_name: Some("Copper Coin".into()),
14126                ..Default::default()
14127            },
14128            flatland_protocol::ItemStack {
14129                template_id: "oak_log".into(),
14130                quantity: 2,
14131                item_instance_id: Some(uuid::Uuid::from_u128(11)),
14132                display_name: Some("Oak Log".into()),
14133                ..Default::default()
14134            },
14135            flatland_protocol::ItemStack {
14136                template_id: "whisper_stone".into(),
14137                quantity: 1,
14138                item_instance_id: Some(uuid::Uuid::from_u128(12)),
14139                display_name: Some("Whisper Stone".into()),
14140                category: Some("quest".into()),
14141                listable: Some(false),
14142                ..Default::default()
14143            },
14144        ];
14145        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
14146        assert_eq!(opts.len(), 1);
14147        assert!(opts[0].label.contains("Oak"));
14148    }
14149
14150    #[test]
14151    fn market_browse_filters_by_category_and_search() {
14152        let mut state = sample_state();
14153        state.market_panel = Some(flatland_protocol::MarketPanel {
14154            npc_id: "mira_market".into(),
14155            npc_label: "Mira".into(),
14156            building_id: "town_market".into(),
14157            building_label: "Town Market".into(),
14158            used_volume: 0.0,
14159            max_volume: 100.0,
14160            listings: vec![
14161                flatland_protocol::MarketListingView {
14162                    listing_id: uuid::Uuid::from_u128(1),
14163                    seller_character_id: uuid::Uuid::from_u128(2),
14164                    seller_label: "Ada".into(),
14165                    hall_building_id: "town_market".into(),
14166                    hall_label: "Town Market".into(),
14167                    template_id: "oak_log".into(),
14168                    display_name: "Oak Log".into(),
14169                    category: "resource".into(),
14170                    quantity: 3,
14171                    unit_price_copper: 10,
14172                    line_total_copper: 30,
14173                    mine: false,
14174                },
14175                flatland_protocol::MarketListingView {
14176                    listing_id: uuid::Uuid::from_u128(3),
14177                    seller_character_id: uuid::Uuid::from_u128(2),
14178                    seller_label: "Ada".into(),
14179                    hall_building_id: "town_market".into(),
14180                    hall_label: "Town Market".into(),
14181                    template_id: "short_sword".into(),
14182                    display_name: "Short Sword".into(),
14183                    category: "weapon".into(),
14184                    quantity: 1,
14185                    unit_price_copper: 100,
14186                    line_total_copper: 100,
14187                    mine: false,
14188                },
14189            ],
14190            tax_bps: 0,
14191            tax_flat_copper: 0,
14192            list_vaults: vec![],
14193        });
14194        assert_eq!(state.market_filtered_listing_indices().len(), 2);
14195        state.market_category_filter = Some("Weapons");
14196        let weapons = state.market_filtered_listing_indices();
14197        assert_eq!(weapons.len(), 1);
14198        assert_eq!(
14199            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
14200            "Short Sword"
14201        );
14202        state.market_category_filter = None;
14203        state.market_filter = "oak".into();
14204        let oak = state.market_filtered_listing_indices();
14205        assert_eq!(oak.len(), 1);
14206        assert_eq!(
14207            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
14208            "Oak Log"
14209        );
14210    }
14211
14212    #[test]
14213    fn market_list_source_includes_person_and_vaults() {
14214        let mut state = sample_state();
14215        let item_id = uuid::Uuid::from_u128(1);
14216        state.inventory_stacks = vec![flatland_protocol::ItemStack {
14217            template_id: "oak_log".into(),
14218            quantity: 2,
14219            item_instance_id: Some(item_id),
14220            display_name: Some("Oak Log".into()),
14221            ..Default::default()
14222        }];
14223        state.market_panel = Some(flatland_protocol::MarketPanel {
14224            npc_id: "mira_market".into(),
14225            npc_label: "Mira".into(),
14226            building_id: "town_market".into(),
14227            building_label: "Town Market".into(),
14228            used_volume: 0.0,
14229            max_volume: 100.0,
14230            listings: vec![],
14231            tax_bps: 0,
14232            tax_flat_copper: 0,
14233            list_vaults: vec![flatland_protocol::MarketListVault {
14234                building_id: "town_storage".into(),
14235                building_label: "Town Storage".into(),
14236                contents: vec![flatland_protocol::ItemStack {
14237                    template_id: "lumber".into(),
14238                    quantity: 1,
14239                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
14240                    display_name: Some("Lumber".into()),
14241                    ..Default::default()
14242                }],
14243            }],
14244        });
14245        let sources = state.market_list_source_options();
14246        assert_eq!(sources.len(), 2);
14247        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
14248        assert!(matches!(
14249            sources[1].0,
14250            MarketListSourceKind::TownStorage { .. }
14251        ));
14252        assert!(sources[1].1.contains("Town Storage"));
14253    }
14254
14255    #[test]
14256    fn probe_use_world_npc_beats_nearby_loot() {
14257        let mut state = sample_state();
14258        state.npcs.push(flatland_protocol::NpcView {
14259            id: "ada".into(),
14260            label: "Ada".into(),
14261            role: "broker".into(),
14262            x: 129.0,
14263            y: 128.0,
14264            building_id: None,
14265            entity_id: None,
14266            life_state: None,
14267            hp_pct: None,
14268            can_trade: true,
14269            tile_id: None,
14270            behavior_state: None,
14271            presentation_state: None,
14272            sprite_mode: None,
14273            paperdoll_ref: None,
14274        });
14275        state.ground_drops.push(flatland_protocol::GroundDropView {
14276            id: "d1".into(),
14277            template_id: "lumber".into(),
14278            quantity: 1,
14279            x: 128.5,
14280            y: 128.0,
14281            z: 0.0,
14282            tile_id: None,
14283            display_name: None,
14284            yaw: 0.0,
14285            pitch: 0.0,
14286            roll: 0.0,
14287            draw_scale: 1.0,
14288        });
14289        let probe = state.probe_use_world();
14290        let primary = probe.primary.expect("primary");
14291        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
14292        assert_eq!(primary.id, "ada");
14293    }
14294
14295    #[test]
14296    fn probe_use_world_harvest_when_in_range() {
14297        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
14298        let probe = state.probe_use_world();
14299        assert!(
14300            probe.primary.is_none(),
14301            "oak is 2m away, out of harvest range"
14302        );
14303        assert!(probe
14304            .candidates
14305            .iter()
14306            .any(|c| c.kind == crate::UseWorldKind::Harvest));
14307
14308        let mut state = sample_state();
14309        state.resource_nodes[0].x = 129.0;
14310        let probe = state.probe_use_world();
14311        let primary = probe.primary.expect("primary");
14312        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
14313    }
14314
14315    #[test]
14316    fn probe_use_world_door_uses_building_label() {
14317        let mut state = sample_state();
14318        state.doors[0].x = 129.0;
14319        state.doors[0].y = 128.0;
14320        let probe = state.probe_use_world();
14321        let primary = probe.primary.expect("primary");
14322        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
14323        assert_eq!(primary.label, "Broker");
14324        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
14325    }
14326
14327    #[test]
14328    fn empty_entity_tick_preserves_welcome_snapshot() {
14329        let mut state = sample_state();
14330        state.inventory.insert("carrot".into(), 3);
14331        let delta = TickDelta {
14332            tick: 1,
14333            entities: vec![],
14334            resource_nodes: vec![],
14335            ground_drops: vec![],
14336            placed_containers: vec![],
14337            buildings: vec![],
14338            doors: vec![],
14339            interior_map: None,
14340            npcs: vec![],
14341            inventory: vec![],
14342            blueprints: vec![],
14343            world_clock: flatland_protocol::WorldClock::default(),
14344            combat: None,
14345            quest_log: vec![],
14346            hired_workers: Vec::new(),
14347            interactables: vec![],
14348            ledger: None,
14349            career: None,
14350            combat_fx: Vec::new(),
14351            property_plots: Vec::new(),
14352        terrain_overlays: Vec::new(),
14353        };
14354
14355        state.apply_tick_fields(&delta, 1);
14356
14357        assert_eq!(state.entities.len(), 1);
14358        assert!(state.player.is_some());
14359        assert_eq!(state.inventory.get("carrot"), Some(&3));
14360        assert_eq!(state.resource_nodes.len(), 1);
14361    }
14362
14363    #[test]
14364    fn tick_preserves_world_layers_when_delta_omits_them() {
14365        let mut state = sample_state();
14366        let delta = TickDelta {
14367            tick: 1,
14368            entities: state.entities.clone(),
14369            resource_nodes: vec![],
14370            ground_drops: vec![],
14371            placed_containers: vec![],
14372            buildings: vec![],
14373            doors: vec![],
14374            interior_map: None,
14375            npcs: vec![],
14376            inventory: vec![],
14377            blueprints: vec![],
14378            world_clock: flatland_protocol::WorldClock::default(),
14379            combat: None,
14380            quest_log: vec![],
14381            hired_workers: Vec::new(),
14382            interactables: vec![],
14383            ledger: None,
14384            career: None,
14385            combat_fx: Vec::new(),
14386            property_plots: Vec::new(),
14387        terrain_overlays: Vec::new(),
14388        };
14389
14390        state.apply_tick_fields(&delta, 1);
14391
14392        assert_eq!(state.resource_nodes.len(), 1);
14393        assert_eq!(state.buildings.len(), 1);
14394        assert_eq!(state.doors.len(), 1);
14395    }
14396
14397    #[test]
14398    fn tick_updates_resource_nodes_when_server_sends_them() {
14399        let mut state = sample_state();
14400        let delta = TickDelta {
14401            tick: 1,
14402            entities: state.entities.clone(),
14403            resource_nodes: vec![ResourceNodeView {
14404                id: "oak-1".into(),
14405                label: "Oak".into(),
14406                x: 130.0,
14407                y: 128.0,
14408                z: 0.0,
14409                item_template: "oak_log".into(),
14410                state: ResourceNodeState::Cooldown,
14411                blocking: true,
14412                blocking_radius_m: 0.8,
14413                tile_id: None,
14414                                yaw: 0.0,
14415                pitch: 0.0,
14416                roll: 0.0,
14417                draw_scale: 1.0,
14418                sprite_mode: None,
14419                growth_progress: None,
14420                presentation_state: None,
14421                channel_start_tick: None,
14422                channel_end_tick: None,
14423                harvest_drop_templates: vec![],
14424            }],
14425            buildings: vec![],
14426            doors: vec![],
14427            interior_map: None,
14428            npcs: vec![],
14429            inventory: vec![],
14430            blueprints: vec![],
14431            world_clock: flatland_protocol::WorldClock::default(),
14432            ground_drops: vec![],
14433            placed_containers: vec![],
14434            combat: None,
14435            quest_log: vec![],
14436            hired_workers: Vec::new(),
14437            interactables: vec![],
14438            ledger: None,
14439            career: None,
14440            combat_fx: Vec::new(),
14441            property_plots: Vec::new(),
14442        terrain_overlays: Vec::new(),
14443        };
14444
14445        state.apply_tick_fields(&delta, 1);
14446
14447        assert!(matches!(
14448            state.resource_nodes[0].state,
14449            ResourceNodeState::Cooldown
14450        ));
14451    }
14452
14453    #[test]
14454    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
14455        let mut state = GameState {
14456            session_id: 1,
14457            entity_id: 1,
14458            character_id: None,
14459            tick: 0,
14460            chunk_rev: 0,
14461            content_rev: 0,
14462            publish_rev: 0,
14463            entities: vec![EntityState {
14464                id: 1,
14465                label: "You".into(),
14466                transform: Transform {
14467                    position: WorldCoord::surface(4.5, 2.0),
14468                    yaw: 0.0,
14469                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14470                },
14471                vitals: None,
14472                attributes: None,
14473                skills: None,
14474                inside_building: Some("broker_hut".into()),
14475                tile_id: None,
14476                paperdoll_ref: None,
14477                presentation_state: None,
14478                sprite_mode: None,
14479                progression_xp: None,
14480                combat_cues: vec![],
14481            }],
14482            player: None,
14483            resource_nodes: vec![],
14484            ground_drops: vec![],
14485            placed_containers: vec![],
14486            buildings: vec![BuildingView {
14487                id: "broker_hut".into(),
14488                label: "Broker".into(),
14489                x: 158.0,
14490                y: 124.0,
14491                width_m: 8.0,
14492                depth_m: 6.0,
14493                interior_blueprint: Some("broker_hut".into()),
14494                tags: vec![],
14495                market_boundary_zone_ids: vec![],
14496                market_max_volume: None,
14497                wall_set: None,
14498                roof_set: None,
14499            }],
14500            doors: vec![flatland_protocol::DoorView {
14501                id: "broker_hut_exit".into(),
14502                building_id: "broker_hut".into(),
14503                x: 4.3,
14504                y: 0.9,
14505                open: true,
14506                portal: Some("front".into()),
14507            }],
14508            interior_map: None,
14509            npcs: vec![flatland_protocol::NpcView {
14510                id: "ada_broker".into(),
14511                label: "Ada".into(),
14512                x: 4.5,
14513                y: 2.0,
14514                building_id: Some("broker_hut".into()),
14515                role: "broker".into(),
14516                entity_id: None,
14517                life_state: None,
14518                hp_pct: None,
14519                can_trade: true,
14520                tile_id: None,
14521                behavior_state: None,
14522                presentation_state: None,
14523                sprite_mode: None,
14524                paperdoll_ref: None,
14525            }],
14526            blueprints: vec![],
14527            world_x0: 0.0,
14528            world_y0: 0.0,
14529            world_width_m: 256.0,
14530            world_height_m: 256.0,
14531            terrain_zones: Vec::new(),
14532            z_platforms: Vec::new(),
14533            z_transitions: Vec::new(),
14534            world_clock: flatland_protocol::WorldClock::default(),
14535            inventory: std::collections::HashMap::new(),
14536            inventory_hints: std::collections::HashMap::new(),
14537            logs: VecDeque::new(),
14538            intents_sent: 0,
14539            ticks_received: 0,
14540            connected: true,
14541            disconnect_reason: None,
14542            show_stats: false,
14543            hud_log_hidden: false,
14544            show_equip_menu: false,
14545            equip_menu_index: 0,
14546            show_craft_menu: false,
14547            craft_menu_index: 0,
14548            craft_batch_quantity: 1,
14549            show_shop_menu: false,
14550            shop_catalog: None,
14551            bank_panel: None,
14552            bank_menu_index: 0,
14553            bank_ui_mode: BankUiMode::Menu,
14554            storage_panel: None,
14555                market_panel: None,
14556            market_menu_index: 0,
14557            market_filter: String::new(),
14558            market_filter_focused: false,
14559            market_category_filter: None,
14560            market_buy_confirm: None,
14561            market_ui_mode: MarketUiMode::Browse,
14562            storage_menu_index: 0,
14563            storage_ui_mode: StorageUiMode::Menu,
14564            shop_tab: ShopTab::default(),
14565            shop_menu_index: 0,
14566            shop_quantity: 1,
14567            shop_trade_log: VecDeque::new(),
14568            show_npc_verb_menu: false,
14569            npc_verb_target: None,
14570            npc_verb_index: 0,
14571            player_verbs: crate::social::PlayerVerbState::default(),
14572            social_chat: crate::social::SocialChatState::default(),
14573            trade_ui: crate::social::TradeUiState::default(),
14574            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14575            show_npc_chat: false,
14576            npc_chat: None,
14577            show_inventory_menu: false,
14578            inventory_menu_index: 0,
14579            inventory_tab: InventoryTab::OnPerson,
14580            inventory_filter: String::new(),
14581            inventory_filter_focused: false,
14582            show_move_picker: false,
14583            show_rename_prompt: false,
14584            show_worker_rename: false,
14585            rename_buffer: String::new(),
14586            move_picker_index: 0,
14587            move_picker: None,
14588            show_grant_picker: false,
14589            grant_picker_index: 0,
14590            grant_picker: None,
14591            show_destroy_picker: false,
14592            destroy_confirm_pending: false,
14593            destroy_picker: None,
14594            combat_target: None,
14595            combat_target_label: None,
14596            combat_fx: Vec::new(),
14597            property_zones: Vec::new(),
14598            tax_zones: Vec::new(),
14599            growth_zones: Vec::new(),
14600            biome_zones: Vec::new(),
14601            property_plots: Vec::new(),
14602            property_plot_settings: None,
14603            claim_mode: None,
14604            relocate_mode: None,
14605            sell_plot_confirm: None,
14606            sell_plot_armed_at: None,
14607            show_plant_menu: false,
14608            plant_menu_index: 0,
14609            show_farm_access: false,
14610            farm_access_name_draft: String::new(),
14611            farm_access_discount_bps: 0,
14612            farm_access_index: 0,
14613            plant_quantity: 1,
14614            in_combat: false,
14615            auto_attack: true,
14616            combat_has_los: false,
14617            attack_cd_ticks: 0,
14618            gcd_ticks: 0,
14619            weapon_ability_id: "unarmed".into(),
14620            mainhand_template_id: None,
14621            mainhand_label: None,
14622            offhand_template_id: None,
14623            offhand_label: None,
14624            mainhand_hand_slots: 1,
14625            defense: None,
14626            worn: BTreeMap::new(),
14627            carry_mass: 0.0,
14628            carry_mass_max: 0.0,
14629            encumbrance: flatland_protocol::EncumbranceState::Light,
14630            inventory_stacks: Vec::new(),
14631            keychain_stacks: Vec::new(),
14632            whisper_pouch_stacks: Vec::new(),
14633            combat_target_detail: None,
14634            statuses: Vec::new(),
14635            cast_progress: None,
14636            timed_channel: None,
14637            ability_cooldowns: Vec::new(),
14638            blocking_active: false,
14639            max_target_slots: 1,
14640            combat_slots: Vec::new(),
14641            rotation_presets: Vec::new(),
14642            known_abilities: Vec::new(),
14643            hotbar: vec![None; 9],
14644            max_abilities_per_rotation: 0,
14645            show_loadout_menu: false,
14646            show_keychain_menu: false,
14647            keychain_menu_index: 0,
14648            show_rotation_editor: false,
14649            loadout_menu_index: 0,
14650            loadout_hotbar_slot: 1,
14651            loadout_ability_index: 0,
14652            loadout_focus_presets: false,
14653            rotation_editor: RotationEditorState::default(),
14654            harvest_in_progress: false,
14655            harvest_started_at: None,
14656            pending_craft_ack: None,
14657            pending_worker_job_ack: None,
14658            attending_worker_instance_id: None,
14659            quest_log: Vec::new(),
14660            interactables: Vec::new(),
14661            ledger: None,
14662            career: None,
14663            character_sheet_tab: CharacterSheetTab::Character,
14664            ledger_period: LedgerPeriod::Day,
14665            show_quest_offer: false,
14666            pending_quest_offer: None,
14667            show_quest_menu: false,
14668            quest_menu_index: 0,
14669            quest_withdraw_confirm: false,
14670            hired_workers: Vec::new(),
14671            show_workers_menu: false,
14672            workers_menu_index: 0,
14673            workers_menu_compact: false,
14674            worker_step_display: BTreeMap::new(),
14675            worker_error_display: BTreeMap::new(),
14676            show_worker_give_picker: false,
14677            worker_give_picker_index: 0,
14678            worker_give_picker: None,
14679            show_worker_give_target_picker: false,
14680            worker_give_target_picker_index: 0,
14681            worker_give_target_picker: None,
14682            show_worker_take_picker: false,
14683            worker_take_picker_index: 0,
14684            worker_take_picker: None,
14685            show_worker_teach_picker: false,
14686            worker_teach_picker_index: 0,
14687            worker_teach_picker: None,
14688            worker_route_editor: None,
14689            progression_curve: None,
14690        };
14691        state.player = state.entities.first().cloned();
14692        assert_eq!(
14693            state.nearest_interact_target().as_deref(),
14694            Some("ada_broker")
14695        );
14696    }
14697
14698    #[test]
14699    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
14700        let mut state = sample_state();
14701        // Player is at (128, 128) per sample_state(). One chest just inside
14702        // CONTAINER_RANGE_M, one clearly beyond it.
14703        state.placed_containers = vec![
14704            flatland_protocol::PlacedContainerView {
14705                id: "near".into(),
14706                template_id: "wooden_chest_small".into(),
14707                display_name: "Wooden Chest".into(),
14708                x: 130.0,
14709                y: 128.0,
14710                z: 0.0,
14711                locked: true,
14712                accessible: true,
14713                owner_character_id: None,
14714                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
14715                lock_id: None,
14716                capacity_volume: None,
14717                item_instance_id: Some(uuid::Uuid::from_u128(1)),
14718                tile_id: None,
14719                worker_lodging_capacity: None,
14720                blocking: false,
14721                blocking_radius_m: 0.0,
14722            },
14723            flatland_protocol::PlacedContainerView {
14724                id: "far".into(),
14725                template_id: "wooden_chest_small".into(),
14726                display_name: "Distant Chest".into(),
14727                x: 128.0 + CONTAINER_RANGE_M + 5.0,
14728                y: 128.0,
14729                z: 0.0,
14730                locked: false,
14731                accessible: true,
14732                owner_character_id: None,
14733                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
14734                lock_id: None,
14735                capacity_volume: None,
14736                item_instance_id: Some(uuid::Uuid::from_u128(2)),
14737                tile_id: None,
14738                worker_lodging_capacity: None,
14739                blocking: false,
14740                blocking_radius_m: 0.0,
14741            },
14742        ];
14743
14744        let nearby = state.nearby_containers();
14745        assert_eq!(
14746            nearby.len(),
14747            1,
14748            "far chest must not appear once out of range"
14749        );
14750        assert_eq!(nearby[0].view.id, "near");
14751        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
14752        assert!(nearby[0].rows[0].is_chest_shell);
14753
14754        // The same chest, but locked and inaccessible (no key held), must hide
14755        // contents but still show the selectable chest shell row.
14756        state.placed_containers[0].accessible = false;
14757        let nearby = state.nearby_containers();
14758        assert_eq!(nearby.len(), 1);
14759        assert_eq!(nearby[0].rows.len(), 1);
14760        assert!(nearby[0].rows[0].is_chest_shell);
14761    }
14762
14763    #[test]
14764    fn chest_pickup_destinations_offer_person_and_worn_bag() {
14765        let mut state = sample_state();
14766        let back_id = uuid::Uuid::from_u128(42);
14767        state.worn.insert(
14768            BodySlot::Back,
14769            flatland_protocol::ItemStack {
14770                template_id: "travel_backpack".into(),
14771                quantity: 1,
14772                item_instance_id: Some(back_id),
14773                props: Default::default(),
14774                status_bindings: Vec::new(),
14775                contents: Vec::new(),
14776                display_name: Some("Travel Backpack".into()),
14777                category: Some("container".into()),
14778                base_mass: Some(2.5),
14779                base_volume: Some(12.0),
14780                capacity_volume: Some(80.0),
14781                stackable: Some(false),
14782                world_placeable: Some(false),
14783                worker_lodging_capacity: None,
14784                equip_slot: None,
14785                armor_physical: None,
14786                resists: vec![],
14787                hand_slots: None,
14788            listable: None,
14789        },
14790        );
14791        let opts = state.chest_pickup_destinations("chest-1");
14792        assert!(matches!(
14793            opts.first().map(|o| &o.kind),
14794            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
14795        ));
14796        assert!(opts.iter().any(|o| matches!(
14797            &o.kind,
14798            MoveOptionKind::PickupPlaced {
14799                nest_parent_instance_id: None,
14800                ..
14801            }
14802        )));
14803        assert!(opts.iter().any(|o| matches!(
14804            &o.kind,
14805            MoveOptionKind::PickupPlaced {
14806                nest_parent_instance_id: Some(id),
14807                ..
14808            } if *id == back_id
14809        )));
14810        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14811    }
14812
14813    #[test]
14814    fn placed_container_public_label_hides_owner_custom_name() {
14815        let owner = uuid::Uuid::from_u128(99);
14816        let mut state = sample_state();
14817        state.character_id = Some(uuid::Uuid::from_u128(1));
14818        state.inventory_hints.insert(
14819            "wooden_chest_medium".into(),
14820            InventoryHint {
14821                display_name: "Medium Wooden Chest".into(),
14822                category: "container".into(),
14823                base_mass: None,
14824                base_volume: None,
14825                capacity_volume: None,
14826                stackable: false,
14827                listable: true,
14828            },
14829        );
14830        let chest = flatland_protocol::PlacedContainerView {
14831            id: "c1".into(),
14832            template_id: "wooden_chest_medium".into(),
14833            display_name: "Barry's Loot #a3f2".into(),
14834            x: 128.0,
14835            y: 128.0,
14836            z: 0.0,
14837            locked: false,
14838            accessible: true,
14839            owner_character_id: Some(owner),
14840            contents: vec![],
14841            lock_id: None,
14842            capacity_volume: None,
14843            item_instance_id: None,
14844            tile_id: None,
14845            worker_lodging_capacity: None,
14846            blocking: false,
14847            blocking_radius_m: 0.0,
14848        };
14849        assert_eq!(
14850            state.placed_container_public_label(&chest),
14851            "Medium Wooden Chest"
14852        );
14853        state.character_id = Some(owner);
14854        assert_eq!(
14855            state.placed_container_public_label(&chest),
14856            "Barry's Loot #a3f2"
14857        );
14858    }
14859
14860    #[test]
14861    fn location_context_shows_crop_growth_percent_not_depleted() {
14862        let mut state = sample_state();
14863        state.player = state.entities.first().cloned();
14864        state.resource_nodes[0].label = "Carrot (growing)".into();
14865        state.resource_nodes[0].x = 128.2;
14866        state.resource_nodes[0].y = 128.0;
14867        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
14868        state.resource_nodes[0].growth_progress = Some(0.47);
14869        let lines = state.location_context_lines();
14870        let line = lines
14871            .iter()
14872            .find(|l| l.text.contains("Carrot"))
14873            .map(|l| l.text.as_str())
14874            .unwrap_or("");
14875        assert!(
14876            line.contains("(growing, 47%)"),
14877            "expected growth percent, got: {line}"
14878        );
14879        assert!(
14880            !line.contains("depleted"),
14881            "growing crop should not show depleted: {line}"
14882        );
14883    }
14884
14885    #[test]
14886    fn resource_node_near_action_suffix_prefers_growth() {
14887        let node = ResourceNodeView {
14888            id: "crop".into(),
14889            label: "Wheat".into(),
14890            x: 0.0,
14891            y: 0.0,
14892            z: 0.0,
14893            item_template: "wheat".into(),
14894            state: ResourceNodeState::Cooldown,
14895            blocking: false,
14896            blocking_radius_m: 0.0,
14897            tile_id: None,
14898                        yaw: 0.0,
14899            pitch: 0.0,
14900            roll: 0.0,
14901            draw_scale: 1.0,
14902            sprite_mode: None,
14903            growth_progress: Some(0.12),
14904            presentation_state: None,
14905            channel_start_tick: None,
14906            channel_end_tick: None,
14907            harvest_drop_templates: vec![],
14908        };
14909        assert_eq!(
14910            resource_node_near_action_suffix(&node),
14911            " (growing, 12%)"
14912        );
14913    }
14914
14915    #[test]
14916    fn location_context_lists_nearby_resource_node() {
14917        let mut state = sample_state();
14918        state.player = state.entities.first().cloned();
14919        state.resource_nodes[0].x = 128.2;
14920        state.resource_nodes[0].y = 128.0;
14921        let lines = state.location_context_lines();
14922        assert!(
14923            lines
14924                .iter()
14925                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
14926            "expected resource node in context: {:?}",
14927            lines
14928        );
14929    }
14930
14931    #[test]
14932    fn quest_board_usable_within_board_radius() {
14933        let mut state = sample_state();
14934        state.player = state.entities.first().cloned();
14935        state.interactables = vec![flatland_protocol::InteractableView {
14936            id: "board-1".into(),
14937            kind: "quest_board".into(),
14938            label: "Town Quest Board".into(),
14939            x: 130.5,
14940            y: 128.0,
14941            z: 0.0,
14942            board_id: Some("starter_town_board".into()),
14943        }];
14944        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
14945        assert_eq!(
14946            state.nearest_interact_target().as_deref(),
14947            Some("board-1"),
14948            "quest board should be selectable at ~2.5m"
14949        );
14950        let lines = state.location_context_lines();
14951        assert!(
14952            lines
14953                .iter()
14954                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
14955            "HUD should advertise f when board is in range: {:?}",
14956            lines
14957        );
14958    }
14959
14960    #[test]
14961    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
14962        let mut state = sample_state();
14963        state.worn.insert(
14964            BodySlot::Back,
14965            flatland_protocol::ItemStack {
14966                template_id: "travel_backpack".into(),
14967                quantity: 1,
14968                item_instance_id: Some(uuid::Uuid::from_u128(3)),
14969                props: Default::default(),
14970                status_bindings: Vec::new(),
14971                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
14972                display_name: None,
14973                category: None,
14974                base_mass: None,
14975                base_volume: None,
14976                capacity_volume: None,
14977                stackable: None,
14978                world_placeable: None,
14979                worker_lodging_capacity: None,
14980                equip_slot: None,
14981                armor_physical: None,
14982                resists: vec![],
14983                hand_slots: None,
14984            listable: None,
14985        },
14986        );
14987        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
14988        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
14989            id: "chest-1".into(),
14990            template_id: "wooden_chest_small".into(),
14991            display_name: "Wooden Chest".into(),
14992            x: 129.0,
14993            y: 128.0,
14994            z: 0.0,
14995            locked: false,
14996            accessible: true,
14997            owner_character_id: None,
14998            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
14999            lock_id: None,
15000            capacity_volume: None,
15001            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15002            tile_id: None,
15003            worker_lodging_capacity: None,
15004            blocking: false,
15005            blocking_radius_m: 0.0,
15006        }];
15007
15008        state.inventory_tab = InventoryTab::OnPerson;
15009        let rows = state.inventory_selectable_rows();
15010        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15011        assert_eq!(
15012            sections,
15013            vec![
15014                InventorySection::Worn,   // backpack shell
15015                InventorySection::Worn,   // iron_ore nested in backpack
15016                InventorySection::Person, // lumber
15017            ]
15018        );
15019        assert_eq!(rows[0].stack.template_id, "travel_backpack");
15020        assert!(rows[0].is_equip_shell);
15021        assert_eq!(rows[1].stack.template_id, "iron_ore");
15022        assert_eq!(rows[1].depth, 1);
15023        assert_eq!(rows[2].stack.template_id, "lumber");
15024
15025        let lines = state.inventory_browser_lines();
15026        assert!(lines.iter().any(|l| matches!(
15027            l,
15028            InventoryBrowserLine::Section(s) if s.contains("Worn")
15029        )));
15030        assert!(lines.iter().any(|l| matches!(
15031            l,
15032            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15033                || text.contains("backpack")
15034        )));
15035        assert!(!lines.iter().any(|l| matches!(
15036            l,
15037            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15038        )));
15039
15040        state.inventory_tab = InventoryTab::Nearby;
15041        let nearby_rows = state.inventory_selectable_rows();
15042        assert_eq!(nearby_rows.len(), 2);
15043        assert!(nearby_rows[0].is_chest_shell);
15044        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15045        let nearby_lines = state.inventory_browser_lines();
15046        assert!(nearby_lines.iter().any(|l| matches!(
15047            l,
15048            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15049        )));
15050    }
15051
15052    #[test]
15053    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
15054        let mut state = sample_state();
15055        let back_id = uuid::Uuid::from_u128(5);
15056        state.worn.insert(
15057            BodySlot::Back,
15058            flatland_protocol::ItemStack {
15059                template_id: "travel_backpack".into(),
15060                quantity: 1,
15061                item_instance_id: Some(back_id),
15062                props: Default::default(),
15063                status_bindings: Vec::new(),
15064                contents: Vec::new(),
15065                display_name: None,
15066                category: Some("container".into()),
15067                base_mass: None,
15068                base_volume: None,
15069                capacity_volume: Some(80.0),
15070                stackable: None,
15071                world_placeable: None,
15072                worker_lodging_capacity: None,
15073                equip_slot: None,
15074                armor_physical: None,
15075                resists: vec![],
15076                hand_slots: None,
15077            listable: None,
15078        },
15079        );
15080        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15081            id: "chest-1".into(),
15082            template_id: "wooden_chest_small".into(),
15083            display_name: "Wooden Chest".into(),
15084            x: 129.0,
15085            y: 128.0,
15086            z: 0.0,
15087            locked: false,
15088            accessible: true,
15089            owner_character_id: None,
15090            contents: Vec::new(),
15091            lock_id: None,
15092            capacity_volume: None,
15093            item_instance_id: Some(uuid::Uuid::from_u128(6)),
15094            tile_id: None,
15095            worker_lodging_capacity: None,
15096            blocking: false,
15097            blocking_radius_m: 0.0,
15098        }];
15099
15100        // Item currently sitting loose on the person (Root): backpack + nearby
15101        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
15102        let opts = state.move_destinations_for(
15103            &flatland_protocol::InventoryLocation::Root,
15104            None,
15105            None,
15106            "lumber",
15107        );
15108        assert!(!opts.iter().any(|o| matches!(
15109            &o.kind,
15110            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15111        )));
15112        assert!(opts.iter().any(|o| matches!(
15113            &o.kind,
15114            MoveOptionKind::Move { location, parent_instance_id, .. }
15115                if *location == flatland_protocol::InventoryLocation::Worn {
15116                    slot: BodySlot::Back,
15117                } && *parent_instance_id == Some(back_id)
15118        )));
15119        assert!(opts.iter().any(|o| matches!(
15120            &o.kind,
15121            MoveOptionKind::Move { location, .. }
15122                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
15123        )));
15124        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15125        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
15126
15127        // Item currently inside the worn backpack: the backpack itself must be
15128        // excluded from its own destination list (can't move an item into the
15129        // container it's already in).
15130        let from_backpack = flatland_protocol::InventoryLocation::Worn {
15131            slot: BodySlot::Back,
15132        };
15133        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
15134        assert!(!opts.iter().any(|o| matches!(
15135            &o.kind,
15136            MoveOptionKind::Move { location, parent_instance_id, .. }
15137                if *location == from_backpack && *parent_instance_id == Some(back_id)
15138        )));
15139        assert!(opts.iter().any(|o| matches!(
15140            &o.kind,
15141            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15142        )));
15143    }
15144
15145    #[test]
15146    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
15147        let mut state = sample_state();
15148        // Insert out of display order — BTreeMap iteration must still yield the
15149        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
15150        state.worn.insert(
15151            BodySlot::Waist,
15152            flatland_protocol::ItemStack {
15153                template_id: "simple_belt".into(),
15154                quantity: 1,
15155                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15156                props: Default::default(),
15157                status_bindings: Vec::new(),
15158                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
15159                display_name: None,
15160                category: Some("container".into()),
15161                base_mass: None,
15162                base_volume: None,
15163                capacity_volume: None,
15164                stackable: None,
15165                world_placeable: None,
15166                worker_lodging_capacity: None,
15167                equip_slot: None,
15168                armor_physical: None,
15169                resists: vec![],
15170                hand_slots: None,
15171            listable: None,
15172        },
15173        );
15174        state.worn.insert(
15175            BodySlot::Head,
15176            flatland_protocol::ItemStack {
15177                template_id: "cloth_cap".into(),
15178                quantity: 1,
15179                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15180                props: Default::default(),
15181                status_bindings: Vec::new(),
15182                contents: Vec::new(),
15183                display_name: None,
15184                category: Some("armor".into()),
15185                base_mass: None,
15186                base_volume: None,
15187                capacity_volume: None,
15188                stackable: None,
15189                world_placeable: None,
15190                worker_lodging_capacity: None,
15191                equip_slot: None,
15192                armor_physical: None,
15193                resists: vec![],
15194                hand_slots: None,
15195            listable: None,
15196        },
15197        );
15198        state.worn.insert(
15199            BodySlot::Back,
15200            flatland_protocol::ItemStack {
15201                template_id: "travel_backpack".into(),
15202                quantity: 1,
15203                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15204                props: Default::default(),
15205                status_bindings: Vec::new(),
15206                contents: Vec::new(),
15207                display_name: None,
15208                category: Some("container".into()),
15209                base_mass: None,
15210                base_volume: None,
15211                capacity_volume: None,
15212                stackable: None,
15213                world_placeable: None,
15214                worker_lodging_capacity: None,
15215                equip_slot: None,
15216                armor_physical: None,
15217                resists: vec![],
15218                hand_slots: None,
15219            listable: None,
15220        },
15221        );
15222
15223        let rows = state.worn_rows();
15224        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
15225        assert_eq!(rows.len(), 4);
15226        assert_eq!(rows[0].stack.template_id, "cloth_cap");
15227        assert!(rows[0].is_equip_shell);
15228        assert_eq!(rows[1].stack.template_id, "travel_backpack");
15229        assert!(rows[1].is_equip_shell);
15230        assert_eq!(rows[2].stack.template_id, "simple_belt");
15231        assert!(rows[2].is_equip_shell);
15232        assert_eq!(rows[3].stack.template_id, "leather_pouch");
15233        assert_eq!(rows[3].depth, 1);
15234        assert!(!rows[3].is_equip_shell);
15235    }
15236
15237    #[test]
15238    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
15239        let mut state = sample_state();
15240        state.worn.insert(
15241            BodySlot::Waist,
15242            flatland_protocol::ItemStack {
15243                template_id: "simple_belt".into(),
15244                quantity: 1,
15245                item_instance_id: Some(uuid::Uuid::from_u128(20)),
15246                props: Default::default(),
15247                status_bindings: Vec::new(),
15248                contents: Vec::new(),
15249                display_name: Some("Simple Belt".into()),
15250                category: Some("container".into()),
15251                base_mass: None,
15252                base_volume: None,
15253                capacity_volume: None,
15254                stackable: None,
15255                world_placeable: None,
15256                worker_lodging_capacity: None,
15257                equip_slot: None,
15258                armor_physical: None,
15259                resists: vec![],
15260                hand_slots: None,
15261            listable: None,
15262        },
15263        );
15264        state.worn.insert(
15265            BodySlot::Head,
15266            flatland_protocol::ItemStack {
15267                template_id: "cloth_cap".into(),
15268                quantity: 1,
15269                item_instance_id: Some(uuid::Uuid::from_u128(21)),
15270                props: Default::default(),
15271                status_bindings: Vec::new(),
15272                contents: Vec::new(),
15273                display_name: Some("Cloth Cap".into()),
15274                category: Some("armor".into()),
15275                base_mass: None,
15276                base_volume: None,
15277                capacity_volume: None,
15278                stackable: None,
15279                world_placeable: None,
15280                worker_lodging_capacity: None,
15281                equip_slot: None,
15282                armor_physical: None,
15283                resists: vec![],
15284                hand_slots: None,
15285            listable: None,
15286        },
15287        );
15288
15289        let opts = state.move_destinations_for(
15290            &flatland_protocol::InventoryLocation::Root,
15291            None,
15292            None,
15293            "leather_pouch",
15294        );
15295        assert!(
15296            opts.iter().any(|o| matches!(
15297                &o.kind,
15298                MoveOptionKind::Move { location, .. }
15299                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15300            )),
15301            "belt loop must be offered when moving a pouch"
15302        );
15303        assert!(
15304            !opts.iter().any(|o| matches!(
15305                &o.kind,
15306                MoveOptionKind::Move { location, .. }
15307                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
15308            )),
15309            "armor slots can't hold other items and must not appear as move destinations"
15310        );
15311        let belt_opt = opts
15312            .iter()
15313            .find(|o| matches!(
15314                &o.kind,
15315                MoveOptionKind::Move { location, .. }
15316                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15317            ))
15318            .unwrap();
15319        assert!(belt_opt.label.contains("belt loop"));
15320
15321        let opts = state.move_destinations_for(
15322            &flatland_protocol::InventoryLocation::Root,
15323            None,
15324            None,
15325            "lumber",
15326        );
15327        assert!(
15328            !opts.iter().any(|o| o.label.contains("belt loop")),
15329            "loose materials must not target the belt shell — only nested pouches"
15330        );
15331    }
15332
15333    #[test]
15334    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
15335        let mut state = sample_state();
15336        let belt_id = uuid::Uuid::from_u128(30);
15337        let pouch_id = uuid::Uuid::from_u128(31);
15338        state.worn.insert(
15339            BodySlot::Waist,
15340            flatland_protocol::ItemStack {
15341                template_id: "simple_belt".into(),
15342                quantity: 1,
15343                item_instance_id: Some(belt_id),
15344                props: Default::default(),
15345                status_bindings: Vec::new(),
15346                world_placeable: None,
15347                worker_lodging_capacity: None,
15348                equip_slot: None,
15349                armor_physical: None,
15350                resists: vec![],
15351                hand_slots: None,
15352                contents: vec![flatland_protocol::ItemStack {
15353                    template_id: "dimensional_pouch".into(),
15354                    quantity: 1,
15355                    item_instance_id: Some(pouch_id),
15356                    props: Default::default(),
15357                    status_bindings: Vec::new(),
15358                    contents: Vec::new(),
15359                    display_name: Some("Dimensional Pouch".into()),
15360                    category: Some("container".into()),
15361                    base_mass: None,
15362                    base_volume: None,
15363                    capacity_volume: Some(200.0),
15364                    stackable: None,
15365                    world_placeable: None,
15366                    worker_lodging_capacity: None,
15367                    equip_slot: None,
15368                    armor_physical: None,
15369                    resists: vec![],
15370                    hand_slots: None,
15371            listable: None,
15372        }],
15373                display_name: Some("Simple Belt".into()),
15374                category: Some("container".into()),
15375                base_mass: None,
15376                base_volume: None,
15377                capacity_volume: None,
15378                stackable: None,
15379            listable: None,
15380        },
15381        );
15382
15383        let opts = state.move_destinations_for(
15384            &flatland_protocol::InventoryLocation::Root,
15385            None,
15386            None,
15387            "iron_ore",
15388        );
15389        assert!(
15390            opts.iter().any(|o| matches!(
15391                &o.kind,
15392                MoveOptionKind::Move {
15393                    location,
15394                    parent_instance_id,
15395                    ..
15396                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15397                    && *parent_instance_id == Some(pouch_id)
15398            )),
15399            "dimensional pouch clipped on belt must accept loose items"
15400        );
15401        assert!(
15402            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
15403            "destination label should name the pouch"
15404        );
15405    }
15406
15407    #[test]
15408    fn container_volume_label_on_placed_chest_shell() {
15409        let mut state = sample_state();
15410        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15411            id: "chest-1".into(),
15412            template_id: "wooden_chest_small".into(),
15413            display_name: "Camp Chest".into(),
15414            x: 129.0,
15415            y: 128.0,
15416            z: 0.0,
15417            locked: false,
15418            accessible: true,
15419            owner_character_id: None,
15420            contents: vec![flatland_protocol::ItemStack {
15421                template_id: "iron_ore".into(),
15422                quantity: 2,
15423                item_instance_id: None,
15424                props: Default::default(),
15425                status_bindings: Vec::new(),
15426                contents: Vec::new(),
15427                display_name: None,
15428                category: None,
15429                base_mass: None,
15430                base_volume: Some(2.0),
15431                capacity_volume: None,
15432                stackable: None,
15433                world_placeable: None,
15434                worker_lodging_capacity: None,
15435                equip_slot: None,
15436                armor_physical: None,
15437                resists: vec![],
15438                hand_slots: None,
15439            listable: None,
15440        }],
15441            lock_id: None,
15442            capacity_volume: Some(60.0),
15443            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15444            tile_id: None,
15445            worker_lodging_capacity: None,
15446            blocking: false,
15447            blocking_radius_m: 0.0,
15448        }];
15449        let nearby = state.nearby_containers();
15450        let label = state.container_volume_label(&nearby[0].rows[0]);
15451        assert!(
15452            label.contains("vol 4/60"),
15453            "expected used/cap in label, got {label}"
15454        );
15455        assert!(
15456            label.contains("56 free"),
15457            "expected free space, got {label}"
15458        );
15459    }
15460
15461    #[test]
15462    fn key_pair_chest_label_from_placed_lock_id() {
15463        let mut state = sample_state();
15464        let owner = uuid::Uuid::from_u128(77);
15465        state.character_id = Some(owner);
15466        let lock = uuid::Uuid::from_u128(99).to_string();
15467        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15468            id: "chest-1".into(),
15469            template_id: "wooden_chest_small".into(),
15470            display_name: "Barry's Loot #a3f2".into(),
15471            x: 129.0,
15472            y: 128.0,
15473            z: 0.0,
15474            locked: true,
15475            accessible: true,
15476            owner_character_id: Some(owner),
15477            contents: Vec::new(),
15478            lock_id: Some(lock.clone()),
15479            capacity_volume: None,
15480            item_instance_id: Some(uuid::Uuid::from_u128(4)),
15481            tile_id: None,
15482            worker_lodging_capacity: None,
15483            blocking: false,
15484            blocking_radius_m: 0.0,
15485        }];
15486        let key_id = uuid::Uuid::from_u128(5);
15487        let key = flatland_protocol::ItemStack {
15488            template_id: KEY_TEMPLATE.into(),
15489            quantity: 1,
15490            item_instance_id: Some(key_id),
15491            props: BTreeMap::from([
15492                (PROP_OPENS_LOCK_ID.into(), lock),
15493                (
15494                    PROP_OPENS_CONTAINER_NAME.into(),
15495                    "Barry's Loot #a3f2".into(),
15496                ),
15497            ]),
15498            status_bindings: Vec::new(),
15499            contents: Vec::new(),
15500            display_name: Some("Container Key".into()),
15501            category: Some("key".into()),
15502            base_mass: None,
15503            base_volume: None,
15504            capacity_volume: None,
15505            stackable: None,
15506            world_placeable: None,
15507            worker_lodging_capacity: None,
15508            equip_slot: None,
15509            armor_physical: None,
15510            resists: vec![],
15511            hand_slots: None,
15512            listable: None,
15513        };
15514        state.inventory_stacks = vec![key.clone()];
15515        assert_eq!(
15516            state.key_pair_chest_label(&key).as_deref(),
15517            Some("Barry's Loot #a3f2")
15518        );
15519        assert!(state.key_drop_blocked(&key));
15520    }
15521
15522    #[test]
15523    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
15524        let mut state = sample_state();
15525        let lock = uuid::Uuid::from_u128(101).to_string();
15526        let key = flatland_protocol::ItemStack {
15527            template_id: KEY_TEMPLATE.into(),
15528            quantity: 1,
15529            item_instance_id: Some(uuid::Uuid::from_u128(7)),
15530            props: BTreeMap::from([
15531                (PROP_OPENS_LOCK_ID.into(), lock),
15532                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
15533            ]),
15534            status_bindings: Vec::new(),
15535            contents: Vec::new(),
15536            display_name: None,
15537            category: Some("key".into()),
15538            base_mass: None,
15539            base_volume: None,
15540            capacity_volume: None,
15541            stackable: None,
15542            world_placeable: None,
15543            worker_lodging_capacity: None,
15544            equip_slot: None,
15545            armor_physical: None,
15546            resists: vec![],
15547            hand_slots: None,
15548            listable: None,
15549        };
15550        state.placed_containers.clear();
15551        assert_eq!(
15552            state.key_pair_chest_label(&key).as_deref(),
15553            Some("Camp Stash")
15554        );
15555    }
15556
15557    #[test]
15558    fn key_drop_allowed_when_paired_chest_unlocked() {
15559        let mut state = sample_state();
15560        let lock = uuid::Uuid::from_u128(100).to_string();
15561        let key_id = uuid::Uuid::from_u128(6);
15562        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15563            id: "chest-1".into(),
15564            template_id: "wooden_chest_small".into(),
15565            display_name: "Camp Chest".into(),
15566            x: 129.0,
15567            y: 128.0,
15568            z: 0.0,
15569            locked: false,
15570            accessible: true,
15571            owner_character_id: None,
15572            contents: Vec::new(),
15573            lock_id: Some(lock.clone()),
15574            capacity_volume: None,
15575            item_instance_id: None,
15576            tile_id: None,
15577            worker_lodging_capacity: None,
15578            blocking: false,
15579            blocking_radius_m: 0.0,
15580        }];
15581        let key = flatland_protocol::ItemStack {
15582            template_id: KEY_TEMPLATE.into(),
15583            quantity: 1,
15584            item_instance_id: Some(key_id),
15585            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
15586            status_bindings: Vec::new(),
15587            contents: Vec::new(),
15588            display_name: None,
15589            category: Some("key".into()),
15590            base_mass: None,
15591            base_volume: None,
15592            capacity_volume: None,
15593            stackable: None,
15594            world_placeable: None,
15595            worker_lodging_capacity: None,
15596                equip_slot: None,
15597                armor_physical: None,
15598                resists: vec![],
15599                hand_slots: None,
15600            listable: None,
15601        };
15602        state.inventory_stacks = vec![key.clone()];
15603        assert!(!state.key_drop_blocked(&key));
15604        let opts = state.move_destinations_for(
15605            &flatland_protocol::InventoryLocation::Root,
15606            None,
15607            Some(key_id),
15608            KEY_TEMPLATE,
15609        );
15610        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
15611    }
15612
15613    #[test]
15614    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
15615        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
15616
15617        let mut state = sample_state();
15618        let curve = ProgressionCurve::default();
15619        let bootstrap = ProgressionXp::bootstrap_new(
15620            curve.baseline_display,
15621            curve.xp_base,
15622            curve.xp_growth,
15623        );
15624        let mut fresh = bootstrap.clone();
15625        fresh.strength += 0.08;
15626        if let Some(player) = state.player.as_mut() {
15627            player.progression_xp = Some(bootstrap);
15628        }
15629
15630        let combat = CombatHud {
15631            progression_xp: Some(fresh.clone()),
15632            progression_baseline: curve.baseline_display,
15633            progression_xp_base: curve.xp_base,
15634            progression_xp_growth: curve.xp_growth,
15635            attributes: state.player.as_ref().and_then(|p| p.attributes),
15636            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
15637            ..CombatHud::default()
15638        };
15639        state.apply_combat_hud(&combat);
15640
15641        let xp = state
15642            .player
15643            .as_ref()
15644            .and_then(|p| p.progression_xp.as_ref())
15645            .expect("xp");
15646        assert!((xp.strength - fresh.strength).abs() < 0.001);
15647        assert!(state.progression_curve.is_some());
15648    }
15649
15650    #[test]
15651    fn combat_hud_syncs_known_abilities_and_hotbar() {
15652        use flatland_protocol::CombatHud;
15653
15654        let mut state = sample_state();
15655        let combat = CombatHud {
15656            known_abilities: vec!["unarmed".into(), "fireball".into()],
15657            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
15658            max_abilities_per_rotation: 4,
15659            ability_id: "short_sword_slash".into(),
15660            ..CombatHud::default()
15661        };
15662        state.apply_combat_hud(&combat);
15663
15664        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
15665        assert_eq!(state.hotbar_ability(1), Some("fireball"));
15666        assert_eq!(state.hotbar_ability(2), None);
15667        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
15668        assert_eq!(state.max_abilities_per_rotation, 4);
15669        let choices = state.loadout_ability_choices();
15670        assert!(choices.iter().any(|a| a == "short_sword_slash"));
15671        assert!(choices.iter().any(|a| a == "fireball"));
15672    }
15673
15674    #[test]
15675    fn loadout_hotbar_choices_include_inventory_consumables() {
15676        let mut state = sample_state();
15677        state.known_abilities = vec!["unarmed".into()];
15678        state.weapon_ability_id = "unarmed".into();
15679        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15680            template_id: "bottle_of_water".into(),
15681            quantity: 3,
15682            item_instance_id: Some(uuid::Uuid::from_u128(9)),
15683            display_name: Some("Bottle of Water".into()),
15684            category: Some("consumable".into()),
15685            ..Default::default()
15686        }];
15687        state.inventory.insert("bottle_of_water".into(), 3);
15688        state.inventory_hints.insert(
15689            "bottle_of_water".into(),
15690            InventoryHint {
15691                display_name: "Bottle of Water".into(),
15692                category: "consumable".into(),
15693                ..Default::default()
15694            },
15695        );
15696
15697        let choices = state.loadout_hotbar_choices();
15698        assert!(choices.iter().any(|c| c.binding == "unarmed"));
15699        let water = choices
15700            .iter()
15701            .find(|c| c.binding == "item:bottle_of_water")
15702            .expect("water binding");
15703        assert_eq!(water.meta.as_deref(), Some("use"));
15704        assert!(water.label.contains("Water"));
15705        assert_eq!(
15706            state.hotbar_slot_label(1),
15707            None,
15708            "unbound until set"
15709        );
15710        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
15711        assert_eq!(
15712            state.hotbar_slot_label(5).as_deref(),
15713            Some("Bottle of Water×3")
15714        );
15715    }
15716
15717    #[test]
15718    fn loose_consumable_move_picker_offers_use_and_storage() {
15719        let mut state = sample_state();
15720        let inst = uuid::Uuid::from_u128(77);
15721        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15722            template_id: "carrot".into(),
15723            quantity: 2,
15724            item_instance_id: Some(inst),
15725            props: Default::default(),
15726            status_bindings: Vec::new(),
15727            contents: Vec::new(),
15728            display_name: Some("Wild Carrot".into()),
15729            category: Some("consumable".into()),
15730            base_mass: None,
15731            base_volume: None,
15732            capacity_volume: None,
15733            stackable: Some(true),
15734            world_placeable: None,
15735            worker_lodging_capacity: None,
15736                equip_slot: None,
15737                armor_physical: None,
15738                resists: vec![],
15739                hand_slots: None,
15740            listable: None,
15741        }];
15742        state.inventory_hints.insert(
15743            "carrot".into(),
15744            InventoryHint {
15745                display_name: "Wild Carrot".into(),
15746                category: "consumable".into(),
15747                base_mass: Some(0.15),
15748                base_volume: Some(0.3),
15749                capacity_volume: None,
15750                stackable: true,
15751                listable: true,
15752            },
15753        );
15754        state.show_inventory_menu = true;
15755        state.inventory_menu_index = 0;
15756
15757        let row = state.inventory_selected_row().expect("carrot row");
15758        let mut options = state.move_destinations_for(
15759            &row.from,
15760            row.from_parent_instance_id,
15761            row.stack.item_instance_id,
15762            &row.stack.template_id,
15763        );
15764        if row.from == flatland_protocol::InventoryLocation::Root
15765            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
15766        {
15767            options.insert(
15768                0,
15769                MoveOption {
15770                    label: "Use (eat / drink)".into(),
15771                    kind: MoveOptionKind::Use,
15772                },
15773            );
15774        }
15775
15776        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
15777        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
15778        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
15779    }
15780
15781    #[test]
15782    fn inventory_category_group_order_is_stable() {
15783        assert_eq!(inventory_category_group("weapon").0, "Weapons");
15784        assert_eq!(inventory_category_group("armor").0, "Armor");
15785        assert_eq!(inventory_category_group("consumable").0, "Consumables");
15786        assert_eq!(inventory_category_group("resource").0, "Resources");
15787        assert_eq!(inventory_category_group("container").0, "Containers");
15788        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
15789        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
15790    }
15791
15792    #[test]
15793    fn page_list_index_clamps_without_wrap() {
15794        assert_eq!(page_list_index(0, -1, 25), 0);
15795        assert_eq!(page_list_index(0, 1, 25), 10);
15796        assert_eq!(page_list_index(12, 1, 25), 22);
15797        assert_eq!(page_list_index(22, 1, 25), 24);
15798        assert_eq!(page_list_index(5, 1, 0), 0);
15799        assert_eq!(page_list_index(3, -1, 8), 0);
15800    }
15801
15802    #[test]
15803    fn inventory_filter_hides_non_matching_person_items() {
15804        let mut state = sample_state();
15805        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15806        sword.display_name = Some("Iron Sword".into());
15807        sword.category = Some("weapon".into());
15808        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
15809        herb.display_name = Some("Wild Herb".into());
15810        herb.category = Some("consumable".into());
15811        state.inventory_stacks = vec![sword, herb];
15812        state.inventory_tab = InventoryTab::OnPerson;
15813        state.inventory_filter = "sword".into();
15814
15815        let rows = state.inventory_selectable_rows();
15816        assert_eq!(rows.len(), 1);
15817        assert_eq!(rows[0].stack.template_id, "iron_sword");
15818
15819        let lines = state.inventory_browser_lines();
15820        assert!(lines.iter().any(|l| matches!(
15821            l,
15822            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
15823        )));
15824        assert!(!lines.iter().any(|l| matches!(
15825            l,
15826            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
15827        )));
15828    }
15829
15830    #[test]
15831    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
15832        let mut state = sample_state();
15833        let id_a = uuid::Uuid::from_u128(0xa1);
15834        let id_b = uuid::Uuid::from_u128(0xb2);
15835        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
15836        sword_a.display_name = Some("Iron Sword".into());
15837        sword_a.category = Some("weapon".into());
15838        sword_a.item_instance_id = Some(id_a);
15839        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
15840        sword_b.display_name = Some("Iron Sword".into());
15841        sword_b.category = Some("weapon".into());
15842        sword_b.item_instance_id = Some(id_b);
15843        state.inventory_stacks = vec![sword_a, sword_b];
15844        state.inventory_tab = InventoryTab::OnPerson;
15845
15846        let lines = state.inventory_browser_lines();
15847        let items: Vec<_> = lines
15848            .iter()
15849            .filter_map(|l| match l {
15850                InventoryBrowserLine::Item {
15851                    title,
15852                    instance_tooltip,
15853                    ..
15854                } => Some((title.clone(), instance_tooltip.clone())),
15855                _ => None,
15856            })
15857            .collect();
15858        assert_eq!(items.len(), 2);
15859        for (title, tip) in &items {
15860            assert!(
15861                !title.contains('#'),
15862                "title should not show instance suffix: {title}"
15863            );
15864            assert!(
15865                tip.is_some(),
15866                "two identical rows should expose instance on hover"
15867            );
15868        }
15869
15870        state.inventory_stacks.pop();
15871        let lines = state.inventory_browser_lines();
15872        let one = lines.iter().find_map(|l| match l {
15873            InventoryBrowserLine::Item {
15874                title,
15875                instance_tooltip,
15876                ..
15877            } => Some((title.clone(), instance_tooltip.clone())),
15878            _ => None,
15879        });
15880        let (title, tip) = one.expect("one sword row");
15881        assert!(!title.contains('#'));
15882        assert!(tip.is_none(), "single row should not need instance tooltip");
15883    }
15884
15885    #[test]
15886    fn inventory_person_rows_group_by_category() {
15887        let mut state = sample_state();
15888        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15889        sword.category = Some("weapon".into());
15890        sword.display_name = Some("Iron Sword".into());
15891        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
15892        ore.category = Some("resource".into());
15893        ore.display_name = Some("Iron Ore".into());
15894        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
15895        potion.category = Some("consumable".into());
15896        potion.display_name = Some("Health Potion".into());
15897        state.inventory_stacks = vec![ore, potion, sword];
15898        state.inventory_tab = InventoryTab::OnPerson;
15899
15900        let lines = state.inventory_browser_lines();
15901        let labels: Vec<&str> = lines
15902            .iter()
15903            .filter_map(|l| match l {
15904                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
15905                _ => None,
15906            })
15907            .collect();
15908        assert!(
15909            labels.iter().any(|s| s.contains("Weapons")),
15910            "expected Weapons group: {labels:?}"
15911        );
15912        assert!(labels.iter().any(|s| s.contains("Consumables")));
15913        assert!(labels.iter().any(|s| s.contains("Resources")));
15914
15915        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
15916        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
15917        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
15918        assert!(weapon_pos < consumable_pos);
15919        assert!(consumable_pos < resource_pos);
15920    }
15921
15922    #[test]
15923    fn inventory_tab_cycle_resets_selection() {
15924        let mut state = sample_state();
15925        state.inventory_tab = InventoryTab::OnPerson;
15926        state.inventory_menu_index = 3;
15927        state.inventory_tab = state.inventory_tab.cycle(true);
15928        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
15929        // Client method resets index; enum cycle alone does not — verify cycle labels.
15930        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
15931        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
15932        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
15933        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
15934    }
15935
15936    #[test]
15937    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
15938        assert_eq!(parse_bank_copper_amount(""), Some(0));
15939        assert_eq!(parse_bank_copper_amount("  "), Some(0));
15940        assert_eq!(parse_bank_copper_amount("0"), Some(0));
15941        assert_eq!(parse_bank_copper_amount("250"), Some(250));
15942        assert_eq!(parse_bank_copper_amount("nope"), None);
15943    }
15944
15945    #[test]
15946    fn parse_storage_quantity_blank_and_zero_mean_all() {
15947        assert_eq!(parse_storage_quantity(""), Some(None));
15948        assert_eq!(parse_storage_quantity("  "), Some(None));
15949        assert_eq!(parse_storage_quantity("0"), Some(None));
15950        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
15951        assert_eq!(parse_storage_quantity("nope"), None);
15952    }
15953
15954    #[test]
15955    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
15956        assert!(worker_error_is_hud_noise("path stuck — repathing"));
15957        assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
15958        assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
15959        // Real problem: player may need to place lodging / fix assignment.
15960        assert!(!worker_error_is_hud_noise(
15961            "path stuck — no lodging to reset to"
15962        ));
15963    }
15964}