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, CombatCueKind,
6    CombatFxHitOutcome, CombatFxKind, CombatHud, CombatSlotHud, CombatTargetHud, DoorView,
7    EntityId, EntityState, Intent, InteriorMapView, LifeState, NpcView, RotationPreset, Seq,
8    SessionId, TerrainKindView, TerrainZoneView, Tick, 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 = 2.0;
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/// Keep a defender's HP ring visible after a hit when combat has already ended.
118const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
119
120/// Catalog hints synced from server `ItemStack` wire rows.
121#[derive(Debug, Clone, Default)]
122pub struct InventoryHint {
123    pub display_name: String,
124    pub category: String,
125    pub base_mass: Option<f32>,
126    pub base_volume: Option<f32>,
127    pub capacity_volume: Option<f32>,
128    pub stackable: bool,
129    /// Market-hall list eligibility (from catalog enrichment).
130    pub listable: bool,
131    /// NPC trade base for dump-queue payout hints.
132    pub base_value_copper: Option<u32>,
133}
134
135/// One row in the loadout hotbar picker (ability or inventory consumable).
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct LoadoutHotbarChoice {
138    /// Value sent via [`Intent::SetHotbarSlot`] (`ability_id` or `item:<template>`).
139    pub binding: String,
140    /// Display label (ability id, or "Carrot ×3").
141    pub label: String,
142    /// Optional meta badge (`weapon`, `use`, …).
143    pub meta: Option<String>,
144}
145
146/// Rotation editor overlay mode (`plans/26` §C2.5).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum RotationEditorMode {
149    #[default]
150    List,
151    EditSequence,
152    PickAbility,
153    EditLabel,
154}
155
156/// Local rotation editor UI state (not persisted).
157#[derive(Debug, Clone, Default)]
158pub struct RotationEditorState {
159    pub mode: RotationEditorMode,
160    pub list_index: usize,
161    pub ability_index: usize,
162    pub picker_index: usize,
163    pub draft: Option<RotationPreset>,
164    pub label_buffer: String,
165}
166
167impl RotationEditorState {
168    pub fn reset(&mut self) {
169        *self = Self::default();
170    }
171}
172
173/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
174/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
175/// client only ever shows chests the server will actually let you use — this is
176/// what makes a chest disappear from the menu as soon as you walk away.
177pub const CONTAINER_RANGE_M: f32 = 3.0;
178
179/// Broad section of the inventory browser a row belongs to (drives the grouped
180/// "Worn" / "On you" / "Nearby chest" headers in the UI).
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum InventorySection {
183    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
184    Worn,
185    /// Loose on your person — not worn, not inside a placed chest.
186    Person,
187    /// Inside a placed chest within reach.
188    Nearby,
189}
190
191/// Top-level inventory browser tab (`b` menu).
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum InventoryTab {
194    #[default]
195    OnPerson,
196    Nearby,
197}
198
199impl InventoryTab {
200    pub fn label(self) -> &'static str {
201        match self {
202            Self::OnPerson => "On person",
203            Self::Nearby => "Nearby storage",
204        }
205    }
206
207    pub fn cycle(self, forward: bool) -> Self {
208        match (self, forward) {
209            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
210            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
211        }
212    }
213}
214
215/// Rows jumped by PageUp / PageDown in list UIs.
216pub const LIST_PAGE_SIZE: usize = 10;
217
218/// Case-insensitive substring match for list filters (name / template / label).
219pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
220    if filter.is_empty() {
221        return true;
222    }
223    haystack
224        .to_ascii_lowercase()
225        .contains(&filter.to_ascii_lowercase())
226}
227
228/// Clamp a list selection after a page jump (`pages` is typically ±1).
229pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
230    if len == 0 {
231        return 0;
232    }
233    let page = LIST_PAGE_SIZE as i32;
234    let next = index as i32 + pages * page;
235    next.clamp(0, (len as i32) - 1) as usize
236}
237
238/// Advance `index` by `delta` among indices where `pred` is true (wraps).
239pub fn step_filtered_index(
240    index: usize,
241    delta: i32,
242    len: usize,
243    pred: impl Fn(usize) -> bool,
244) -> usize {
245    if len == 0 {
246        return 0;
247    }
248    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
249    if matching.is_empty() {
250        return index.min(len - 1);
251    }
252    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
253    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
254    matching[next]
255}
256
257/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
258pub fn page_filtered_index(
259    index: usize,
260    pages: i32,
261    len: usize,
262    pred: impl Fn(usize) -> bool,
263) -> usize {
264    if len == 0 {
265        return 0;
266    }
267    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
268    if matching.is_empty() {
269        return index.min(len - 1);
270    }
271    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
272    let next = page_list_index(pos, pages, matching.len());
273    matching[next]
274}
275
276/// Category group for On-person loose items (stable order).
277pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
278    match category {
279        "weapon" | "ammo" => ("Weapons", 0),
280        "armor" | "shield" | "offhand" => ("Armor", 1),
281        "consumable" => ("Consumables", 2),
282        "resource" | "harvest_node" | "seed" => ("Resources", 3),
283        "container" | "lodging" => ("Containers", 4),
284        "currency" | "key" => ("Currency & keys", 5),
285        "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
286        _ => ("Other", 7),
287    }
288}
289
290/// Whether a template category is listable when the wire/hint omits an explicit flag.
291pub fn category_default_listable(category: &str) -> bool {
292    !matches!(
293        category,
294        "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
295    )
296}
297
298/// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
299pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
300    if base_value == 0 {
301        return None;
302    }
303    let unit = ((base_value as f32) * 0.5).floor() as u32;
304    if unit == 0 {
305        return None;
306    }
307    Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
308}
309
310/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
311fn parse_bank_copper_amount(input: &str) -> Option<u64> {
312    let s = input.trim();
313    if s.is_empty() {
314        return Some(0);
315    }
316    s.parse::<u64>().ok()
317}
318
319/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
320fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
321    let s = input.trim();
322    if s.is_empty() || s == "0" {
323        return Some(None);
324    }
325    let n = s.parse::<u32>().ok()?;
326    if n == 0 {
327        return Some(None);
328    }
329    Some(Some(n))
330}
331
332fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
333    let name = stack
334        .display_name
335        .as_deref()
336        .unwrap_or(stack.template_id.as_str());
337    if stack.quantity > 1 {
338        format!("{name} ×{}", stack.quantity)
339    } else {
340        name.to_string()
341    }
342}
343
344/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
345/// browser section headers and the move-destination picker.
346pub fn body_slot_label(slot: BodySlot) -> &'static str {
347    match slot {
348        BodySlot::Head => "Head",
349        BodySlot::Chest => "Chest",
350        BodySlot::Forearms => "Forearms",
351        BodySlot::Legs => "Legs",
352        BodySlot::Feet => "Feet",
353        BodySlot::Cloak => "Cloak",
354        BodySlot::Back => "Back",
355        BodySlot::Waist => "Waist",
356        BodySlot::Earrings => "Earrings",
357        BodySlot::Necklace => "Necklace",
358        BodySlot::Eyeglasses => "Eyeglasses",
359        BodySlot::RingLeft1 => "Ring L1",
360        BodySlot::RingLeft2 => "Ring L2",
361        BodySlot::RingRight1 => "Ring R1",
362        BodySlot::RingRight2 => "Ring R2",
363    }
364}
365
366fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
367    let cat = stack.category.as_deref().unwrap_or("");
368    match mode {
369        "while_equipped" => {
370            stack.equip_slot.is_some()
371                || cat == "weapon"
372                || cat == "shield"
373                || cat == "offhand"
374                || cat == "armor"
375        }
376        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
377    }
378}
379
380fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
381    if grant_tags.is_empty() {
382        return true;
383    }
384    let target_tags: Vec<&str> = stack
385        .props
386        .get("allowed_enchant_tags")
387        .map(|s| {
388            s.split(',')
389                .map(str::trim)
390                .filter(|t| !t.is_empty())
391                .collect()
392        })
393        .unwrap_or_default();
394    if target_tags.is_empty() {
395        return true;
396    }
397    grant_tags.iter().any(|t| target_tags.contains(t))
398}
399
400/// Default sim tick rate when the client has no settings (matches server-settings).
401pub const DEFAULT_TICK_HZ: u32 = 30;
402
403/// Human-readable remaining time for an item status binding.
404pub fn format_binding_ttl(
405    binding: &flatland_protocol::ItemStatusBinding,
406    tick: u64,
407    tick_hz: u32,
408) -> String {
409    let Some(expires) = binding.expires_at_tick else {
410        return "permanent".into();
411    };
412    let hz = tick_hz.max(1) as f32;
413    let remaining = expires.saturating_sub(tick) as f32 / hz;
414    if remaining <= 0.0 {
415        return "expired".into();
416    }
417    if remaining >= 120.0 {
418        format!("{:.0}m left", remaining / 60.0)
419    } else if remaining >= 10.0 {
420        format!("{remaining:.0}s left")
421    } else {
422        format!("{remaining:.1}s left")
423    }
424}
425
426pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
427    match mode {
428        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
429        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
430    }
431}
432
433/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
434pub fn format_status_bindings_suffix(
435    bindings: &[flatland_protocol::ItemStatusBinding],
436    tick: u64,
437    tick_hz: u32,
438) -> String {
439    if bindings.is_empty() {
440        return String::new();
441    }
442    let parts: Vec<String> = bindings
443        .iter()
444        .map(|b| {
445            format!(
446                "{} ({}, {})",
447                b.effect_id,
448                format_binding_mode(b.mode),
449                format_binding_ttl(b, tick, tick_hz)
450            )
451        })
452        .collect();
453    format!(" · {}", parts.join("; "))
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum EquipPaperdollRow {
458    Body { slot: BodySlot, filled: bool },
459    Mainhand { filled: bool },
460    Offhand { filled: bool, locked: bool },
461}
462
463pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
464    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
465        .iter()
466        .map(|slot| EquipPaperdollRow::Body {
467            slot: *slot,
468            filled: state.worn.contains_key(slot),
469        })
470        .collect();
471    let two_hand = state.mainhand_hand_slots >= 2;
472    rows.push(EquipPaperdollRow::Mainhand {
473        filled: state.mainhand_template_id.is_some(),
474    });
475    rows.push(EquipPaperdollRow::Offhand {
476        filled: state.offhand_template_id.is_some(),
477        locked: two_hand,
478    });
479    rows
480}
481
482fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
483    for stack in &state.inventory_stacks {
484        let matches = stack
485            .equip_slot
486            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
487            .unwrap_or(false)
488            || guess_body_slot(&stack.template_id) == Some(slot);
489        if matches {
490            return stack.item_instance_id;
491        }
492    }
493    None
494}
495
496fn is_client_ring(slot: BodySlot) -> bool {
497    matches!(
498        slot,
499        BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
500    )
501}
502
503fn first_inventory_weapon(state: &GameState) -> Option<String> {
504    for stack in &state.inventory_stacks {
505        if stack.category.as_deref() == Some("weapon") {
506            return Some(stack.template_id.clone());
507        }
508    }
509    None
510}
511
512fn first_inventory_offhand(state: &GameState) -> Option<String> {
513    for stack in &state.inventory_stacks {
514        let cat = stack.category.as_deref().unwrap_or("");
515        if matches!(cat, "shield" | "offhand") {
516            return Some(stack.template_id.clone());
517        }
518    }
519    None
520}
521
522/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
523/// on the stack when present; otherwise guesses from `template_id`.
524fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
525    if template_id.contains("backpack") {
526        Some(BodySlot::Back)
527    } else if template_id.contains("belt") {
528        Some(BodySlot::Waist)
529    } else if template_id.contains("cloak") || template_id.contains("cape") {
530        Some(BodySlot::Cloak)
531    } else if template_id.contains("cap")
532        || template_id.contains("hat")
533        || template_id.contains("helm")
534    {
535        Some(BodySlot::Head)
536    } else if template_id.contains("shirt")
537        || template_id.contains("robe")
538        || template_id.contains("vest")
539        || template_id.contains("chest")
540        || template_id.contains("jerkin")
541    {
542        Some(BodySlot::Chest)
543    } else if template_id.contains("sleeves")
544        || template_id.contains("gloves")
545        || template_id.contains("gauntlets")
546    {
547        Some(BodySlot::Forearms)
548    } else if template_id.contains("pants") || template_id.contains("leggings") {
549        Some(BodySlot::Legs)
550    } else if template_id.contains("boots") || template_id.contains("shoes") {
551        Some(BodySlot::Feet)
552    } else if template_id.contains("earring") {
553        Some(BodySlot::Earrings)
554    } else if template_id.contains("necklace") || template_id.contains("amulet") {
555        Some(BodySlot::Necklace)
556    } else if template_id.contains("glass")
557        || template_id.contains("spectacles")
558        || template_id.contains("goggles")
559    {
560        Some(BodySlot::Eyeglasses)
561    } else if template_id.contains("ring") {
562        Some(BodySlot::RingLeft1)
563    } else {
564        None
565    }
566}
567
568/// One row in the inventory browser tree.
569#[derive(Debug, Clone)]
570pub struct InventoryRow {
571    pub depth: usize,
572    pub stack: flatland_protocol::ItemStack,
573    /// `MoveItem` source location for this stack.
574    pub from: flatland_protocol::InventoryLocation,
575    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
576    pub from_parent_instance_id: Option<uuid::Uuid>,
577    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
578    pub is_equip_shell: bool,
579    /// Placed world chest shell — lock/unlock via Enter or `l`.
580    pub is_chest_shell: bool,
581    pub section: InventorySection,
582}
583
584/// Formatted inventory row text shared by TUI and gfx browsers.
585#[derive(Debug, Clone)]
586pub struct InventoryRowView {
587    pub depth: usize,
588    /// Dense single-line label (legacy / TUI).
589    pub text: String,
590    /// Primary label for redesigned gfx rows (name, qty, slot).
591    pub title: String,
592    pub mass_kg: Option<f32>,
593    pub volume: Option<(f32, f32)>,
594    /// Hover hint when multiple rows share the same visible identity (template + label + mods).
595    pub instance_tooltip: Option<String>,
596}
597
598/// One line in the sectioned inventory browser (headers are non-selectable).
599#[derive(Debug, Clone)]
600pub enum InventoryBrowserLine {
601    Section(String),
602    SlotLabel(String),
603    Hint(String),
604    Blank,
605    Item {
606        selectable_index: usize,
607        selected: bool,
608        depth: usize,
609        text: String,
610        title: String,
611        mass_kg: Option<f32>,
612        volume: Option<(f32, f32)>,
613        instance_tooltip: Option<String>,
614    },
615}
616
617/// Bank teller panel focus (action list vs amount / transfer prompts).
618#[derive(Debug, Clone, PartialEq, Eq, Default)]
619pub enum BankUiMode {
620    #[default]
621    Menu,
622    DepositAmount {
623        input: String,
624    },
625    WithdrawAmount {
626        input: String,
627    },
628    TransferName {
629        input: String,
630    },
631    TransferAmount {
632        to_name: String,
633        input: String,
634    },
635}
636
637/// Town storage manager focus (action list vs item pickers vs quantity).
638#[derive(Debug, Clone, PartialEq, Eq, Default)]
639pub enum StorageUiMode {
640    #[default]
641    Menu,
642    /// Pick a loose on-person stack to store.
643    StorePick { index: usize },
644    /// Quantity for a chosen store stack (blank/0 = all).
645    StoreAmount {
646        pick_index: usize,
647        item_instance_id: uuid::Uuid,
648        label: String,
649        max_qty: u32,
650        input: String,
651    },
652    /// Pick a vault stack to take.
653    TakePick { index: usize },
654    /// Quantity for a chosen take stack (blank/0 = all).
655    TakeAmount {
656        pick_index: usize,
657        item_instance_id: uuid::Uuid,
658        label: String,
659        max_qty: u32,
660        input: String,
661    },
662    /// Pick a vault stack to ship to `dest_building_id`.
663    ShipPick {
664        dest_building_id: String,
665        dest_label: String,
666        index: usize,
667    },
668    /// Quantity for a chosen ship stack (blank/0 = all).
669    ShipAmount {
670        dest_building_id: String,
671        dest_label: String,
672        pick_index: usize,
673        item_instance_id: uuid::Uuid,
674        label: String,
675        max_qty: u32,
676        input: String,
677    },
678}
679
680/// Where market list goods are taken from (`GoodsLocation` on submit).
681#[derive(Debug, Clone, PartialEq, Eq)]
682pub enum MarketListSourceKind {
683    Person,
684    TownStorage { building_id: String },
685}
686
687/// Market hall clerk focus (browse vs list wizard).
688#[derive(Debug, Clone, PartialEq, Eq, Default)]
689pub enum MarketUiMode {
690    #[default]
691    Browse,
692    /// Pick on-person vs an eligible town vault.
693    ListSource { index: usize },
694    /// Pick a stack from the chosen source.
695    ListPick {
696        source: MarketListSourceKind,
697        index: usize,
698    },
699    /// Quantity to list (blank/0 = all).
700    ListAmount {
701        source: MarketListSourceKind,
702        pick_index: usize,
703        item_instance_id: uuid::Uuid,
704        template_id: String,
705        label: String,
706        max_qty: u32,
707        input: String,
708    },
709    /// Choose Fixed copper vs NPC-price dump queue (`plans/53`).
710    ListPricingMode {
711        source: MarketListSourceKind,
712        pick_index: usize,
713        item_instance_id: uuid::Uuid,
714        template_id: String,
715        label: String,
716        quantity: Option<u32>,
717        max_qty: u32,
718        /// 0 = NPC price, 1 = Fixed.
719        index: usize,
720    },
721    /// Unit price in copper.
722    ListPrice {
723        source: MarketListSourceKind,
724        pick_index: usize,
725        item_instance_id: uuid::Uuid,
726        template_id: String,
727        label: String,
728        /// Resolved quantity to list (`None` = all / omit on wire).
729        quantity: Option<u32>,
730        max_qty: u32,
731        input: String,
732    },
733}
734
735/// One selectable stack in a storage store/take/ship picker.
736#[derive(Debug, Clone)]
737pub struct StoragePickOption {
738    pub item_instance_id: uuid::Uuid,
739    pub template_id: String,
740    pub label: String,
741    pub quantity: u32,
742    /// Catalog category when known (market list filters).
743    pub category: String,
744}
745
746/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
747/// the browser (empty when locked without the matching key).
748#[derive(Debug, Clone)]
749pub struct NearbyContainer {
750    pub view: flatland_protocol::PlacedContainerView,
751    pub distance_m: f32,
752    pub rows: Vec<InventoryRow>,
753}
754
755/// One key row in the keychain overlay (carried vs stowed).
756#[derive(Debug, Clone)]
757pub struct KeychainEntry {
758    pub stack: flatland_protocol::ItemStack,
759    pub stowed: bool,
760}
761
762/// A destination the currently-picked item could be moved to.
763#[derive(Debug, Clone)]
764pub struct MoveOption {
765    pub label: String,
766    pub kind: MoveOptionKind,
767}
768
769#[derive(Debug, Clone, PartialEq)]
770pub enum MoveOptionKind {
771    Move {
772        location: flatland_protocol::InventoryLocation,
773        parent_instance_id: Option<uuid::Uuid>,
774    },
775    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
776    PickupPlaced {
777        container_id: String,
778        nest_location: flatland_protocol::InventoryLocation,
779        nest_parent_instance_id: Option<uuid::Uuid>,
780    },
781    /// Enter map relocate mode for a placed chest (no pickup).
782    RelocatePlaced {
783        container_id: String,
784    },
785    /// Eat/drink a loose consumable (one unit per use).
786    Use,
787    /// Open grant-target picker for `grants_item_status` consumables.
788    GrantApply,
789    Drop,
790    /// Sell the plot tied to a property deed back to the crown.
791    SellPlotToCrown {
792        plot_id: uuid::Uuid,
793    },
794    Cancel,
795}
796
797/// One navigable row in the deed farm-access panel.
798#[derive(Debug, Clone, PartialEq)]
799pub enum FarmAccessRow {
800    PublicToggle,
801    PublicDiscount,
802    AllowRemove {
803        character_id: uuid::Uuid,
804        label: String,
805        tax_discount_bps: u32,
806    },
807    NearbyAdd {
808        name: String,
809    },
810}
811
812/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
813#[derive(Debug, Clone)]
814pub struct GrantTargetPicker {
815    pub grant_instance_id: uuid::Uuid,
816    pub grant_label: String,
817    pub effect_id: String,
818    pub mode: String,
819    pub options: Vec<GrantTargetOption>,
820    pub filter: String,
821    pub filter_focused: bool,
822}
823
824#[derive(Debug, Clone)]
825pub struct GrantTargetOption {
826    pub label: String,
827    pub target_instance_id: uuid::Uuid,
828}
829
830/// Active "move to…" destination picker state for the selected inventory item.
831#[derive(Debug, Clone)]
832pub struct MovePicker {
833    pub item_instance_id: uuid::Uuid,
834    pub from: flatland_protocol::InventoryLocation,
835    pub item_label: String,
836    pub template_id: String,
837    pub stack_quantity: u32,
838    pub quantity: u32,
839    pub options: Vec<MoveOption>,
840    pub filter: String,
841    pub filter_focused: bool,
842}
843
844/// Active permanent-delete picker for the selected inventory item.
845#[derive(Debug, Clone)]
846pub struct DestroyPicker {
847    pub item_instance_id: uuid::Uuid,
848    pub from: flatland_protocol::InventoryLocation,
849    pub item_label: String,
850    pub stack_quantity: u32,
851    pub quantity: u32,
852}
853
854/// One giveable stack in the workers-menu give picker.
855#[derive(Debug, Clone)]
856pub struct WorkerGiveOption {
857    pub item_instance_id: uuid::Uuid,
858    pub label: String,
859    pub quantity: u32,
860    pub template_id: String,
861}
862
863/// Give an on-person inventory stack to the selected hired worker.
864#[derive(Debug, Clone)]
865pub struct WorkerGivePicker {
866    pub worker_instance_id: String,
867    pub worker_label: String,
868    pub options: Vec<WorkerGiveOption>,
869}
870
871/// Nearby hired worker choice when giving a selected inventory stack (`g`).
872#[derive(Debug, Clone)]
873pub struct WorkerGiveTargetOption {
874    pub instance_id: String,
875    pub label: String,
876    pub distance_m: f32,
877}
878
879/// Pick which nearby worker receives the selected inventory item.
880#[derive(Debug, Clone)]
881pub struct WorkerGiveTargetPicker {
882    pub item_instance_id: uuid::Uuid,
883    pub item_label: String,
884    pub quantity: Option<u32>,
885    pub options: Vec<WorkerGiveTargetOption>,
886}
887
888/// Take an item from a hired worker back into the employer's inventory.
889#[derive(Debug, Clone)]
890pub struct WorkerTakePicker {
891    pub worker_instance_id: String,
892    pub worker_label: String,
893    pub options: Vec<WorkerGiveOption>,
894    /// How many of the selected stack to take (1..=stack quantity).
895    pub quantity: u32,
896}
897
898/// Max distance (m) to hand an item to a hired worker.
899pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
900
901/// One teachable blueprint in the workers-menu teach picker.
902#[derive(Debug, Clone)]
903pub struct WorkerTeachOption {
904    pub blueprint_id: String,
905    pub label: String,
906    pub cost_copper: u64,
907    pub min_level: u32,
908    pub worker_level: u32,
909    pub can_afford: bool,
910    pub level_ok: bool,
911}
912
913/// Teach a known blueprint to the selected hired worker.
914#[derive(Debug, Clone)]
915pub struct WorkerTeachPicker {
916    pub worker_instance_id: String,
917    pub worker_label: String,
918    pub worker_level: u32,
919    pub options: Vec<WorkerTeachOption>,
920}
921
922/// Selected worker awaiting confirmation before permanent dismissal.
923#[derive(Debug, Clone)]
924pub struct WorkerDismissConfirmation {
925    pub worker_instance_id: String,
926    pub worker_label: String,
927}
928
929/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
930/// do not thrash the UI.
931#[derive(Debug, Clone, Default)]
932pub struct StickyWorkerStep {
933    shown: String,
934    pending: String,
935    pending_since: Option<Instant>,
936}
937
938impl StickyWorkerStep {
939    fn from_label(label: String) -> Self {
940        Self {
941            shown: label.clone(),
942            pending: label,
943            pending_since: Some(Instant::now()),
944        }
945    }
946
947    fn observe(&mut self, label: &str, now: Instant) {
948        let pending_since = self.pending_since.unwrap_or(now);
949        if label == self.pending {
950            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
951                self.shown = self.pending.clone();
952            }
953            return;
954        }
955        self.pending = label.to_string();
956        self.pending_since = Some(now);
957        // Empty → first value, or first observation: show immediately.
958        if self.shown.is_empty() {
959            self.shown = self.pending.clone();
960        }
961    }
962}
963
964/// Last non-transient worker error — held so route flicker stays readable.
965/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
966#[derive(Debug, Clone, Default)]
967pub struct StickyWorkerError {
968    message: String,
969    last_seen: Option<Instant>,
970}
971
972impl StickyWorkerError {
973    fn observe(&mut self, err: Option<&str>, now: Instant) {
974        if let Some(e) = err {
975            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
976                self.message = e.to_string();
977                self.last_seen = Some(now);
978            }
979            return;
980        }
981        if let Some(seen) = self.last_seen {
982            if now.duration_since(seen) > WORKER_ERROR_HOLD {
983                self.message.clear();
984                self.last_seen = None;
985            }
986        }
987    }
988
989    pub fn shown(&self, now: Instant) -> Option<&str> {
990        if self.message.is_empty() {
991            return None;
992        }
993        let seen = self.last_seen?;
994        if now.duration_since(seen) > WORKER_ERROR_HOLD {
995            return None;
996        }
997        Some(self.message.as_str())
998    }
999}
1000
1001/// First non-transient worker issue for the status bar (strike, route error, etc.).
1002/// Routine recovery noise (path failures → lodging) stays in the game log only.
1003pub fn worker_attention_line(state: &GameState) -> Option<String> {
1004    use flatland_protocol::WorkerStateView;
1005    let now = Instant::now();
1006    for w in &state.hired_workers {
1007        if matches!(w.state, WorkerStateView::Strike) {
1008            return Some(format!(
1009                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1010                w.label
1011            ));
1012        }
1013        let sticky = state
1014            .worker_error_display
1015            .get(&w.instance_id)
1016            .and_then(|s| s.shown(now))
1017            .filter(|e| !worker_error_is_hud_noise(e));
1018        let live = w
1019            .last_error
1020            .as_deref()
1021            .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1022        if let Some(err) = sticky.or(live) {
1023            if let Some(hint) = w
1024                .issue_hint
1025                .as_deref()
1026                .filter(|h| !h.is_empty())
1027                .or_else(|| worker_issue_fix_hint(err))
1028            {
1029                return Some(format!("Worker {}: {err} — {hint}", w.label));
1030            }
1031            return Some(format!("Worker {}: {err}", w.label));
1032        }
1033        // Plan issue with hint but cleared last_error (sticky already shown above).
1034        if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1035            return Some(format!("Worker {}: {hint}", w.label));
1036        }
1037    }
1038    None
1039}
1040
1041/// Fallback fix copy when the server did not send `issue_hint`.
1042pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1043    let e = err.to_ascii_lowercase();
1044    if e.contains("missing")
1045        || e.contains("container not found")
1046        || e.contains("lodging container not found")
1047    {
1048        return Some("edit route (e): replace the missing chest/bed");
1049    }
1050    if e.contains("stranded at interior") || e.contains("interior map coords") {
1051        return Some("recovered — continuing route");
1052    }
1053    if e.contains("stuck inside")
1054        || e.contains("sent outside")
1055        || e.contains("sent to door")
1056        || e.contains("left building")
1057    {
1058        return Some("auto-exit for outdoor work — restart after update if it still loops");
1059    }
1060    if e.contains("collapsed") || e.contains("need food") {
1061        return Some("stock lodging bed with food and drink");
1062    }
1063    if e.contains("overburdened") {
1064        return Some("add a deposit/sell stop, or empty their pack");
1065    }
1066    if e.contains("need a hoe") || e.contains("need a dibber") {
1067        return Some("give them the tool or withdraw it on the route");
1068    }
1069    None
1070}
1071
1072/// True when a worker `last_error` is transient noise (soft-skip chatter).
1073pub fn worker_error_is_transient(err: &str) -> bool {
1074    let e = err.to_ascii_lowercase();
1075    e.contains("continuing route")
1076        || e.contains("storage full")
1077        || e.starts_with("nothing to withdraw")
1078}
1079
1080/// True for routine worker recoveries that should not paint the status bar.
1081/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1082pub fn worker_error_is_hud_noise(err: &str) -> bool {
1083    let e = err.to_ascii_lowercase();
1084    e.contains("returned to lodging after path")
1085        || e.contains("path failure")
1086        || e.contains("no path to")
1087        || e.contains("pathfinding")
1088        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1089        || e.contains("repathing")
1090        || e.contains("nudged clear")
1091        // Auto space rescue — informative in the log, not a red alert.
1092        || e.contains("auto-recovery")
1093        || e.contains("stranded at interior map coords")
1094}
1095
1096/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1097#[derive(Debug, Clone)]
1098pub struct PendingWorkerJobAck {
1099    pub seq: u32,
1100    pub worker_instance_id: String,
1101    pub worker_label: String,
1102    pub idle: bool,
1103    pub stop_count: usize,
1104    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1105    pub prev_mode: flatland_protocol::WorkerModeView,
1106    pub prev_step_label: String,
1107    pub prev_last_error: Option<String>,
1108}
1109
1110fn push_inventory_rows(
1111    rows: &mut Vec<InventoryRow>,
1112    depth: usize,
1113    stack: &flatland_protocol::ItemStack,
1114    from: &flatland_protocol::InventoryLocation,
1115    from_parent_instance_id: Option<uuid::Uuid>,
1116    section: InventorySection,
1117) {
1118    push_inventory_rows_filtered(
1119        rows,
1120        depth,
1121        stack,
1122        from,
1123        from_parent_instance_id,
1124        section,
1125        "",
1126    );
1127}
1128
1129fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1130    if filter.is_empty() {
1131        return true;
1132    }
1133    let f = filter.to_ascii_lowercase();
1134    let name = stack
1135        .display_name
1136        .as_deref()
1137        .unwrap_or("")
1138        .to_ascii_lowercase();
1139    let tid = stack.template_id.to_ascii_lowercase();
1140    name.contains(&f)
1141        || tid.contains(&f)
1142        || stack
1143            .contents
1144            .iter()
1145            .any(|c| stack_matches_filter(c, filter))
1146}
1147
1148fn push_inventory_rows_filtered(
1149    rows: &mut Vec<InventoryRow>,
1150    depth: usize,
1151    stack: &flatland_protocol::ItemStack,
1152    from: &flatland_protocol::InventoryLocation,
1153    from_parent_instance_id: Option<uuid::Uuid>,
1154    section: InventorySection,
1155    filter: &str,
1156) {
1157    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1158        return;
1159    }
1160    let self_hit = filter.is_empty() || {
1161        let f = filter.to_ascii_lowercase();
1162        let name = stack
1163            .display_name
1164            .as_deref()
1165            .unwrap_or("")
1166            .to_ascii_lowercase();
1167        let tid = stack.template_id.to_ascii_lowercase();
1168        name.contains(&f) || tid.contains(&f)
1169    };
1170    rows.push(InventoryRow {
1171        depth,
1172        stack: stack.clone(),
1173        from: from.clone(),
1174        from_parent_instance_id,
1175        is_equip_shell: false,
1176        is_chest_shell: false,
1177        section,
1178    });
1179    for child in &stack.contents {
1180        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1181            push_inventory_rows_filtered(
1182                rows,
1183                depth + 1,
1184                child,
1185                from,
1186                stack.item_instance_id,
1187                section,
1188                if self_hit { "" } else { filter },
1189            );
1190        }
1191    }
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1195pub enum ShopTab {
1196    #[default]
1197    Buy,
1198    Sell,
1199}
1200
1201#[derive(Debug, Clone)]
1202pub struct NpcChatState {
1203    pub npc_id: String,
1204    pub npc_label: String,
1205    pub lines: Vec<String>,
1206    pub input: String,
1207    pub pending: bool,
1208    pub talk_depth: flatland_protocol::NpcTalkDepth,
1209    pub trade_allowed: bool,
1210    pub banner: Option<String>,
1211    pub suggested_topics: Vec<String>,
1212}
1213
1214impl Default for NpcChatState {
1215    fn default() -> Self {
1216        Self {
1217            npc_id: String::new(),
1218            npc_label: String::new(),
1219            lines: Vec::new(),
1220            input: String::new(),
1221            pending: false,
1222            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1223            trade_allowed: true,
1224            banner: None,
1225            suggested_topics: Vec::new(),
1226        }
1227    }
1228}
1229
1230/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1231pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1232    npc.entity_id
1233        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1234        .map(|e| (e.transform.position.x, e.transform.position.y))
1235        .unwrap_or((npc.x, npc.y))
1236}
1237
1238#[derive(Debug, Clone)]
1239pub struct GameState {
1240    pub session_id: SessionId,
1241    pub entity_id: EntityId,
1242    /// Logged-in character — used to show owner-only container labels.
1243    pub character_id: Option<uuid::Uuid>,
1244    pub tick: Tick,
1245    pub chunk_rev: u64,
1246    pub content_rev: u64,
1247    pub publish_rev: u64,
1248    pub entities: Vec<EntityState>,
1249    pub player: Option<EntityState>,
1250    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1251    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1252    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1253    pub buildings: Vec<BuildingView>,
1254    pub doors: Vec<DoorView>,
1255    pub interior_map: Option<InteriorMapView>,
1256    pub npcs: Vec<NpcView>,
1257    pub blueprints: Vec<BlueprintView>,
1258    /// Wall/roof packs for the B plot-build menu.
1259    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1260    /// Outdoor play AABB origin (meters).
1261    pub world_x0: f32,
1262    pub world_y0: f32,
1263    pub world_width_m: f32,
1264    pub world_height_m: f32,
1265    pub terrain_zones: Vec<TerrainZoneView>,
1266    pub z_platforms: Vec<ZPlatformView>,
1267    pub z_transitions: Vec<ZTransitionView>,
1268    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1269    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1270    #[doc(hidden)]
1271    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1272    pub world_clock: flatland_protocol::WorldClock,
1273    pub inventory: std::collections::HashMap<String, u32>,
1274    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1275    pub logs: VecDeque<String>,
1276    pub intents_sent: u64,
1277    pub ticks_received: u64,
1278    pub connected: bool,
1279    pub disconnect_reason: Option<String>,
1280    pub show_stats: bool,
1281    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1282    pub hud_log_hidden: bool,
1283    pub show_equip_menu: bool,
1284    pub equip_menu_index: usize,
1285    pub show_craft_menu: bool,
1286    pub craft_menu_index: usize,
1287    /// How many timed crafts to queue when confirming the craft menu.
1288    pub craft_batch_quantity: u32,
1289    /// B — wall/roof material picker for plot buildings.
1290    pub show_plot_build_menu: bool,
1291    /// True = navigate walls; false = navigate roofs (Tab switches).
1292    pub plot_build_focus_wall: bool,
1293    pub plot_build_wall_index: usize,
1294    pub plot_build_roof_index: usize,
1295    pub show_shop_menu: bool,
1296    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1297    pub bank_panel: Option<flatland_protocol::BankPanel>,
1298    pub bank_menu_index: usize,
1299    pub bank_ui_mode: BankUiMode,
1300    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1301    pub market_panel: Option<flatland_protocol::MarketPanel>,
1302    /// Selected row among filtered market browse listings.
1303    pub market_menu_index: usize,
1304    /// Browse / list-pick text filter (`/` to focus).
1305    pub market_filter: String,
1306    pub market_filter_focused: bool,
1307    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1308    pub market_category_filter: Option<&'static str>,
1309    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1310    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1311    pub market_ui_mode: MarketUiMode,
1312    pub storage_menu_index: usize,
1313    pub storage_ui_mode: StorageUiMode,
1314    pub shop_tab: ShopTab,
1315    pub shop_menu_index: usize,
1316    pub shop_quantity: u32,
1317    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1318    pub shop_trade_log: VecDeque<String>,
1319    pub show_npc_verb_menu: bool,
1320    pub npc_verb_target: Option<String>,
1321    pub npc_verb_index: usize,
1322    /// Nearby player Speak / Whisper / Trade dock.
1323    pub player_verbs: crate::social::PlayerVerbState,
1324    pub social_chat: crate::social::SocialChatState,
1325    pub trade_ui: crate::social::TradeUiState,
1326    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1327    pub show_npc_chat: bool,
1328    pub npc_chat: Option<NpcChatState>,
1329    pub show_inventory_menu: bool,
1330    pub inventory_menu_index: usize,
1331    pub inventory_tab: InventoryTab,
1332    pub inventory_filter: String,
1333    pub inventory_filter_focused: bool,
1334    pub show_move_picker: bool,
1335    pub move_picker_index: usize,
1336    pub move_picker: Option<MovePicker>,
1337    pub show_grant_picker: bool,
1338    pub grant_picker_index: usize,
1339    pub grant_picker: Option<GrantTargetPicker>,
1340    pub show_destroy_picker: bool,
1341    pub destroy_confirm_pending: bool,
1342    pub destroy_picker: Option<DestroyPicker>,
1343    /// Rename prompt for a selected container (`n` in inventory).
1344    pub show_rename_prompt: bool,
1345    /// When set, the rename prompt targets a property plot (deed or on-plot).
1346    pub rename_plot_id: Option<uuid::Uuid>,
1347    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1348    pub highlighted_plot_id: Option<uuid::Uuid>,
1349    /// Rename prompt for a hired worker (`n` in workers menu).
1350    pub show_worker_rename: bool,
1351    pub rename_buffer: String,
1352    /// Slot-1 combat target (mirrors server after SetTarget).
1353    pub combat_target: Option<EntityId>,
1354    pub combat_target_label: Option<String>,
1355    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1356    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1357    pub ground_target: Option<(f32, f32, f32)>,
1358    /// Active combat footprints from the server (`plans/39`).
1359    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1360    /// Lingering ground hazards (boss puddles) from the server.
1361    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1362    /// Authored crown property zones (claimable land) — plan 40.
1363    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1364    /// Tax overlays for claim cost premium preview.
1365    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1366    /// Growth / fertility overlays (farming, respawn).
1367    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1368    /// Climate / biome overlays.
1369    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1370    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1371    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1372    /// Claimed property plots near the observer.
1373    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1374    /// Server knobs for client-side claim quotes.
1375    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1376    /// Local claim-footprint editor (not server state).
1377    pub claim_mode: Option<ClaimModeState>,
1378    /// Local relocate ghost for a placed chest/lodging (not server state).
1379    pub relocate_mode: Option<RelocateModeState>,
1380    /// Second `f` within a short window confirms selling this plot back to the crown.
1381    pub sell_plot_confirm: Option<uuid::Uuid>,
1382    /// When `sell_plot_confirm` was armed (for double-tap window).
1383    pub sell_plot_armed_at: Option<Instant>,
1384    /// Choose seed type when planting on owned tilled soil (`f`).
1385    pub show_plant_menu: bool,
1386    pub plant_menu_index: usize,
1387    /// Deed-holder farm access panel (public toggle + allow-list).
1388    pub show_farm_access: bool,
1389    /// Draft name for adding a tenant from the farm-access panel.
1390    pub farm_access_name_draft: String,
1391    /// Public / grant discount draft (bps) while editing farm access.
1392    pub farm_access_discount_bps: u32,
1393    /// Selected row in the farm-access panel (0 = public toggle).
1394    pub farm_access_index: usize,
1395    pub plant_quantity: u32,
1396    pub in_combat: bool,
1397    pub auto_attack: bool,
1398    pub combat_has_los: bool,
1399    pub attack_cd_ticks: u64,
1400    pub gcd_ticks: u64,
1401    pub weapon_ability_id: String,
1402    pub mainhand_template_id: Option<String>,
1403    pub mainhand_label: Option<String>,
1404    pub mainhand_instance_id: Option<uuid::Uuid>,
1405    pub offhand_template_id: Option<String>,
1406    pub offhand_label: Option<String>,
1407    pub offhand_instance_id: Option<uuid::Uuid>,
1408    pub mainhand_hand_slots: u8,
1409    pub defense: Option<flatland_protocol::DefenseHud>,
1410    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1411    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1412    pub carry_mass: f32,
1413    pub carry_mass_max: f32,
1414    pub encumbrance: flatland_protocol::EncumbranceState,
1415    /// Full nested inventory stacks from the server (root only; worn are separate).
1416    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1417    /// Keys stowed on the virtual keychain (zero carry mass).
1418    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1419    /// Whisper stones in the pouch (zero carry mass).
1420    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1421    /// Active status effects on the local player (buff/debuff strip).
1422    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1423    pub combat_target_detail: Option<CombatTargetHud>,
1424    pub cast_progress: Option<CastProgressHud>,
1425    /// Till / plant / other non-combat timed channels on the local player.
1426    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1427    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1428    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1429    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1430    pub blocking_active: bool,
1431    pub max_target_slots: u8,
1432    pub combat_slots: Vec<CombatSlotHud>,
1433    pub rotation_presets: Vec<RotationPreset>,
1434    /// Learned abilities from combat HUD (usable casts).
1435    pub known_abilities: Vec<String>,
1436    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1437    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1438    /// Per-ability mastery from combat HUD (tier / XP).
1439    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1440    /// Server-persisted hotbar bindings (index 0 = key 1).
1441    pub hotbar: Vec<Option<String>>,
1442    /// Max abilities allowed in one rotation (mind score).
1443    pub max_abilities_per_rotation: u8,
1444    pub show_loadout_menu: bool,
1445    pub show_keychain_menu: bool,
1446    pub keychain_menu_index: usize,
1447    pub show_rotation_editor: bool,
1448    /// Selected rotation preset in the loadout menu.
1449    pub loadout_menu_index: usize,
1450    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1451    pub loadout_hotbar_slot: u8,
1452    /// Selected known-ability row in the loadout menu.
1453    pub loadout_ability_index: usize,
1454    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1455    pub loadout_focus_presets: bool,
1456    pub rotation_editor: RotationEditorState,
1457    /// True after a harvest intent is accepted until result/reject/disconnect.
1458    pub harvest_in_progress: bool,
1459    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1460    pub harvest_started_at: Option<Instant>,
1461    /// Craft log deferred until the server acks the craft intent.
1462    pub pending_craft_ack: Option<(u32, String, u32)>,
1463    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1464    pub interactables: Vec<flatland_protocol::InteractableView>,
1465    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1466    pub career: Option<flatland_protocol::PlayerCareerView>,
1467    pub character_sheet_tab: CharacterSheetTab,
1468    pub ledger_period: LedgerPeriod,
1469    pub show_quest_offer: bool,
1470    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1471    pub show_quest_menu: bool,
1472    pub quest_menu_index: usize,
1473    pub quest_withdraw_confirm: bool,
1474    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1475    pub show_workers_menu: bool,
1476    pub workers_menu_index: usize,
1477    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1478    /// Workers panel: one-line rows instead of full cards (more on screen).
1479    pub workers_menu_compact: bool,
1480    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1481    /// Public so out-of-crate tests can construct [`GameState`].
1482    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1483    /// Held worker route errors (readable when server clears `last_error` each tick).
1484    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1485    /// Wall-clock expiry for defender HP rings after their last observed hit.
1486    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1487    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1488    pub show_worker_give_picker: bool,
1489    pub worker_give_picker_index: usize,
1490    pub worker_give_picker: Option<WorkerGivePicker>,
1491    /// Inventory `g` — pick which nearby worker receives the selected stack.
1492    pub show_worker_give_target_picker: bool,
1493    pub worker_give_target_picker_index: usize,
1494    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1495    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1496    pub show_worker_take_picker: bool,
1497    pub worker_take_picker_index: usize,
1498    pub worker_take_picker: Option<WorkerTakePicker>,
1499    /// Teach-blueprint sheet opened from the workers menu (`t`).
1500    pub show_worker_teach_picker: bool,
1501    pub worker_teach_picker_index: usize,
1502    pub worker_teach_picker: Option<WorkerTeachPicker>,
1503    /// Active harvest-route editor (`h` → `e` on a worker).
1504    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1505    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1506    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1507    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1508    pub attending_worker_instance_id: Option<String>,
1509    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1510    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1511}
1512
1513impl GameState {
1514    pub fn push_log(&mut self, line: impl Into<String>) {
1515        self.logs.push_back(line.into());
1516        while self.logs.len() > MAX_LOG_LINES {
1517            self.logs.pop_front();
1518        }
1519    }
1520
1521    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1522        self.shop_trade_log.push_back(line.into());
1523        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1524            self.shop_trade_log.pop_front();
1525        }
1526    }
1527
1528    pub fn clear_shop_trade_log(&mut self) {
1529        self.shop_trade_log.clear();
1530    }
1531
1532    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1533        if !self.show_shop_menu {
1534            return;
1535        }
1536        let msg = notice.message.trim();
1537        if msg.is_empty() {
1538            return;
1539        }
1540        if notice.coins_delta != 0
1541            || msg.starts_with("Bought ")
1542            || msg.starts_with("Sold ")
1543            || msg.contains("taught you how to craft")
1544            || msg.starts_with("need ")
1545        {
1546            self.push_shop_trade_log(msg);
1547        }
1548    }
1549
1550    pub fn is_alive(&self) -> bool {
1551        self.player
1552            .as_ref()
1553            .and_then(|p| p.vitals)
1554            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1555            .unwrap_or(true)
1556    }
1557
1558    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1559        self.social_chat.push_cue(cue);
1560    }
1561
1562    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1563    fn sync_gameplay_audio(&mut self) {
1564        use crate::social::AudioCue;
1565        use flatland_protocol::PrimaryAttributes;
1566
1567        let alive = self.is_alive();
1568        let casting = self.cast_progress.is_some();
1569        let telegraph = self.focus_attack_telegraph_active();
1570        let in_aoe = self.player_inside_spatial_telegraph();
1571        let quest_sig = self.quest_audio_signature();
1572        let entity_id = self.entity_id;
1573        let char_level = self
1574            .player
1575            .as_ref()
1576            .and_then(|p| p.attributes)
1577            .map(|a| {
1578                PrimaryAttributes::display(a.strength)
1579                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1580                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1581                    .saturating_add(PrimaryAttributes::display(a.stamina))
1582                    .saturating_add(PrimaryAttributes::display(a.vitality))
1583                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1584                    .saturating_add(PrimaryAttributes::display(a.charisma))
1585            })
1586            .unwrap_or(0);
1587
1588        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1589        let mut hit_cues = Vec::new();
1590        {
1591            let seen = &self.social_chat.audio_seen_fx_ids;
1592            for fx in &self.combat_fx {
1593                if seen.contains(&fx.id) {
1594                    continue;
1595                }
1596                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1597                    continue;
1598                };
1599                if hit.outcome == CombatFxHitOutcome::Blocked {
1600                    hit_cues.push(AudioCue::CombatBlock);
1601                } else {
1602                    let heavy = matches!(
1603                        fx.kind,
1604                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1605                    );
1606                    hit_cues.push(if heavy {
1607                        AudioCue::CombatHitHeavy
1608                    } else {
1609                        AudioCue::CombatHitLight
1610                    });
1611                }
1612            }
1613        }
1614
1615        let audio = &mut self.social_chat;
1616        if !audio.audio_bootstrapped {
1617            audio.audio_was_alive = alive;
1618            audio.audio_was_casting = casting;
1619            audio.audio_had_target_telegraph = telegraph;
1620            audio.audio_was_in_aoe = in_aoe;
1621            audio.audio_quest_sig = quest_sig;
1622            audio.audio_char_level = char_level;
1623            audio.audio_seen_fx_ids = fx_ids;
1624            audio.audio_bootstrapped = true;
1625            return;
1626        }
1627
1628        if telegraph && !audio.audio_had_target_telegraph {
1629            audio.push_cue(AudioCue::CombatTelegraphStart);
1630        } else if !telegraph && audio.audio_had_target_telegraph {
1631            audio.push_cue(AudioCue::CombatTelegraphImpact);
1632        }
1633        audio.audio_had_target_telegraph = telegraph;
1634
1635        if in_aoe && !audio.audio_was_in_aoe {
1636            audio.push_cue(AudioCue::CombatAoeWarn);
1637        }
1638        audio.audio_was_in_aoe = in_aoe;
1639
1640        if casting && !audio.audio_was_casting {
1641            audio.push_cue(AudioCue::AbilityCastSelf);
1642        }
1643        audio.audio_was_casting = casting;
1644
1645        if !alive && audio.audio_was_alive {
1646            audio.push_cue(AudioCue::PlayerDeath);
1647        }
1648        audio.audio_was_alive = alive;
1649
1650        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1651            audio.push_cue(AudioCue::QuestUpdate);
1652        }
1653        audio.audio_quest_sig = quest_sig;
1654
1655        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1656            audio.push_cue(AudioCue::LevelUp);
1657        }
1658        audio.audio_char_level = char_level;
1659
1660        for cue in hit_cues {
1661            audio.push_cue(cue);
1662        }
1663        audio.audio_seen_fx_ids = fx_ids;
1664    }
1665
1666    fn focus_attack_telegraph_active(&self) -> bool {
1667        let Some(tid) = self.combat_target else {
1668            return false;
1669        };
1670        self.entities
1671            .iter()
1672            .find(|e| e.id == tid)
1673            .map(|e| {
1674                e.combat_cues.iter().any(|c| {
1675                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1676                })
1677            })
1678            .unwrap_or(false)
1679    }
1680
1681    fn player_inside_spatial_telegraph(&self) -> bool {
1682        let (px, py) = self.player_position();
1683        for e in &self.entities {
1684            for cue in &e.combat_cues {
1685                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1686                    || cue.until_tick <= self.tick
1687                {
1688                    continue;
1689                }
1690                let Some(kind) = cue.telegraph_kind else {
1691                    continue;
1692                };
1693                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1694                    (Some(x), Some(y)) => (x, y),
1695                    _ => continue,
1696                };
1697                match kind {
1698                    CombatFxKind::Sphere => {
1699                        let r = cue.radius_m.unwrap_or(1.0);
1700                        let dx = px - ox;
1701                        let dy = py - oy;
1702                        if dx * dx + dy * dy <= r * r {
1703                            return true;
1704                        }
1705                    }
1706                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1707                        let reach = cue.reach_m.unwrap_or(2.0);
1708                        let yaw = cue.yaw.unwrap_or(0.0);
1709                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1710                        let dx = px - ox;
1711                        let dy = py - oy;
1712                        let dist = (dx * dx + dy * dy).sqrt();
1713                        if dist > reach || dist < 0.05 {
1714                            continue;
1715                        }
1716                        let ang = dx.atan2(dy);
1717                        let mut delta = ang - yaw;
1718                        while delta > std::f32::consts::PI {
1719                            delta -= std::f32::consts::TAU;
1720                        }
1721                        while delta < -std::f32::consts::PI {
1722                            delta += std::f32::consts::TAU;
1723                        }
1724                        if delta.abs() <= arc * 0.5 {
1725                            return true;
1726                        }
1727                    }
1728                    _ => {}
1729                }
1730            }
1731        }
1732        false
1733    }
1734
1735    fn quest_audio_signature(&self) -> u64 {
1736        use std::collections::hash_map::DefaultHasher;
1737        use std::hash::{Hash, Hasher};
1738        let mut h = DefaultHasher::new();
1739        for q in &self.quest_log {
1740            q.quest_id.hash(&mut h);
1741            format!("{:?}", q.status).hash(&mut h);
1742            q.current_step_id.hash(&mut h);
1743            for o in &q.objectives {
1744                o.done.hash(&mut h);
1745                o.current.hash(&mut h);
1746            }
1747        }
1748        h.finish()
1749    }
1750
1751    /// Verb menu entries for the current `npc_verb_target`.
1752    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1753        let Some(ref id) = self.npc_verb_target else {
1754            return vec![];
1755        };
1756        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1757            return vec!["Talk"];
1758        };
1759        let role = npc.role.as_str();
1760        if Self::npc_role_is_bank(role) {
1761            return vec!["Bank", "Talk"];
1762        }
1763        if Self::npc_role_is_storage(role) {
1764            return vec!["Storage", "Talk"];
1765        }
1766        if Self::npc_role_is_market(role) {
1767            return vec!["Market", "Talk"];
1768        }
1769        if npc.can_trade || Self::npc_role_can_trade(role) {
1770            vec!["Talk", "Trade"]
1771        } else {
1772            vec!["Talk"]
1773        }
1774    }
1775
1776    fn npc_role_can_trade(role: &str) -> bool {
1777        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1778    }
1779
1780    fn npc_role_is_bank(role: &str) -> bool {
1781        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1782    }
1783
1784    fn npc_role_is_storage(role: &str) -> bool {
1785        role.eq_ignore_ascii_case("storage_manager")
1786    }
1787
1788    fn npc_role_is_market(role: &str) -> bool {
1789        role.eq_ignore_ascii_case("market_clerk")
1790    }
1791
1792    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1793        vec![
1794            "Deposit…",
1795            "Withdraw…",
1796            "Deposit all",
1797            "Withdraw all",
1798            "Transfer…",
1799        ]
1800    }
1801
1802    pub fn storage_menu_options(&self) -> Vec<String> {
1803        let mut opts = vec!["Store…".into(), "Take…".into()];
1804        if let Some(panel) = &self.storage_panel {
1805            for dest in &panel.ship_destinations {
1806                opts.push(format!(
1807                    "Ship → {} ({} cp / {} ticks)",
1808                    dest.label, dest.fee_copper, dest.travel_ticks
1809                ));
1810            }
1811        }
1812        opts
1813    }
1814
1815    /// Loose on-person stacks eligible for town-storage store.
1816    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
1817    /// already outside root inventory.
1818    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1819        let equipped = self.hand_equipped_instance_ids();
1820        self.person_rows()
1821            .into_iter()
1822            .filter(|r| r.depth == 0)
1823            .filter_map(|r| {
1824                let id = r.stack.item_instance_id?;
1825                if equipped.contains(&id) {
1826                    return None;
1827                }
1828                Some(StoragePickOption {
1829                    item_instance_id: id,
1830                    template_id: r.stack.template_id.clone(),
1831                    label: storage_stack_label(&r.stack),
1832                    quantity: r.stack.quantity,
1833                    category: r.stack.category.clone().unwrap_or_default(),
1834                })
1835            })
1836            .collect()
1837    }
1838
1839    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
1840    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1841        let mut ids = std::collections::HashSet::new();
1842        if let Some(id) = self.mainhand_instance_id {
1843            ids.insert(id);
1844        } else if let Some(tid) = &self.mainhand_template_id {
1845            if let Some(id) = self
1846                .inventory_stacks
1847                .iter()
1848                .find(|s| &s.template_id == tid)
1849                .and_then(|s| s.item_instance_id)
1850            {
1851                ids.insert(id);
1852            }
1853        }
1854        if let Some(id) = self.offhand_instance_id {
1855            ids.insert(id);
1856        } else if let Some(tid) = &self.offhand_template_id {
1857            if let Some(id) = self
1858                .inventory_stacks
1859                .iter()
1860                .find(|s| {
1861                    &s.template_id == tid
1862                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
1863                })
1864                .and_then(|s| s.item_instance_id)
1865            {
1866                ids.insert(id);
1867            }
1868        }
1869        ids
1870    }
1871
1872    /// Vault stacks eligible for take / ship.
1873    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1874        let Some(panel) = &self.storage_panel else {
1875            return Vec::new();
1876        };
1877        panel
1878            .contents
1879            .iter()
1880            .filter_map(|s| {
1881                let id = s.item_instance_id?;
1882                Some(StoragePickOption {
1883                    item_instance_id: id,
1884                    template_id: s.template_id.clone(),
1885                    label: storage_stack_label(s),
1886                    quantity: s.quantity,
1887                    category: s.category.clone().unwrap_or_default(),
1888                })
1889            })
1890            .collect()
1891    }
1892
1893    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1894    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1895        let mut opts = Vec::new();
1896        if !self
1897            .market_list_item_options(&MarketListSourceKind::Person)
1898            .is_empty()
1899        {
1900            opts.push((MarketListSourceKind::Person, "On person".into()));
1901        }
1902        if let Some(panel) = &self.market_panel {
1903            for vault in &panel.list_vaults {
1904                let source = MarketListSourceKind::TownStorage {
1905                    building_id: vault.building_id.clone(),
1906                };
1907                if self.market_list_item_options(&source).is_empty() {
1908                    continue;
1909                }
1910                let label = if vault.building_label.is_empty() {
1911                    format!("Town storage ({})", vault.building_id)
1912                } else {
1913                    format!("Town storage — {}", vault.building_label)
1914                };
1915                opts.push((source, label));
1916            }
1917        }
1918        opts
1919    }
1920
1921    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1922    pub fn market_list_item_options(
1923        &self,
1924        source: &MarketListSourceKind,
1925    ) -> Vec<StoragePickOption> {
1926        let filter = self.market_filter.as_str();
1927        let cat_filter = self.market_category_filter;
1928        let mut opts: Vec<StoragePickOption> = match source {
1929            MarketListSourceKind::Person => {
1930                let equipped = self.hand_equipped_instance_ids();
1931                self.person_rows()
1932                    .into_iter()
1933                    .filter(|r| r.depth == 0)
1934                    .filter(|r| self.stack_is_market_listable(&r.stack))
1935                    .filter_map(|r| {
1936                        let id = r.stack.item_instance_id?;
1937                        if equipped.contains(&id) {
1938                            return None;
1939                        }
1940                        Some(StoragePickOption {
1941                            item_instance_id: id,
1942                            template_id: r.stack.template_id.clone(),
1943                            label: storage_stack_label(&r.stack),
1944                            quantity: r.stack.quantity,
1945                            category: r
1946                                .stack
1947                                .category
1948                                .clone()
1949                                .or_else(|| {
1950                                    self.inventory_item_category(&r.stack.template_id)
1951                                        .map(str::to_string)
1952                                })
1953                                .unwrap_or_default(),
1954                        })
1955                    })
1956                    .collect()
1957            }
1958            MarketListSourceKind::TownStorage { building_id } => {
1959                let Some(panel) = &self.market_panel else {
1960                    return Vec::new();
1961                };
1962                let Some(vault) = panel
1963                    .list_vaults
1964                    .iter()
1965                    .find(|v| &v.building_id == building_id)
1966                else {
1967                    return Vec::new();
1968                };
1969                vault
1970                    .contents
1971                    .iter()
1972                    .filter(|s| self.stack_is_market_listable(s))
1973                    .filter_map(|s| {
1974                        let id = s.item_instance_id?;
1975                        Some(StoragePickOption {
1976                            item_instance_id: id,
1977                            template_id: s.template_id.clone(),
1978                            label: storage_stack_label(s),
1979                            quantity: s.quantity,
1980                            category: s
1981                                .category
1982                                .clone()
1983                                .or_else(|| {
1984                                    self.inventory_item_category(&s.template_id)
1985                                        .map(str::to_string)
1986                                })
1987                                .unwrap_or_default(),
1988                        })
1989                    })
1990                    .collect()
1991            }
1992        };
1993        opts.retain(|o| {
1994            if !list_label_matches(&o.label, filter) {
1995                return false;
1996            }
1997            if let Some(group) = cat_filter {
1998                inventory_category_group(&o.category).0 == group
1999            } else {
2000                true
2001            }
2002        });
2003        opts
2004    }
2005
2006    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2007    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2008        if let Some(hint) = self.inventory_hints.get(template_id) {
2009            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2010                return Some(v);
2011            }
2012        }
2013        if let Some(v) = self
2014            .inventory_stacks
2015            .iter()
2016            .find(|s| s.template_id == template_id)
2017            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2018        {
2019            return Some(v);
2020        }
2021        self.market_panel.as_ref().and_then(|panel| {
2022            panel.list_vaults.iter().find_map(|vault| {
2023                vault.contents.iter().find_map(|stack| {
2024                    (stack.template_id == template_id)
2025                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2026                        .flatten()
2027                })
2028            })
2029        })
2030    }
2031
2032    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2033    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2034        let base = self.item_base_value_copper_hint(template_id)?;
2035        npc_market_dump_unit_estimate_copper(base)
2036    }
2037
2038    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2039        if crate::currency::is_currency(&stack.template_id) {
2040            return false;
2041        }
2042        if let Some(flag) = stack.listable {
2043            return flag;
2044        }
2045        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2046            return hint.listable;
2047        }
2048        let cat = stack
2049            .category
2050            .as_deref()
2051            .or_else(|| self.inventory_item_category(&stack.template_id))
2052            .unwrap_or("");
2053        category_default_listable(cat)
2054    }
2055
2056    /// Category group labels present in the current browse book or list-pick source.
2057    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2058        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2059        match &self.market_ui_mode {
2060            MarketUiMode::ListPick { source, .. } => {
2061                let raw: Vec<_> = match source {
2062                    MarketListSourceKind::Person => self
2063                        .person_rows()
2064                        .into_iter()
2065                        .filter(|r| r.depth == 0)
2066                        .filter(|r| self.stack_is_market_listable(&r.stack))
2067                        .filter(|r| {
2068                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2069                        })
2070                        .map(|r| {
2071                            r.stack
2072                                .category
2073                                .clone()
2074                                .or_else(|| {
2075                                    self.inventory_item_category(&r.stack.template_id)
2076                                        .map(str::to_string)
2077                                })
2078                                .unwrap_or_default()
2079                        })
2080                        .collect(),
2081                    MarketListSourceKind::TownStorage { building_id } => self
2082                        .market_panel
2083                        .as_ref()
2084                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2085                        .map(|vault| {
2086                            vault
2087                                .contents
2088                                .iter()
2089                                .filter(|s| self.stack_is_market_listable(s))
2090                                .filter(|s| {
2091                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2092                                })
2093                                .map(|s| {
2094                                    s.category
2095                                        .clone()
2096                                        .or_else(|| {
2097                                            self.inventory_item_category(&s.template_id)
2098                                                .map(str::to_string)
2099                                        })
2100                                        .unwrap_or_default()
2101                                })
2102                                .collect::<Vec<_>>()
2103                        })
2104                        .unwrap_or_default(),
2105                };
2106                for category in raw {
2107                    let (label, ord) = inventory_category_group(&category);
2108                    seen.insert(ord, label);
2109                }
2110            }
2111            _ => {
2112                if let Some(panel) = &self.market_panel {
2113                    for listing in &panel.listings {
2114                        if !list_label_matches(&listing.display_name, &self.market_filter)
2115                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2116                        {
2117                            continue;
2118                        }
2119                        let (label, ord) = inventory_category_group(&listing.category);
2120                        seen.insert(ord, label);
2121                    }
2122                }
2123            }
2124        }
2125        seen.into_values().collect()
2126    }
2127
2128    /// Indices into `market_panel.listings` after category + text filter.
2129    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2130        let Some(panel) = &self.market_panel else {
2131            return Vec::new();
2132        };
2133        let filter = self.market_filter.as_str();
2134        let cat_filter = self.market_category_filter;
2135        panel
2136            .listings
2137            .iter()
2138            .enumerate()
2139            .filter(|(_, listing)| {
2140                if !list_label_matches(&listing.display_name, filter)
2141                    && !list_label_matches(&listing.seller_label, filter)
2142                    && !list_label_matches(&listing.template_id, filter)
2143                {
2144                    return false;
2145                }
2146                if let Some(group) = cat_filter {
2147                    inventory_category_group(&listing.category).0 == group
2148                } else {
2149                    true
2150                }
2151            })
2152            .map(|(i, _)| i)
2153            .collect()
2154    }
2155
2156    pub fn clear_harvest_state(&mut self) {
2157        self.harvest_in_progress = false;
2158        self.harvest_started_at = None;
2159    }
2160
2161    fn harvest_state_stale(&self) -> bool {
2162        match self.harvest_started_at {
2163            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2164            None => self.harvest_in_progress,
2165        }
2166    }
2167
2168    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2169        self.player.as_ref().and_then(|p| p.vitals)
2170    }
2171
2172    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2173        let materials_ok = blueprint.inputs.iter().all(|input| {
2174            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2175        });
2176        let tools_ok = blueprint
2177            .required_tools
2178            .iter()
2179            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
2180        let station_ok = match blueprint.station.as_deref() {
2181            None | Some("hand") => true,
2182            Some(tag) => self.player_at_station_tag(tag),
2183        };
2184        materials_ok && tools_ok && station_ok
2185    }
2186
2187    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2188        if !self.can_craft_blueprint(blueprint) {
2189            return 0;
2190        }
2191        let mut limit = u32::MAX;
2192        for input in &blueprint.inputs {
2193            if input.quantity == 0 {
2194                continue;
2195            }
2196            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2197            limit = limit.min(have / input.quantity);
2198        }
2199        for tool in &blueprint.required_tools {
2200            if tool.consumed {
2201                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2202                limit = limit.min(have);
2203            }
2204        }
2205        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2206        if CRAFT_STAMINA_COST > 0.0 {
2207            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2208        }
2209        limit
2210    }
2211
2212    pub fn clamp_craft_batch_quantity(&mut self) {
2213        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
2214            self.craft_batch_quantity = 1;
2215            return;
2216        };
2217        let max = self.max_craft_batches(bp).max(1);
2218        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2219    }
2220
2221    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2222        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2223            return;
2224        };
2225        let max = self.max_craft_batches(&bp).max(1);
2226        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
2227        self.craft_batch_quantity = next as u32;
2228    }
2229
2230    pub fn craft_batch_set_max(&mut self) {
2231        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2232            return;
2233        };
2234        let max = self.max_craft_batches(&bp);
2235        self.craft_batch_quantity = if max == 0 { 1 } else { max };
2236    }
2237
2238    pub fn craft_batch_set_min(&mut self) {
2239        self.craft_batch_quantity = 1;
2240    }
2241
2242    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
2243        let preserve_ui = self.show_shop_menu;
2244        let tab = self.shop_tab;
2245        let index = self.shop_menu_index;
2246        let qty = self.shop_quantity;
2247
2248        self.show_shop_menu = true;
2249        self.bank_panel = None;
2250        self.show_craft_menu = false;
2251        self.show_inventory_menu = false;
2252        self.show_stats = false;
2253        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
2254            self.npc_verb_target = Some(catalog.npc_id.clone());
2255        }
2256        self.shop_catalog = Some(catalog);
2257
2258        if preserve_ui {
2259            self.shop_tab = tab;
2260            self.shop_menu_index = index;
2261            self.shop_quantity = qty;
2262        } else {
2263            self.shop_tab = ShopTab::Buy;
2264            self.shop_menu_index = 0;
2265            self.shop_quantity = 1;
2266            self.clear_shop_trade_log();
2267        }
2268        self.show_npc_verb_menu = false;
2269        self.clamp_shop_selection();
2270    }
2271
2272    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
2273        let same_teller = self
2274            .bank_panel
2275            .as_ref()
2276            .is_some_and(|p| p.npc_id == panel.npc_id);
2277        self.bank_panel = Some(panel);
2278        self.storage_panel = None;
2279        self.market_panel = None;
2280        self.shop_catalog = None;
2281        self.show_shop_menu = false;
2282        self.show_craft_menu = false;
2283        self.show_inventory_menu = false;
2284        self.show_stats = false;
2285        self.show_npc_verb_menu = false;
2286        self.show_npc_chat = false;
2287        self.npc_chat = None;
2288        if !same_teller {
2289            self.bank_menu_index = 0;
2290            self.bank_ui_mode = BankUiMode::Menu;
2291        }
2292        if let Some(panel) = &self.bank_panel {
2293            if self.npc_verb_target.is_none() {
2294                self.npc_verb_target = Some(panel.npc_id.clone());
2295            }
2296        }
2297    }
2298
2299    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
2300        let same_manager = self
2301            .storage_panel
2302            .as_ref()
2303            .is_some_and(|p| p.npc_id == panel.npc_id);
2304        self.storage_panel = Some(panel);
2305        self.bank_panel = None;
2306        self.market_panel = None;
2307        self.bank_ui_mode = BankUiMode::Menu;
2308        self.shop_catalog = None;
2309        self.show_shop_menu = false;
2310        self.show_craft_menu = false;
2311        self.show_inventory_menu = false;
2312        self.show_stats = false;
2313        self.show_npc_verb_menu = false;
2314        self.show_npc_chat = false;
2315        self.npc_chat = None;
2316        if !same_manager {
2317            self.storage_menu_index = 0;
2318            self.storage_ui_mode = StorageUiMode::Menu;
2319        } else {
2320            self.clamp_storage_pick_index();
2321        }
2322        if let Some(panel) = &self.storage_panel {
2323            if self.npc_verb_target.is_none() {
2324                self.npc_verb_target = Some(panel.npc_id.clone());
2325            }
2326        }
2327    }
2328
2329    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2330        for vault in &panel.list_vaults {
2331            self.merge_stack_catalog_hints(&vault.contents);
2332        }
2333        self.market_panel = Some(panel);
2334        self.bank_panel = None;
2335        self.storage_panel = None;
2336        self.shop_catalog = None;
2337        self.show_shop_menu = false;
2338        self.show_craft_menu = false;
2339        self.show_inventory_menu = false;
2340        self.show_stats = false;
2341        self.show_npc_verb_menu = false;
2342        self.show_npc_chat = false;
2343        self.npc_chat = None;
2344        self.market_menu_index = 0;
2345        self.market_buy_confirm = None;
2346        self.market_ui_mode = MarketUiMode::Browse;
2347        self.market_filter.clear();
2348        self.market_filter_focused = false;
2349        self.market_category_filter = None;
2350        if let Some(panel) = &self.market_panel {
2351            if self.npc_verb_target.is_none() {
2352                self.npc_verb_target = Some(panel.npc_id.clone());
2353            }
2354        }
2355    }
2356
2357    pub fn clear_market_panel(&mut self) {
2358        self.market_panel = None;
2359        self.market_menu_index = 0;
2360        self.market_buy_confirm = None;
2361        self.market_ui_mode = MarketUiMode::Browse;
2362        self.market_filter.clear();
2363        self.market_filter_focused = false;
2364        self.market_category_filter = None;
2365    }
2366
2367    pub fn clear_bank_panel(&mut self) {
2368        self.bank_panel = None;
2369        self.bank_menu_index = 0;
2370        self.bank_ui_mode = BankUiMode::Menu;
2371    }
2372
2373    pub fn clear_storage_panel(&mut self) {
2374        self.storage_panel = None;
2375        self.storage_menu_index = 0;
2376        self.storage_ui_mode = StorageUiMode::Menu;
2377    }
2378
2379    fn clamp_storage_pick_index(&mut self) {
2380        match &self.storage_ui_mode {
2381            StorageUiMode::StorePick { index } => {
2382                let n = self.storage_store_options().len();
2383                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2384                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2385            }
2386            StorageUiMode::TakePick { index } => {
2387                let n = self.storage_vault_options().len();
2388                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2389                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2390            }
2391            StorageUiMode::ShipPick {
2392                dest_building_id,
2393                dest_label,
2394                index,
2395            } => {
2396                let n = self.storage_vault_options().len();
2397                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2398                self.storage_ui_mode = StorageUiMode::ShipPick {
2399                    dest_building_id: dest_building_id.clone(),
2400                    dest_label: dest_label.clone(),
2401                    index: next,
2402                };
2403            }
2404            StorageUiMode::Menu
2405            | StorageUiMode::StoreAmount { .. }
2406            | StorageUiMode::TakeAmount { .. }
2407            | StorageUiMode::ShipAmount { .. } => {}
2408        }
2409    }
2410
2411    pub fn shop_list_len(&self) -> usize {
2412        let Some(catalog) = &self.shop_catalog else {
2413            return 0;
2414        };
2415        match self.shop_tab {
2416            ShopTab::Buy => catalog.sells.len(),
2417            ShopTab::Sell => catalog.buys.len(),
2418        }
2419    }
2420
2421    pub fn shop_menu_move(&mut self, delta: i32) {
2422        let n = self.shop_list_len();
2423        if n == 0 {
2424            return;
2425        }
2426        let idx = self.shop_menu_index as i32;
2427        let next = (idx + delta).rem_euclid(n as i32);
2428        self.shop_menu_index = next as usize;
2429        self.clamp_shop_quantity();
2430    }
2431
2432    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2433        let max = self.shop_quantity_max();
2434        if max == 0 {
2435            self.shop_quantity = 0;
2436            return;
2437        }
2438        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2439        self.shop_quantity = next as u32;
2440    }
2441
2442    pub(crate) fn clamp_shop_selection(&mut self) {
2443        let n = self.shop_list_len();
2444        if n == 0 {
2445            self.shop_menu_index = 0;
2446        } else {
2447            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2448        }
2449        self.clamp_shop_quantity();
2450    }
2451
2452    fn shop_quantity_max(&self) -> u32 {
2453        let Some(catalog) = &self.shop_catalog else {
2454            return 1;
2455        };
2456        match self.shop_tab {
2457            ShopTab::Buy => {
2458                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2459                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2460                        return 1;
2461                    }
2462                }
2463                99
2464            }
2465            ShopTab::Sell => catalog
2466                .buys
2467                .get(self.shop_menu_index)
2468                .map(|l| l.quantity)
2469                .unwrap_or(0),
2470        }
2471    }
2472
2473    pub fn shop_quantity_set_max(&mut self) {
2474        self.shop_quantity = self.shop_quantity_max();
2475    }
2476
2477    pub fn shop_quantity_set_min(&mut self) {
2478        let max = self.shop_quantity_max();
2479        self.shop_quantity = if max == 0 { 0 } else { 1 };
2480    }
2481
2482    fn clamp_shop_quantity(&mut self) {
2483        let max = self.shop_quantity_max();
2484        if max == 0 {
2485            self.shop_quantity = 0;
2486        } else {
2487            self.shop_quantity = self.shop_quantity.max(1).min(max);
2488        }
2489    }
2490
2491    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2492        let Some(id) = self.effective_inside_building() else {
2493            return false;
2494        };
2495        self.buildings
2496            .iter()
2497            .find(|b| b.id == id)
2498            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2499    }
2500
2501    /// Short hint for UI when a recipe cannot be started.
2502    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2503        if self.can_craft_blueprint(blueprint) {
2504            return None;
2505        }
2506        let mut missing = Vec::new();
2507        for input in &blueprint.inputs {
2508            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2509            if have < input.quantity {
2510                let name = self.blueprint_ingredient_label(input);
2511                missing.push(format!("{}×{} (have {have})", input.quantity, name));
2512            }
2513        }
2514        for tool in &blueprint.required_tools {
2515            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2516            if have < 1 {
2517                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2518            }
2519        }
2520        if let Some(station) = blueprint.station.as_deref() {
2521            if station != "hand" && !self.player_at_station_tag(station) {
2522                missing.push(format!("station: {station} (enter building)"));
2523            }
2524        }
2525        if missing.is_empty() {
2526            None
2527        } else {
2528            Some(missing.join(", "))
2529        }
2530    }
2531
2532    pub fn player_entity(&self) -> Option<&EntityState> {
2533        self.player
2534            .as_ref()
2535            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2536    }
2537
2538    /// Apply persisted HUD / workers UI prefs from `client.json`.
2539    pub fn apply_client_ui_prefs(&mut self) {
2540        let cfg = crate::client_config::ClientConfig::load();
2541        if let Some(hidden) = cfg.hud_log_hidden {
2542            self.hud_log_hidden = hidden;
2543        }
2544        if let Some(compact) = cfg.workers_menu_compact {
2545            self.workers_menu_compact = compact;
2546        }
2547    }
2548
2549    pub fn player_position(&self) -> (f32, f32) {
2550        let (x, y, _) = self.player_position_with_z();
2551        (x, y)
2552    }
2553
2554    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2555        if let Some(p) = self.player_entity() {
2556            (
2557                p.transform.position.x,
2558                p.transform.position.y,
2559                p.transform.position.z,
2560            )
2561        } else {
2562            (0.0, 0.0, 0.0)
2563        }
2564    }
2565
2566    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2567        let mut rows: Vec<(String, u32, String)> = self
2568            .inventory
2569            .iter()
2570            .filter(|(_, q)| **q > 0)
2571            .map(|(id, qty)| {
2572                let label = self
2573                    .inventory_hints
2574                    .get(id)
2575                    .map(|h| h.display_name.clone())
2576                    .unwrap_or_else(|| id.clone());
2577                (id.clone(), *qty, label)
2578            })
2579            .collect();
2580        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2581        rows
2582    }
2583
2584    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2585        self.inventory_hints
2586            .get(template_id)
2587            .map(|h| h.category.as_str())
2588            .filter(|c| !c.is_empty())
2589    }
2590
2591    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2592        stack
2593            .props
2594            .get("grants_item_status_effect")
2595            .map(|s| !s.is_empty())
2596            .unwrap_or(false)
2597    }
2598
2599    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2600        stack
2601            .props
2602            .get("grants_item_status_effect")
2603            .map(String::as_str)
2604            .filter(|s| !s.is_empty())
2605    }
2606
2607    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2608        stack
2609            .props
2610            .get("grants_item_status_mode")
2611            .map(String::as_str)
2612            .unwrap_or("on_hit")
2613    }
2614
2615    /// Candidate gear for a grant consumable (inventory + worn).
2616    pub fn grant_target_options(
2617        &self,
2618        grant: &flatland_protocol::ItemStack,
2619    ) -> Vec<GrantTargetOption> {
2620        let mode = Self::grant_mode(grant);
2621        let grant_tags: Vec<&str> = grant
2622            .props
2623            .get("grants_item_status_tags")
2624            .map(|s| {
2625                s.split(',')
2626                    .map(str::trim)
2627                    .filter(|t| !t.is_empty())
2628                    .collect()
2629            })
2630            .unwrap_or_default();
2631        let grant_id = grant.item_instance_id;
2632        let mut out = Vec::new();
2633        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2634            let Some(iid) = stack.item_instance_id else {
2635                return;
2636            };
2637            if Some(iid) == grant_id {
2638                return;
2639            }
2640            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2641                return;
2642            }
2643            if !grant_target_matches_mode(stack, mode) {
2644                return;
2645            }
2646            if !grant_tags_match(stack, &grant_tags) {
2647                return;
2648            }
2649            let name = stack
2650                .display_name
2651                .clone()
2652                .unwrap_or_else(|| stack.template_id.clone());
2653            let bindings = if stack.status_bindings.is_empty() {
2654                String::new()
2655            } else {
2656                format!(
2657                    " · {}",
2658                    stack
2659                        .status_bindings
2660                        .iter()
2661                        .map(|b| b.effect_id.as_str())
2662                        .collect::<Vec<_>>()
2663                        .join(", ")
2664                )
2665            };
2666            out.push(GrantTargetOption {
2667                label: format!("{where_label}: {name}{bindings}"),
2668                target_instance_id: iid,
2669            });
2670        };
2671        fn walk(
2672            stacks: &[flatland_protocol::ItemStack],
2673            where_label: &str,
2674            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2675        ) {
2676            for s in stacks {
2677                push(s, where_label);
2678                if !s.contents.is_empty() {
2679                    let nested = format!(
2680                        "{where_label}/{}",
2681                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
2682                    );
2683                    walk(&s.contents, &nested, push);
2684                }
2685            }
2686        }
2687        walk(&self.inventory_stacks, "Bag", &mut push);
2688        for (slot, stack) in &self.worn {
2689            push(stack, body_slot_label(*slot));
2690            let nest = format!(
2691                "{}/{}",
2692                body_slot_label(*slot),
2693                stack
2694                    .display_name
2695                    .as_deref()
2696                    .unwrap_or(stack.template_id.as_str())
2697            );
2698            walk(&stack.contents, &nest, &mut push);
2699        }
2700        out
2701    }
2702
2703    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2704        self.inventory_hints
2705            .get(template_id)
2706            .and_then(|h| h.base_mass)
2707            .unwrap_or(0.5)
2708    }
2709
2710    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2711        self.inventory_hints
2712            .get(template_id)
2713            .and_then(|h| h.base_volume)
2714            .unwrap_or(1.0)
2715    }
2716
2717    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2718        let unit = stack
2719            .base_mass
2720            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2721        unit * stack.quantity as f32
2722    }
2723
2724    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2725        let unit = stack.base_volume.unwrap_or(1.0);
2726        unit * stack.quantity as f32
2727            + stack
2728                .contents
2729                .iter()
2730                .map(Self::stack_tree_volume)
2731                .sum::<f32>()
2732    }
2733
2734    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2735        contents.iter().map(Self::stack_tree_volume).sum()
2736    }
2737
2738    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2739        self.inventory_hints
2740            .get(template_id)
2741            .and_then(|h| h.capacity_volume)
2742            .filter(|c| *c > 0.0)
2743    }
2744
2745    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2746        stack
2747            .capacity_volume
2748            .filter(|c| *c > 0.0)
2749            .or_else(|| self.template_capacity_volume(&stack.template_id))
2750    }
2751
2752    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2753    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2754        let Some((used, cap)) = self.container_volume_stats(row) else {
2755            return String::new();
2756        };
2757        let free = (cap - used).max(0.0);
2758        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2759    }
2760
2761    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2762        if row.is_chest_shell {
2763            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2764                return None;
2765            };
2766            let chest = self
2767                .placed_containers
2768                .iter()
2769                .find(|c| c.id == *container_id)?;
2770            let cap = self
2771                .stack_capacity_volume(&row.stack)
2772                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2773            let used = if chest.accessible {
2774                Self::contents_used_volume(&chest.contents)
2775            } else {
2776                0.0
2777            };
2778            return Some((used, cap));
2779        }
2780
2781        let cap = self.stack_capacity_volume(&row.stack)?;
2782        let used = Self::contents_used_volume(&row.stack.contents);
2783        Some((used, cap))
2784    }
2785
2786    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2787        if row.is_chest_shell {
2788            return true;
2789        }
2790        if row.is_equip_shell {
2791            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2792        }
2793        self.inventory_item_category(&row.stack.template_id) == Some("container")
2794            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2795    }
2796
2797    fn container_stack_for(
2798        &self,
2799        location: &flatland_protocol::InventoryLocation,
2800        parent_instance_id: Option<uuid::Uuid>,
2801    ) -> Option<flatland_protocol::ItemStack> {
2802        match location {
2803            flatland_protocol::InventoryLocation::Root => {
2804                let pid = parent_instance_id?;
2805                self.find_stack_by_instance(&self.inventory_stacks, pid)
2806            }
2807            flatland_protocol::InventoryLocation::Worn { slot } => {
2808                let worn = self.worn.get(slot)?;
2809                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2810                    Some(worn.clone())
2811                } else {
2812                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2813                }
2814            }
2815            flatland_protocol::InventoryLocation::Placed { container_id } => {
2816                let chest = self
2817                    .placed_containers
2818                    .iter()
2819                    .find(|c| c.id == *container_id)?;
2820                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2821                    Some(flatland_protocol::ItemStack {
2822                        template_id: chest.template_id.clone(),
2823                        quantity: 1,
2824                        item_instance_id: chest.item_instance_id,
2825                        props: Default::default(),
2826                        status_bindings: Vec::new(),
2827                        contents: chest.contents.clone(),
2828                        display_name: Some(chest.display_name.clone()),
2829                        category: Some("container".into()),
2830                        capacity_volume: self
2831                            .inventory_hints
2832                            .get(&chest.template_id)
2833                            .and_then(|h| h.capacity_volume),
2834                        worker_lodging_capacity: chest.worker_lodging_capacity,
2835                        ..Default::default()
2836                    })
2837                } else {
2838                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2839                }
2840            }
2841            flatland_protocol::InventoryLocation::Keychain => None,
2842            flatland_protocol::InventoryLocation::WhisperPouch => None,
2843        }
2844    }
2845
2846    fn find_stack_by_instance(
2847        &self,
2848        stacks: &[flatland_protocol::ItemStack],
2849        instance_id: uuid::Uuid,
2850    ) -> Option<flatland_protocol::ItemStack> {
2851        for stack in stacks {
2852            if stack.item_instance_id == Some(instance_id) {
2853                return Some(stack.clone());
2854            }
2855            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2856                return Some(found);
2857            }
2858        }
2859        None
2860    }
2861
2862    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2863    pub fn max_movable_to(
2864        &self,
2865        template_id: &str,
2866        stack_qty: u32,
2867        from: &flatland_protocol::InventoryLocation,
2868        to: &flatland_protocol::InventoryLocation,
2869        parent_instance_id: Option<uuid::Uuid>,
2870    ) -> u32 {
2871        let unit_vol = self.item_base_volume(template_id);
2872        let unit_mass = self.item_base_mass(template_id);
2873        let mut limit = stack_qty;
2874
2875        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2876            let cap = parent
2877                .capacity_volume
2878                .or_else(|| {
2879                    self.inventory_hints
2880                        .get(&parent.template_id)
2881                        .and_then(|h| h.capacity_volume)
2882                })
2883                .unwrap_or(0.0);
2884            if cap > 0.0 && unit_vol > 0.0 {
2885                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2886                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2887            }
2888        }
2889
2890        let to_person = matches!(
2891            to,
2892            flatland_protocol::InventoryLocation::Root
2893                | flatland_protocol::InventoryLocation::Worn { .. }
2894        );
2895        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2896        if to_person && from_placed && unit_mass > 0.0 {
2897            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2898            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2899                limit = 0;
2900            } else {
2901                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2902            }
2903        }
2904
2905        limit.max(0).min(stack_qty)
2906    }
2907
2908    pub fn move_picker_max_at_selection(&self) -> u32 {
2909        let Some(picker) = &self.move_picker else {
2910            return 1;
2911        };
2912        let Some(opt) = picker.options.get(self.move_picker_index) else {
2913            return picker.stack_quantity;
2914        };
2915        match &opt.kind {
2916            MoveOptionKind::Cancel
2917            | MoveOptionKind::Drop
2918            | MoveOptionKind::Use
2919            | MoveOptionKind::GrantApply
2920            | MoveOptionKind::SellPlotToCrown { .. }
2921            | MoveOptionKind::PickupPlaced { .. }
2922            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2923            MoveOptionKind::Move {
2924                location,
2925                parent_instance_id,
2926            } => self.max_movable_to(
2927                &picker.template_id,
2928                picker.stack_quantity,
2929                &picker.from,
2930                location,
2931                *parent_instance_id,
2932            ),
2933        }
2934    }
2935
2936    pub fn clamp_move_picker_quantity(&mut self) {
2937        let max = self.move_picker_max_at_selection();
2938        if let Some(picker) = &mut self.move_picker {
2939            if max == 0 {
2940                picker.quantity = 1;
2941            } else {
2942                picker.quantity = picker.quantity.clamp(1, max);
2943            }
2944        }
2945    }
2946
2947    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2948        let max = self.move_picker_max_at_selection().max(1);
2949        if let Some(picker) = &mut self.move_picker {
2950            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2951            picker.quantity = next as u32;
2952        }
2953    }
2954
2955    pub fn move_picker_set_quantity_max(&mut self) {
2956        let max = self.move_picker_max_at_selection();
2957        if let Some(picker) = &mut self.move_picker {
2958            picker.quantity = if max == 0 {
2959                1
2960            } else {
2961                max.min(picker.stack_quantity)
2962            };
2963        }
2964    }
2965
2966    pub fn move_picker_set_quantity_min(&mut self) {
2967        if let Some(picker) = &mut self.move_picker {
2968            picker.quantity = 1;
2969        }
2970    }
2971
2972    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2973        if let Some(picker) = &mut self.destroy_picker {
2974            let max = picker.stack_quantity.max(1);
2975            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2976            picker.quantity = next as u32;
2977        }
2978    }
2979
2980    pub fn destroy_picker_set_quantity_max(&mut self) {
2981        if let Some(picker) = &mut self.destroy_picker {
2982            picker.quantity = picker.stack_quantity.max(1);
2983        }
2984    }
2985
2986    pub fn destroy_picker_set_quantity_min(&mut self) {
2987        if let Some(picker) = &mut self.destroy_picker {
2988            picker.quantity = 1;
2989        }
2990    }
2991
2992    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2993        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2994        (have, have >= need)
2995    }
2996
2997    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
2998    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2999        let have = self
3000            .plot_build_offer
3001            .as_ref()
3002            .and_then(|o| {
3003                o.available
3004                    .iter()
3005                    .find(|s| s.template_id == template_id)
3006                    .map(|s| s.quantity)
3007            })
3008            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3009        (have, have >= need)
3010    }
3011
3012    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3013        self.building_materials
3014            .iter()
3015            .filter(|m| m.can_wall)
3016            .collect()
3017    }
3018
3019    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3020        self.building_materials
3021            .iter()
3022            .filter(|m| m.can_roof)
3023            .collect()
3024    }
3025
3026    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3027        self.plot_build_wall_options()
3028            .get(self.plot_build_wall_index)
3029            .copied()
3030    }
3031
3032    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3033        self.plot_build_roof_options()
3034            .get(self.plot_build_roof_index)
3035            .copied()
3036    }
3037
3038    /// BOM lines for the current wall/roof selection and tilled pad area.
3039    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3040        let Some(wall) = self.plot_build_selected_wall() else {
3041            return Vec::new();
3042        };
3043        let Some(roof) = self.plot_build_selected_roof() else {
3044            return Vec::new();
3045        };
3046        let area = self
3047            .plot_build_offer
3048            .as_ref()
3049            .filter(|o| o.pad_ok)
3050            .map(|o| o.pad_width_m * o.pad_depth_m)
3051            .unwrap_or(0.0);
3052        if area <= 0.0 {
3053            return Vec::new();
3054        }
3055        let mut map: std::collections::HashMap<String, (String, u32)> =
3056            std::collections::HashMap::new();
3057        for line in &wall.wall_bom {
3058            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3059            if qty == 0 {
3060                continue;
3061            }
3062            let name = if line.display_name.is_empty() {
3063                line.template_id.clone()
3064            } else {
3065                line.display_name.clone()
3066            };
3067            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3068            entry.1 = entry.1.saturating_add(qty);
3069        }
3070        for line in &roof.roof_bom {
3071            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3072            if qty == 0 {
3073                continue;
3074            }
3075            let name = if line.display_name.is_empty() {
3076                line.template_id.clone()
3077            } else {
3078                line.display_name.clone()
3079            };
3080            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3081            entry.1 = entry.1.saturating_add(qty);
3082        }
3083        let mut out: Vec<_> = map
3084            .into_iter()
3085            .map(|(id, (name, qty))| (id, name, qty))
3086            .collect();
3087        out.sort_by(|a, b| a.0.cmp(&b.0));
3088        out
3089    }
3090
3091    pub fn plot_build_duration_secs(&self) -> Option<f32> {
3092        let wall = self.plot_build_selected_wall()?;
3093        let roof = self.plot_build_selected_roof()?;
3094        let offer = self.plot_build_offer.as_ref()?;
3095        if !offer.pad_ok {
3096            return None;
3097        }
3098        let area = offer.pad_width_m * offer.pad_depth_m;
3099        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3100        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3101        Some(ticks.max(2.0) / 30.0)
3102    }
3103
3104    pub fn plot_build_can_afford(&self) -> bool {
3105        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3106            return false;
3107        }
3108        self.plot_build_bom_lines()
3109            .iter()
3110            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3111    }
3112
3113    pub fn currency_display(&self) -> String {
3114        crate::currency::currency_line(&self.inventory)
3115    }
3116
3117    /// True when standing in a shallow-water terrain zone from the segment snapshot.
3118    pub fn in_shallow_water(&self) -> bool {
3119        let (px, py) = self.player_position();
3120        self.terrain_at(px, py)
3121            .is_some_and(|k| k == TerrainKindView::ShallowWater)
3122    }
3123
3124    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3125        self.terrain_zone_at(x, y).map(|z| z.kind)
3126    }
3127
3128    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
3129    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3130        use std::cell::RefCell;
3131
3132        const CHUNK: i32 = 8;
3133        thread_local! {
3134            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
3135                RefCell::new(None);
3136        }
3137
3138        let zones = &self.terrain_zones;
3139        if zones.is_empty() {
3140            return None;
3141        }
3142        if zones.len() <= 48 {
3143            return zones
3144                .iter()
3145                .enumerate()
3146                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
3147                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
3148                .map(|(_, z)| z);
3149        }
3150
3151        let ptr = zones.as_ptr();
3152        let len = zones.len();
3153        INDEX.with(|cell| {
3154            let mut slot = cell.borrow_mut();
3155            let stale = match slot.as_ref() {
3156                Some((p, l, _)) => *p != ptr || *l != len,
3157                None => true,
3158            };
3159            if stale {
3160                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
3161                    std::collections::HashMap::new();
3162                for (zi, z) in zones.iter().enumerate() {
3163                    let x0 = z.x0.min(z.x1).floor() as i32;
3164                    let y0 = z.y0.min(z.y1).floor() as i32;
3165                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
3166                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
3167                    let cx0 = x0.div_euclid(CHUNK);
3168                    let cy0 = y0.div_euclid(CHUNK);
3169                    let cx1 = x1.div_euclid(CHUNK);
3170                    let cy1 = y1.div_euclid(CHUNK);
3171                    for cy in cy0..=cy1 {
3172                        for cx in cx0..=cx1 {
3173                            chunks.entry((cx, cy)).or_default().push(zi);
3174                        }
3175                    }
3176                }
3177                *slot = Some((ptr, len, chunks));
3178            }
3179            let chunks = &slot.as_ref().expect("index").2;
3180            let cx = (x.floor() as i32).div_euclid(CHUNK);
3181            let cy = (y.floor() as i32).div_euclid(CHUNK);
3182            let mut best: Option<(usize, &TerrainZoneView)> = None;
3183            if let Some(list) = chunks.get(&(cx, cy)) {
3184                for &zi in list {
3185                    let Some(z) = zones.get(zi) else { continue };
3186                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
3187                        continue;
3188                    }
3189                    best = match best {
3190                        None => Some((zi, z)),
3191                        Some((bi, bz)) => {
3192                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
3193                                Some((zi, z))
3194                            } else {
3195                                Some((bi, bz))
3196                            }
3197                        }
3198                    };
3199                }
3200            }
3201            best.map(|(_, z)| z)
3202        })
3203    }
3204
3205    /// Ground elevation from terrain zones (m).
3206    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
3207        self.terrain_zone_at(x, y)
3208            .map(|z| z.elevation)
3209            .unwrap_or(0.0)
3210    }
3211
3212    /// Walkable z levels at a map column (terrain + platforms).
3213    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
3214        const TOL: f32 = 0.35;
3215        let mut levels = vec![self.elevation_at(x, y)];
3216        for p in &self.z_platforms {
3217            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3218                levels.push(p.z);
3219            }
3220        }
3221        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3222        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
3223        levels
3224    }
3225
3226    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
3227        const TOL: f32 = 0.35;
3228        self.walkable_levels_at(x, y)
3229            .iter()
3230            .any(|&l| (l - z).abs() <= TOL)
3231    }
3232
3233    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
3234        let mut top = self.elevation_at(x, y);
3235        for p in &self.z_platforms {
3236            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3237                top = top.max(p.z);
3238            }
3239        }
3240        top
3241    }
3242
3243    /// Authoritative interior context from the server (`inside_building` flag).
3244    pub fn effective_inside_building(&self) -> Option<String> {
3245        self.player_entity().and_then(|p| p.inside_building.clone())
3246    }
3247
3248    /// True when a placed chest shares the player's outdoor/interior space.
3249    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
3250    /// labels); they must not be drawn or treated as nearby world props.
3251    pub fn placed_container_in_current_space(
3252        &self,
3253        c: &flatland_protocol::PlacedContainerView,
3254    ) -> bool {
3255        match (
3256            self.effective_inside_building().as_deref(),
3257            c.building_id.as_deref(),
3258        ) {
3259            (None, None) => true,
3260            (Some(a), Some(b)) => a == b,
3261            _ => false,
3262        }
3263    }
3264
3265    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3266        fn walk(
3267            stacks: &[flatland_protocol::ItemStack],
3268            hints: &mut std::collections::HashMap<String, InventoryHint>,
3269        ) {
3270            for stack in stacks {
3271                if stack.display_name.is_some()
3272                    || stack.category.is_some()
3273                    || stack.base_mass.is_some()
3274                    || stack.base_volume.is_some()
3275                    || stack.base_value_copper.is_some()
3276                {
3277                    hints.insert(
3278                        stack.template_id.clone(),
3279                        InventoryHint {
3280                            display_name: stack
3281                                .display_name
3282                                .clone()
3283                                .unwrap_or_else(|| stack.template_id.clone()),
3284                            category: stack.category.clone().unwrap_or_default(),
3285                            base_mass: stack.base_mass,
3286                            base_volume: stack.base_volume,
3287                            capacity_volume: stack.capacity_volume,
3288                            stackable: stack.stackable.unwrap_or(true),
3289                            listable: stack.listable.unwrap_or_else(|| {
3290                                category_default_listable(stack.category.as_deref().unwrap_or(""))
3291                            }),
3292                            base_value_copper: stack.base_value_copper,
3293                        },
3294                    );
3295                }
3296                walk(&stack.contents, hints);
3297            }
3298        }
3299        walk(stacks, &mut self.inventory_hints);
3300    }
3301
3302    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3303        self.inventory_stacks = stacks.to_vec();
3304        self.inventory.clear();
3305        self.inventory_hints.clear();
3306        fn walk(
3307            stacks: &[flatland_protocol::ItemStack],
3308            inventory: &mut std::collections::HashMap<String, u32>,
3309            hints: &mut std::collections::HashMap<String, InventoryHint>,
3310        ) {
3311            for stack in stacks {
3312                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
3313                if stack.display_name.is_some()
3314                    || stack.category.is_some()
3315                    || stack.base_mass.is_some()
3316                    || stack.base_volume.is_some()
3317                    || stack.base_value_copper.is_some()
3318                {
3319                    hints.insert(
3320                        stack.template_id.clone(),
3321                        InventoryHint {
3322                            display_name: stack
3323                                .display_name
3324                                .clone()
3325                                .unwrap_or_else(|| stack.template_id.clone()),
3326                            category: stack.category.clone().unwrap_or_default(),
3327                            base_mass: stack.base_mass,
3328                            base_volume: stack.base_volume,
3329                            capacity_volume: stack.capacity_volume,
3330                            stackable: stack.stackable.unwrap_or(true),
3331                            listable: stack.listable.unwrap_or_else(|| {
3332                                category_default_listable(stack.category.as_deref().unwrap_or(""))
3333                            }),
3334                            base_value_copper: stack.base_value_copper,
3335                        },
3336                    );
3337                }
3338                walk(&stack.contents, inventory, hints);
3339            }
3340        }
3341        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
3342        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
3343        for item in self.worn.values() {
3344            walk(
3345                std::slice::from_ref(item),
3346                &mut self.inventory,
3347                &mut self.inventory_hints,
3348            );
3349        }
3350    }
3351
3352    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
3353    /// inventory UI updates before the next tick snapshot arrives.
3354    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3355        let subtract_items =
3356            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3357        for stack in &notice.inventory_delta {
3358            if stack.quantity == 0 {
3359                continue;
3360            }
3361            if subtract_items {
3362                crate::currency::drain_template_stacks(
3363                    &mut self.inventory_stacks,
3364                    &stack.template_id,
3365                    stack.quantity,
3366                );
3367                continue;
3368            }
3369            let stackable = self
3370                .inventory_hints
3371                .get(&stack.template_id)
3372                .map(|h| h.stackable)
3373                .or(stack.stackable)
3374                .unwrap_or(true);
3375            if stackable {
3376                if let Some(existing) = self
3377                    .inventory_stacks
3378                    .iter_mut()
3379                    .find(|s| s.template_id == stack.template_id)
3380                {
3381                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
3382                    if stack.display_name.is_some() {
3383                        existing.display_name = stack.display_name.clone();
3384                    }
3385                    if stack.category.is_some() {
3386                        existing.category = stack.category.clone();
3387                    }
3388                    continue;
3389                }
3390            }
3391            self.inventory_stacks.push(stack.clone());
3392        }
3393        if notice.coins_delta != 0 {
3394            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3395        }
3396        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3397            let stacks = self.inventory_stacks.clone();
3398            self.sync_inventory_from_stacks(&stacks);
3399        }
3400        self.record_shop_trade_notice(notice);
3401    }
3402
3403    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
3404    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
3405    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
3406    /// iteration alone gives a stable row order.
3407    pub fn worn_rows(&self) -> Vec<InventoryRow> {
3408        let mut rows = Vec::new();
3409        for (slot, item) in &self.worn {
3410            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3411            rows.push(InventoryRow {
3412                depth: 0,
3413                stack: item.clone(),
3414                from: from.clone(),
3415                from_parent_instance_id: None,
3416                is_equip_shell: true,
3417                is_chest_shell: false,
3418                section: InventorySection::Worn,
3419            });
3420            for child in &item.contents {
3421                push_inventory_rows(
3422                    &mut rows,
3423                    1,
3424                    child,
3425                    &from,
3426                    item.item_instance_id,
3427                    InventorySection::Worn,
3428                );
3429            }
3430        }
3431        rows
3432    }
3433
3434    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
3435    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3436        let equipped = self.hand_equipped_instance_ids();
3437        self.inventory_stacks
3438            .iter()
3439            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
3440            .collect()
3441    }
3442
3443    /// Root on-person stacks that can be handed to a hired worker.
3444    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3445        let equipped = self.hand_equipped_instance_ids();
3446        self.inventory_stacks
3447            .iter()
3448            .filter_map(|stack| {
3449                let item_instance_id = stack.item_instance_id?;
3450                if equipped.contains(&item_instance_id) {
3451                    return None;
3452                }
3453                let label = stack
3454                    .display_name
3455                    .clone()
3456                    .unwrap_or_else(|| stack.template_id.clone());
3457                let label = if stack.quantity > 1 {
3458                    format!("{label} ×{}", stack.quantity)
3459                } else {
3460                    label
3461                };
3462                Some(WorkerGiveOption {
3463                    item_instance_id,
3464                    label,
3465                    quantity: stack.quantity,
3466                    template_id: stack.template_id.clone(),
3467                })
3468            })
3469            .collect()
3470    }
3471
3472    /// Employer-known blueprints the selected worker does not yet know.
3473    pub fn teachable_blueprint_options(
3474        &self,
3475        worker: &flatland_protocol::HiredWorkerView,
3476    ) -> Vec<WorkerTeachOption> {
3477        let copper = crate::currency::copper_from_counts(&self.inventory);
3478        let mut options: Vec<WorkerTeachOption> = self
3479            .blueprints
3480            .iter()
3481            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3482            .map(|bp| {
3483                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3484                let cost = bp.worker_train_copper;
3485                WorkerTeachOption {
3486                    blueprint_id: bp.id.clone(),
3487                    label: if bp.label.is_empty() {
3488                        bp.id.clone()
3489                    } else {
3490                        bp.label.clone()
3491                    },
3492                    cost_copper: cost,
3493                    min_level,
3494                    worker_level: worker.level,
3495                    can_afford: copper >= cost,
3496                    level_ok: worker.level >= min_level,
3497                }
3498            })
3499            .collect();
3500        options.sort_by(|a, b| a.label.cmp(&b.label));
3501        options
3502    }
3503
3504    /// Loose on-person inventory (not worn, not inside a placed chest).
3505    /// Root stacks are ordered by category group; nested contents stay under their parent.
3506    pub fn person_rows(&self) -> Vec<InventoryRow> {
3507        self.person_rows_filtered("")
3508    }
3509
3510    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3511        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3512        roots.sort_by(|a, b| {
3513            let ca = a
3514                .category
3515                .as_deref()
3516                .or_else(|| self.inventory_item_category(&a.template_id))
3517                .unwrap_or("");
3518            let cb = b
3519                .category
3520                .as_deref()
3521                .or_else(|| self.inventory_item_category(&b.template_id))
3522                .unwrap_or("");
3523            let ga = inventory_category_group(ca).1;
3524            let gb = inventory_category_group(cb).1;
3525            ga.cmp(&gb).then_with(|| {
3526                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
3527                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
3528                na.cmp(nb)
3529            })
3530        });
3531        let mut rows = Vec::new();
3532        for stack in roots {
3533            push_inventory_rows_filtered(
3534                &mut rows,
3535                0,
3536                stack,
3537                &flatland_protocol::InventoryLocation::Root,
3538                None,
3539                InventorySection::Person,
3540                filter,
3541            );
3542        }
3543        rows
3544    }
3545
3546    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3547        if filter.is_empty() {
3548            return self.worn_rows();
3549        }
3550        let mut rows = Vec::new();
3551        for (slot, item) in &self.worn {
3552            if !stack_matches_filter(item, filter) {
3553                continue;
3554            }
3555            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3556            let self_hit = {
3557                let f = filter.to_ascii_lowercase();
3558                let name = item
3559                    .display_name
3560                    .as_deref()
3561                    .unwrap_or("")
3562                    .to_ascii_lowercase();
3563                let tid = item.template_id.to_ascii_lowercase();
3564                name.contains(&f) || tid.contains(&f)
3565            };
3566            rows.push(InventoryRow {
3567                depth: 0,
3568                stack: item.clone(),
3569                from: from.clone(),
3570                from_parent_instance_id: None,
3571                is_equip_shell: true,
3572                is_chest_shell: false,
3573                section: InventorySection::Worn,
3574            });
3575            for child in &item.contents {
3576                if self_hit || stack_matches_filter(child, filter) {
3577                    push_inventory_rows_filtered(
3578                        &mut rows,
3579                        1,
3580                        child,
3581                        &from,
3582                        item.item_instance_id,
3583                        InventorySection::Worn,
3584                        if self_hit { "" } else { filter },
3585                    );
3586                }
3587            }
3588        }
3589        rows
3590    }
3591
3592    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3593    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3594        let mut rows = self.worn_rows();
3595        rows.extend(self.person_rows());
3596        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3597    }
3598
3599    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3600    /// populated when `accessible` — this is what makes a chest's contents
3601    /// disappear the moment you walk away or it's locked without your key.
3602    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3603        let (px, py) = self.player_position();
3604        let mut list: Vec<NearbyContainer> = self
3605            .placed_containers
3606            .iter()
3607            .filter(|c| self.placed_container_in_current_space(c))
3608            .filter_map(|c| {
3609                let distance_m = (c.x - px).hypot(c.y - py);
3610                if distance_m > CONTAINER_RANGE_M {
3611                    return None;
3612                }
3613                let mut rows = Vec::new();
3614                let from = flatland_protocol::InventoryLocation::Placed {
3615                    container_id: c.id.clone(),
3616                };
3617                rows.push(InventoryRow {
3618                    depth: 0,
3619                    stack: flatland_protocol::ItemStack {
3620                        template_id: c.template_id.clone(),
3621                        quantity: 1,
3622                        item_instance_id: c.item_instance_id,
3623                        props: Default::default(),
3624                        status_bindings: Vec::new(),
3625                        contents: Vec::new(),
3626                        display_name: Some(c.display_name.clone()),
3627                        category: Some("container".into()),
3628                        capacity_volume: c.capacity_volume,
3629                        worker_lodging_capacity: c.worker_lodging_capacity,
3630                        ..Default::default()
3631                    },
3632                    from: from.clone(),
3633                    from_parent_instance_id: None,
3634                    is_equip_shell: false,
3635                    is_chest_shell: true,
3636                    section: InventorySection::Nearby,
3637                });
3638                if c.accessible {
3639                    for child in &c.contents {
3640                        push_inventory_rows(
3641                            &mut rows,
3642                            1,
3643                            child,
3644                            &from,
3645                            c.item_instance_id,
3646                            InventorySection::Nearby,
3647                        );
3648                    }
3649                }
3650                Some(NearbyContainer {
3651                    view: c.clone(),
3652                    distance_m,
3653                    rows,
3654                })
3655            })
3656            .collect();
3657        list.sort_by(|a, b| {
3658            a.distance_m
3659                .partial_cmp(&b.distance_m)
3660                .unwrap_or(std::cmp::Ordering::Equal)
3661        });
3662        list
3663    }
3664
3665    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3666    pub fn nearest_placed_container(
3667        &self,
3668        max_dist: f32,
3669    ) -> Option<flatland_protocol::PlacedContainerView> {
3670        let (px, py) = self.player_position();
3671        self.placed_containers
3672            .iter()
3673            .filter(|c| self.placed_container_in_current_space(c))
3674            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3675            .min_by(|a, b| {
3676                let da = (a.x - px).hypot(a.y - py);
3677                let db = (b.x - px).hypot(b.y - py);
3678                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3679            })
3680            .cloned()
3681    }
3682
3683    /// Selectable rows for the **active inventory tab** (and current filter).
3684    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3685    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3686        let filter = self.inventory_filter.as_str();
3687        match self.inventory_tab {
3688            InventoryTab::OnPerson => {
3689                let mut rows = self.worn_rows_filtered(filter);
3690                rows.extend(self.person_rows_filtered(filter));
3691                rows
3692            }
3693            InventoryTab::Nearby => {
3694                let mut rows = Vec::new();
3695                for nc in self.nearby_containers() {
3696                    if filter.is_empty() {
3697                        rows.extend(nc.rows);
3698                        continue;
3699                    }
3700                    let shell = nc.rows.first().cloned();
3701                    let contents: Vec<_> = nc
3702                        .rows
3703                        .iter()
3704                        .skip(1)
3705                        .filter(|r| stack_matches_filter(&r.stack, filter))
3706                        .cloned()
3707                        .collect();
3708                    let shell_hit = shell
3709                        .as_ref()
3710                        .map(|s| stack_matches_filter(&s.stack, filter))
3711                        .unwrap_or(false);
3712                    if shell_hit || !contents.is_empty() {
3713                        if let Some(s) = shell {
3714                            rows.push(s);
3715                        }
3716                        if shell_hit {
3717                            rows.extend(nc.rows.into_iter().skip(1));
3718                        } else {
3719                            rows.extend(contents);
3720                        }
3721                    }
3722                }
3723                rows
3724            }
3725        }
3726    }
3727
3728    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3729        self.inventory_selectable_rows()
3730            .into_iter()
3731            .nth(self.inventory_menu_index)
3732    }
3733
3734    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3735        let cat = self
3736            .inventory_item_category(&row.stack.template_id)
3737            .unwrap_or("");
3738        if cat == "key" {
3739            self.key_inventory_label(&row.stack)
3740        } else {
3741            row.stack
3742                .display_name
3743                .clone()
3744                .unwrap_or_else(|| row.stack.template_id.clone())
3745        }
3746    }
3747
3748    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3749    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3750        let bindings =
3751            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3752        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3753            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3754            let mode = Self::grant_mode(&row.stack);
3755            format!(" [grant {effect} · {mode} — e apply]")
3756        } else {
3757            String::new()
3758        };
3759        let qty = if row.stack.quantity > 1 {
3760            format!(" ×{}", row.stack.quantity)
3761        } else {
3762            String::new()
3763        };
3764        let worn_slot = if row.is_equip_shell {
3765            match row.from {
3766                flatland_protocol::InventoryLocation::Worn { slot } => {
3767                    format!(" ({})", body_slot_label(slot))
3768                }
3769                _ => String::new(),
3770            }
3771        } else {
3772            String::new()
3773        };
3774        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3775    }
3776
3777    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3778        (
3779            row.stack.template_id.clone(),
3780            self.inventory_row_base_label(row),
3781            self.inventory_row_visible_mod_signature(row),
3782        )
3783    }
3784
3785    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3786    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3787        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3788        for row in self.inventory_selectable_rows() {
3789            if row.stack.item_instance_id.is_none() {
3790                continue;
3791            }
3792            let key = self.inventory_row_instance_identity_key(&row);
3793            *counts.entry(key).or_default() += 1;
3794        }
3795        counts
3796            .into_iter()
3797            .filter(|(_, n)| *n > 1)
3798            .map(|(k, _)| k)
3799            .collect()
3800    }
3801
3802    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3803        let hex: String = id
3804            .as_simple()
3805            .to_string()
3806            .chars()
3807            .filter(|c| c.is_ascii_hexdigit())
3808            .collect();
3809        let short = if hex.len() >= 4 {
3810            &hex[hex.len() - 4..]
3811        } else {
3812            hex.as_str()
3813        };
3814        format!("Instance {id} (#{short})")
3815    }
3816
3817    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3818    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3819        let cat = self
3820            .inventory_item_category(&row.stack.template_id)
3821            .unwrap_or("");
3822        let label = self.inventory_row_base_label(row);
3823        let hint: String = if row.is_equip_shell {
3824            " [worn — Enter to unequip]".into()
3825        } else if row.is_chest_shell {
3826            let (locked, lodging_note) = match &row.from {
3827                flatland_protocol::InventoryLocation::Placed { container_id } => {
3828                    let locked = self
3829                        .placed_containers
3830                        .iter()
3831                        .find(|c| c.id == *container_id)
3832                        .map(|c| c.locked)
3833                        .unwrap_or(false);
3834                    let lodging_note = self
3835                        .lodging_occupancy_label(container_id)
3836                        .map(|who| format!(" [lodging: {who}]"))
3837                        .unwrap_or_default();
3838                    (locked, lodging_note)
3839                }
3840                _ => (false, String::new()),
3841            };
3842            if locked {
3843                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3844            } else {
3845                format!(" [Enter pick up · l lock]{lodging_note}")
3846            }
3847        } else if cat == "key" {
3848            self.key_inventory_hint(&row.stack)
3849        } else {
3850            match cat {
3851                "weapon" => " [weapon]".into(),
3852                "container" => " [bag/chest/belt]".into(),
3853                "lodging" => " [worker lodging]".into(),
3854                "armor" => " [armor]".into(),
3855                _ => String::new(),
3856            }
3857        };
3858        let qty = if row.stack.quantity > 1 {
3859            format!(" ×{}", row.stack.quantity)
3860        } else {
3861            String::new()
3862        };
3863        let bindings =
3864            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3865        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3866            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3867            let mode = Self::grant_mode(&row.stack);
3868            format!(" [grant {effect} · {mode} — e apply]")
3869        } else {
3870            String::new()
3871        };
3872        let mass = self.stack_mass(&row.stack);
3873        let mass_kg = (mass >= 0.05).then_some(mass);
3874        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
3875        let volume = self.container_volume_stats(row);
3876        let vol_str = self.container_volume_label(row);
3877
3878        let mut title = label.clone();
3879        title.push_str(&qty);
3880        if row.is_equip_shell {
3881            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3882                title.push_str(&format!(" ({})", body_slot_label(slot)));
3883            }
3884        }
3885
3886        InventoryRowView {
3887            depth: row.depth,
3888            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3889            title: format!("{title}{grant_hint}{bindings}"),
3890            mass_kg,
3891            volume,
3892            instance_tooltip: None,
3893        }
3894    }
3895
3896    fn push_browser_item(
3897        &self,
3898        lines: &mut Vec<InventoryBrowserLine>,
3899        row: &InventoryRow,
3900        global_idx: &mut usize,
3901        target: usize,
3902        highlight: bool,
3903        ambiguous_instance_keys: &HashSet<(String, String, String)>,
3904    ) {
3905        let mut view = self.format_inventory_row(row);
3906        if let Some(id) = row.stack.item_instance_id {
3907            let key = self.inventory_row_instance_identity_key(row);
3908            if ambiguous_instance_keys.contains(&key) {
3909                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3910            }
3911        }
3912        lines.push(InventoryBrowserLine::Item {
3913            selectable_index: *global_idx,
3914            selected: highlight && *global_idx == target,
3915            depth: view.depth,
3916            text: view.text,
3917            title: view.title,
3918            mass_kg: view.mass_kg,
3919            volume: view.volume,
3920            instance_tooltip: view.instance_tooltip,
3921        });
3922        *global_idx += 1;
3923    }
3924
3925    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
3926    /// `selectable_index` matching `inventory_menu_index`.
3927    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3928        let mut lines = Vec::new();
3929        let target = self.inventory_menu_index;
3930        let highlight = !self.show_move_picker && !self.show_grant_picker;
3931        let filter = self.inventory_filter.as_str();
3932        let mut global_idx = 0usize;
3933        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3934
3935        match self.inventory_tab {
3936            InventoryTab::OnPerson => {
3937                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3938                let worn = self.worn_rows_filtered(filter);
3939                if worn.is_empty() {
3940                    lines.push(InventoryBrowserLine::Hint(
3941                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3942                    ));
3943                } else {
3944                    for row in &worn {
3945                        if row.is_equip_shell {
3946                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3947                                lines.push(InventoryBrowserLine::SlotLabel(format!(
3948                                    "  {}:",
3949                                    body_slot_label(slot)
3950                                )));
3951                            }
3952                        }
3953                        self.push_browser_item(
3954                            &mut lines,
3955                            row,
3956                            &mut global_idx,
3957                            target,
3958                            highlight,
3959                            &ambiguous_instance_keys,
3960                        );
3961                    }
3962                }
3963
3964                lines.push(InventoryBrowserLine::Blank);
3965                lines.push(InventoryBrowserLine::Section(
3966                    "— On you (loose, not worn) —".into(),
3967                ));
3968                let person = self.person_rows_filtered(filter);
3969                if person.is_empty() {
3970                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
3971                } else {
3972                    let mut last_group: Option<&'static str> = None;
3973                    for row in &person {
3974                        if row.depth == 0 {
3975                            let cat = row
3976                                .stack
3977                                .category
3978                                .as_deref()
3979                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
3980                                .unwrap_or("");
3981                            let (group, _) = inventory_category_group(cat);
3982                            if last_group != Some(group) {
3983                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
3984                                last_group = Some(group);
3985                            }
3986                        }
3987                        self.push_browser_item(
3988                            &mut lines,
3989                            row,
3990                            &mut global_idx,
3991                            target,
3992                            highlight,
3993                            &ambiguous_instance_keys,
3994                        );
3995                    }
3996                }
3997            }
3998            InventoryTab::Nearby => {
3999                let nearby = self.nearby_containers();
4000                if nearby.is_empty() {
4001                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4002                    lines.push(InventoryBrowserLine::Hint(
4003                        "  (none within reach — walk up to a chest)".into(),
4004                    ));
4005                    lines.push(InventoryBrowserLine::Hint(
4006                        "  Select an on-person item, then m / Enter → move into chest.".into(),
4007                    ));
4008                } else {
4009                    let mut any_visible = false;
4010                    for nc in &nearby {
4011                        let shell = nc.rows.first();
4012                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
4013                            nc.rows.iter().skip(1).collect()
4014                        } else {
4015                            let shell_hit = shell
4016                                .map(|s| {
4017                                    let f = filter.to_ascii_lowercase();
4018                                    let name = s
4019                                        .stack
4020                                        .display_name
4021                                        .as_deref()
4022                                        .unwrap_or("")
4023                                        .to_ascii_lowercase();
4024                                    let tid = s.stack.template_id.to_ascii_lowercase();
4025                                    name.contains(&f) || tid.contains(&f)
4026                                })
4027                                .unwrap_or(false);
4028                            if shell_hit {
4029                                nc.rows.iter().skip(1).collect()
4030                            } else {
4031                                nc.rows
4032                                    .iter()
4033                                    .skip(1)
4034                                    .filter(|r| stack_matches_filter(&r.stack, filter))
4035                                    .collect()
4036                            }
4037                        };
4038                        let shell_visible = filter.is_empty()
4039                            || shell
4040                                .map(|s| stack_matches_filter(&s.stack, filter))
4041                                .unwrap_or(false)
4042                            || !contents.is_empty();
4043                        if !shell_visible && shell.is_some() {
4044                            continue;
4045                        }
4046                        any_visible = true;
4047                        lines.push(InventoryBrowserLine::Blank);
4048                        let lock_note = if nc.view.locked && nc.view.accessible {
4049                            "  unlocked with your key"
4050                        } else if nc.view.locked {
4051                            "  locked"
4052                        } else {
4053                            ""
4054                        };
4055                        lines.push(InventoryBrowserLine::Section(format!(
4056                            "— {} ({:.0}m away){lock_note} —",
4057                            nc.view.display_name, nc.distance_m
4058                        )));
4059                        if !nc.view.accessible {
4060                            lines.push(InventoryBrowserLine::Hint(
4061                                "  locked — need the matching key (l to try)".into(),
4062                            ));
4063                        } else if nc.rows.is_empty() {
4064                            lines.push(InventoryBrowserLine::Hint(
4065                                "  (empty — switch to On person, select an item, m to move in)"
4066                                    .into(),
4067                            ));
4068                        } else if let Some(shell_row) = shell {
4069                            self.push_browser_item(
4070                                &mut lines,
4071                                shell_row,
4072                                &mut global_idx,
4073                                target,
4074                                highlight,
4075                                &ambiguous_instance_keys,
4076                            );
4077                            for row in contents {
4078                                self.push_browser_item(
4079                                    &mut lines,
4080                                    row,
4081                                    &mut global_idx,
4082                                    target,
4083                                    highlight,
4084                                    &ambiguous_instance_keys,
4085                                );
4086                            }
4087                        }
4088                    }
4089                    if !any_visible {
4090                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4091                        lines.push(InventoryBrowserLine::Hint(
4092                            "  (no matching items — clear filter with Esc)".into(),
4093                        ));
4094                    }
4095                }
4096            }
4097        }
4098        lines
4099    }
4100
4101    /// Destinations for picking up a placed chest/crate into inventory.
4102    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
4103        let mut opts = Vec::new();
4104        opts.push(MoveOption {
4105            label: "Relocate…".into(),
4106            kind: MoveOptionKind::RelocatePlaced {
4107                container_id: container_id.to_string(),
4108            },
4109        });
4110        opts.push(MoveOption {
4111            label: "On your person (loose)".into(),
4112            kind: MoveOptionKind::PickupPlaced {
4113                container_id: container_id.to_string(),
4114                nest_location: flatland_protocol::InventoryLocation::Root,
4115                nest_parent_instance_id: None,
4116            },
4117        });
4118        for (slot, item) in &self.worn {
4119            if item.category.as_deref() != Some("container") {
4120                continue;
4121            }
4122            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
4123                continue;
4124            }
4125            let Some(parent_id) = item.item_instance_id else {
4126                continue;
4127            };
4128            let shell_name = item
4129                .display_name
4130                .clone()
4131                .unwrap_or_else(|| item.template_id.clone());
4132            opts.push(MoveOption {
4133                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
4134                kind: MoveOptionKind::PickupPlaced {
4135                    container_id: container_id.to_string(),
4136                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
4137                    nest_parent_instance_id: Some(parent_id),
4138                },
4139            });
4140            // Nested pouches inside the worn bag.
4141            Self::append_chest_pickup_nested(
4142                &mut opts,
4143                container_id,
4144                flatland_protocol::InventoryLocation::Worn { slot: *slot },
4145                item,
4146                &format!("in {shell_name}"),
4147            );
4148        }
4149        opts.push(MoveOption {
4150            label: "Cancel".into(),
4151            kind: MoveOptionKind::Cancel,
4152        });
4153        opts
4154    }
4155
4156    fn append_chest_pickup_nested(
4157        opts: &mut Vec<MoveOption>,
4158        container_id: &str,
4159        location: flatland_protocol::InventoryLocation,
4160        parent: &flatland_protocol::ItemStack,
4161        context: &str,
4162    ) {
4163        for child in &parent.contents {
4164            if child.category.as_deref() != Some("container") {
4165                continue;
4166            }
4167            if !Self::is_volume_container_stack(child) {
4168                continue;
4169            }
4170            // Skip placeable nested chests — relocating into another chest is not useful.
4171            if child.world_placeable == Some(true) {
4172                continue;
4173            }
4174            let Some(child_id) = child.item_instance_id else {
4175                continue;
4176            };
4177            let name = child
4178                .display_name
4179                .clone()
4180                .unwrap_or_else(|| child.template_id.clone());
4181            opts.push(MoveOption {
4182                label: format!("{name} ({context})"),
4183                kind: MoveOptionKind::PickupPlaced {
4184                    container_id: container_id.to_string(),
4185                    nest_location: location.clone(),
4186                    nest_parent_instance_id: Some(child_id),
4187                },
4188            });
4189            Self::append_chest_pickup_nested(
4190                opts,
4191                container_id,
4192                location.clone(),
4193                child,
4194                &format!("in {name}"),
4195            );
4196        }
4197    }
4198
4199    /// Build the "move to…" destination list for an item currently at `from`.
4200    pub fn move_destinations_for(
4201        &self,
4202        from: &flatland_protocol::InventoryLocation,
4203        from_parent_instance_id: Option<uuid::Uuid>,
4204        moving_instance_id: Option<uuid::Uuid>,
4205        moving_template_id: &str,
4206    ) -> Vec<MoveOption> {
4207        let mut opts = Vec::new();
4208        if *from != flatland_protocol::InventoryLocation::Root {
4209            opts.push(MoveOption {
4210                label: "On your person (loose)".into(),
4211                kind: MoveOptionKind::Move {
4212                    location: flatland_protocol::InventoryLocation::Root,
4213                    parent_instance_id: None,
4214                },
4215            });
4216        }
4217        for (slot, item) in &self.worn {
4218            if item.category.as_deref() != Some("container") {
4219                continue;
4220            }
4221            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4222            let shell_name = item
4223                .display_name
4224                .clone()
4225                .unwrap_or_else(|| item.template_id.clone());
4226
4227            // Backpack and other worn volume containers — store directly inside the shell.
4228            if *slot != BodySlot::Waist
4229                && item.item_instance_id != moving_instance_id
4230                && Self::is_volume_container_stack(item)
4231            {
4232                Self::push_move_destination(
4233                    &mut opts,
4234                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
4235                    location.clone(),
4236                    item.item_instance_id,
4237                    from,
4238                    from_parent_instance_id,
4239                );
4240            }
4241
4242            // Belt loops only accept pouch attachments — not loose materials.
4243            if *slot == BodySlot::Waist
4244                && Self::attaches_to_belt_loop(moving_template_id)
4245                && item.item_instance_id != moving_instance_id
4246            {
4247                Self::push_move_destination(
4248                    &mut opts,
4249                    format!("{shell_name} (belt loop)"),
4250                    location.clone(),
4251                    item.item_instance_id,
4252                    from,
4253                    from_parent_instance_id,
4254                );
4255            }
4256
4257            let context = if *slot == BodySlot::Waist {
4258                format!("on {shell_name}")
4259            } else {
4260                format!("in {shell_name}")
4261            };
4262            Self::append_nested_container_destinations(
4263                &mut opts,
4264                location,
4265                item,
4266                &context,
4267                from,
4268                from_parent_instance_id,
4269                moving_instance_id,
4270            );
4271        }
4272        for nc in self.nearby_containers() {
4273            if !nc.view.accessible {
4274                continue;
4275            }
4276            let location = flatland_protocol::InventoryLocation::Placed {
4277                container_id: nc.view.id.clone(),
4278            };
4279            Self::push_move_destination(
4280                &mut opts,
4281                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
4282                location,
4283                nc.view.item_instance_id,
4284                from,
4285                from_parent_instance_id,
4286            );
4287        }
4288        let allow_drop = moving_instance_id
4289            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
4290            .unwrap_or(true)
4291            && moving_instance_id
4292                .and_then(|id| self.stack_for_instance(id))
4293                .map(|stack| {
4294                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
4295                })
4296                .unwrap_or(
4297                    moving_template_id != KEY_TEMPLATE
4298                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
4299                );
4300        if allow_drop {
4301            opts.push(MoveOption {
4302                label: "Drop on the ground".into(),
4303                kind: MoveOptionKind::Drop,
4304            });
4305        }
4306        opts.push(MoveOption {
4307            label: "Cancel".into(),
4308            kind: MoveOptionKind::Cancel,
4309        });
4310        opts
4311    }
4312
4313    fn is_same_container_dest(
4314        dest_location: &flatland_protocol::InventoryLocation,
4315        dest_parent: Option<uuid::Uuid>,
4316        from: &flatland_protocol::InventoryLocation,
4317        from_parent: Option<uuid::Uuid>,
4318    ) -> bool {
4319        dest_location == from && dest_parent == from_parent
4320    }
4321
4322    fn push_move_destination(
4323        opts: &mut Vec<MoveOption>,
4324        label: String,
4325        location: flatland_protocol::InventoryLocation,
4326        parent_instance_id: Option<uuid::Uuid>,
4327        from: &flatland_protocol::InventoryLocation,
4328        from_parent_instance_id: Option<uuid::Uuid>,
4329    ) {
4330        if Self::is_same_container_dest(
4331            &location,
4332            parent_instance_id,
4333            from,
4334            from_parent_instance_id,
4335        ) {
4336            return;
4337        }
4338        opts.push(MoveOption {
4339            label,
4340            kind: MoveOptionKind::Move {
4341                location,
4342                parent_instance_id,
4343            },
4344        });
4345    }
4346
4347    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4348        stack.capacity_volume.is_some_and(|c| c > 0.0)
4349    }
4350
4351    fn attaches_to_belt_loop(template_id: &str) -> bool {
4352        matches!(template_id, "leather_pouch" | "dimensional_pouch")
4353    }
4354
4355    fn append_nested_container_destinations(
4356        opts: &mut Vec<MoveOption>,
4357        location: flatland_protocol::InventoryLocation,
4358        container: &flatland_protocol::ItemStack,
4359        context: &str,
4360        from: &flatland_protocol::InventoryLocation,
4361        from_parent_instance_id: Option<uuid::Uuid>,
4362        moving_instance_id: Option<uuid::Uuid>,
4363    ) {
4364        for child in &container.contents {
4365            if Self::is_volume_container_stack(child)
4366                && child.item_instance_id != moving_instance_id
4367            {
4368                let name = child
4369                    .display_name
4370                    .clone()
4371                    .unwrap_or_else(|| child.template_id.clone());
4372                Self::push_move_destination(
4373                    opts,
4374                    format!("{name} ({context})"),
4375                    location.clone(),
4376                    child.item_instance_id,
4377                    from,
4378                    from_parent_instance_id,
4379                );
4380            }
4381            let nested_context = format!(
4382                "in {}",
4383                child.display_name.as_deref().unwrap_or(&child.template_id)
4384            );
4385            Self::append_nested_container_destinations(
4386                opts,
4387                location.clone(),
4388                child,
4389                &nested_context,
4390                from,
4391                from_parent_instance_id,
4392                moving_instance_id,
4393            );
4394        }
4395    }
4396
4397    fn clamp_inventory_indices(&mut self) {
4398        let n = self.inventory_selectable_rows().len();
4399        self.inventory_menu_index = if n == 0 {
4400            0
4401        } else {
4402            self.inventory_menu_index.min(n - 1)
4403        };
4404        if let Some(picker) = &self.move_picker {
4405            let pn = picker.options.len();
4406            self.move_picker_index = if pn == 0 {
4407                0
4408            } else {
4409                self.move_picker_index.min(pn - 1)
4410            };
4411        }
4412    }
4413
4414    /// Drop interior map layers whenever the player is outdoors.
4415    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
4416    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
4417    /// force `build_grid` into a full-map z pass on every click until client restart.
4418    fn sync_interior_map_context(&mut self) {
4419        if self.effective_inside_building().is_none() {
4420            self.interior_map = None;
4421            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4422                self.z_platforms = platforms;
4423                self.z_transitions = transitions;
4424            }
4425            return;
4426        }
4427        self.sync_interior_z_bands();
4428    }
4429
4430    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
4431    fn sync_interior_z_bands(&mut self) {
4432        if self.effective_inside_building().is_some() {
4433            if let Some(map) = &self.interior_map {
4434                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4435                    if self.z_bands_outdoor_backup.is_none() {
4436                        self.z_bands_outdoor_backup = Some((
4437                            std::mem::take(&mut self.z_platforms),
4438                            std::mem::take(&mut self.z_transitions),
4439                        ));
4440                    }
4441                    self.z_platforms = map.z_platforms.clone();
4442                    self.z_transitions = map.z_transitions.clone();
4443                }
4444            }
4445        }
4446    }
4447
4448    fn apply_snapshot_fields(
4449        &mut self,
4450        snapshot: &flatland_protocol::Snapshot,
4451        entity_id: EntityId,
4452    ) {
4453        self.tick = snapshot.tick;
4454        self.chunk_rev = snapshot.chunk_rev;
4455        self.content_rev = snapshot.content_rev;
4456        self.publish_rev = snapshot.publish_rev;
4457        self.resource_nodes = snapshot.resource_nodes.clone();
4458        self.ground_drops = snapshot.ground_drops.clone();
4459        self.placed_containers = snapshot.placed_containers.clone();
4460        self.world_x0 = snapshot.world_x0;
4461        self.world_y0 = snapshot.world_y0;
4462        self.world_width_m = snapshot.world_width_m;
4463        self.world_height_m = snapshot.world_height_m;
4464        self.world_clock = snapshot.world_clock;
4465        self.terrain_zones = snapshot.terrain_zones.clone();
4466        self.z_platforms = snapshot.z_platforms.clone();
4467        self.z_transitions = snapshot.z_transitions.clone();
4468        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
4469        self.z_bands_outdoor_backup = None;
4470        self.buildings = snapshot.buildings.clone();
4471        self.doors = snapshot.doors.clone();
4472        self.interior_map = snapshot.interior_map.clone();
4473        self.npcs = snapshot.npcs.clone();
4474        self.blueprints = snapshot.blueprints.clone();
4475        self.building_materials = snapshot.building_materials.clone();
4476        self.sync_inventory_from_stacks(&snapshot.inventory);
4477        self.player = snapshot
4478            .entities
4479            .iter()
4480            .find(|e| e.id == entity_id)
4481            .cloned();
4482        self.entities = snapshot.entities.clone();
4483        self.quest_log = snapshot.quest_log.clone();
4484        self.apply_hired_workers(snapshot.hired_workers.clone());
4485        self.interactables = snapshot.interactables.clone();
4486        self.ledger = snapshot.ledger.clone();
4487        self.career = snapshot.career.clone();
4488        self.combat_fx = snapshot.combat_fx.clone();
4489        self.ground_hazards = snapshot.ground_hazards.clone();
4490        self.property_zones = snapshot.property_zones.clone();
4491        self.tax_zones = snapshot.tax_zones.clone();
4492        self.growth_zones = snapshot.growth_zones.clone();
4493        self.biome_zones = snapshot.biome_zones.clone();
4494        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4495        self.property_plots = snapshot.property_plots.clone();
4496        self.property_plot_settings = snapshot.property_plot_settings.clone();
4497        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
4498        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
4499        if self.effective_inside_building().is_some() {
4500            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4501        }
4502        self.sync_interior_map_context();
4503        self.refresh_whisper_range();
4504        self.sync_gameplay_audio();
4505    }
4506
4507    /// Re-derive inventory selection state after a snapshot/tick — chests that
4508    /// went out of range or got locked simply vanish from the row list, and the
4509    /// move picker (if any) closes once its item is no longer reachable.
4510    fn refresh_inventory_ui(&mut self) {
4511        if let Some(picker) = &self.move_picker {
4512            let instance_id = picker.item_instance_id;
4513            let still_exists = self
4514                .inventory_selectable_rows()
4515                .iter()
4516                .any(|r| r.stack.item_instance_id == Some(instance_id));
4517            if !still_exists {
4518                self.move_picker = None;
4519                self.show_move_picker = false;
4520            }
4521        }
4522        if let Some(picker) = &self.destroy_picker {
4523            let instance_id = picker.item_instance_id;
4524            let still_exists = self
4525                .inventory_selectable_rows()
4526                .iter()
4527                .any(|r| r.stack.item_instance_id == Some(instance_id));
4528            if !still_exists {
4529                self.destroy_picker = None;
4530                self.show_destroy_picker = false;
4531                self.destroy_confirm_pending = false;
4532            }
4533        }
4534        self.clamp_inventory_indices();
4535    }
4536
4537    /// Replace the hired-worker list from a snapshot/delta.
4538    ///
4539    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
4540    /// selected worker (and its step line) does not jump when the server rebuilds
4541    /// the list.
4542    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4543        let selected_id = self
4544            .hired_workers
4545            .get(self.workers_menu_index)
4546            .map(|w| w.instance_id.clone());
4547        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4548        let now = Instant::now();
4549        for worker in &workers {
4550            let was_hit = self
4551                .hired_workers
4552                .iter()
4553                .find(|previous| previous.instance_id == worker.instance_id)
4554                .is_some_and(|previous| {
4555                    matches!(
4556                        worker.mode,
4557                        flatland_protocol::WorkerModeView::Defender
4558                    ) && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
4559                });
4560            if was_hit {
4561                self.worker_health_ring_until.insert(
4562                    worker.entity_id,
4563                    now + WORKER_HEALTH_RING_HOLD,
4564                );
4565            }
4566        }
4567        let worker_entity_ids: HashSet<EntityId> =
4568            workers.iter().map(|worker| worker.entity_id).collect();
4569        self.worker_health_ring_until
4570            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
4571        for w in &workers {
4572            let prev_err = self
4573                .hired_workers
4574                .iter()
4575                .find(|p| p.instance_id == w.instance_id)
4576                .and_then(|p| p.last_error.as_deref());
4577            let new_err = w.last_error.as_deref();
4578            if new_err != prev_err {
4579                if let Some(err) = new_err {
4580                    if !worker_error_is_transient(err) {
4581                        self.push_log(format!("Worker {}: {err}", w.label));
4582                    }
4583                }
4584            }
4585        }
4586        let mut next_display = BTreeMap::new();
4587        let mut next_errors = BTreeMap::new();
4588        for w in &workers {
4589            let mut sticky = self
4590                .worker_step_display
4591                .remove(&w.instance_id)
4592                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4593            sticky.observe(&w.step_label, now);
4594            next_display.insert(w.instance_id.clone(), sticky);
4595
4596            let mut err_sticky = self
4597                .worker_error_display
4598                .remove(&w.instance_id)
4599                .unwrap_or_default();
4600            err_sticky.observe(w.last_error.as_deref(), now);
4601            if err_sticky.shown(now).is_some() {
4602                next_errors.insert(w.instance_id.clone(), err_sticky);
4603            }
4604        }
4605        self.worker_step_display = next_display;
4606        self.worker_error_display = next_errors;
4607        self.hired_workers = workers;
4608        self.sync_worker_take_picker_from_hired();
4609        if let Some(id) = selected_id {
4610            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4611                self.workers_menu_index = idx;
4612                return;
4613            }
4614        }
4615        if self.workers_menu_index >= self.hired_workers.len() {
4616            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4617        }
4618    }
4619
4620    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4621    fn sync_worker_take_picker_from_hired(&mut self) {
4622        if !self.show_worker_take_picker {
4623            return;
4624        }
4625        let Some(picker) = self.worker_take_picker.clone() else {
4626            return;
4627        };
4628        let Some(worker) = self
4629            .hired_workers
4630            .iter()
4631            .find(|w| w.instance_id == picker.worker_instance_id)
4632            .cloned()
4633        else {
4634            self.show_worker_take_picker = false;
4635            self.worker_take_picker = None;
4636            self.worker_take_picker_index = 0;
4637            return;
4638        };
4639        let options: Vec<WorkerGiveOption> = worker
4640            .inventory
4641            .iter()
4642            .filter_map(|stack| {
4643                let item_instance_id = stack.item_instance_id?;
4644                let label = stack
4645                    .display_name
4646                    .clone()
4647                    .unwrap_or_else(|| stack.template_id.clone());
4648                let label = if stack.quantity > 1 {
4649                    format!("{label} ×{}", stack.quantity)
4650                } else {
4651                    label
4652                };
4653                Some(WorkerGiveOption {
4654                    item_instance_id,
4655                    label,
4656                    quantity: stack.quantity,
4657                    template_id: stack.template_id.clone(),
4658                })
4659            })
4660            .collect();
4661        if options.is_empty() {
4662            self.show_worker_take_picker = false;
4663            self.worker_take_picker = None;
4664            self.worker_take_picker_index = 0;
4665            return;
4666        }
4667        let prev_id = picker
4668            .options
4669            .get(self.worker_take_picker_index)
4670            .map(|o| o.item_instance_id);
4671        let idx = prev_id
4672            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4673            .unwrap_or(0)
4674            .min(options.len().saturating_sub(1));
4675        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4676        let quantity = picker.quantity.clamp(1, max_qty);
4677        self.worker_take_picker_index = idx;
4678        self.worker_take_picker = Some(WorkerTakePicker {
4679            worker_instance_id: picker.worker_instance_id,
4680            worker_label: picker.worker_label,
4681            options,
4682            quantity,
4683        });
4684    }
4685
4686    /// Held coarse step label for the workers menu (`step:` line).
4687    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4688        self.worker_step_display
4689            .get(worker_instance_id)
4690            .map(|s| s.shown.as_str())
4691            .or_else(|| {
4692                self.hired_workers
4693                    .iter()
4694                    .find(|w| w.instance_id == worker_instance_id)
4695                    .map(|w| w.step_label.as_str())
4696            })
4697            .unwrap_or("")
4698    }
4699
4700    /// Held error line for workers UI (detail + compact).
4701    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4702        let now = Instant::now();
4703        self.worker_error_display
4704            .get(worker_instance_id)
4705            .and_then(|s| s.shown(now))
4706            .or_else(|| {
4707                self.hired_workers
4708                    .iter()
4709                    .find(|w| w.instance_id == worker_instance_id)
4710                    .and_then(|w| w.last_error.as_deref())
4711                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4712            })
4713            .filter(|e| !worker_error_is_hud_noise(e))
4714    }
4715
4716    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4717        self.in_combat = combat.in_combat;
4718        self.auto_attack = combat.auto_attack;
4719        self.combat_has_los = combat.has_los;
4720        self.attack_cd_ticks = combat.attack_cd_ticks;
4721        self.gcd_ticks = combat.gcd_ticks;
4722        self.weapon_ability_id = combat.ability_id.clone();
4723        self.mainhand_template_id = combat.mainhand_template_id.clone();
4724        self.mainhand_label = combat.mainhand_label.clone();
4725        self.mainhand_instance_id = combat.mainhand_instance_id;
4726        self.offhand_template_id = combat.offhand_template_id.clone();
4727        self.offhand_label = combat.offhand_label.clone();
4728        self.offhand_instance_id = combat.offhand_instance_id;
4729        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4730            1
4731        } else {
4732            combat.mainhand_hand_slots
4733        };
4734        self.defense = combat.defense.clone();
4735        self.worn = combat.worn.iter().cloned().collect();
4736        self.carry_mass = combat.carry_mass;
4737        self.carry_mass_max = combat.carry_mass_max;
4738        self.encumbrance = combat.encumbrance;
4739        self.cast_progress = combat.cast.clone();
4740        self.timed_channel = combat.timed_channel.clone();
4741        self.plot_build_offer = combat.plot_build.clone();
4742        self.ability_cooldowns = combat.ability_cooldowns.clone();
4743        self.blocking_active = combat.blocking_active;
4744        self.max_target_slots = combat.max_target_slots.max(1);
4745        self.combat_slots = combat.slots.clone();
4746        self.rotation_presets = combat.rotation_presets.clone();
4747        self.known_abilities = combat.known_abilities.clone();
4748        self.ability_meta = combat
4749            .ability_meta
4750            .iter()
4751            .cloned()
4752            .map(|meta| (meta.id.clone(), meta))
4753            .collect();
4754        self.ability_mastery = combat
4755            .ability_mastery
4756            .iter()
4757            .cloned()
4758            .map(|row| (row.ability_id.clone(), row))
4759            .collect();
4760        self.hotbar = combat.hotbar.clone();
4761        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4762        self.keychain_stacks = combat.keychain.clone();
4763        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4764        self.combat_target_detail = combat.target.clone();
4765        self.statuses = combat.statuses.clone();
4766        self.combat_target = combat.target_entity_id;
4767        if combat.progression_xp_base > 0.0 {
4768            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4769                baseline_display: combat.progression_baseline,
4770                xp_base: combat.progression_xp_base,
4771                xp_growth: combat.progression_xp_growth,
4772            });
4773        }
4774        if let Some(xp) = &combat.progression_xp {
4775            if let Some(player) = &mut self.player {
4776                player.progression_xp = Some(xp.clone());
4777                if let Some(attrs) = combat.attributes {
4778                    player.attributes = Some(attrs);
4779                }
4780                if let Some(skills) = &combat.skills {
4781                    player.skills = Some(skills.clone());
4782                }
4783            }
4784        }
4785        if let Some(label) = &combat.target_label {
4786            self.combat_target_label = Some(label.clone());
4787        } else if let Some(id) = combat.target_entity_id {
4788            self.combat_target_label = self
4789                .entities
4790                .iter()
4791                .find(|e| e.id == id)
4792                .map(|e| e.label.clone())
4793                .or_else(|| self.combat_target_label.clone());
4794        }
4795        self.refresh_inventory_ui();
4796    }
4797
4798    /// Entity id assigned to a combat slot (from server HUD).
4799    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4800        self.combat_slots
4801            .iter()
4802            .find(|s| s.slot_index == slot)
4803            .and_then(|s| s.target_entity_id)
4804            .or_else(|| if slot == 1 { self.combat_target } else { None })
4805    }
4806
4807    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4808    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4809        self.ability_meta
4810            .get(ability_id)
4811            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4812            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4813            // the server validates aim_mode.
4814            .unwrap_or(self.ground_target.is_some())
4815    }
4816
4817    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4818    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4819        self.ability_meta
4820            .get(ability_id)
4821            .map(|meta| meta.aim_mode == "ground")
4822            .unwrap_or(false)
4823    }
4824
4825    /// True when the ability may be placed in an auto-rotation preset.
4826    /// Missing meta (pre-sync) defaults to eligible; server still validates.
4827    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4828        self.ability_meta
4829            .get(ability_id)
4830            .map(|meta| meta.auto_rotation_eligible)
4831            .unwrap_or(true)
4832    }
4833
4834    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4835    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4836        self.ground_target = Some((x, y, 0.0));
4837    }
4838
4839    /// Clear the local free-aim ground target.
4840    pub fn clear_ground_target(&mut self) {
4841        self.ground_target = None;
4842    }
4843
4844    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4845    /// May be an ability id or `item:<template_id>` consumable binding.
4846    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4847        if !(1..=9).contains(&slot_1_to_9) {
4848            return None;
4849        }
4850        self.hotbar
4851            .get((slot_1_to_9 - 1) as usize)
4852            .and_then(|a| a.as_deref())
4853            .filter(|id| !id.is_empty())
4854    }
4855
4856    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4857    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4858        let binding = self.hotbar_ability(slot_1_to_9)?;
4859        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4860            let name = self
4861                .inventory_hints
4862                .get(template_id)
4863                .map(|h| h.display_name.as_str())
4864                .unwrap_or(template_id);
4865            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4866            Some(format!("{name}×{qty}"))
4867        } else {
4868            Some(binding.to_string())
4869        }
4870    }
4871
4872    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4873    pub fn loadout_ability_choices(&self) -> Vec<String> {
4874        let mut out = self.known_abilities.clone();
4875        let weapon = self.weapon_ability_id.trim();
4876        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4877            out.push(weapon.to_string());
4878        }
4879        out
4880    }
4881
4882    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4883    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4884        let mut out = Vec::new();
4885        for ability in self.loadout_ability_choices() {
4886            let meta = if ability == self.weapon_ability_id {
4887                Some("weapon".into())
4888            } else {
4889                None
4890            };
4891            out.push(LoadoutHotbarChoice {
4892                binding: ability.clone(),
4893                label: ability,
4894                meta,
4895            });
4896        }
4897        let mut consumables: Vec<(String, String, u32)> = Vec::new();
4898        for stack in &self.inventory_stacks {
4899            if Self::stack_is_item_grant(stack) {
4900                continue;
4901            }
4902            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4903                continue;
4904            }
4905            let qty = stack.quantity.max(1);
4906            if let Some((_, _, existing)) = consumables
4907                .iter_mut()
4908                .find(|(id, _, _)| id == &stack.template_id)
4909            {
4910                *existing = existing.saturating_add(qty);
4911            } else {
4912                let label = stack
4913                    .display_name
4914                    .clone()
4915                    .or_else(|| {
4916                        self.inventory_hints
4917                            .get(&stack.template_id)
4918                            .map(|h| h.display_name.clone())
4919                    })
4920                    .unwrap_or_else(|| stack.template_id.clone());
4921                consumables.push((stack.template_id.clone(), label, qty));
4922            }
4923        }
4924        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4925        for (template_id, label, qty) in consumables {
4926            out.push(LoadoutHotbarChoice {
4927                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4928                label: format!("{label} ×{qty}"),
4929                meta: Some("use".into()),
4930            });
4931        }
4932        out
4933    }
4934
4935    /// Hostile wildlife / monsters for T1 (`Tab`).
4936    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4937        self.combat_candidates()
4938    }
4939
4940    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
4941    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4942        let (px, py) = self.player_position();
4943        let dist = |id: EntityId| {
4944            self.entities
4945                .iter()
4946                .find(|e| e.id == id)
4947                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4948                .unwrap_or(f32::MAX)
4949        };
4950
4951        let mut allies = Vec::new();
4952        // Self first — heal_touch on T2.
4953        if let Some(me) = self.player.as_ref() {
4954            let alive = me
4955                .vitals
4956                .as_ref()
4957                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4958                .unwrap_or(true);
4959            if alive {
4960                allies.push((self.entity_id, "Yourself".into()));
4961            }
4962        }
4963        for entity in &self.entities {
4964            if entity.id == self.entity_id {
4965                continue;
4966            }
4967            if entity.vitals.is_some() {
4968                let alive = entity
4969                    .vitals
4970                    .as_ref()
4971                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4972                    .unwrap_or(true);
4973                if alive {
4974                    allies.push((entity.id, entity.label.clone()));
4975                }
4976            }
4977        }
4978        allies.sort_by(|(a, _), (b, _)| {
4979            if *a == self.entity_id {
4980                return std::cmp::Ordering::Less;
4981            }
4982            if *b == self.entity_id {
4983                return std::cmp::Ordering::Greater;
4984            }
4985            dist(*a)
4986                .partial_cmp(&dist(*b))
4987                .unwrap_or(std::cmp::Ordering::Equal)
4988        });
4989
4990        let mut monsters = self.combat_candidates();
4991        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4992        allies.into_iter().chain(monsters).collect()
4993    }
4994
4995    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4996        match slot_index {
4997            2 => self.t2_candidates(),
4998            _ => self.t1_candidates(),
4999        }
5000    }
5001
5002    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
5003    pub fn pick_combat_target_at(
5004        &self,
5005        wx: f32,
5006        wy: f32,
5007        slot_index: u8,
5008        radius_m: f32,
5009    ) -> Option<(EntityId, String)> {
5010        let mut best: Option<(f32, EntityId, String)> = None;
5011        for (id, label) in self.candidates_for_slot(slot_index) {
5012            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5013                // NPCs may only appear in NpcView — fall back to npc list coords.
5014                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5015                    let d = distance(wx, wy, npc.x, npc.y);
5016                    if d <= radius_m {
5017                        best = match best {
5018                            Some((bd, _, _)) if bd <= d => best,
5019                            _ => Some((d, id, label)),
5020                        };
5021                    }
5022                }
5023                continue;
5024            };
5025            let d = distance(
5026                wx,
5027                wy,
5028                entity.transform.position.x,
5029                entity.transform.position.y,
5030            );
5031            if d <= radius_m {
5032                best = match best {
5033                    Some((bd, _, _)) if bd <= d => best,
5034                    _ => Some((d, id, label)),
5035                };
5036            }
5037        }
5038        best.map(|(_, id, label)| (id, label))
5039    }
5040
5041    /// Full client reset after Welcome (initial connect or reconnect).
5042    pub(crate) fn restore_from_welcome(
5043        &mut self,
5044        session_id: SessionId,
5045        entity_id: EntityId,
5046        snapshot: &flatland_protocol::Snapshot,
5047    ) {
5048        self.clear_harvest_state();
5049        self.disconnect_reason = None;
5050        self.show_stats = false;
5051        self.show_craft_menu = false;
5052        self.show_shop_menu = false;
5053        self.shop_catalog = None;
5054        self.show_inventory_menu = false;
5055        self.session_id = session_id;
5056        self.entity_id = entity_id;
5057        self.connected = true;
5058        self.apply_snapshot_fields(snapshot, entity_id);
5059        if let Some(combat) = &snapshot.combat {
5060            self.apply_combat_hud(combat);
5061            let stacks = self.inventory_stacks.clone();
5062            self.sync_inventory_from_stacks(&stacks);
5063        }
5064    }
5065
5066    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
5067        self.tick = delta.tick;
5068        self.world_clock = delta.world_clock;
5069
5070        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
5071        if delta.entities.is_empty() {
5072            self.ground_drops = delta.ground_drops.clone();
5073            self.combat_fx = delta.combat_fx.clone();
5074            self.ground_hazards = delta.ground_hazards.clone();
5075            self.property_plots = delta.property_plots.clone();
5076            self.apply_terrain_overlays(&delta.terrain_overlays);
5077            if let Some(combat) = &delta.combat {
5078                self.apply_combat_hud(combat);
5079                let stacks = self.inventory_stacks.clone();
5080                self.sync_inventory_from_stacks(&stacks);
5081            }
5082            // Peer vanished from AOI — drop directed whisper.
5083            self.refresh_whisper_range();
5084            self.sync_gameplay_audio();
5085            return;
5086        }
5087        if !delta.buildings.is_empty() {
5088            self.buildings = delta.buildings.clone();
5089        }
5090        if !delta.blueprints.is_empty() {
5091            self.blueprints = delta.blueprints.clone();
5092        }
5093        if !delta.building_materials.is_empty() {
5094            self.building_materials = delta.building_materials.clone();
5095        }
5096        self.sync_inventory_from_stacks(&delta.inventory);
5097
5098        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
5099            self.player = Some(updated.clone());
5100        }
5101        self.entities = delta.entities.clone();
5102        if self.player.is_none() {
5103            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
5104        }
5105
5106        self.sync_interior_map_context();
5107
5108        // Static world layers: ticks often omit these (indoors, or unchanged).
5109        // Never wipe the welcome snapshot with an empty vec — except when indoors,
5110        // where empty means "no nodes in this building" (clear outdoor leftovers).
5111        if !delta.resource_nodes.is_empty() {
5112            self.resource_nodes = delta.resource_nodes.clone();
5113        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
5114            self.resource_nodes = delta.resource_nodes.clone();
5115        }
5116        self.ground_drops = delta.ground_drops.clone();
5117        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
5118        self.placed_containers = delta.placed_containers.clone();
5119        if !delta.doors.is_empty() {
5120            self.doors = delta.doors.clone();
5121        }
5122        if self.effective_inside_building().is_some() {
5123            if let Some(map) = &delta.interior_map {
5124                self.interior_map = Some(map.clone());
5125            }
5126        } else {
5127            self.interior_map = None;
5128        }
5129        self.sync_interior_z_bands();
5130        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
5131        self.npcs = delta.npcs.clone();
5132        if !delta.quest_log.is_empty() {
5133            self.quest_log = delta.quest_log.clone();
5134        }
5135        self.apply_hired_workers(delta.hired_workers.clone());
5136        if !delta.interactables.is_empty() {
5137            self.interactables = delta.interactables.clone();
5138        }
5139        if delta.ledger.is_some() {
5140            self.ledger = delta.ledger.clone();
5141        }
5142        if delta.career.is_some() {
5143            self.career = delta.career.clone();
5144        }
5145        self.combat_fx = delta.combat_fx.clone();
5146        self.ground_hazards = delta.ground_hazards.clone();
5147        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
5148        if !delta.property_plots.is_empty() {
5149            self.property_plots = delta.property_plots.clone();
5150        }
5151        self.apply_terrain_overlays(&delta.terrain_overlays);
5152        if let Some(combat) = &delta.combat {
5153            self.apply_combat_hud(combat);
5154            let stacks = self.inventory_stacks.clone();
5155            self.sync_inventory_from_stacks(&stacks);
5156        } else {
5157            self.refresh_inventory_ui();
5158        }
5159        self.refresh_whisper_range();
5160        self.sync_gameplay_audio();
5161    }
5162
5163    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
5164    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
5165    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
5166        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
5167        self.terrain_zones.extend(overlays.iter().cloned());
5168    }
5169
5170    /// End directed whisper when the peer is farther than interact range (or missing).
5171    /// Each client runs this locally so both players drop whisper mode when either walks away.
5172    fn refresh_whisper_range(&mut self) {
5173        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
5174            return;
5175        };
5176        let (px, py) = self.player_position();
5177        let in_range = self.entities.iter().any(|e| {
5178            e.id == peer
5179                && distance(px, py, e.transform.position.x, e.transform.position.y)
5180                    <= INTERACTION_RADIUS_M
5181        });
5182        if !in_range {
5183            self.social_chat.cancel_whisper_out_of_range();
5184        }
5185    }
5186
5187    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
5188    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
5189        let (px, py) = self.player_position();
5190        let mut out = Vec::new();
5191        for npc in &self.npcs {
5192            let Some(eid) = npc.entity_id else {
5193                continue;
5194            };
5195            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
5196            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
5197            if alive && has_hp {
5198                out.push((eid, npc.label.clone()));
5199            }
5200        }
5201        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
5202            let dist = |id: EntityId| {
5203                self.entities
5204                    .iter()
5205                    .find(|e| e.id == id)
5206                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5207                    .unwrap_or(f32::MAX)
5208            };
5209            dist(*a_id)
5210                .partial_cmp(&dist(*b_id))
5211                .unwrap_or(std::cmp::Ordering::Equal)
5212                .then_with(|| a_label.cmp(b_label))
5213                .then_with(|| a_id.cmp(b_id))
5214        });
5215        out
5216    }
5217
5218    pub fn refresh_combat_target_label(&mut self) {
5219        let Some(id) = self.combat_target else {
5220            return;
5221        };
5222        if let Some((_, label)) = self
5223            .combat_candidates()
5224            .into_iter()
5225            .find(|(eid, _)| *eid == id)
5226        {
5227            self.combat_target_label = Some(label);
5228        } else if let Some(label) = self
5229            .entities
5230            .iter()
5231            .find(|e| e.id == id)
5232            .map(|e| e.label.clone())
5233        {
5234            self.combat_target_label = Some(label);
5235        }
5236    }
5237
5238    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
5239        self.quest_log
5240            .iter()
5241            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
5242            .collect()
5243    }
5244
5245    /// True when the observer has at least one free lodging slot (max workers = beds).
5246    pub fn has_worker_lodging(&self) -> bool {
5247        self.free_worker_lodging_slots() > 0
5248    }
5249
5250    /// Owned lodging capacity minus currently hired workers.
5251    pub fn free_worker_lodging_slots(&self) -> i64 {
5252        let slots: u32 = self
5253            .placed_containers
5254            .iter()
5255            .filter(|c| match (self.character_id, c.owner_character_id) {
5256                (Some(me), Some(owner)) => me == owner,
5257                (Some(_), None) => false,
5258                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
5259            })
5260            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
5261            .sum();
5262        let used = self.hired_workers.len() as u32;
5263        slots as i64 - used as i64
5264    }
5265
5266    /// Display names of hired workers assigned to this lodging container.
5267    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
5268        let mut names: Vec<String> = self
5269            .hired_workers
5270            .iter()
5271            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
5272            .map(|w| w.label.clone())
5273            .collect();
5274        names.sort();
5275        names
5276    }
5277
5278    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
5279    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
5280        let is_lodging = self
5281            .placed_containers
5282            .iter()
5283            .find(|c| c.id == container_id)
5284            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
5285        if !is_lodging {
5286            return None;
5287        }
5288        let names = self.lodging_occupant_labels(container_id);
5289        Some(if names.is_empty() {
5290            "vacant".into()
5291        } else {
5292            names.join(", ")
5293        })
5294    }
5295
5296    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
5297        self.quest_log
5298            .iter()
5299            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
5300            .or_else(|| {
5301                self.quest_log
5302                    .iter()
5303                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
5304            })
5305    }
5306
5307    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
5308    pub fn nearby_lockable_door(&self) -> bool {
5309        let (px, py) = self.player_position();
5310        self.doors
5311            .iter()
5312            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
5313    }
5314
5315    /// True when an open player-house door is in range for Enter-to-go-inside.
5316    pub fn nearby_open_player_door(&self) -> bool {
5317        if self.effective_inside_building().is_some() {
5318            return false;
5319        }
5320        let (px, py) = self.player_position();
5321        self.doors.iter().any(|d| {
5322            if !d.open || d.locked {
5323                return false;
5324            }
5325            let player_house = self
5326                .buildings
5327                .iter()
5328                .find(|b| b.id == d.building_id)
5329                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5330            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
5331        })
5332    }
5333
5334    /// True when inside a player house near the exterior portal (Enter exits freely).
5335    pub fn nearby_player_exit_door(&self) -> bool {
5336        let Some(bid) = self.effective_inside_building() else {
5337            return false;
5338        };
5339        let (px, py) = self.player_position();
5340        self.doors.iter().any(|d| {
5341            if d.building_id != bid || d.portal.is_none() {
5342                return false;
5343            }
5344            let player_house = self
5345                .buildings
5346                .iter()
5347                .find(|b| b.id == d.building_id)
5348                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5349            player_house && (d.x - px).hypot(d.y - py) <= 1.5
5350        })
5351    }
5352
5353    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
5354    pub fn nearest_interact_target(&self) -> Option<String> {
5355        let (px, py) = self.player_position();
5356        let inside = self.effective_inside_building();
5357
5358        #[derive(Clone, Copy, PartialEq, Eq)]
5359        enum Kind {
5360            Player,
5361            Npc,
5362            HiredWorker,
5363            QuestBoard,
5364            ExitDoor,
5365            EnterDoor,
5366            Well,
5367            Water,
5368        }
5369
5370        fn kind_priority(kind: Kind) -> u8 {
5371            match kind {
5372                Kind::Player => 0,
5373                Kind::Npc => 0,
5374                Kind::HiredWorker => 0,
5375                Kind::QuestBoard => 1,
5376                Kind::ExitDoor => 2,
5377                Kind::EnterDoor => 3,
5378                Kind::Well => 4,
5379                Kind::Water => 5,
5380            }
5381        }
5382
5383        let mut best: Option<(f32, Kind, String)> = None;
5384
5385        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5386            if dist > max {
5387                return;
5388            }
5389            let replace = match best {
5390                None => true,
5391                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5392                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5393                    kind_priority(kind) < kind_priority(bk)
5394                }
5395                _ => false,
5396            };
5397            if replace {
5398                best = Some((dist, kind, id));
5399            }
5400        };
5401
5402        for npc in &self.npcs {
5403            consider(
5404                distance(px, py, npc.x, npc.y),
5405                INTERACTION_RADIUS_M,
5406                Kind::Npc,
5407                npc.id.clone(),
5408            );
5409        }
5410
5411        for worker in &self.hired_workers {
5412            consider(
5413                distance(px, py, worker.x, worker.y),
5414                INTERACTION_RADIUS_M,
5415                Kind::HiredWorker,
5416                worker.instance_id.clone(),
5417            );
5418        }
5419
5420        for entity in &self.entities {
5421            if entity.id == self.entity_id
5422                || entity.vitals.is_none()
5423                || entity.label.trim().is_empty()
5424            {
5425                continue;
5426            }
5427            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
5428            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
5429                continue;
5430            }
5431            consider(
5432                distance(
5433                    px,
5434                    py,
5435                    entity.transform.position.x,
5436                    entity.transform.position.y,
5437                ),
5438                INTERACTION_RADIUS_M,
5439                Kind::Player,
5440                entity.id.to_string(),
5441            );
5442        }
5443
5444        for door in &self.doors {
5445            if let Some(ref bid) = inside {
5446                if door.building_id != *bid {
5447                    continue;
5448                }
5449                let is_exit = door.portal.is_some();
5450                let max = if is_exit {
5451                    INTERACTION_RADIUS_M
5452                } else {
5453                    DOOR_INTERACTION_RADIUS_M
5454                };
5455                let kind = if is_exit {
5456                    Kind::ExitDoor
5457                } else {
5458                    Kind::EnterDoor
5459                };
5460                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5461                continue;
5462            }
5463            consider(
5464                distance(px, py, door.x, door.y),
5465                DOOR_INTERACTION_RADIUS_M,
5466                Kind::EnterDoor,
5467                door.id.clone(),
5468            );
5469        }
5470
5471        if inside.is_none() {
5472            for inter in &self.interactables {
5473                if inter.kind == "quest_board" {
5474                    consider(
5475                        distance(px, py, inter.x, inter.y),
5476                        QUEST_BOARD_INTERACTION_RADIUS_M,
5477                        Kind::QuestBoard,
5478                        inter.id.clone(),
5479                    );
5480                }
5481            }
5482            for building in &self.buildings {
5483                if !building.tags.iter().any(|t| t == "well") {
5484                    continue;
5485                }
5486                consider(
5487                    distance(px, py, building.x, building.y),
5488                    INTERACTION_RADIUS_M,
5489                    Kind::Well,
5490                    building.id.clone(),
5491                );
5492            }
5493            if self.in_shallow_water() {
5494                consider(
5495                    0.0,
5496                    INTERACTION_RADIUS_M,
5497                    Kind::Water,
5498                    "water_source".into(),
5499                );
5500            }
5501        }
5502
5503        best.map(|(_, _, id)| id)
5504    }
5505
5506    /// Nearest quest board and distance (any distance), for out-of-range feedback.
5507    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5508        if self.effective_inside_building().is_some() {
5509            return None;
5510        }
5511        let (px, py) = self.player_position();
5512        self.interactables
5513            .iter()
5514            .filter(|i| i.kind == "quest_board")
5515            .map(|i| {
5516                let label = if i.label.is_empty() {
5517                    "Quest board".to_string()
5518                } else {
5519                    i.label.clone()
5520                };
5521                (label, distance(px, py, i.x, i.y))
5522            })
5523            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5524    }
5525
5526    /// Human-readable template name from synced catalog hints.
5527    pub fn template_display_name(&self, template_id: &str) -> String {
5528        self.inventory_hints
5529            .get(template_id)
5530            .map(|h| h.display_name.clone())
5531            .filter(|n| !n.is_empty())
5532            .unwrap_or_else(|| humanize_template_id(template_id))
5533    }
5534
5535    /// Prefer blueprint-view display name, then inventory hints / humanized id.
5536    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5537        if !display_name.is_empty() {
5538            display_name.to_string()
5539        } else {
5540            self.template_display_name(template_id)
5541        }
5542    }
5543
5544    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5545        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5546    }
5547
5548    pub fn blueprint_ingredient_label(
5549        &self,
5550        input: &flatland_protocol::BlueprintIngredientView,
5551    ) -> String {
5552        self.blueprint_item_label(&input.template_id, &input.display_name)
5553    }
5554
5555    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5556        self.blueprint_item_label(&tool.item, &tool.display_name)
5557    }
5558
5559    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
5560    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5561        use crate::worker_route_editor::{
5562            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5563        };
5564        let lodging = self
5565            .worker_route_editor
5566            .as_ref()
5567            .and_then(|ed| ed.lodging_container_id.as_deref());
5568        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5569            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5570            None => node_candidates_stable(&self.resource_nodes),
5571        }
5572    }
5573
5574    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5575        if dist_m.is_nan() {
5576            return "—".into();
5577        }
5578        let from_bed = self
5579            .worker_route_editor
5580            .as_ref()
5581            .and_then(|ed| ed.lodging_container_id.as_deref())
5582            .and_then(|id| {
5583                self.placed_containers
5584                    .iter()
5585                    .find(|c| c.id == id)
5586                    .map(|c| c.display_name.clone())
5587            });
5588        match from_bed {
5589            Some(bed) => format!("{dist_m:.0}m from {bed}"),
5590            None => format!("{dist_m:.0}m"),
5591        }
5592    }
5593
5594    /// Chest label for the location panel — generic type unless this player owns it.
5595    pub fn placed_container_public_label(
5596        &self,
5597        c: &flatland_protocol::PlacedContainerView,
5598    ) -> String {
5599        let is_owner = match (self.character_id, c.owner_character_id) {
5600            (Some(me), Some(owner)) => me == owner,
5601            _ => false,
5602        };
5603        if is_owner {
5604            c.display_name.clone()
5605        } else {
5606            self.template_display_name(&c.template_id)
5607        }
5608    }
5609
5610    /// Keys on person and stowed on the keychain for the keychain overlay.
5611    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5612        let mut out = Vec::new();
5613        for stack in &self.inventory_stacks {
5614            if stack.template_id == KEY_TEMPLATE {
5615                out.push(KeychainEntry {
5616                    stack: stack.clone(),
5617                    stowed: false,
5618                });
5619            }
5620        }
5621        for stack in &self.keychain_stacks {
5622            if stack.template_id == KEY_TEMPLATE {
5623                out.push(KeychainEntry {
5624                    stack: stack.clone(),
5625                    stowed: true,
5626                });
5627            }
5628        }
5629        out
5630    }
5631
5632    /// Display name of the chest a `container_key` opens, when known on the client.
5633    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5634        if stack.template_id != KEY_TEMPLATE {
5635            return None;
5636        }
5637        if let Some(name) = stack
5638            .props
5639            .get(PROP_OPENS_CONTAINER_NAME)
5640            .filter(|n| !n.is_empty())
5641        {
5642            return Some(name.clone());
5643        }
5644        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5645        self.container_name_for_lock_id(opens)
5646    }
5647
5648    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
5649    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5650        if stack.template_id == KEY_TEMPLATE {
5651            self.template_display_name(KEY_TEMPLATE)
5652        } else {
5653            stack
5654                .display_name
5655                .clone()
5656                .unwrap_or_else(|| stack.template_id.clone())
5657        }
5658    }
5659
5660    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
5661    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5662        if stack.template_id != KEY_TEMPLATE {
5663            return String::new();
5664        }
5665        match self.key_pair_chest_label(stack) {
5666            Some(chest) if self.key_drop_blocked(stack) => {
5667                format!(" [key for {chest} — can't drop while locked]")
5668            }
5669            Some(chest) => format!(" [key for {chest}]"),
5670            None => " [key — unpaired]".into(),
5671        }
5672    }
5673
5674    /// Resolve a lock id to a container label (placed chest or one still on your person).
5675    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5676        for c in &self.placed_containers {
5677            if c.lock_id.as_deref() == Some(lock) {
5678                return Some(c.display_name.clone());
5679            }
5680        }
5681        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5682            self.worn
5683                .values()
5684                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5685        })
5686    }
5687
5688    /// Keys cannot be dropped while their paired chest is locked.
5689    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5690        if stack.template_id != KEY_TEMPLATE {
5691            return false;
5692        }
5693        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5694            return false;
5695        };
5696        for c in &self.placed_containers {
5697            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5698                return true;
5699            }
5700        }
5701        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5702            return true;
5703        }
5704        self.worn
5705            .values()
5706            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5707    }
5708
5709    /// Property deeds cannot be dropped or destroyed (store or trade only).
5710    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5711        stack.template_id == PROPERTY_DEED_TEMPLATE
5712    }
5713
5714    pub fn is_property_deed_template(template_id: &str) -> bool {
5715        template_id == PROPERTY_DEED_TEMPLATE
5716    }
5717
5718    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5719        stack
5720            .props
5721            .get("plot_id")
5722            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5723    }
5724
5725    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5726    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5727        let (px, py) = self.player_position();
5728        let (cx, cy) = self.farm_plot_cell_under_player()?;
5729        let tx = cx as f32 + 0.5;
5730        let ty = cy as f32 + 0.5;
5731        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5732            return None;
5733        }
5734        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
5735        if kind == Some(TerrainKindView::Tilled) {
5736            return None;
5737        }
5738        if matches!(
5739            kind,
5740            Some(TerrainKindView::ShallowWater)
5741                | Some(TerrainKindView::DeepWater)
5742                | Some(TerrainKindView::Rock)
5743        ) {
5744            return None;
5745        }
5746        Some((tx, ty))
5747    }
5748
5749    fn container_name_in_stacks(
5750        stacks: &[flatland_protocol::ItemStack],
5751        lock: &str,
5752    ) -> Option<String> {
5753        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5754            for s in stacks {
5755                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5756                    return Some(GameState::stack_container_label(s));
5757                }
5758                if let Some(name) = walk(&s.contents, lock) {
5759                    return Some(name);
5760                }
5761            }
5762            None
5763        }
5764        walk(stacks, lock)
5765    }
5766
5767    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5768        stack
5769            .props
5770            .get(PROP_CUSTOM_NAME)
5771            .cloned()
5772            .or_else(|| stack.display_name.clone())
5773            .unwrap_or_else(|| stack.template_id.clone())
5774    }
5775
5776    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5777        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5778            for s in stacks {
5779                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5780                    return true;
5781                }
5782                if walk(&s.contents, lock) {
5783                    return true;
5784                }
5785            }
5786            false
5787        }
5788        walk(stacks, lock)
5789    }
5790
5791    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5792        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5793            return Some(stack.clone());
5794        }
5795        for worn in self.worn.values() {
5796            if worn.item_instance_id == Some(instance_id) {
5797                return Some(worn.clone());
5798            }
5799            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5800                return Some(stack.clone());
5801            }
5802        }
5803        None
5804    }
5805
5806    /// Highest-`z_order` property zone covering `(x, y)`.
5807    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5808        self.property_zones
5809            .iter()
5810            .enumerate()
5811            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5812            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5813            .map(|(_, z)| z)
5814    }
5815
5816    /// Highest-`z_order` tax zone covering `(x, y)`.
5817    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5818        self.tax_zones
5819            .iter()
5820            .enumerate()
5821            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5822            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5823            .map(|(_, z)| z)
5824    }
5825
5826    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5827    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5828        let mut max_bps = 0u32;
5829        let mut y = y0 + 0.5;
5830        while y < y1 {
5831            let mut x = x0 + 0.5;
5832            while x < x1 {
5833                if let Some(tz) = self.tax_zone_at(x, y) {
5834                    max_bps = max_bps.max(tz.rate_bps);
5835                }
5836                x += 1.0;
5837            }
5838            y += 1.0;
5839        }
5840        max_bps
5841    }
5842
5843    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5844    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5845        let mode = self.claim_mode.as_ref()?;
5846        let w = mode.width_m.max(1) as f32;
5847        let h = mode.height_m.max(1) as f32;
5848        Some((
5849            mode.anchor_x,
5850            mode.anchor_y,
5851            mode.anchor_x + w,
5852            mode.anchor_y + h,
5853        ))
5854    }
5855
5856    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5857    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5858        let mode = self.relocate_mode.as_ref()?;
5859        let x0 = mode.cursor_x.floor();
5860        let y0 = mode.cursor_y.floor();
5861        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5862    }
5863
5864    /// Client quote matching server `quote_plot`:
5865    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5866    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5867        let mode = self.claim_mode.as_ref()?;
5868        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
5869        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5870        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5871        let zone_area = zone_view_area_m2(zone).max(1.0);
5872        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5873        let weight = self
5874            .property_plot_settings
5875            .as_ref()
5876            .map(|s| s.tax_premium_weight)
5877            .unwrap_or(0.5)
5878            .max(0.0);
5879        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5880        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5881        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
5882            .ceil()
5883            .max(0.0) as u64;
5884        let upkeep = if zone.upkeep_copper_per_day == 0 {
5885            0
5886        } else {
5887            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5888                .ceil()
5889                .max(1.0) as u64
5890        };
5891        let copper = crate::currency::copper_from_counts(&self.inventory);
5892        let can_afford = copper >= purchase;
5893        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5894        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5895    }
5896
5897    fn validate_claim_footprint(
5898        &self,
5899        zone: &flatland_protocol::PropertyZoneView,
5900        x0: f32,
5901        y0: f32,
5902        x1: f32,
5903        y1: f32,
5904        area: f32,
5905    ) -> (bool, String) {
5906        let min_area = self
5907            .property_plot_settings
5908            .as_ref()
5909            .map(|s| s.min_plot_area_m2)
5910            .unwrap_or(4.0);
5911        if area + f32::EPSILON < min_area {
5912            return (false, "plot too small".into());
5913        }
5914        if zone.max_area_m2.is_some_and(|m| area > m) {
5915            return (false, "plot exceeds max area".into());
5916        }
5917        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5918            return (false, "plot must lie inside the property zone".into());
5919        }
5920        if self
5921            .property_plots
5922            .iter()
5923            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
5924        {
5925            return (false, "plot overlaps an existing claim".into());
5926        }
5927        (true, String::new())
5928    }
5929
5930    /// Standing in a property zone on a free cell (not already claimed).
5931    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
5932        let (px, py) = self.player_position();
5933        let zone = self.property_zone_at(px, py)?;
5934        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
5935            return None;
5936        }
5937        Some(zone)
5938    }
5939
5940    /// Own plot covering the player (`is_mine`).
5941    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5942        let (px, py) = self.player_position();
5943        self.property_plots
5944            .iter()
5945            .find(|p| p.is_mine && point_in_plot(px, py, p))
5946    }
5947
5948    /// Plot under the player that they may farm (deed, grant, or public).
5949    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5950        let (px, py) = self.player_position();
5951        self.property_plots
5952            .iter()
5953            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5954    }
5955
5956    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
5957    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5958        if self.farmable_plot_under_player().is_none() {
5959            return None;
5960        }
5961        let (px, py) = self.player_position();
5962        Some((px.floor() as i32, py.floor() as i32))
5963    }
5964
5965    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5966        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5967        self.resource_nodes.iter().any(|n| {
5968            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5969            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5970        })
5971    }
5972
5973    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5974        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5975        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5976            || self
5977                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5978                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5979        if !tilled {
5980            return false;
5981        }
5982        !self.resource_node_occupies_farm_cell(cx, cy)
5983    }
5984
5985    /// Empty tilled soil on the cell the player is standing on (no crop node).
5986    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5987        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5988            return false;
5989        };
5990        self.free_tilled_plant_slot_at(cx, cy)
5991    }
5992
5993    /// True when a free tilled cell (no resource node) is within interact range.
5994    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5995        let (px, py) = self.player_position();
5996        for dy in -2..=2 {
5997            for dx in -2..=2 {
5998                let cx = px.floor() as i32 + dx;
5999                let cy = py.floor() as i32 + dy;
6000                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6001                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6002                    continue;
6003                }
6004                if self.free_tilled_plant_slot_at(cx, cy) {
6005                    return true;
6006                }
6007            }
6008        }
6009        false
6010    }
6011
6012    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
6013        stack.quantity > 0
6014            && (stack.props.contains_key("seed_for")
6015                || stack.template_id.ends_with("_seed")
6016                || stack.template_id == "potato_seed"
6017                || stack.template_id == "carrot_seed")
6018    }
6019
6020    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
6021    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
6022        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
6023        fn walk(
6024            stacks: &[flatland_protocol::ItemStack],
6025            counts: &mut std::collections::HashMap<String, u32>,
6026        ) {
6027            for s in stacks {
6028                if GameState::stack_is_farm_seed(s) {
6029                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
6030                }
6031                walk(&s.contents, counts);
6032            }
6033        }
6034        walk(&self.inventory_stacks, &mut counts);
6035        for worn in self.worn.values() {
6036            walk(std::slice::from_ref(worn), &mut counts);
6037        }
6038        let mut out: Vec<_> = counts
6039            .into_iter()
6040            .map(|(template_id, quantity)| {
6041                let label = self
6042                    .inventory_hints
6043                    .get(&template_id)
6044                    .map(|h| h.display_name.clone())
6045                    .filter(|n| !n.trim().is_empty())
6046                    .unwrap_or_else(|| humanize_template_id(&template_id));
6047                (template_id, quantity, label)
6048            })
6049            .collect();
6050        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
6051        out
6052    }
6053
6054    /// First farm seed template in inventory (root + nested bags).
6055    pub fn first_farm_seed_template(&self) -> Option<String> {
6056        self.farm_seed_entries()
6057            .into_iter()
6058            .next()
6059            .map(|(id, _, _)| id)
6060    }
6061
6062    pub fn clamp_plant_menu(&mut self) {
6063        let n = self.farm_seed_entries().len();
6064        if n == 0 {
6065            self.plant_menu_index = 0;
6066            self.plant_quantity = 1;
6067            return;
6068        }
6069        self.plant_menu_index = self.plant_menu_index.min(n - 1);
6070        let max_qty = self
6071            .farm_seed_entries()
6072            .get(self.plant_menu_index)
6073            .map(|(_, q, _)| *q)
6074            .unwrap_or(1)
6075            .max(1);
6076        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
6077    }
6078
6079    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
6080        let entries = self.farm_seed_entries();
6081        let (id, max, label) = entries.get(self.plant_menu_index)?;
6082        let qty = self.plant_quantity.min(*max).max(1);
6083        Some((id.clone(), qty, label.clone()))
6084    }
6085
6086    /// Terrain, interactables, and map objects near the player for the HUD location panel.
6087    pub fn location_context_lines(&self) -> Vec<ContextLine> {
6088        let (px, py) = self.player_position();
6089        let inside = self.effective_inside_building();
6090        let mut lines = Vec::new();
6091
6092        if let Some(kind) = self.terrain_at(px, py) {
6093            lines.push(ContextLine {
6094                on_top: true,
6095                text: format!("Terrain: {}", terrain_kind_label(kind)),
6096            });
6097        }
6098
6099        if let Some(id) = inside.as_ref() {
6100            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
6101                lines.push(ContextLine {
6102                    on_top: true,
6103                    text: format!("Inside: {}", b.label),
6104                });
6105            }
6106        }
6107
6108        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
6109
6110        for node in &self.resource_nodes {
6111            if node.id.starts_with("preview:") {
6112                continue;
6113            }
6114            let dist = distance(px, py, node.x, node.y);
6115            if dist > NEARBY_SCAN_M {
6116                continue;
6117            }
6118            let on_top = dist <= ON_TOP_RADIUS_M;
6119            let prefix = if on_top { "On" } else { "Near" };
6120            let name = resource_node_near_display_label(&node.label);
6121            let action = resource_node_near_action_suffix(node);
6122            nearby.push((
6123                dist,
6124                ContextLine {
6125                    on_top,
6126                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
6127                },
6128            ));
6129        }
6130
6131        for drop in &self.ground_drops {
6132            let dist = distance(px, py, drop.x, drop.y);
6133            if dist > INTERACTION_RADIUS_M {
6134                continue;
6135            }
6136            let on_top = dist <= ON_TOP_RADIUS_M;
6137            let name = self.template_display_name(&drop.template_id);
6138            let prefix = if on_top { "On" } else { "Near" };
6139            let qty = if drop.quantity > 1 {
6140                format!(" ×{}", drop.quantity)
6141            } else {
6142                String::new()
6143            };
6144            nearby.push((
6145                dist,
6146                ContextLine {
6147                    on_top,
6148                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
6149                },
6150            ));
6151        }
6152
6153        for c in &self.placed_containers {
6154            if !self.placed_container_in_current_space(c) {
6155                continue;
6156            }
6157            let dist = distance(px, py, c.x, c.y);
6158            if dist > CONTAINER_RANGE_M {
6159                continue;
6160            }
6161            let on_top = dist <= ON_TOP_RADIUS_M;
6162            let name = self.placed_container_public_label(c);
6163            let lock = if c.locked { " [locked]" } else { "" };
6164            let prefix = if on_top { "On" } else { "Near" };
6165            nearby.push((
6166                dist,
6167                ContextLine {
6168                    on_top,
6169                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
6170                },
6171            ));
6172        }
6173
6174        for npc in &self.npcs {
6175            let dist = distance(px, py, npc.x, npc.y);
6176            if dist > NEARBY_SCAN_M {
6177                continue;
6178            }
6179            let on_top = dist <= ON_TOP_RADIUS_M;
6180            let prefix = if on_top { "On" } else { "Near" };
6181            nearby.push((
6182                dist,
6183                ContextLine {
6184                    on_top,
6185                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
6186                },
6187            ));
6188        }
6189
6190        for door in &self.doors {
6191            let dist = distance(px, py, door.x, door.y);
6192            if dist > DOOR_INTERACTION_RADIUS_M {
6193                continue;
6194            }
6195            let building = self
6196                .buildings
6197                .iter()
6198                .find(|b| b.id == door.building_id)
6199                .map(|b| b.label.as_str())
6200                .unwrap_or(door.building_id.as_str());
6201            let player_house = self
6202                .buildings
6203                .iter()
6204                .find(|b| b.id == door.building_id)
6205                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6206            let action = if inside.is_some() && door.portal.is_some() {
6207                if player_house {
6208                    if door.locked {
6209                        "locked — l unlock · Enter exit".to_string()
6210                    } else if door.open {
6211                        "close · Enter exit · l lock".to_string()
6212                    } else {
6213                        "open · Enter exit · l lock".to_string()
6214                    }
6215                } else {
6216                    "exit".to_string()
6217                }
6218            } else if player_house {
6219                if door.locked {
6220                    "locked — l unlock".to_string()
6221                } else if door.open {
6222                    "close · Enter go inside · l lock".to_string()
6223                } else {
6224                    "open · l lock".to_string()
6225                }
6226            } else {
6227                "enter".to_string()
6228            };
6229            nearby.push((
6230                dist,
6231                ContextLine {
6232                    on_top: dist <= ON_TOP_RADIUS_M,
6233                    text: format!("{building} door ({dist:.1}m) — f {action}"),
6234                },
6235            ));
6236        }
6237
6238        if inside.is_none() {
6239            for inter in &self.interactables {
6240                if inter.kind != "quest_board" {
6241                    continue;
6242                }
6243                let dist = distance(px, py, inter.x, inter.y);
6244                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
6245                    continue;
6246                }
6247                let on_top = dist <= ON_TOP_RADIUS_M;
6248                let prefix = if on_top { "On" } else { "Near" };
6249                let label = if inter.label.is_empty() {
6250                    "Quest board".to_string()
6251                } else {
6252                    inter.label.clone()
6253                };
6254                nearby.push((
6255                    dist,
6256                    ContextLine {
6257                        on_top,
6258                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
6259                    },
6260                ));
6261            }
6262        }
6263
6264        if self.in_shallow_water() {
6265            let already = self
6266                .terrain_at(px, py)
6267                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
6268            if !already {
6269                nearby.push((
6270                    0.0,
6271                    ContextLine {
6272                        on_top: true,
6273                        text: "Shallow water — f fill bottle".into(),
6274                    },
6275                ));
6276            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
6277                line.text.push_str(" — f fill bottle");
6278            }
6279        }
6280
6281        if self.claim_mode.is_some() {
6282            nearby.push((
6283                0.0,
6284                ContextLine {
6285                    on_top: true,
6286                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
6287                        .into(),
6288                },
6289            ));
6290        } else if let Some(plot) = self.my_plot_under_player() {
6291            let name = plot_public_label(plot);
6292            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
6293                format!("{name} — f again to sell to crown")
6294            } else {
6295                format!(
6296                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
6297                )
6298            };
6299            nearby.push((
6300                0.0,
6301                ContextLine {
6302                    on_top: true,
6303                    text: prompt,
6304                },
6305            ));
6306        } else if let Some(plot) = self.farmable_plot_under_player() {
6307            let name = plot_public_label(plot);
6308            let disc = if plot.farm_public {
6309                plot.public_tax_discount_bps / 100
6310            } else {
6311                plot.farm_allow
6312                    .iter()
6313                    .find(|g| Some(g.character_id) == self.character_id)
6314                    .map(|g| g.tax_discount_bps / 100)
6315                    .unwrap_or(0)
6316            };
6317            nearby.push((
6318                0.0,
6319                ContextLine {
6320                    on_top: true,
6321                    text: format!(
6322                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
6323                    ),
6324                },
6325            ));
6326        } else if let Some(zone) = self.free_property_zone_under_player() {
6327            let label = zone
6328                .label
6329                .as_deref()
6330                .filter(|s| !s.trim().is_empty())
6331                .unwrap_or(zone.id.as_str());
6332            nearby.push((
6333                0.0,
6334                ContextLine {
6335                    on_top: true,
6336                    text: format!("Claimable land: {label} — k buy plot"),
6337                },
6338            ));
6339        }
6340
6341        for entity in &self.entities {
6342            if entity.id == self.entity_id {
6343                continue;
6344            }
6345            let dist = distance(
6346                px,
6347                py,
6348                entity.transform.position.x,
6349                entity.transform.position.y,
6350            );
6351            if dist > NEARBY_SCAN_M {
6352                continue;
6353            }
6354            let label = if entity.label.is_empty() {
6355                format!("entity {}", entity.id)
6356            } else {
6357                entity.label.clone()
6358            };
6359            nearby.push((
6360                dist,
6361                ContextLine {
6362                    on_top: dist <= ON_TOP_RADIUS_M,
6363                    text: format!("Near: {label} ({dist:.1}m)"),
6364                },
6365            ));
6366        }
6367
6368        nearby.sort_by(|a, b| {
6369            a.0.partial_cmp(&b.0)
6370                .unwrap_or(std::cmp::Ordering::Equal)
6371                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6372        });
6373        lines.extend(nearby.into_iter().map(|(_, l)| l));
6374
6375        if lines.is_empty() {
6376            lines.push(ContextLine {
6377                on_top: false,
6378                text: "(nothing notable nearby)".into(),
6379            });
6380        }
6381
6382        lines
6383    }
6384}
6385
6386/// HUD line for the location / nearby panel.
6387#[derive(Debug, Clone)]
6388pub struct ContextLine {
6389    pub on_top: bool,
6390    pub text: String,
6391}
6392
6393const ON_TOP_RADIUS_M: f32 = 0.65;
6394const NEARBY_SCAN_M: f32 = 5.0;
6395
6396/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
6397pub fn resource_node_near_display_label(label: &str) -> String {
6398    label
6399        .strip_suffix(" (growing)")
6400        .unwrap_or(label)
6401        .to_string()
6402}
6403
6404fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6405    let t = label.trim();
6406    if t.is_empty() || t == id {
6407        return true;
6408    }
6409    let lower = t.to_ascii_lowercase();
6410    if lower.contains("_copy") {
6411        return true;
6412    }
6413    false
6414}
6415
6416fn humanize_item_template_label(template: &str) -> String {
6417    let base = template.rsplit('/').next().unwrap_or(template).trim();
6418    if base.is_empty() {
6419        return "Resource".into();
6420    }
6421    let stripped = base
6422        .strip_prefix("crop-")
6423        .or_else(|| base.strip_prefix("crop_"))
6424        .unwrap_or(base);
6425    stripped
6426        .split(|c: char| c == '-' || c == '_')
6427        .filter(|p| !p.is_empty())
6428        .map(|p| {
6429            let mut chars = p.chars();
6430            match chars.next() {
6431                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6432                None => String::new(),
6433            }
6434        })
6435        .collect::<Vec<_>>()
6436        .join(" ")
6437}
6438
6439/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
6440pub fn resource_node_id_suffix(id: &str) -> String {
6441    let chars: Vec<char> = id
6442        .chars()
6443        .rev()
6444        .filter(|c| c.is_ascii_alphanumeric())
6445        .take(4)
6446        .collect();
6447    chars.into_iter().rev().collect()
6448}
6449
6450/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
6451pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6452    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6453}
6454
6455pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6456    let cleaned = resource_node_near_display_label(label);
6457    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6458        cleaned
6459    } else if !item_template.trim().is_empty() {
6460        humanize_item_template_label(item_template)
6461    } else {
6462        id.to_string()
6463    };
6464    let suffix = resource_node_id_suffix(id);
6465    if suffix.is_empty() {
6466        friendly
6467    } else {
6468        format!("{friendly} ({suffix})")
6469    }
6470}
6471
6472/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
6473pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6474    use flatland_protocol::ResourceNodeState;
6475    if node.harvest_off {
6476        return " (decorative)".to_string();
6477    }
6478    if let Some(p) = node.growth_progress {
6479        if p < 1.0 - f32::EPSILON {
6480            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6481            return format!(" (growing, {pct}%)");
6482        }
6483        return " — f harvest".to_string();
6484    }
6485    match node.state {
6486        ResourceNodeState::Available => " — f harvest".to_string(),
6487        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6488        ResourceNodeState::Cooldown => " (depleted)".to_string(),
6489    }
6490}
6491
6492fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6493    use flatland_protocol::TerrainKindView;
6494    match kind {
6495        TerrainKindView::Grass => "Grass",
6496        TerrainKindView::Dirt => "Dirt",
6497        TerrainKindView::Tilled => "Tilled",
6498        TerrainKindView::Desert => "Desert",
6499        TerrainKindView::Hill => "Hills",
6500        TerrainKindView::Bog => "Bog",
6501        TerrainKindView::Beach => "Beach",
6502        TerrainKindView::ShallowWater => "Shallow water",
6503        TerrainKindView::DeepWater => "Deep water",
6504        TerrainKindView::Trail => "Trail",
6505        TerrainKindView::Road => "Road",
6506        TerrainKindView::Rock => "Rock",
6507    }
6508}
6509
6510fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6511    crate::world_zones::zone_rects_contain(rects, x, y)
6512}
6513
6514fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6515    zone.rects
6516        .iter()
6517        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6518        .sum()
6519}
6520
6521fn claim_rect_fully_inside_zone(
6522    zone: &flatland_protocol::PropertyZoneView,
6523    x0: f32,
6524    y0: f32,
6525    x1: f32,
6526    y1: f32,
6527) -> bool {
6528    let mut y = y0 + 0.5;
6529    while y < y1 {
6530        let mut x = x0 + 0.5;
6531        while x < x1 {
6532            if !zone_rects_contain(&zone.rects, x, y) {
6533                return false;
6534            }
6535            x += 1.0;
6536        }
6537        y += 1.0;
6538    }
6539    true
6540}
6541
6542fn rects_overlap_half_open(
6543    ax0: f32,
6544    ay0: f32,
6545    ax1: f32,
6546    ay1: f32,
6547    bx0: f32,
6548    by0: f32,
6549    bx1: f32,
6550    by1: f32,
6551) -> bool {
6552    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6553}
6554
6555fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6556    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6557}
6558
6559fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6560    plot_public_label(p)
6561}
6562
6563/// Canonical plot name: `{owner} — {zone} — {label}`.
6564pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6565    let zone = p
6566        .zone_label
6567        .as_deref()
6568        .filter(|s| !s.trim().is_empty())
6569        .unwrap_or_else(|| {
6570            if p.property_zone_id.is_empty() {
6571                "Homestead"
6572            } else {
6573                p.property_zone_id.as_str()
6574            }
6575        });
6576    let label = if p.label.trim().is_empty() {
6577        if p.plot_code.trim().is_empty() {
6578            p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6579        } else {
6580            p.plot_code.clone()
6581        }
6582    } else {
6583        p.label.clone()
6584    };
6585    match p
6586        .owner_label
6587        .as_deref()
6588        .map(str::trim)
6589        .filter(|s| !s.is_empty())
6590    {
6591        Some(owner) => format!("{owner} — {zone} — {label}"),
6592        None => format!("{zone} — {label}"),
6593    }
6594}
6595
6596/// Match `flatland_sim::economy_zones::snap_claim_rect`.
6597fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6598    let a = x0.min(x1).floor();
6599    let b = y0.min(y1).floor();
6600    let mut c = x0.max(x1).ceil();
6601    let mut d = y0.max(y1).ceil();
6602    if (c - a) < 1.0 {
6603        c = a + 1.0;
6604    }
6605    if (d - b) < 1.0 {
6606        d = b + 1.0;
6607    }
6608    (a, b, c, d)
6609}
6610
6611fn humanize_template_id(template_id: &str) -> String {
6612    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
6613    if looks_like_template_uuid(template_id) {
6614        return "Unknown item".into();
6615    }
6616    template_id
6617        .split('_')
6618        .map(|word| {
6619            let mut chars = word.chars();
6620            match chars.next() {
6621                None => String::new(),
6622                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6623            }
6624        })
6625        .collect::<Vec<_>>()
6626        .join(" ")
6627}
6628
6629fn looks_like_template_uuid(template_id: &str) -> bool {
6630    let bytes = template_id.as_bytes();
6631    if bytes.len() != 36 {
6632        return false;
6633    }
6634    let is_hex = |b: u8| b.is_ascii_hexdigit();
6635    let groups = [8usize, 4, 4, 4, 12];
6636    let mut i = 0;
6637    for (gi, &len) in groups.iter().enumerate() {
6638        if gi > 0 {
6639            if bytes.get(i) != Some(&b'-') {
6640                return false;
6641            }
6642            i += 1;
6643        }
6644        for _ in 0..len {
6645            if !bytes.get(i).copied().is_some_and(is_hex) {
6646                return false;
6647            }
6648            i += 1;
6649        }
6650    }
6651    true
6652}
6653
6654/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
6655const HARVEST_RANGE_M: f32 = 1.5;
6656
6657pub struct GameClient<S: PlayConnection> {
6658    session: S,
6659    seq: Seq,
6660    pub state: GameState,
6661    last_move_forward: f32,
6662    last_move_strafe: f32,
6663}
6664
6665impl<S: PlayConnection> GameClient<S> {
6666    pub fn new(session: S) -> Self {
6667        let session_id = session.session_id();
6668        let entity_id = session.entity_id();
6669        let mut client = Self {
6670            session,
6671            seq: 0,
6672            last_move_forward: 0.0,
6673            last_move_strafe: 0.0,
6674            state: GameState {
6675                session_id,
6676                entity_id,
6677                character_id: None,
6678                tick: 0,
6679                chunk_rev: 0,
6680                content_rev: 0,
6681                publish_rev: 0,
6682                entities: Vec::new(),
6683                player: None,
6684                resource_nodes: Vec::new(),
6685                ground_drops: Vec::new(),
6686                placed_containers: Vec::new(),
6687                buildings: Vec::new(),
6688                doors: Vec::new(),
6689                interior_map: None,
6690                npcs: Vec::new(),
6691                blueprints: Vec::new(),
6692                building_materials: Vec::new(),
6693                world_x0: 0.0,
6694                world_y0: 0.0,
6695                world_width_m: 0.0,
6696                world_height_m: 0.0,
6697                terrain_zones: Vec::new(),
6698                z_platforms: Vec::new(),
6699                z_transitions: Vec::new(),
6700                z_bands_outdoor_backup: None,
6701                world_clock: flatland_protocol::WorldClock::default(),
6702                inventory: std::collections::HashMap::new(),
6703                inventory_hints: std::collections::HashMap::new(),
6704                logs: VecDeque::new(),
6705                intents_sent: 0,
6706                ticks_received: 0,
6707                connected: false,
6708                disconnect_reason: None,
6709                show_stats: false,
6710                hud_log_hidden: false,
6711                show_equip_menu: false,
6712                equip_menu_index: 0,
6713                show_craft_menu: false,
6714                show_plot_build_menu: false,
6715                plot_build_focus_wall: true,
6716                plot_build_wall_index: 0,
6717                plot_build_roof_index: 0,
6718                craft_menu_index: 0,
6719                craft_batch_quantity: 1,
6720                show_shop_menu: false,
6721                shop_catalog: None,
6722                bank_panel: None,
6723                bank_menu_index: 0,
6724                bank_ui_mode: BankUiMode::Menu,
6725                storage_panel: None,
6726                market_panel: None,
6727                market_menu_index: 0,
6728                market_filter: String::new(),
6729                market_filter_focused: false,
6730                market_category_filter: None,
6731                market_buy_confirm: None,
6732                market_ui_mode: MarketUiMode::Browse,
6733                storage_menu_index: 0,
6734                storage_ui_mode: StorageUiMode::Menu,
6735                shop_tab: ShopTab::default(),
6736                shop_menu_index: 0,
6737                shop_quantity: 1,
6738                shop_trade_log: VecDeque::new(),
6739                show_npc_verb_menu: false,
6740                npc_verb_target: None,
6741                npc_verb_index: 0,
6742                player_verbs: crate::social::PlayerVerbState::default(),
6743                social_chat: crate::social::SocialChatState::default(),
6744                trade_ui: crate::social::TradeUiState::default(),
6745                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6746                show_npc_chat: false,
6747                npc_chat: None,
6748                show_inventory_menu: false,
6749                inventory_menu_index: 0,
6750                inventory_tab: InventoryTab::OnPerson,
6751                inventory_filter: String::new(),
6752                inventory_filter_focused: false,
6753                show_move_picker: false,
6754                show_rename_prompt: false,
6755                rename_plot_id: None,
6756                highlighted_plot_id: None,
6757                show_worker_rename: false,
6758                rename_buffer: String::new(),
6759                move_picker_index: 0,
6760                move_picker: None,
6761                show_grant_picker: false,
6762                grant_picker_index: 0,
6763                grant_picker: None,
6764                show_destroy_picker: false,
6765                destroy_confirm_pending: false,
6766                destroy_picker: None,
6767                combat_target: None,
6768                combat_target_label: None,
6769                ground_target: None,
6770                combat_fx: Vec::new(),
6771                ground_hazards: Vec::new(),
6772                property_zones: Vec::new(),
6773                tax_zones: Vec::new(),
6774                growth_zones: Vec::new(),
6775                biome_zones: Vec::new(),
6776                terrain_kind_nav: Vec::new(),
6777                property_plots: Vec::new(),
6778                property_plot_settings: None,
6779                claim_mode: None,
6780                relocate_mode: None,
6781                sell_plot_confirm: None,
6782                sell_plot_armed_at: None,
6783                show_plant_menu: false,
6784                plant_menu_index: 0,
6785                show_farm_access: false,
6786                farm_access_name_draft: String::new(),
6787                farm_access_discount_bps: 0,
6788                farm_access_index: 0,
6789                plant_quantity: 1,
6790                in_combat: false,
6791                auto_attack: true,
6792                combat_has_los: false,
6793                attack_cd_ticks: 0,
6794                gcd_ticks: 0,
6795                weapon_ability_id: "unarmed".into(),
6796                mainhand_template_id: None,
6797                mainhand_label: None,
6798                mainhand_instance_id: None,
6799                offhand_template_id: None,
6800                offhand_label: None,
6801                offhand_instance_id: None,
6802                mainhand_hand_slots: 1,
6803                defense: None,
6804                worn: BTreeMap::new(),
6805                carry_mass: 0.0,
6806                carry_mass_max: 0.0,
6807                encumbrance: flatland_protocol::EncumbranceState::Light,
6808                inventory_stacks: Vec::new(),
6809                keychain_stacks: Vec::new(),
6810                whisper_pouch_stacks: Vec::new(),
6811                combat_target_detail: None,
6812                statuses: Vec::new(),
6813                cast_progress: None,
6814                timed_channel: None,
6815                plot_build_offer: None,
6816                ability_cooldowns: Vec::new(),
6817                blocking_active: false,
6818                max_target_slots: 1,
6819                combat_slots: Vec::new(),
6820                rotation_presets: Vec::new(),
6821                known_abilities: Vec::new(),
6822                ability_meta: std::collections::HashMap::new(),
6823                ability_mastery: std::collections::HashMap::new(),
6824                hotbar: vec![None; 9],
6825                max_abilities_per_rotation: 0,
6826                show_loadout_menu: false,
6827                show_keychain_menu: false,
6828                keychain_menu_index: 0,
6829                show_rotation_editor: false,
6830                loadout_menu_index: 0,
6831                loadout_hotbar_slot: 1,
6832                loadout_ability_index: 0,
6833                loadout_focus_presets: false,
6834                rotation_editor: RotationEditorState::default(),
6835                harvest_in_progress: false,
6836                harvest_started_at: None,
6837                pending_craft_ack: None,
6838                pending_worker_job_ack: None,
6839                attending_worker_instance_id: None,
6840                quest_log: Vec::new(),
6841                interactables: Vec::new(),
6842                ledger: None,
6843                career: None,
6844                character_sheet_tab: CharacterSheetTab::Character,
6845                ledger_period: LedgerPeriod::Day,
6846                show_quest_offer: false,
6847                pending_quest_offer: None,
6848                show_quest_menu: false,
6849                quest_menu_index: 0,
6850                quest_withdraw_confirm: false,
6851                hired_workers: Vec::new(),
6852                show_workers_menu: false,
6853                workers_menu_index: 0,
6854                worker_dismiss_confirmation: None,
6855                workers_menu_compact: false,
6856                worker_step_display: BTreeMap::new(),
6857                worker_error_display: BTreeMap::new(),
6858                worker_health_ring_until: BTreeMap::new(),
6859                show_worker_give_picker: false,
6860                worker_give_picker_index: 0,
6861                worker_give_picker: None,
6862                show_worker_give_target_picker: false,
6863                worker_give_target_picker_index: 0,
6864                worker_give_target_picker: None,
6865                show_worker_take_picker: false,
6866                worker_take_picker_index: 0,
6867                worker_take_picker: None,
6868                show_worker_teach_picker: false,
6869                worker_teach_picker_index: 0,
6870                worker_teach_picker: None,
6871                worker_route_editor: None,
6872                progression_curve: None,
6873            },
6874        };
6875        client.state.apply_client_ui_prefs();
6876        client
6877    }
6878
6879    pub fn entity_id(&self) -> EntityId {
6880        self.state.entity_id
6881    }
6882
6883    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6884        if self.state.connected {
6885            return Ok(());
6886        }
6887
6888        loop {
6889            match self.session.next_event().await {
6890                Some(SessionEvent::Welcome {
6891                    session_id,
6892                    entity_id,
6893                    snapshot,
6894                }) => {
6895                    self.state
6896                        .restore_from_welcome(session_id, entity_id, &snapshot);
6897                    self.state.apply_client_ui_prefs();
6898                    self.state.push_log(format!(
6899                        "Connected — session {session_id}, entity {entity_id}"
6900                    ));
6901                    return Ok(());
6902                }
6903                Some(SessionEvent::Disconnected { .. }) => {
6904                    anyhow::bail!("disconnected before welcome");
6905                }
6906                Some(_) => continue,
6907                None => anyhow::bail!("session closed before welcome"),
6908            }
6909        }
6910    }
6911
6912    /// Drain all pending server events (non-blocking).
6913    pub fn drain_events(&mut self) {
6914        while let Some(event) = self.session.try_next_event() {
6915            if self.handle_event_sync(event).is_err() {
6916                break;
6917            }
6918        }
6919    }
6920
6921    /// Wait for the next server event.
6922    pub async fn next_event(&mut self) -> Option<SessionEvent> {
6923        self.session.next_event().await
6924    }
6925
6926    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6927        self.handle_event_sync(event)
6928    }
6929
6930    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6931        match event {
6932            SessionEvent::Welcome {
6933                session_id,
6934                entity_id,
6935                snapshot,
6936            } => {
6937                let resumed = self.state.connected;
6938                self.state
6939                    .restore_from_welcome(session_id, entity_id, &snapshot);
6940                if resumed {
6941                    self.state.push_log(format!(
6942                        "Session restored — session {session_id}, entity {entity_id}"
6943                    ));
6944                }
6945            }
6946            SessionEvent::ContentUpdated { snapshot } => {
6947                self.state
6948                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
6949                self.state.push_log(format!(
6950                    "World updated (content rev {})",
6951                    snapshot.content_rev
6952                ));
6953            }
6954            SessionEvent::QuestCatalogUpdated(update) => {
6955                self.state.push_log(format!(
6956                    "Quest board updated (revision {}, {} new, {} retired)",
6957                    update.revision,
6958                    update.accepted.len(),
6959                    update.retired.len()
6960                ));
6961            }
6962            SessionEvent::Tick(delta) => {
6963                self.state.apply_tick_fields(&delta, self.state.entity_id);
6964                self.state.ticks_received += 1;
6965            }
6966            SessionEvent::IntentAck {
6967                entity_id,
6968                seq,
6969                tick,
6970            } => {
6971                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6972                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6973                    if *craft_seq == seq {
6974                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6975                        if batches > 1 {
6976                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
6977                        } else {
6978                            self.state.push_log(format!("Crafting {label}…"));
6979                        }
6980                    }
6981                }
6982                if self
6983                    .state
6984                    .pending_worker_job_ack
6985                    .as_ref()
6986                    .is_some_and(|p| p.seq == seq)
6987                {
6988                    let pending = self.state.pending_worker_job_ack.take().unwrap();
6989                    if pending.idle {
6990                        self.state.push_log(format!(
6991                            "Route cleared for {} — worker idle",
6992                            pending.worker_label
6993                        ));
6994                    } else {
6995                        self.state.push_log(format!(
6996                            "Route saved for {} — {} stop(s), job loop active",
6997                            pending.worker_label, pending.stop_count
6998                        ));
6999                    }
7000                    if self
7001                        .state
7002                        .worker_route_editor
7003                        .as_ref()
7004                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
7005                    {
7006                        self.close_worker_route_editor();
7007                    }
7008                }
7009            }
7010            SessionEvent::Chat(msg) => {
7011                let label = match msg.channel {
7012                    flatland_protocol::ChatChannel::Nearby => "nearby",
7013                    flatland_protocol::ChatChannel::Direct => "speak",
7014                    flatland_protocol::ChatChannel::Whisper => "whisper",
7015                    flatland_protocol::ChatChannel::WhisperStone => "stone",
7016                };
7017                let clarity = match msg.clarity {
7018                    flatland_protocol::ChatClarity::Clear => "",
7019                    flatland_protocol::ChatClarity::Partial => "~",
7020                    flatland_protocol::ChatClarity::Heavy => "…",
7021                };
7022                self.state.push_log(format!(
7023                    "[{label}{clarity}] {}: {}",
7024                    msg.from_name, msg.text
7025                ));
7026                let now_ms = std::time::SystemTime::now()
7027                    .duration_since(std::time::UNIX_EPOCH)
7028                    .map(|d| d.as_millis() as u64)
7029                    .unwrap_or(0);
7030                self.state
7031                    .social_chat
7032                    .note_speech(&msg, self.state.entity_id, now_ms);
7033                self.state
7034                    .social_chat
7035                    .push(crate::social::ChatLogEntry::from_message(
7036                        msg,
7037                        self.state.entity_id,
7038                    ));
7039            }
7040            SessionEvent::TradeOpened(panel) => {
7041                self.state.social_chat.pending_trade = None;
7042                let peer = panel.peer_name.clone();
7043                self.state.trade_ui.open(panel);
7044                self.state.social_chat.push_system(format!(
7045                    "Trade open with {peer} — p present · r ready · Esc cancel"
7046                ));
7047                self.state
7048                    .social_chat
7049                    .push_cue(crate::social::AudioCue::TradeOpened);
7050            }
7051            SessionEvent::TradeClosed { reason } => {
7052                self.state.push_log(reason.clone());
7053                self.state.social_chat.push_system(reason);
7054                self.state.trade_ui.close();
7055            }
7056            SessionEvent::HarvestResult(result) => {
7057                self.state.clear_harvest_state();
7058                crate::harvest_trace!(
7059                    entity_id = self.state.entity_id,
7060                    node_id = %result.node_id,
7061                    template = %result.item_template,
7062                    quantity = result.quantity,
7063                    client_tick = self.state.tick,
7064                    "client applied harvest result"
7065                );
7066                let msg = if result.quantity == 0 {
7067                    format!(
7068                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
7069                        result.item_template
7070                    )
7071                } else {
7072                    format!(
7073                        "Harvested {} x{} (on the ground — press P to pick up)",
7074                        result.item_template, result.quantity
7075                    )
7076                };
7077                self.state.push_log(msg);
7078            }
7079            SessionEvent::CraftResult(result) => {
7080                for stack in &result.consumed {
7081                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
7082                        *qty = qty.saturating_sub(stack.quantity);
7083                        if *qty == 0 {
7084                            self.state.inventory.remove(&stack.template_id);
7085                        }
7086                    }
7087                }
7088                for stack in &result.outputs {
7089                    *self
7090                        .state
7091                        .inventory
7092                        .entry(stack.template_id.clone())
7093                        .or_insert(0) += stack.quantity;
7094                }
7095                if let Some(output) = result.outputs.first() {
7096                    if result.batch_total > 1 {
7097                        self.state.push_log(format!(
7098                            "Crafted {} x{} ({}/{})",
7099                            output.template_id,
7100                            output.quantity,
7101                            result.batch_index,
7102                            result.batch_total
7103                        ));
7104                    } else {
7105                        self.state.push_log(format!(
7106                            "Crafted {} x{}",
7107                            output.template_id, output.quantity
7108                        ));
7109                    }
7110                } else {
7111                    self.state
7112                        .push_log(format!("Craft finished: {}", result.blueprint_id));
7113                }
7114            }
7115            SessionEvent::Death(notice) => {
7116                self.state.clear_harvest_state();
7117                self.state.push_log(notice.message.clone());
7118                self.state.push_log(format!(
7119                    "Respawned at ({:.1}, {:.1})",
7120                    notice.respawn_x, notice.respawn_y
7121                ));
7122            }
7123            SessionEvent::Interaction(notice) => {
7124                if notice.message.starts_with("Harvest failed:") {
7125                    self.state.clear_harvest_state();
7126                }
7127                if notice.message.starts_with("Can't do that:") {
7128                    self.state.pending_craft_ack = None;
7129                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
7130                        if let Some(w) = self
7131                            .state
7132                            .hired_workers
7133                            .iter_mut()
7134                            .find(|w| w.instance_id == pending.worker_instance_id)
7135                        {
7136                            w.route = pending.prev_route;
7137                            w.mode = pending.prev_mode;
7138                            w.step_label = pending.prev_step_label;
7139                            w.last_error = pending.prev_last_error;
7140                        }
7141                        let reason = notice
7142                            .message
7143                            .strip_prefix("Can't do that:")
7144                            .unwrap_or(&notice.message)
7145                            .trim();
7146                        self.state.push_log(format!(
7147                            "Route save failed for {}: {reason}",
7148                            pending.worker_label
7149                        ));
7150                    }
7151                    let reason = notice
7152                        .message
7153                        .strip_prefix("Can't do that:")
7154                        .unwrap_or(&notice.message)
7155                        .trim();
7156                    if reason.contains("already tilled") {
7157                        if let Some(plot) = self.state.my_plot_under_player() {
7158                            self.state.sell_plot_confirm = Some(plot.plot_id);
7159                            self.state.sell_plot_armed_at = Some(Instant::now());
7160                        }
7161                    }
7162                }
7163                if notice.message.starts_with("Cast failed:") {
7164                    self.state.cast_progress = None;
7165                }
7166                if notice.message.contains("slain the") {
7167                    self.state.combat_target = None;
7168                    self.state.combat_target_label = None;
7169                }
7170                // Inbound trade request → inline Y/N in the CHAT column (no popup).
7171                if notice.message.contains("wants to trade") {
7172                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
7173                        let from_name = notice
7174                            .message
7175                            .split(" wants to trade")
7176                            .next()
7177                            .unwrap_or("Player")
7178                            .to_string();
7179                        self.state.social_chat.pending_trade =
7180                            Some(crate::social::PendingTradeRequest {
7181                                from_entity,
7182                                from_name: from_name.clone(),
7183                            });
7184                        self.state.social_chat.push_system(format!(
7185                            "{from_name} wants to trade — [Y] accept · [N] decline"
7186                        ));
7187                        self.state
7188                            .social_chat
7189                            .push_cue(crate::social::AudioCue::TradeOffer);
7190                    }
7191                }
7192                if notice.message.starts_with("trade request declined") {
7193                    self.state.social_chat.push_system(notice.message.clone());
7194                    self.state
7195                        .social_chat
7196                        .push_cue(crate::social::AudioCue::TradeDeclined);
7197                }
7198                self.state.apply_interaction_notice(&notice);
7199                self.state.push_log(notice.message.clone());
7200            }
7201            SessionEvent::ShopOpened(catalog) => {
7202                self.state.apply_shop_catalog(catalog);
7203            }
7204            SessionEvent::BankOpened(panel) => {
7205                self.state.apply_bank_panel(panel);
7206            }
7207            SessionEvent::StorageOpened(panel) => {
7208                self.state.apply_storage_panel(panel);
7209            }
7210            SessionEvent::MarketOpened(panel) => {
7211                self.state.apply_market_panel(panel);
7212            }
7213            SessionEvent::NpcTalkOpened(opened) => {
7214                self.state.show_npc_verb_menu = false;
7215                if self.state.npc_verb_target.is_none() {
7216                    self.state.npc_verb_target = Some(opened.npc_id.clone());
7217                }
7218                let label = opened.npc_label.clone();
7219                let banner = if !opened.trade_allowed {
7220                    Some("Trade is unavailable right now.".to_string())
7221                } else {
7222                    None
7223                };
7224                self.state.show_npc_chat = true;
7225                self.state.npc_chat = Some(NpcChatState {
7226                    npc_id: opened.npc_id,
7227                    npc_label: opened.npc_label,
7228                    lines: if opened.greeting.is_empty() {
7229                        vec![]
7230                    } else {
7231                        vec![format!("{label}: {}", opened.greeting)]
7232                    },
7233                    input: String::new(),
7234                    pending: opened.greeting.is_empty(),
7235                    talk_depth: opened.talk_depth,
7236                    trade_allowed: opened.trade_allowed,
7237                    banner,
7238                    suggested_topics: opened.suggested_topics,
7239                });
7240            }
7241            SessionEvent::NpcTalkPending(_) => {
7242                if let Some(chat) = self.state.npc_chat.as_mut() {
7243                    chat.pending = true;
7244                }
7245            }
7246            SessionEvent::NpcTalkReply(reply) => {
7247                if let Some(chat) = self.state.npc_chat.as_mut() {
7248                    if chat.npc_id == reply.npc_id {
7249                        chat.pending = false;
7250                        if reply.trade_disabled {
7251                            chat.trade_allowed = false;
7252                            chat.banner = Some("Trade is unavailable right now.".to_string());
7253                        }
7254                        if reply.wind_down {
7255                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
7256                            if chat.banner.is_none() {
7257                                chat.banner =
7258                                    Some("They're wrapping up — keep it brief.".to_string());
7259                            }
7260                        }
7261                        chat.lines
7262                            .push(format!("{}: {}", chat.npc_label, reply.line));
7263                    }
7264                }
7265            }
7266            SessionEvent::NpcTalkClosed(closed) => {
7267                if self
7268                    .state
7269                    .npc_chat
7270                    .as_ref()
7271                    .is_some_and(|c| c.npc_id == closed.npc_id)
7272                {
7273                    self.state.show_npc_chat = false;
7274                    self.state.npc_chat = None;
7275                }
7276            }
7277            SessionEvent::NpcTalkError(err) => {
7278                self.state.push_log(format!("Talk failed: {}", err.reason));
7279                if let Some(chat) = self.state.npc_chat.as_mut() {
7280                    chat.pending = false;
7281                }
7282            }
7283            SessionEvent::UseResult(result) => {
7284                // Inventory count hint only — the Interaction notice already logs
7285                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
7286                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
7287                    *qty = qty.saturating_sub(1);
7288                    if *qty == 0 {
7289                        self.state.inventory.remove(&result.template_id);
7290                    }
7291                }
7292            }
7293            SessionEvent::QuestOffer(offer) => {
7294                self.state.pending_quest_offer = Some(offer.clone());
7295                self.state.show_quest_offer = true;
7296                self.state
7297                    .push_log(format!("Quest offered: {}", offer.title));
7298            }
7299            SessionEvent::QuestAccepted(notice) => {
7300                self.state.show_quest_offer = false;
7301                self.state.pending_quest_offer = None;
7302                self.state.push_log(notice.message);
7303            }
7304            SessionEvent::QuestWithdrawn(notice) => {
7305                self.state.show_quest_menu = false;
7306                self.state.quest_withdraw_confirm = false;
7307                self.state.push_log(notice.message);
7308            }
7309            SessionEvent::QuestStepCompleted(notice) => {
7310                self.state.push_log(notice.message);
7311            }
7312            SessionEvent::QuestCompleted(notice) => {
7313                self.state.push_log(notice.message);
7314            }
7315            SessionEvent::Disconnected { reason } => {
7316                self.state.clear_harvest_state();
7317                self.state.connected = false;
7318                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
7319                if let Some(r) = &self.state.disconnect_reason {
7320                    self.state.push_log(format!("Disconnected: {r}"));
7321                } else {
7322                    self.state.push_log("Disconnected from server");
7323                }
7324            }
7325        }
7326        Ok(())
7327    }
7328
7329    pub fn is_connected(&self) -> bool {
7330        self.state.connected
7331    }
7332
7333    pub fn close_overlays(&mut self) {
7334        self.state.show_stats = false;
7335        self.state.show_craft_menu = false;
7336        self.state.show_plot_build_menu = false;
7337        self.state.show_shop_menu = false;
7338        self.state.shop_catalog = None;
7339        self.state.show_npc_verb_menu = false;
7340        self.state.npc_verb_target = None;
7341        self.state.show_npc_chat = false;
7342        self.state.npc_chat = None;
7343        self.state.show_inventory_menu = false;
7344        self.state.show_loadout_menu = false;
7345        self.state.show_rotation_editor = false;
7346        self.state.rotation_editor.reset();
7347        self.state.show_rename_prompt = false;
7348        self.state.show_worker_rename = false;
7349        self.state.rename_buffer.clear();
7350        self.state.show_move_picker = false;
7351        self.state.move_picker = None;
7352        self.state.show_destroy_picker = false;
7353        self.state.destroy_confirm_pending = false;
7354        self.state.destroy_picker = None;
7355        self.state.show_quest_offer = false;
7356        self.state.pending_quest_offer = None;
7357        self.state.show_quest_menu = false;
7358        self.state.quest_withdraw_confirm = false;
7359        self.state.show_workers_menu = false;
7360        self.close_worker_give_picker();
7361        self.close_worker_give_target_picker();
7362        self.close_worker_take_picker();
7363        self.close_worker_teach_picker();
7364        self.state.worker_route_editor = None;
7365        self.state.claim_mode = None;
7366        self.state.relocate_mode = None;
7367        self.state.sell_plot_confirm = None;
7368        self.state.sell_plot_armed_at = None;
7369        self.close_farm_access_panel();
7370        if self.state.show_plant_menu {
7371            self.close_plant_menu();
7372        }
7373    }
7374
7375    /// Esc / back — pop one UI layer instead of closing every overlay at once.
7376    pub fn back_on_esc(&mut self) -> bool {
7377        if self.state.social_chat.composer_open() {
7378            self.state.social_chat.close_composer();
7379            return true;
7380        }
7381        if self.state.player_verbs.open {
7382            self.state.player_verbs.close();
7383            return true;
7384        }
7385        if self.state.whisper_pouch_ui.open {
7386            self.state.whisper_pouch_ui.open = false;
7387            return true;
7388        }
7389        if self.state.trade_ui.panel.is_some() {
7390            // async cancel — caller should prefer trade_cancel; close UI optimistically
7391            self.state.trade_ui.close();
7392            return true;
7393        }
7394        if self.state.show_rename_prompt {
7395            self.cancel_rename_prompt();
7396            return true;
7397        }
7398        if self.state.show_worker_rename {
7399            self.cancel_worker_rename();
7400            return true;
7401        }
7402        if self.state.show_destroy_picker {
7403            if self.state.destroy_confirm_pending {
7404                self.cancel_destroy_confirm();
7405            } else {
7406                self.close_destroy_picker();
7407            }
7408            return true;
7409        }
7410        if self.state.claim_mode.is_some() {
7411            self.cancel_claim_mode();
7412            return true;
7413        }
7414        if self.state.relocate_mode.is_some() {
7415            self.cancel_relocate_mode();
7416            return true;
7417        }
7418        if self.state.show_plant_menu {
7419            self.close_plant_menu();
7420            return true;
7421        }
7422        if self.state.show_farm_access {
7423            self.close_farm_access_panel();
7424            return true;
7425        }
7426        if self.state.sell_plot_confirm.is_some() {
7427            self.state.sell_plot_confirm = None;
7428            self.state.sell_plot_armed_at = None;
7429            self.state.push_log("Sell cancelled");
7430            return true;
7431        }
7432        if self.state.show_move_picker {
7433            self.close_move_picker();
7434            return true;
7435        }
7436        if self.state.show_rotation_editor {
7437            match self.state.rotation_editor.mode {
7438                RotationEditorMode::List => {
7439                    self.state.show_rotation_editor = false;
7440                    self.state.rotation_editor.reset();
7441                }
7442                RotationEditorMode::EditLabel => {
7443                    self.state.rotation_editor.label_buffer.clear();
7444                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7445                }
7446                RotationEditorMode::PickAbility => {
7447                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7448                }
7449                RotationEditorMode::EditSequence => {
7450                    self.state.rotation_editor.draft = None;
7451                    self.state.rotation_editor.mode = RotationEditorMode::List;
7452                }
7453            }
7454            return true;
7455        }
7456        if self.state.show_inventory_menu {
7457            self.close_inventory_menu();
7458            return true;
7459        }
7460        if self.state.show_craft_menu {
7461            self.close_craft_menu();
7462            return true;
7463        }
7464        if self.state.show_plot_build_menu {
7465            self.close_plot_build_menu();
7466            return true;
7467        }
7468        if self.state.show_keychain_menu {
7469            self.close_keychain_menu();
7470            return true;
7471        }
7472        if self.state.show_quest_offer {
7473            self.quest_offer_decline();
7474            return true;
7475        }
7476        if self.state.show_shop_menu {
7477            // Caller must await shop close (npc_interaction_back / play-loop).
7478            return false;
7479        }
7480        if self.state.bank_panel.is_some() {
7481            return false;
7482        }
7483        if self.state.storage_panel.is_some() {
7484            return false;
7485        }
7486        if self.state.market_panel.is_some() {
7487            return false;
7488        }
7489        if self.state.show_npc_chat {
7490            // play.rs calls npc_interaction_back().await on Esc
7491            return false;
7492        }
7493        if self.state.show_npc_verb_menu {
7494            self.state.show_npc_verb_menu = false;
7495            self.state.npc_verb_target = None;
7496            return true;
7497        }
7498        if self.state.show_quest_menu {
7499            if self.state.quest_withdraw_confirm {
7500                self.state.quest_withdraw_confirm = false;
7501            } else {
7502                self.state.show_quest_menu = false;
7503            }
7504            return true;
7505        }
7506        if self.state.worker_route_editor.is_some() {
7507            // Pop one sheet level; only close the editor at the root stop list.
7508            if self.re_at_root_sheet() {
7509                let reopen = self.state.attending_worker_instance_id.clone();
7510                self.close_worker_route_editor();
7511                if let Some(id) = reopen {
7512                    if let Some(idx) = self
7513                        .state
7514                        .hired_workers
7515                        .iter()
7516                        .position(|w| w.instance_id == id)
7517                    {
7518                        self.state.workers_menu_index = idx;
7519                        self.state.show_workers_menu = true;
7520                    }
7521                }
7522            } else {
7523                self.re_sheet_back();
7524            }
7525            return true;
7526        }
7527        if self.state.show_worker_give_picker {
7528            self.close_worker_give_picker();
7529            return true;
7530        }
7531        if self.state.show_worker_give_target_picker {
7532            self.close_worker_give_target_picker();
7533            return true;
7534        }
7535        if self.state.show_worker_take_picker {
7536            self.close_worker_take_picker();
7537            return true;
7538        }
7539        if self.state.show_worker_teach_picker {
7540            self.close_worker_teach_picker();
7541            return true;
7542        }
7543        if self.state.show_workers_menu {
7544            self.close_workers_menu_ui();
7545            return true;
7546        }
7547        if self.state.show_loadout_menu {
7548            self.state.show_loadout_menu = false;
7549            return true;
7550        }
7551        if self.state.show_stats {
7552            self.state.show_stats = false;
7553            return true;
7554        }
7555        if self.state.show_equip_menu {
7556            self.state.show_equip_menu = false;
7557            return true;
7558        }
7559        false
7560    }
7561
7562    pub fn toggle_stats(&mut self) {
7563        self.state.show_stats = !self.state.show_stats;
7564        if self.state.show_stats {
7565            self.state.character_sheet_tab = CharacterSheetTab::Character;
7566            self.state.show_craft_menu = false;
7567            self.state.show_shop_menu = false;
7568            self.state.shop_catalog = None;
7569            self.state.show_inventory_menu = false;
7570            self.state.show_equip_menu = false;
7571        }
7572    }
7573
7574    pub fn toggle_equip_menu(&mut self) {
7575        self.state.show_equip_menu = !self.state.show_equip_menu;
7576        if self.state.show_equip_menu {
7577            self.state.show_stats = false;
7578            self.state.show_craft_menu = false;
7579            self.state.show_shop_menu = false;
7580            self.state.shop_catalog = None;
7581            self.state.show_inventory_menu = false;
7582            self.state.show_loadout_menu = false;
7583        }
7584    }
7585
7586    pub fn cycle_character_sheet_tab(&mut self) {
7587        if self.state.show_stats {
7588            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7589        }
7590    }
7591
7592    pub fn set_ledger_period_digit(&mut self, c: char) {
7593        if self.state.show_stats {
7594            if let Some(p) = LedgerPeriod::from_digit(c) {
7595                self.state.ledger_period = p;
7596                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7597            }
7598        }
7599    }
7600
7601    pub fn cycle_ledger_period(&mut self) {
7602        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
7603            self.state.ledger_period = self.state.ledger_period.cycle();
7604        }
7605    }
7606
7607    pub fn open_inventory_menu(&mut self) {
7608        self.state.show_inventory_menu = true;
7609        self.state.show_craft_menu = false;
7610        self.state.show_shop_menu = false;
7611        self.state.shop_catalog = None;
7612        self.state.show_stats = false;
7613        self.state.show_move_picker = false;
7614        self.state.move_picker = None;
7615        self.state.show_destroy_picker = false;
7616        self.state.destroy_confirm_pending = false;
7617        self.state.destroy_picker = None;
7618        self.state.show_rename_prompt = false;
7619        self.state.rename_plot_id = None;
7620        self.state.rename_buffer.clear();
7621        self.state.inventory_filter_focused = false;
7622        self.state.clamp_inventory_indices();
7623    }
7624
7625    pub fn close_inventory_menu(&mut self) {
7626        self.state.show_inventory_menu = false;
7627        self.state.show_move_picker = false;
7628        self.state.move_picker = None;
7629        self.close_grant_picker();
7630        self.state.show_destroy_picker = false;
7631        self.state.destroy_confirm_pending = false;
7632        self.state.destroy_picker = None;
7633        self.state.show_rename_prompt = false;
7634        self.state.rename_plot_id = None;
7635        self.state.rename_buffer.clear();
7636        self.state.inventory_filter_focused = false;
7637    }
7638
7639    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7640        let Some(row) = self.state.inventory_selected_row() else {
7641            anyhow::bail!("inventory empty");
7642        };
7643        if GameState::is_property_deed_template(&row.stack.template_id) {
7644            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7645                anyhow::bail!("deed has no plot id");
7646            };
7647            let label = self
7648                .state
7649                .property_plots
7650                .iter()
7651                .find(|p| p.plot_id == plot_id)
7652                .map(|p| {
7653                    if p.label.trim().is_empty() {
7654                        p.plot_code.clone()
7655                    } else {
7656                        p.label.clone()
7657                    }
7658                })
7659                .unwrap_or_else(|| {
7660                    row.stack
7661                        .display_name
7662                        .clone()
7663                        .unwrap_or_else(|| "plot".into())
7664                });
7665            self.state.rename_buffer = label;
7666            self.state.rename_plot_id = Some(plot_id);
7667            self.state.highlighted_plot_id = Some(plot_id);
7668            self.state.show_rename_prompt = true;
7669            self.state.show_worker_rename = false;
7670            self.state.show_move_picker = false;
7671            self.state.show_destroy_picker = false;
7672            self.state.destroy_confirm_pending = false;
7673            return Ok(());
7674        }
7675        if !self.state.row_is_renameable_container(&row) {
7676            anyhow::bail!("only storage containers or deeds can be renamed");
7677        }
7678        let current = row
7679            .stack
7680            .display_name
7681            .clone()
7682            .unwrap_or_else(|| row.stack.template_id.clone());
7683        self.state.rename_buffer = current;
7684        self.state.rename_plot_id = None;
7685        self.state.show_rename_prompt = true;
7686        self.state.show_worker_rename = false;
7687        self.state.show_move_picker = false;
7688        self.state.show_destroy_picker = false;
7689        self.state.destroy_confirm_pending = false;
7690        Ok(())
7691    }
7692
7693    /// Rename the owned plot under the player's feet (`n` in world).
7694    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7695        let Some(plot) = self.state.my_plot_under_player().cloned() else {
7696            anyhow::bail!("stand on your plot to rename it");
7697        };
7698        let label = if plot.label.trim().is_empty() {
7699            plot.plot_code.clone()
7700        } else {
7701            plot.label.clone()
7702        };
7703        self.state.rename_buffer = label;
7704        self.state.rename_plot_id = Some(plot.plot_id);
7705        self.state.highlighted_plot_id = Some(plot.plot_id);
7706        self.state.show_rename_prompt = true;
7707        self.state.show_worker_rename = false;
7708        Ok(())
7709    }
7710
7711    pub fn cancel_rename_prompt(&mut self) {
7712        self.state.show_rename_prompt = false;
7713        self.state.rename_plot_id = None;
7714        self.state.rename_buffer.clear();
7715    }
7716
7717    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7718        let name = self.state.rename_buffer.trim().to_string();
7719        if name.is_empty() {
7720            anyhow::bail!("name cannot be empty");
7721        }
7722        if let Some(plot_id) = self.state.rename_plot_id {
7723            if name.chars().count() > 48 {
7724                anyhow::bail!("label must be 1–48 characters");
7725            }
7726            self.seq += 1;
7727            self.session
7728                .submit_intent(Intent::RenamePropertyPlot {
7729                    entity_id: self.state.entity_id,
7730                    plot_id,
7731                    label: name,
7732                    seq: self.seq,
7733                })
7734                .await?;
7735            self.state.intents_sent += 1;
7736            self.state.show_rename_prompt = false;
7737            self.state.rename_plot_id = None;
7738            self.state.rename_buffer.clear();
7739            return Ok(());
7740        }
7741        if name.chars().count() > 32 {
7742            anyhow::bail!("name must be 1–32 characters");
7743        }
7744        let Some(row) = self.state.inventory_selected_row() else {
7745            anyhow::bail!("inventory empty");
7746        };
7747        let Some(instance_id) = row.stack.item_instance_id else {
7748            anyhow::bail!("item has no instance id");
7749        };
7750        self.seq += 1;
7751        self.session
7752            .submit_intent(Intent::RenameContainer {
7753                entity_id: self.state.entity_id,
7754                item_instance_id: instance_id,
7755                location: row.from.clone(),
7756                name,
7757                seq: self.seq,
7758            })
7759            .await?;
7760        self.state.intents_sent += 1;
7761        self.state.show_rename_prompt = false;
7762        self.state.rename_buffer.clear();
7763        Ok(())
7764    }
7765
7766    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7767        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7768            anyhow::bail!("no worker selected");
7769        };
7770        self.state.rename_buffer = worker.label.clone();
7771        self.state.show_worker_rename = true;
7772        self.state.show_rename_prompt = false;
7773        Ok(())
7774    }
7775
7776    pub fn cancel_worker_rename(&mut self) {
7777        self.state.show_worker_rename = false;
7778        self.state.rename_buffer.clear();
7779    }
7780
7781    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7782        let name = self.state.rename_buffer.trim().to_string();
7783        if name.is_empty() {
7784            anyhow::bail!("name cannot be empty");
7785        }
7786        if name.chars().count() > 32 {
7787            anyhow::bail!("name must be 1–32 characters");
7788        }
7789        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7790            anyhow::bail!("no worker selected");
7791        };
7792        let worker_instance_id = worker.instance_id.clone();
7793        self.seq += 1;
7794        self.session
7795            .submit_intent(Intent::RenameHiredWorker {
7796                entity_id: self.state.entity_id,
7797                worker_instance_id: worker_instance_id.clone(),
7798                name: name.clone(),
7799                seq: self.seq,
7800            })
7801            .await?;
7802        self.state.intents_sent += 1;
7803        if let Some(w) = self
7804            .state
7805            .hired_workers
7806            .iter_mut()
7807            .find(|w| w.instance_id == worker_instance_id)
7808        {
7809            w.label = name.clone();
7810        }
7811        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7812            if ed.worker_instance_id == worker_instance_id {
7813                ed.worker_label = name.clone();
7814            }
7815        }
7816        self.state.show_worker_rename = false;
7817        self.state.rename_buffer.clear();
7818        self.state.push_log(format!("Renamed worker to \"{name}\""));
7819        Ok(())
7820    }
7821
7822    pub fn toggle_inventory_menu(&mut self) {
7823        if self.state.show_inventory_menu {
7824            self.close_inventory_menu();
7825        } else {
7826            self.open_inventory_menu();
7827        }
7828    }
7829
7830    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
7831    pub fn inventory_menu_move(&mut self, delta: i32) {
7832        if self.state.show_grant_picker {
7833            let Some(picker) = self.state.grant_picker.as_ref() else {
7834                return;
7835            };
7836            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7837            let filter = picker.filter.clone();
7838            let n = labels.len();
7839            if n == 0 {
7840                return;
7841            }
7842            self.state.grant_picker_index =
7843                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
7844                    list_label_matches(&labels[i], &filter)
7845                });
7846            return;
7847        }
7848        if self.state.show_move_picker {
7849            let Some(picker) = self.state.move_picker.as_ref() else {
7850                return;
7851            };
7852            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7853            let filter = picker.filter.clone();
7854            let n = labels.len();
7855            if n == 0 {
7856                return;
7857            }
7858            self.state.move_picker_index =
7859                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
7860                    list_label_matches(&labels[i], &filter)
7861                });
7862            self.state.clamp_move_picker_quantity();
7863            return;
7864        }
7865        let n = self.state.inventory_selectable_rows().len();
7866        if n == 0 {
7867            return;
7868        }
7869        let idx = self.state.inventory_menu_index as i32;
7870        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7871    }
7872
7873    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
7874    pub fn inventory_menu_page(&mut self, pages: i32) {
7875        if self.state.show_grant_picker {
7876            let Some(picker) = self.state.grant_picker.as_ref() else {
7877                return;
7878            };
7879            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7880            let filter = picker.filter.clone();
7881            let n = labels.len();
7882            self.state.grant_picker_index =
7883                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
7884                    list_label_matches(&labels[i], &filter)
7885                });
7886            return;
7887        }
7888        if self.state.show_move_picker {
7889            let Some(picker) = self.state.move_picker.as_ref() else {
7890                return;
7891            };
7892            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7893            let filter = picker.filter.clone();
7894            let n = labels.len();
7895            self.state.move_picker_index =
7896                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
7897                    list_label_matches(&labels[i], &filter)
7898                });
7899            self.state.clamp_move_picker_quantity();
7900            return;
7901        }
7902        let n = self.state.inventory_selectable_rows().len();
7903        self.state.inventory_menu_index =
7904            page_list_index(self.state.inventory_menu_index, pages, n);
7905    }
7906
7907    pub fn cycle_inventory_tab(&mut self, forward: bool) {
7908        if self.state.show_move_picker
7909            || self.state.show_grant_picker
7910            || self.state.show_destroy_picker
7911            || self.state.show_rename_prompt
7912            || self.state.inventory_filter_focused
7913        {
7914            return;
7915        }
7916        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7917        self.state.inventory_menu_index = 0;
7918        self.state.clamp_inventory_indices();
7919    }
7920
7921    pub fn focus_inventory_filter(&mut self) {
7922        if self.state.show_grant_picker {
7923            if let Some(p) = self.state.grant_picker.as_mut() {
7924                p.filter_focused = true;
7925            }
7926            return;
7927        }
7928        if self.state.show_move_picker {
7929            if let Some(p) = self.state.move_picker.as_mut() {
7930                p.filter_focused = true;
7931            }
7932            return;
7933        }
7934        self.state.inventory_filter_focused = true;
7935    }
7936
7937    pub fn set_inventory_filter(&mut self, filter: String) {
7938        self.state.inventory_filter = filter;
7939        self.state.inventory_menu_index = 0;
7940        self.state.clamp_inventory_indices();
7941    }
7942
7943    pub fn append_inventory_filter_char(&mut self, ch: char) {
7944        if ch.is_control() {
7945            return;
7946        }
7947        if self.state.show_grant_picker {
7948            if let Some(p) = self.state.grant_picker.as_mut() {
7949                if p.filter_focused {
7950                    p.filter.push(ch);
7951                    self.state.grant_picker_index = 0;
7952                }
7953            }
7954            return;
7955        }
7956        if self.state.show_move_picker {
7957            if let Some(p) = self.state.move_picker.as_mut() {
7958                if p.filter_focused {
7959                    p.filter.push(ch);
7960                    self.state.move_picker_index = 0;
7961                    self.state.clamp_move_picker_quantity();
7962                }
7963            }
7964            return;
7965        }
7966        if !self.state.inventory_filter_focused {
7967            return;
7968        }
7969        self.state.inventory_filter.push(ch);
7970        self.state.inventory_menu_index = 0;
7971        self.state.clamp_inventory_indices();
7972    }
7973
7974    pub fn inventory_filter_backspace(&mut self) {
7975        if self.state.show_grant_picker {
7976            if let Some(p) = self.state.grant_picker.as_mut() {
7977                if p.filter_focused {
7978                    p.filter.pop();
7979                    self.state.grant_picker_index = 0;
7980                }
7981            }
7982            return;
7983        }
7984        if self.state.show_move_picker {
7985            if let Some(p) = self.state.move_picker.as_mut() {
7986                if p.filter_focused {
7987                    p.filter.pop();
7988                    self.state.move_picker_index = 0;
7989                    self.state.clamp_move_picker_quantity();
7990                }
7991            }
7992            return;
7993        }
7994        if !self.state.inventory_filter_focused {
7995            return;
7996        }
7997        self.state.inventory_filter.pop();
7998        self.state.inventory_menu_index = 0;
7999        self.state.clamp_inventory_indices();
8000    }
8001
8002    /// Esc while filter focused: clear filter then blur. Returns true if handled.
8003    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
8004        if self.state.show_grant_picker {
8005            if let Some(p) = self.state.grant_picker.as_mut() {
8006                if p.filter_focused {
8007                    if !p.filter.is_empty() {
8008                        p.filter.clear();
8009                        self.state.grant_picker_index = 0;
8010                    } else {
8011                        p.filter_focused = false;
8012                    }
8013                    return true;
8014                }
8015                if !p.filter.is_empty() {
8016                    p.filter.clear();
8017                    self.state.grant_picker_index = 0;
8018                    return true;
8019                }
8020            }
8021            return false;
8022        }
8023        if self.state.show_move_picker {
8024            if let Some(p) = self.state.move_picker.as_mut() {
8025                if p.filter_focused {
8026                    if !p.filter.is_empty() {
8027                        p.filter.clear();
8028                        self.state.move_picker_index = 0;
8029                        self.state.clamp_move_picker_quantity();
8030                    } else {
8031                        p.filter_focused = false;
8032                    }
8033                    return true;
8034                }
8035                if !p.filter.is_empty() {
8036                    p.filter.clear();
8037                    self.state.move_picker_index = 0;
8038                    self.state.clamp_move_picker_quantity();
8039                    return true;
8040                }
8041            }
8042            return false;
8043        }
8044        if self.state.inventory_filter_focused {
8045            if !self.state.inventory_filter.is_empty() {
8046                self.state.inventory_filter.clear();
8047                self.state.inventory_menu_index = 0;
8048                self.state.clamp_inventory_indices();
8049            } else {
8050                self.state.inventory_filter_focused = false;
8051            }
8052            return true;
8053        }
8054        if !self.state.inventory_filter.is_empty() {
8055            self.state.inventory_filter.clear();
8056            self.state.inventory_menu_index = 0;
8057            self.state.clamp_inventory_indices();
8058            return true;
8059        }
8060        false
8061    }
8062
8063    pub fn craft_menu_page(&mut self, pages: i32) {
8064        let n = self.state.blueprints.len();
8065        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
8066        self.state.clamp_craft_batch_quantity();
8067    }
8068
8069    pub fn shop_menu_page(&mut self, pages: i32) {
8070        let n = self.state.shop_list_len();
8071        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
8072        self.state.clamp_shop_quantity();
8073    }
8074
8075    pub fn workers_menu_page(&mut self, pages: i32) {
8076        let n = self.state.hired_workers.len();
8077        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
8078    }
8079
8080    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
8081    /// chest — or fall back to the "move to…" destination picker (including
8082    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
8083    /// Placed chest shells open a pick-up destination picker (`l` still locks).
8084    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
8085        if self.state.show_destroy_picker {
8086            if self.state.destroy_confirm_pending {
8087                return self.confirm_destroy_item().await;
8088            }
8089            return self.request_destroy_confirm();
8090        }
8091        if self.state.show_grant_picker {
8092            return self.confirm_grant_picker().await;
8093        }
8094        if self.state.show_move_picker {
8095            return self.confirm_move_picker().await;
8096        }
8097        let Some(row) = self.state.inventory_selected_row() else {
8098            anyhow::bail!("inventory empty");
8099        };
8100        if row.is_equip_shell {
8101            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
8102                anyhow::bail!("not a worn item");
8103            };
8104            return self.equip_worn(slot, None).await;
8105        }
8106        if row.is_chest_shell {
8107            return self.open_chest_pickup_picker();
8108        }
8109        let template_id = row.stack.template_id.clone();
8110        let instance_id = row.stack.item_instance_id;
8111        let category = self.state.inventory_item_category(&template_id);
8112        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8113
8114        if category == Some("weapon") {
8115            return self.equip_mainhand(Some(template_id)).await;
8116        }
8117        if category == Some("lodging") && on_person {
8118            if let Some(inst) = instance_id {
8119                return self.place_container(inst).await;
8120            }
8121        }
8122        if (category == Some("container") || category == Some("armor")) && on_person {
8123            if let Some(inst) = instance_id {
8124                let world_placeable =
8125                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
8126                if world_placeable {
8127                    return self.place_container(inst).await;
8128                }
8129                // Pouches no longer equip directly — they clip onto a worn belt's loops
8130                // instead, so fall through to the move picker (offers "belt loop" when a
8131                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
8132                if let Some(slot) = guess_body_slot(&template_id) {
8133                    return self.equip_worn(slot, Some(inst)).await;
8134                }
8135            }
8136        }
8137        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
8138        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
8139        // instead of guessing.
8140        self.open_move_picker()
8141    }
8142
8143    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
8144    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
8145        let Some(row) = self.state.inventory_selected_row() else {
8146            anyhow::bail!("inventory empty");
8147        };
8148        if row.from != flatland_protocol::InventoryLocation::Root {
8149            anyhow::bail!("select a consumable on your person");
8150        }
8151        if GameState::stack_is_item_grant(&row.stack) {
8152            return self.open_grant_target_picker();
8153        }
8154        if GameState::is_property_deed_template(&row.stack.template_id) {
8155            return self.open_move_picker();
8156        }
8157        let category = self.state.inventory_item_category(&row.stack.template_id);
8158        if category != Some("consumable") {
8159            anyhow::bail!("selected item is not consumable");
8160        }
8161        self.use_item(&row.stack.template_id).await
8162    }
8163
8164    /// Open picker: apply selected grant item onto inventory / worn gear.
8165    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
8166        let Some(row) = self.state.inventory_selected_row() else {
8167            anyhow::bail!("inventory empty");
8168        };
8169        if row.from != flatland_protocol::InventoryLocation::Root {
8170            anyhow::bail!("select a grant item on your person");
8171        }
8172        if !GameState::stack_is_item_grant(&row.stack) {
8173            anyhow::bail!("selected item does not grant onto gear");
8174        }
8175        let Some(grant_instance_id) = row.stack.item_instance_id else {
8176            anyhow::bail!("grant has no instance id");
8177        };
8178        let effect_id = GameState::grant_effect_id(&row.stack)
8179            .unwrap_or("?")
8180            .to_string();
8181        let mode = GameState::grant_mode(&row.stack).to_string();
8182        let options = self.state.grant_target_options(&row.stack);
8183        if options.is_empty() {
8184            anyhow::bail!("no valid gear to apply {effect_id} to");
8185        }
8186        let grant_label = row
8187            .stack
8188            .display_name
8189            .clone()
8190            .unwrap_or_else(|| row.stack.template_id.clone());
8191        self.state.show_grant_picker = true;
8192        self.state.grant_picker_index = 0;
8193        self.state.grant_picker = Some(GrantTargetPicker {
8194            grant_instance_id,
8195            grant_label,
8196            effect_id,
8197            mode,
8198            options,
8199            filter: String::new(),
8200            filter_focused: false,
8201        });
8202        Ok(())
8203    }
8204
8205    pub fn close_grant_picker(&mut self) {
8206        self.state.show_grant_picker = false;
8207        self.state.grant_picker = None;
8208        self.state.grant_picker_index = 0;
8209    }
8210
8211    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
8212        let Some(picker) = self.state.grant_picker.clone() else {
8213            self.close_grant_picker();
8214            return Ok(());
8215        };
8216        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
8217            self.close_grant_picker();
8218            return Ok(());
8219        };
8220        self.close_grant_picker();
8221        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
8222            .await?;
8223        self.state
8224            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
8225        Ok(())
8226    }
8227
8228    /// `m`: always open the "move to…" picker for the selected item, even for
8229    /// weapons/wearables that Enter would otherwise equip/wear directly.
8230    /// Placed chests open the pick-up destination picker instead.
8231    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
8232        let Some(row) = self.state.inventory_selected_row() else {
8233            anyhow::bail!("inventory empty");
8234        };
8235        if row.is_equip_shell {
8236            anyhow::bail!("this is a worn bag — press Enter to unequip it");
8237        }
8238        if row.is_chest_shell {
8239            return self.open_chest_pickup_picker();
8240        }
8241        let Some(instance_id) = row.stack.item_instance_id else {
8242            anyhow::bail!("item has no instance id");
8243        };
8244        let mut options = self.state.move_destinations_for(
8245            &row.from,
8246            row.from_parent_instance_id,
8247            row.stack.item_instance_id,
8248            &row.stack.template_id,
8249        );
8250        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8251        let category = self.state.inventory_item_category(&row.stack.template_id);
8252        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
8253            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
8254                options.insert(
8255                    0,
8256                    MoveOption {
8257                        label: "Sell plot to crown…".into(),
8258                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
8259                    },
8260                );
8261            }
8262        }
8263        if on_person && category == Some("consumable") {
8264            if GameState::stack_is_item_grant(&row.stack) {
8265                options.insert(
8266                    0,
8267                    MoveOption {
8268                        label: "Apply onto gear…".into(),
8269                        kind: MoveOptionKind::GrantApply,
8270                    },
8271                );
8272            } else {
8273                options.insert(
8274                    0,
8275                    MoveOption {
8276                        label: "Use (eat / drink)".into(),
8277                        kind: MoveOptionKind::Use,
8278                    },
8279                );
8280            }
8281        }
8282        let item_label = row
8283            .stack
8284            .display_name
8285            .clone()
8286            .unwrap_or_else(|| row.stack.template_id.clone());
8287        // Default to 1 so withdrawing from storage is partial unless the player
8288        // presses `a` for max fit (full stack when the destination allows it).
8289        let initial_qty = if row.stack.quantity > 1 {
8290            1
8291        } else {
8292            row.stack.quantity
8293        };
8294        self.state.move_picker = Some(MovePicker {
8295            item_instance_id: instance_id,
8296            from: row.from,
8297            item_label,
8298            template_id: row.stack.template_id.clone(),
8299            stack_quantity: row.stack.quantity,
8300            quantity: initial_qty.max(1),
8301            options,
8302            filter: String::new(),
8303            filter_focused: false,
8304        });
8305        self.state.move_picker_index = 0;
8306        self.state.show_move_picker = true;
8307        self.state.show_destroy_picker = false;
8308        self.state.destroy_confirm_pending = false;
8309        self.state.destroy_picker = None;
8310        self.state.clamp_move_picker_quantity();
8311        Ok(())
8312    }
8313
8314    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
8315    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
8316        let Some(row) = self.state.inventory_selected_row() else {
8317            anyhow::bail!("inventory empty");
8318        };
8319        if !row.is_chest_shell {
8320            anyhow::bail!("not a placed chest");
8321        }
8322        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
8323            anyhow::bail!("not a placed chest");
8324        };
8325        let Some(instance_id) = row.stack.item_instance_id else {
8326            anyhow::bail!("chest has no instance id");
8327        };
8328        let chest = self
8329            .state
8330            .placed_containers
8331            .iter()
8332            .find(|c| c.id == *container_id)
8333            .cloned()
8334            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8335        let (px, py) = self.state.player_position();
8336        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8337            anyhow::bail!("too far from {}", chest.display_name);
8338        }
8339        if chest.locked && !chest.accessible {
8340            anyhow::bail!(
8341                "need the matching key for {} before picking it up",
8342                chest.display_name
8343            );
8344        }
8345        let options = self.state.chest_pickup_destinations(container_id);
8346        let item_label = row
8347            .stack
8348            .display_name
8349            .clone()
8350            .unwrap_or_else(|| row.stack.template_id.clone());
8351        self.state.move_picker = Some(MovePicker {
8352            item_instance_id: instance_id,
8353            from: row.from.clone(),
8354            item_label,
8355            template_id: row.stack.template_id.clone(),
8356            stack_quantity: 1,
8357            quantity: 1,
8358            options,
8359            filter: String::new(),
8360            filter_focused: false,
8361        });
8362        self.state.move_picker_index = 0;
8363        self.state.show_move_picker = true;
8364        self.state.show_destroy_picker = false;
8365        self.state.destroy_confirm_pending = false;
8366        self.state.destroy_picker = None;
8367        Ok(())
8368    }
8369
8370    pub fn close_move_picker(&mut self) {
8371        self.state.show_move_picker = false;
8372        self.state.move_picker = None;
8373    }
8374
8375    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8376        self.state.move_picker_adjust_quantity(delta);
8377    }
8378
8379    pub fn move_picker_set_quantity_max(&mut self) {
8380        self.state.move_picker_set_quantity_max();
8381    }
8382
8383    pub fn move_picker_set_quantity_min(&mut self) {
8384        self.state.move_picker_set_quantity_min();
8385    }
8386
8387    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8388        self.state.destroy_picker_adjust_quantity(delta);
8389    }
8390
8391    pub fn destroy_picker_set_quantity_max(&mut self) {
8392        self.state.destroy_picker_set_quantity_max();
8393    }
8394
8395    pub fn destroy_picker_set_quantity_min(&mut self) {
8396        self.state.destroy_picker_set_quantity_min();
8397    }
8398
8399    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8400        let Some(picker) = self.state.move_picker.clone() else {
8401            self.close_move_picker();
8402            return Ok(());
8403        };
8404        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8405            self.close_move_picker();
8406            return Ok(());
8407        };
8408        match option.kind {
8409            MoveOptionKind::Cancel => {
8410                self.close_move_picker();
8411            }
8412            MoveOptionKind::Use => {
8413                self.close_move_picker();
8414                self.use_item(&picker.template_id).await?;
8415            }
8416            MoveOptionKind::GrantApply => {
8417                self.close_move_picker();
8418                self.open_grant_target_picker()?;
8419            }
8420            MoveOptionKind::SellPlotToCrown { plot_id } => {
8421                self.close_move_picker();
8422                self.confirm_sell_plot_to_crown(plot_id).await?;
8423            }
8424            MoveOptionKind::RelocatePlaced { container_id } => {
8425                self.close_move_picker();
8426                self.state.show_inventory_menu = false;
8427                self.begin_relocate_container(&container_id)?;
8428            }
8429            MoveOptionKind::Drop => {
8430                self.close_move_picker();
8431                if self
8432                    .state
8433                    .hand_equipped_instance_ids()
8434                    .contains(&picker.item_instance_id)
8435                {
8436                    anyhow::bail!("unequip that item first");
8437                }
8438                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8439                    if self.state.deed_bound(&stack) {
8440                        anyhow::bail!(
8441                            "cannot drop a property deed — store it or trade it to another player"
8442                        );
8443                    }
8444                    if self.state.key_drop_blocked(&stack) {
8445                        anyhow::bail!("cannot drop the key while its chest is locked");
8446                    }
8447                }
8448                self.drop_item(picker.item_instance_id, picker.from).await?;
8449                self.state
8450                    .push_log(format!("Dropped {}", picker.item_label));
8451            }
8452            MoveOptionKind::PickupPlaced {
8453                container_id,
8454                nest_location,
8455                nest_parent_instance_id,
8456            } => {
8457                self.close_move_picker();
8458                self.pickup_container(container_id.clone()).await?;
8459                let nest_into_bag = nest_parent_instance_id.is_some()
8460                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
8461                if nest_into_bag {
8462                    self.move_item(
8463                        picker.item_instance_id,
8464                        flatland_protocol::InventoryLocation::Root,
8465                        nest_location,
8466                        nest_parent_instance_id,
8467                        None,
8468                    )
8469                    .await?;
8470                    self.state
8471                        .push_log(format!("Picked up {} into bag", picker.item_label));
8472                } else {
8473                    self.state
8474                        .push_log(format!("Picked up {}", picker.item_label));
8475                }
8476            }
8477            MoveOptionKind::Move {
8478                location,
8479                parent_instance_id,
8480            } => {
8481                self.close_move_picker();
8482                let qty = if picker.quantity >= picker.stack_quantity {
8483                    None
8484                } else {
8485                    Some(picker.quantity)
8486                };
8487                self.move_item(
8488                    picker.item_instance_id,
8489                    picker.from,
8490                    location,
8491                    parent_instance_id,
8492                    qty,
8493                )
8494                .await?;
8495                let moved = qty.unwrap_or(picker.stack_quantity);
8496                if moved >= picker.stack_quantity {
8497                    self.state.push_log(format!("Moved {}", picker.item_label));
8498                } else {
8499                    self.state.push_log(format!(
8500                        "Moved {} ×{} of {}",
8501                        picker.item_label, moved, picker.stack_quantity
8502                    ));
8503                }
8504            }
8505        }
8506        Ok(())
8507    }
8508
8509    /// `d`: drop the selected item on the ground immediately (no picker).
8510    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8511        let Some(row) = self.state.inventory_selected_row() else {
8512            anyhow::bail!("inventory empty");
8513        };
8514        if row.is_equip_shell {
8515            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8516        }
8517        if row.is_chest_shell {
8518            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8519        }
8520        let Some(inst) = row.stack.item_instance_id else {
8521            anyhow::bail!("item has no instance id");
8522        };
8523        if self.state.hand_equipped_instance_ids().contains(&inst) {
8524            anyhow::bail!("unequip that item first");
8525        }
8526        if self.state.deed_bound(&row.stack) {
8527            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8528        }
8529        if self.state.key_drop_blocked(&row.stack) {
8530            anyhow::bail!("cannot drop the key while its chest is locked");
8531        }
8532        let label = row
8533            .stack
8534            .display_name
8535            .clone()
8536            .unwrap_or_else(|| row.stack.template_id.clone());
8537        self.drop_item(inst, row.from).await?;
8538        self.state.push_log(format!("Dropped {label}"));
8539        Ok(())
8540    }
8541
8542    pub async fn drop_item(
8543        &mut self,
8544        item_instance_id: uuid::Uuid,
8545        from: flatland_protocol::InventoryLocation,
8546    ) -> anyhow::Result<()> {
8547        self.seq += 1;
8548        self.session
8549            .submit_intent(Intent::DropItem {
8550                entity_id: self.state.entity_id,
8551                item_instance_id,
8552                from,
8553                seq: self.seq,
8554            })
8555            .await?;
8556        self.state.intents_sent += 1;
8557        Ok(())
8558    }
8559
8560    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
8561    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8562        let Some(row) = self.state.inventory_selected_row() else {
8563            anyhow::bail!("inventory empty");
8564        };
8565        if row.is_equip_shell {
8566            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8567        }
8568        if row.is_chest_shell {
8569            anyhow::bail!("can't destroy a placed chest from the inventory list");
8570        }
8571        let Some(instance_id) = row.stack.item_instance_id else {
8572            anyhow::bail!("item has no instance id");
8573        };
8574        if self
8575            .state
8576            .hand_equipped_instance_ids()
8577            .contains(&instance_id)
8578        {
8579            anyhow::bail!("unequip that item first");
8580        }
8581        if self.state.deed_bound(&row.stack) {
8582            anyhow::bail!(
8583                "cannot destroy a property deed — store it or trade it to another player"
8584            );
8585        }
8586        if self.state.key_drop_blocked(&row.stack) {
8587            anyhow::bail!("cannot destroy the key while its chest is locked");
8588        }
8589        let item_label = row
8590            .stack
8591            .display_name
8592            .clone()
8593            .unwrap_or_else(|| row.stack.template_id.clone());
8594        self.state.destroy_picker = Some(DestroyPicker {
8595            item_instance_id: instance_id,
8596            from: row.from,
8597            item_label,
8598            stack_quantity: row.stack.quantity,
8599            quantity: row.stack.quantity,
8600        });
8601        self.state.destroy_confirm_pending = false;
8602        self.state.show_destroy_picker = true;
8603        self.state.show_move_picker = false;
8604        self.state.move_picker = None;
8605        Ok(())
8606    }
8607
8608    pub fn close_destroy_picker(&mut self) {
8609        self.state.show_destroy_picker = false;
8610        self.state.destroy_confirm_pending = false;
8611        self.state.destroy_picker = None;
8612    }
8613
8614    pub fn cancel_destroy_confirm(&mut self) {
8615        self.state.destroy_confirm_pending = false;
8616    }
8617
8618    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8619        if self.state.destroy_picker.is_none() {
8620            self.close_destroy_picker();
8621            return Ok(());
8622        }
8623        self.state.destroy_confirm_pending = true;
8624        Ok(())
8625    }
8626
8627    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8628        let Some(picker) = self.state.destroy_picker.clone() else {
8629            self.close_destroy_picker();
8630            return Ok(());
8631        };
8632        let qty = if picker.quantity >= picker.stack_quantity {
8633            None
8634        } else {
8635            Some(picker.quantity)
8636        };
8637        self.destroy_item(picker.item_instance_id, picker.from, qty)
8638            .await?;
8639        let destroyed = qty.unwrap_or(picker.stack_quantity);
8640        if destroyed >= picker.stack_quantity {
8641            self.state
8642                .push_log(format!("Destroyed {}", picker.item_label));
8643        } else {
8644            self.state.push_log(format!(
8645                "Destroyed {} ×{} of {}",
8646                picker.item_label, destroyed, picker.stack_quantity
8647            ));
8648        }
8649        self.close_destroy_picker();
8650        Ok(())
8651    }
8652
8653    pub async fn destroy_item(
8654        &mut self,
8655        item_instance_id: uuid::Uuid,
8656        from: flatland_protocol::InventoryLocation,
8657        quantity: Option<u32>,
8658    ) -> anyhow::Result<()> {
8659        self.seq += 1;
8660        self.session
8661            .submit_intent(Intent::DestroyItem {
8662                entity_id: self.state.entity_id,
8663                item_instance_id,
8664                from,
8665                quantity,
8666                seq: self.seq,
8667            })
8668            .await?;
8669        self.state.intents_sent += 1;
8670        Ok(())
8671    }
8672
8673    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
8674    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8675        if let Some(row) = self.state.inventory_selected_row() {
8676            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8677                return self.toggle_placed_chest_lock(container_id).await;
8678            }
8679        }
8680        self.toggle_nearby_chest_lock().await
8681    }
8682
8683    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8684        let chest = self
8685            .state
8686            .placed_containers
8687            .iter()
8688            .find(|c| c.id == container_id)
8689            .cloned()
8690            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8691        let (px, py) = self.state.player_position();
8692        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8693            anyhow::bail!("too far from {}", chest.display_name);
8694        }
8695        if !chest.accessible && chest.locked {
8696            anyhow::bail!(
8697                "need the matching key for {} (each crafted chest has its own key)",
8698                chest.display_name
8699            );
8700        }
8701        let lock = !chest.locked;
8702        self.set_container_locked(
8703            flatland_protocol::InventoryLocation::Placed {
8704                container_id: chest.id.clone(),
8705            },
8706            lock,
8707        )
8708        .await?;
8709        self.state.push_log(if lock {
8710            format!("Locked {}", chest.display_name)
8711        } else {
8712            format!("Unlocked {}", chest.display_name)
8713        });
8714        Ok(())
8715    }
8716
8717    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
8718    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8719        let chest = self
8720            .state
8721            .nearest_placed_container(CONTAINER_RANGE_M)
8722            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8723        self.toggle_placed_chest_lock(&chest.id).await
8724    }
8725
8726    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8727        self.equip_mainhand(None).await
8728    }
8729
8730    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8731        if !self.state.is_alive() {
8732            anyhow::bail!("you are dead");
8733        }
8734        self.seq += 1;
8735        self.session
8736            .submit_intent(Intent::EquipOffhand {
8737                entity_id: self.state.entity_id,
8738                template_id,
8739                instance_id: None,
8740                seq: self.seq,
8741            })
8742            .await?;
8743        self.state.intents_sent += 1;
8744        Ok(())
8745    }
8746
8747    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8748        self.equip_offhand(None).await
8749    }
8750
8751    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8752        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8753        for slot in slots {
8754            self.equip_worn(slot, None).await?;
8755        }
8756        Ok(())
8757    }
8758
8759    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8760        let (px, py) = self.state.player_position();
8761        let nearest = self
8762            .state
8763            .placed_containers
8764            .iter()
8765            .min_by(|a, b| {
8766                let da = (a.x - px).hypot(a.y - py);
8767                let db = (b.x - px).hypot(b.y - py);
8768                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8769            })
8770            .cloned();
8771        let Some(chest) = nearest else {
8772            anyhow::bail!("no chest nearby");
8773        };
8774        if (chest.x - px).hypot(chest.y - py) > 2.0 {
8775            anyhow::bail!("too far from chest");
8776        }
8777        self.pickup_container(chest.id).await
8778    }
8779
8780    pub async fn equip_worn(
8781        &mut self,
8782        slot: BodySlot,
8783        instance_id: Option<uuid::Uuid>,
8784    ) -> anyhow::Result<()> {
8785        self.seq += 1;
8786        self.session
8787            .submit_intent(Intent::EquipWorn {
8788                entity_id: self.state.entity_id,
8789                slot,
8790                instance_id,
8791                seq: self.seq,
8792            })
8793            .await?;
8794        self.state.intents_sent += 1;
8795        Ok(())
8796    }
8797
8798    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8799        self.seq += 1;
8800        self.session
8801            .submit_intent(Intent::PlaceContainer {
8802                entity_id: self.state.entity_id,
8803                item_instance_id,
8804                seq: self.seq,
8805            })
8806            .await?;
8807        self.state.intents_sent += 1;
8808        Ok(())
8809    }
8810
8811    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8812        self.seq += 1;
8813        self.session
8814            .submit_intent(Intent::PickupContainer {
8815                entity_id: self.state.entity_id,
8816                container_id,
8817                seq: self.seq,
8818            })
8819            .await?;
8820        self.state.intents_sent += 1;
8821        Ok(())
8822    }
8823
8824    pub async fn move_item(
8825        &mut self,
8826        item_instance_id: uuid::Uuid,
8827        from: flatland_protocol::InventoryLocation,
8828        to: flatland_protocol::InventoryLocation,
8829        to_parent_instance_id: Option<uuid::Uuid>,
8830        quantity: Option<u32>,
8831    ) -> anyhow::Result<()> {
8832        self.seq += 1;
8833        self.session
8834            .submit_intent(Intent::MoveItem {
8835                entity_id: self.state.entity_id,
8836                item_instance_id,
8837                from,
8838                to,
8839                to_parent_instance_id,
8840                quantity,
8841                seq: self.seq,
8842            })
8843            .await?;
8844        self.state.intents_sent += 1;
8845        Ok(())
8846    }
8847
8848    pub async fn set_container_locked(
8849        &mut self,
8850        location: flatland_protocol::InventoryLocation,
8851        locked: bool,
8852    ) -> anyhow::Result<()> {
8853        self.seq += 1;
8854        self.session
8855            .submit_intent(Intent::SetContainerLocked {
8856                entity_id: self.state.entity_id,
8857                location,
8858                locked,
8859                seq: self.seq,
8860            })
8861            .await?;
8862        self.state.intents_sent += 1;
8863        Ok(())
8864    }
8865
8866    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8867        if !self.state.is_alive() {
8868            anyhow::bail!("you are dead");
8869        }
8870        self.seq += 1;
8871        self.session
8872            .submit_intent(Intent::Use {
8873                entity_id: self.state.entity_id,
8874                template_id: template_id.to_string(),
8875                seq: self.seq,
8876            })
8877            .await?;
8878        self.state.intents_sent += 1;
8879        Ok(())
8880    }
8881
8882    /// Bind a grant consumable onto a specific item instance (unique gear status).
8883    pub async fn use_grant(
8884        &mut self,
8885        grant_instance_id: uuid::Uuid,
8886        target_instance_id: uuid::Uuid,
8887    ) -> anyhow::Result<()> {
8888        if !self.state.is_alive() {
8889            anyhow::bail!("you are dead");
8890        }
8891        self.seq += 1;
8892        self.session
8893            .submit_intent(Intent::UseGrant {
8894                entity_id: self.state.entity_id,
8895                grant_instance_id,
8896                target_instance_id,
8897                seq: self.seq,
8898            })
8899            .await?;
8900        self.state.intents_sent += 1;
8901        Ok(())
8902    }
8903
8904    pub fn open_craft_menu(&mut self) {
8905        self.state.show_craft_menu = true;
8906        self.state.show_shop_menu = false;
8907        self.state.shop_catalog = None;
8908        self.state.show_stats = false;
8909        self.state.show_inventory_menu = false;
8910        if self.state.blueprints.is_empty() {
8911            self.state.craft_menu_index = 0;
8912            self.state.craft_batch_quantity = 1;
8913            return;
8914        }
8915        self.state.craft_menu_index = self
8916            .state
8917            .craft_menu_index
8918            .min(self.state.blueprints.len() - 1);
8919        if let Some(idx) = self
8920            .state
8921            .blueprints
8922            .iter()
8923            .position(|bp| self.state.can_craft_blueprint(bp))
8924        {
8925            self.state.craft_menu_index = idx;
8926        }
8927        self.state.clamp_craft_batch_quantity();
8928    }
8929
8930    pub fn close_craft_menu(&mut self) {
8931        self.state.show_craft_menu = false;
8932    }
8933
8934    pub fn toggle_keychain_menu(&mut self) {
8935        if self.state.show_keychain_menu {
8936            self.close_keychain_menu();
8937        } else {
8938            self.state.show_keychain_menu = true;
8939            self.state.show_craft_menu = false;
8940            self.state.show_shop_menu = false;
8941            self.state.show_inventory_menu = false;
8942            let n = self.state.keychain_entries().len();
8943            if n == 0 {
8944                self.state.keychain_menu_index = 0;
8945            } else {
8946                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8947            }
8948        }
8949    }
8950
8951    pub fn close_keychain_menu(&mut self) {
8952        self.state.show_keychain_menu = false;
8953    }
8954
8955    pub fn keychain_menu_move(&mut self, delta: i32) {
8956        let n = self.state.keychain_entries().len();
8957        if n == 0 {
8958            self.state.keychain_menu_index = 0;
8959            return;
8960        }
8961        let idx = self.state.keychain_menu_index as i32 + delta;
8962        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8963    }
8964
8965    pub fn keychain_menu_page(&mut self, pages: i32) {
8966        let n = self.state.keychain_entries().len();
8967        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
8968    }
8969
8970    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8971        if !self.state.is_alive() {
8972            anyhow::bail!("you are dead");
8973        }
8974        let entries = self.state.keychain_entries();
8975        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8976            anyhow::bail!("nothing selected");
8977        };
8978        let Some(instance_id) = entry.stack.item_instance_id else {
8979            anyhow::bail!("key has no instance id");
8980        };
8981        if entry.stowed {
8982            self.move_item(
8983                instance_id,
8984                flatland_protocol::InventoryLocation::Keychain,
8985                flatland_protocol::InventoryLocation::Root,
8986                None,
8987                Some(1),
8988            )
8989            .await
8990        } else {
8991            self.move_item(
8992                instance_id,
8993                flatland_protocol::InventoryLocation::Root,
8994                flatland_protocol::InventoryLocation::Keychain,
8995                None,
8996                Some(1),
8997            )
8998            .await
8999        }
9000    }
9001
9002    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
9003        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
9004        self.state.show_shop_menu = false;
9005        self.state.shop_catalog = None;
9006        self.state.clear_shop_trade_log();
9007        if let Some(npc_id) = npc_id {
9008            self.seq += 1;
9009            self.session
9010                .submit_intent(Intent::ShopClose {
9011                    entity_id: self.state.entity_id,
9012                    npc_id,
9013                    seq: self.seq,
9014                })
9015                .await?;
9016            self.state.intents_sent += 1;
9017        }
9018        Ok(())
9019    }
9020
9021    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9022        let Some(panel) = self.state.bank_panel.clone() else {
9023            return Ok(());
9024        };
9025        self.seq += 1;
9026        self.session
9027            .submit_intent(Intent::BankDeposit {
9028                entity_id: self.state.entity_id,
9029                npc_id: panel.npc_id,
9030                amount_copper,
9031                seq: self.seq,
9032            })
9033            .await?;
9034        self.state.intents_sent += 1;
9035        Ok(())
9036    }
9037
9038    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9039        let Some(panel) = self.state.bank_panel.clone() else {
9040            return Ok(());
9041        };
9042        self.seq += 1;
9043        self.session
9044            .submit_intent(Intent::BankWithdraw {
9045                entity_id: self.state.entity_id,
9046                npc_id: panel.npc_id,
9047                amount_copper,
9048                seq: self.seq,
9049            })
9050            .await?;
9051        self.state.intents_sent += 1;
9052        Ok(())
9053    }
9054
9055    pub async fn bank_transfer(
9056        &mut self,
9057        to_character_id: Option<uuid::Uuid>,
9058        to_name: String,
9059        amount_copper: u64,
9060    ) -> anyhow::Result<()> {
9061        let Some(panel) = self.state.bank_panel.clone() else {
9062            return Ok(());
9063        };
9064        self.seq += 1;
9065        self.session
9066            .submit_intent(Intent::BankTransfer {
9067                entity_id: self.state.entity_id,
9068                npc_id: panel.npc_id,
9069                to_character_id,
9070                to_name,
9071                amount_copper,
9072                seq: self.seq,
9073            })
9074            .await?;
9075        self.state.intents_sent += 1;
9076        Ok(())
9077    }
9078
9079    pub fn bank_menu_move(&mut self, delta: i32) {
9080        let n = self.state.bank_menu_options().len();
9081        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9082            return;
9083        }
9084        let idx = self.state.bank_menu_index as i32;
9085        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9086    }
9087
9088    pub fn storage_menu_move(&mut self, delta: i32) {
9089        let n = self.state.storage_menu_options().len();
9090        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9091            return;
9092        }
9093        let idx = self.state.storage_menu_index as i32;
9094        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9095    }
9096
9097    pub fn storage_pick_move(&mut self, delta: i32) {
9098        let n = match &self.state.storage_ui_mode {
9099            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
9100            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
9101                self.state.storage_vault_options().len()
9102            }
9103            StorageUiMode::Menu
9104            | StorageUiMode::StoreAmount { .. }
9105            | StorageUiMode::TakeAmount { .. }
9106            | StorageUiMode::ShipAmount { .. } => 0,
9107        };
9108        if n == 0 {
9109            return;
9110        }
9111        match &mut self.state.storage_ui_mode {
9112            StorageUiMode::StorePick { index }
9113            | StorageUiMode::TakePick { index }
9114            | StorageUiMode::ShipPick { index, .. } => {
9115                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9116            }
9117            StorageUiMode::Menu
9118            | StorageUiMode::StoreAmount { .. }
9119            | StorageUiMode::TakeAmount { .. }
9120            | StorageUiMode::ShipAmount { .. } => {}
9121        }
9122    }
9123
9124    pub fn storage_ui_back(&mut self) {
9125        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
9126            StorageUiMode::StoreAmount { pick_index, .. } => {
9127                StorageUiMode::StorePick { index: *pick_index }
9128            }
9129            StorageUiMode::TakeAmount { pick_index, .. } => {
9130                StorageUiMode::TakePick { index: *pick_index }
9131            }
9132            StorageUiMode::ShipAmount {
9133                dest_building_id,
9134                dest_label,
9135                pick_index,
9136                ..
9137            } => StorageUiMode::ShipPick {
9138                dest_building_id: dest_building_id.clone(),
9139                dest_label: dest_label.clone(),
9140                index: *pick_index,
9141            },
9142            StorageUiMode::StorePick { .. }
9143            | StorageUiMode::TakePick { .. }
9144            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
9145            StorageUiMode::Menu => StorageUiMode::Menu,
9146        };
9147    }
9148
9149    pub fn storage_amount_append_char(&mut self, c: char) {
9150        match &mut self.state.storage_ui_mode {
9151            StorageUiMode::StoreAmount { input, .. }
9152            | StorageUiMode::TakeAmount { input, .. }
9153            | StorageUiMode::ShipAmount { input, .. } => {
9154                if c.is_ascii_digit() && input.len() < 8 {
9155                    input.push(c);
9156                }
9157            }
9158            _ => {}
9159        }
9160    }
9161
9162    pub fn storage_amount_backspace(&mut self) {
9163        match &mut self.state.storage_ui_mode {
9164            StorageUiMode::StoreAmount { input, .. }
9165            | StorageUiMode::TakeAmount { input, .. }
9166            | StorageUiMode::ShipAmount { input, .. } => {
9167                input.pop();
9168            }
9169            _ => {}
9170        }
9171    }
9172
9173    pub fn storage_ui_typing(&self) -> bool {
9174        matches!(
9175            self.state.storage_ui_mode,
9176            StorageUiMode::StoreAmount { .. }
9177                | StorageUiMode::TakeAmount { .. }
9178                | StorageUiMode::ShipAmount { .. }
9179        )
9180    }
9181
9182    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
9183        match self.state.storage_ui_mode.clone() {
9184            StorageUiMode::Menu => {
9185                let index = self.state.storage_menu_index;
9186                match index {
9187                    0 => {
9188                        let opts = self.state.storage_store_options();
9189                        if opts.is_empty() {
9190                            self.state.push_log("Nothing loose to store.");
9191                            return Ok(());
9192                        }
9193                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
9194                    }
9195                    1 => {
9196                        let opts = self.state.storage_vault_options();
9197                        if opts.is_empty() {
9198                            self.state.push_log("Vault is empty.");
9199                            return Ok(());
9200                        }
9201                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
9202                    }
9203                    n => {
9204                        let dest = self
9205                            .state
9206                            .storage_panel
9207                            .as_ref()
9208                            .and_then(|p| p.ship_destinations.get(n - 2))
9209                            .cloned();
9210                        let Some(dest) = dest else {
9211                            return Ok(());
9212                        };
9213                        let opts = self.state.storage_vault_options();
9214                        if opts.is_empty() {
9215                            self.state.push_log("Vault is empty — nothing to ship.");
9216                            return Ok(());
9217                        }
9218                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
9219                            dest_building_id: dest.building_id,
9220                            dest_label: dest.label,
9221                            index: 0,
9222                        };
9223                    }
9224                }
9225            }
9226            StorageUiMode::StorePick { index } => {
9227                let opts = self.state.storage_store_options();
9228                let Some(opt) = opts.get(index) else {
9229                    self.state.push_log("Nothing loose to store.");
9230                    self.state.storage_ui_mode = StorageUiMode::Menu;
9231                    return Ok(());
9232                };
9233                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
9234                    pick_index: index,
9235                    item_instance_id: opt.item_instance_id,
9236                    label: opt.label.clone(),
9237                    max_qty: opt.quantity.max(1),
9238                    input: String::new(),
9239                };
9240            }
9241            StorageUiMode::TakePick { index } => {
9242                let opts = self.state.storage_vault_options();
9243                let Some(opt) = opts.get(index) else {
9244                    self.state.push_log("Vault is empty.");
9245                    self.state.storage_ui_mode = StorageUiMode::Menu;
9246                    return Ok(());
9247                };
9248                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
9249                    pick_index: index,
9250                    item_instance_id: opt.item_instance_id,
9251                    label: opt.label.clone(),
9252                    max_qty: opt.quantity.max(1),
9253                    input: String::new(),
9254                };
9255            }
9256            StorageUiMode::ShipPick {
9257                dest_building_id,
9258                dest_label,
9259                index,
9260            } => {
9261                let opts = self.state.storage_vault_options();
9262                let Some(opt) = opts.get(index) else {
9263                    self.state.push_log("Vault is empty — nothing to ship.");
9264                    self.state.storage_ui_mode = StorageUiMode::Menu;
9265                    return Ok(());
9266                };
9267                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
9268                    dest_building_id,
9269                    dest_label,
9270                    pick_index: index,
9271                    item_instance_id: opt.item_instance_id,
9272                    label: opt.label.clone(),
9273                    max_qty: opt.quantity.max(1),
9274                    input: String::new(),
9275                };
9276            }
9277            StorageUiMode::StoreAmount {
9278                item_instance_id,
9279                max_qty,
9280                input,
9281                ..
9282            } => {
9283                let Some(qty) = parse_storage_quantity(&input) else {
9284                    self.state.push_log("Enter a quantity (blank or 0 = all).");
9285                    return Ok(());
9286                };
9287                let qty = qty.map(|n| n.min(max_qty).max(1));
9288                self.storage_store(item_instance_id, qty).await?;
9289                self.state.storage_ui_mode = StorageUiMode::Menu;
9290            }
9291            StorageUiMode::TakeAmount {
9292                item_instance_id,
9293                max_qty,
9294                input,
9295                ..
9296            } => {
9297                let Some(qty) = parse_storage_quantity(&input) else {
9298                    self.state.push_log("Enter a quantity (blank or 0 = all).");
9299                    return Ok(());
9300                };
9301                let qty = qty.map(|n| n.min(max_qty).max(1));
9302                self.storage_take(item_instance_id, qty).await?;
9303                self.state.storage_ui_mode = StorageUiMode::Menu;
9304            }
9305            StorageUiMode::ShipAmount {
9306                dest_building_id,
9307                item_instance_id,
9308                max_qty,
9309                input,
9310                ..
9311            } => {
9312                let Some(qty) = parse_storage_quantity(&input) else {
9313                    self.state.push_log("Enter a quantity (blank or 0 = all).");
9314                    return Ok(());
9315                };
9316                let qty = qty.map(|n| n.min(max_qty).max(1));
9317                self.storage_ship(dest_building_id, item_instance_id, qty)
9318                    .await?;
9319                self.state.storage_ui_mode = StorageUiMode::Menu;
9320            }
9321        }
9322        Ok(())
9323    }
9324
9325    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
9326        match self.state.bank_ui_mode.clone() {
9327            BankUiMode::Menu => {
9328                let choice = self
9329                    .state
9330                    .bank_menu_options()
9331                    .get(self.state.bank_menu_index)
9332                    .copied()
9333                    .unwrap_or("Deposit…");
9334                match choice {
9335                    "Withdraw…" => {
9336                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9337                            input: String::new(),
9338                        };
9339                    }
9340                    "Deposit all" => self.bank_deposit(0).await?,
9341                    "Withdraw all" => self.bank_withdraw(0).await?,
9342                    "Transfer…" => {
9343                        self.state.bank_ui_mode = BankUiMode::TransferName {
9344                            input: String::new(),
9345                        };
9346                    }
9347                    _ => {
9348                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
9349                            input: String::new(),
9350                        };
9351                    }
9352                }
9353            }
9354            BankUiMode::DepositAmount { input } => {
9355                let Some(amount) = parse_bank_copper_amount(&input) else {
9356                    self.state
9357                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
9358                    return Ok(());
9359                };
9360                self.bank_deposit(amount).await?;
9361                self.state.bank_ui_mode = BankUiMode::Menu;
9362            }
9363            BankUiMode::WithdrawAmount { input } => {
9364                let Some(amount) = parse_bank_copper_amount(&input) else {
9365                    self.state
9366                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
9367                    return Ok(());
9368                };
9369                self.bank_withdraw(amount).await?;
9370                self.state.bank_ui_mode = BankUiMode::Menu;
9371            }
9372            BankUiMode::TransferName { input } => {
9373                let name = input.trim().to_string();
9374                if name.is_empty() {
9375                    self.state.push_log("Enter the recipient character name.");
9376                    return Ok(());
9377                }
9378                self.state.bank_ui_mode = BankUiMode::TransferAmount {
9379                    to_name: name,
9380                    input: String::new(),
9381                };
9382            }
9383            BankUiMode::TransferAmount { to_name, input } => {
9384                let amount: u64 = match input.trim().parse() {
9385                    Ok(v) if v > 0 => v,
9386                    _ => {
9387                        self.state
9388                            .push_log("Enter a positive copper amount to transfer.");
9389                        return Ok(());
9390                    }
9391                };
9392                self.bank_transfer(None, to_name, amount).await?;
9393                self.state.bank_ui_mode = BankUiMode::Menu;
9394            }
9395        }
9396        Ok(())
9397    }
9398
9399    pub fn bank_transfer_back(&mut self) {
9400        match &self.state.bank_ui_mode {
9401            BankUiMode::TransferAmount { to_name, .. } => {
9402                self.state.bank_ui_mode = BankUiMode::TransferName {
9403                    input: to_name.clone(),
9404                };
9405            }
9406            BankUiMode::TransferName { .. }
9407            | BankUiMode::DepositAmount { .. }
9408            | BankUiMode::WithdrawAmount { .. } => {
9409                self.state.bank_ui_mode = BankUiMode::Menu;
9410            }
9411            BankUiMode::Menu => {}
9412        }
9413    }
9414
9415    pub fn bank_transfer_append_char(&mut self, c: char) {
9416        match &mut self.state.bank_ui_mode {
9417            BankUiMode::TransferName { input } => {
9418                if input.len() < 32 && !c.is_control() {
9419                    input.push(c);
9420                }
9421            }
9422            BankUiMode::DepositAmount { input }
9423            | BankUiMode::WithdrawAmount { input }
9424            | BankUiMode::TransferAmount { input, .. } => {
9425                if c.is_ascii_digit() && input.len() < 12 {
9426                    input.push(c);
9427                }
9428            }
9429            BankUiMode::Menu => {}
9430        }
9431    }
9432
9433    pub fn bank_transfer_backspace(&mut self) {
9434        match &mut self.state.bank_ui_mode {
9435            BankUiMode::TransferName { input }
9436            | BankUiMode::DepositAmount { input }
9437            | BankUiMode::WithdrawAmount { input }
9438            | BankUiMode::TransferAmount { input, .. } => {
9439                input.pop();
9440            }
9441            BankUiMode::Menu => {}
9442        }
9443    }
9444
9445    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9446        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
9447        self.state.clear_bank_panel();
9448        if let Some(npc_id) = npc_id {
9449            self.seq += 1;
9450            self.session
9451                .submit_intent(Intent::BankClose {
9452                    entity_id: self.state.entity_id,
9453                    npc_id,
9454                    seq: self.seq,
9455                })
9456                .await?;
9457            self.state.intents_sent += 1;
9458        }
9459        Ok(())
9460    }
9461
9462    pub async fn storage_store(
9463        &mut self,
9464        item_instance_id: uuid::Uuid,
9465        quantity: Option<u32>,
9466    ) -> anyhow::Result<()> {
9467        let Some(panel) = self.state.storage_panel.clone() else {
9468            return Ok(());
9469        };
9470        self.seq += 1;
9471        self.session
9472            .submit_intent(Intent::StorageStore {
9473                entity_id: self.state.entity_id,
9474                npc_id: panel.npc_id,
9475                item_instance_id,
9476                quantity,
9477                seq: self.seq,
9478            })
9479            .await?;
9480        self.state.intents_sent += 1;
9481        Ok(())
9482    }
9483
9484    pub async fn storage_take(
9485        &mut self,
9486        item_instance_id: uuid::Uuid,
9487        quantity: Option<u32>,
9488    ) -> anyhow::Result<()> {
9489        let Some(panel) = self.state.storage_panel.clone() else {
9490            return Ok(());
9491        };
9492        self.seq += 1;
9493        self.session
9494            .submit_intent(Intent::StorageTake {
9495                entity_id: self.state.entity_id,
9496                npc_id: panel.npc_id,
9497                item_instance_id,
9498                quantity,
9499                seq: self.seq,
9500            })
9501            .await?;
9502        self.state.intents_sent += 1;
9503        Ok(())
9504    }
9505
9506    pub async fn storage_ship(
9507        &mut self,
9508        dest_building_id: String,
9509        item_instance_id: uuid::Uuid,
9510        quantity: Option<u32>,
9511    ) -> anyhow::Result<()> {
9512        let Some(panel) = self.state.storage_panel.clone() else {
9513            return Ok(());
9514        };
9515        self.seq += 1;
9516        self.session
9517            .submit_intent(Intent::StorageShip {
9518                entity_id: self.state.entity_id,
9519                npc_id: panel.npc_id,
9520                dest_building_id,
9521                item_instance_id,
9522                quantity,
9523                seq: self.seq,
9524            })
9525            .await?;
9526        self.state.intents_sent += 1;
9527        Ok(())
9528    }
9529
9530    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9531        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
9532        self.state.clear_storage_panel();
9533        if let Some(npc_id) = npc_id {
9534            self.seq += 1;
9535            self.session
9536                .submit_intent(Intent::StorageClose {
9537                    entity_id: self.state.entity_id,
9538                    npc_id,
9539                    seq: self.seq,
9540                })
9541                .await?;
9542            self.state.intents_sent += 1;
9543        }
9544        Ok(())
9545    }
9546
9547    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9548        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
9549        self.state.clear_market_panel();
9550        if let Some(npc_id) = npc_id {
9551            self.seq += 1;
9552            self.session
9553                .submit_intent(Intent::MarketClose {
9554                    entity_id: self.state.entity_id,
9555                    npc_id,
9556                    seq: self.seq,
9557                })
9558                .await?;
9559            self.state.intents_sent += 1;
9560        }
9561        Ok(())
9562    }
9563
9564    pub fn market_move_selection(&mut self, delta: i32) {
9565        let indices = self.state.market_filtered_listing_indices();
9566        let n = indices.len();
9567        if n == 0 {
9568            self.state.market_menu_index = 0;
9569            return;
9570        }
9571        let cur = self.state.market_menu_index as i32;
9572        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9573    }
9574
9575    pub fn market_page_selection(&mut self, pages: i32) {
9576        let indices = self.state.market_filtered_listing_indices();
9577        let n = indices.len();
9578        if n == 0 {
9579            self.state.market_menu_index = 0;
9580            return;
9581        }
9582        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9583    }
9584
9585    pub fn market_list_page(&mut self, pages: i32) {
9586        match &self.state.market_ui_mode {
9587            MarketUiMode::ListSource { index } => {
9588                let n = self.state.market_list_source_options().len();
9589                if n == 0 {
9590                    return;
9591                }
9592                let next = page_list_index(*index, pages, n);
9593                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9594            }
9595            MarketUiMode::ListPricingMode { index, .. } => {
9596                let next = page_list_index(*index, pages, 2);
9597                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9598                {
9599                    *index = next;
9600                }
9601            }
9602            MarketUiMode::ListPick { source, index } => {
9603                let opts = self.state.market_list_item_options(source);
9604                let n = opts.len();
9605                if n == 0 {
9606                    return;
9607                }
9608                let next = page_list_index(*index, pages, n);
9609                self.state.market_ui_mode = MarketUiMode::ListPick {
9610                    source: source.clone(),
9611                    index: next,
9612                };
9613            }
9614            _ => {}
9615        }
9616    }
9617
9618    pub fn market_cycle_category(&mut self, delta: i32) {
9619        let groups = self.state.market_available_category_groups();
9620        // All + groups
9621        let mut labels: Vec<Option<&'static str>> = vec![None];
9622        labels.extend(groups.into_iter().map(Some));
9623        let n = labels.len() as i32;
9624        let cur = labels
9625            .iter()
9626            .position(|g| *g == self.state.market_category_filter)
9627            .unwrap_or(0) as i32;
9628        let next = (cur + delta).rem_euclid(n) as usize;
9629        self.state.market_category_filter = labels[next];
9630        self.state.market_menu_index = 0;
9631        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9632            let source = source.clone();
9633            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9634        }
9635    }
9636
9637    pub fn focus_market_filter(&mut self) {
9638        self.state.market_filter_focused = true;
9639    }
9640
9641    pub fn append_market_filter_char(&mut self, ch: char) {
9642        if !self.state.market_filter_focused {
9643            return;
9644        }
9645        if ch.is_control() {
9646            return;
9647        }
9648        if self.state.market_filter.len() < 48 {
9649            self.state.market_filter.push(ch);
9650            self.state.market_menu_index = 0;
9651            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9652                let source = source.clone();
9653                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9654            }
9655        }
9656    }
9657
9658    pub fn market_filter_backspace(&mut self) {
9659        if !self.state.market_filter_focused {
9660            return;
9661        }
9662        self.state.market_filter.pop();
9663        self.state.market_menu_index = 0;
9664        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9665            let source = source.clone();
9666            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9667        }
9668    }
9669
9670    /// Clears filter text, blurs search, or returns false if already idle.
9671    pub fn clear_or_blur_market_filter(&mut self) -> bool {
9672        if self.state.market_filter_focused {
9673            if !self.state.market_filter.is_empty() {
9674                self.state.market_filter.clear();
9675                self.state.market_menu_index = 0;
9676                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9677                    let source = source.clone();
9678                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9679                }
9680                return true;
9681            }
9682            self.state.market_filter_focused = false;
9683            return true;
9684        }
9685        if !self.state.market_filter.is_empty() {
9686            self.state.market_filter.clear();
9687            self.state.market_menu_index = 0;
9688            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9689                let source = source.clone();
9690                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9691            }
9692            return true;
9693        }
9694        false
9695    }
9696
9697    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9698        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9699            return self.market_confirm_buy(listing_id, qty).await;
9700        }
9701        let Some(panel) = self.state.market_panel.clone() else {
9702            return Ok(());
9703        };
9704        let indices = self.state.market_filtered_listing_indices();
9705        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9706            return Ok(());
9707        };
9708        let Some(listing) = panel.listings.get(raw_idx) else {
9709            return Ok(());
9710        };
9711        if listing.mine {
9712            self.seq += 1;
9713            self.session
9714                .submit_intent(Intent::MarketDelist {
9715                    entity_id: self.state.entity_id,
9716                    npc_id: panel.npc_id.clone(),
9717                    listing_id: listing.listing_id,
9718                    dest: flatland_protocol::GoodsLocation::Person,
9719                    seq: self.seq,
9720                })
9721                .await?;
9722            self.state.intents_sent += 1;
9723            return Ok(());
9724        }
9725        if listing.npc_price {
9726            self.state
9727                .push_log("NPC-price listings are bought by merchants only.");
9728            return Ok(());
9729        }
9730        let qty = 1u32.min(listing.quantity).max(1);
9731        let line = listing.unit_price_copper.saturating_mul(qty as u64);
9732        self.state.market_buy_confirm = Some((
9733            listing.listing_id,
9734            qty,
9735            listing.unit_price_copper,
9736            line,
9737            listing.display_name.clone(),
9738        ));
9739        Ok(())
9740    }
9741
9742    pub async fn market_confirm_buy(
9743        &mut self,
9744        listing_id: uuid::Uuid,
9745        quantity: u32,
9746    ) -> anyhow::Result<()> {
9747        let Some(panel) = self.state.market_panel.clone() else {
9748            self.state.market_buy_confirm = None;
9749            return Ok(());
9750        };
9751        self.state.market_buy_confirm = None;
9752        self.seq += 1;
9753        self.session
9754            .submit_intent(Intent::MarketBuy {
9755                entity_id: self.state.entity_id,
9756                npc_id: panel.npc_id,
9757                listing_id,
9758                quantity,
9759                dest: flatland_protocol::GoodsLocation::Person,
9760                seq: self.seq,
9761            })
9762            .await?;
9763        self.state.intents_sent += 1;
9764        Ok(())
9765    }
9766
9767    /// Begin the list wizard (`l` in market browse).
9768    pub fn market_begin_list(&mut self) {
9769        if self.state.market_panel.is_none() {
9770            return;
9771        }
9772        let sources = self.state.market_list_source_options();
9773        if sources.is_empty() {
9774            self.state.push_log("Nothing to list from.");
9775            return;
9776        }
9777        // One source (person only) → skip straight to item pick when it has stacks.
9778        if sources.len() == 1 {
9779            let (source, _) = sources[0].clone();
9780            let opts = self.state.market_list_item_options(&source);
9781            if opts.is_empty() {
9782                self.state.push_log("Nothing loose to list.");
9783                return;
9784            }
9785            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9786            self.state.market_buy_confirm = None;
9787            return;
9788        }
9789        self.state.market_buy_confirm = None;
9790        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9791    }
9792
9793    pub fn market_ui_back(&mut self) {
9794        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9795            MarketUiMode::Browse => MarketUiMode::Browse,
9796            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9797            MarketUiMode::ListPick { .. } => {
9798                if self.state.market_list_source_options().len() <= 1 {
9799                    MarketUiMode::Browse
9800                } else {
9801                    MarketUiMode::ListSource { index: 0 }
9802                }
9803            }
9804            MarketUiMode::ListAmount {
9805                source, pick_index, ..
9806            } => MarketUiMode::ListPick {
9807                source,
9808                index: pick_index,
9809            },
9810            MarketUiMode::ListPricingMode {
9811                source,
9812                item_instance_id,
9813                template_id,
9814                label,
9815                max_qty,
9816                quantity,
9817                pick_index,
9818                ..
9819            } => {
9820                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9821                MarketUiMode::ListAmount {
9822                    source,
9823                    pick_index,
9824                    item_instance_id,
9825                    template_id,
9826                    label,
9827                    max_qty,
9828                    input,
9829                }
9830            }
9831            MarketUiMode::ListPrice {
9832                source,
9833                pick_index,
9834                item_instance_id,
9835                template_id,
9836                label,
9837                max_qty,
9838                quantity,
9839                ..
9840            } => MarketUiMode::ListPricingMode {
9841                source,
9842                pick_index,
9843                item_instance_id,
9844                template_id,
9845                label,
9846                quantity,
9847                max_qty,
9848                index: 1,
9849            },
9850        };
9851    }
9852
9853    pub fn market_list_move(&mut self, delta: i32) {
9854        match &self.state.market_ui_mode {
9855            MarketUiMode::ListSource { index } => {
9856                let n = self.state.market_list_source_options().len();
9857                if n == 0 {
9858                    return;
9859                }
9860                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9861                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9862            }
9863            MarketUiMode::ListPricingMode { index, .. } => {
9864                let next = (*index as i32 + delta).rem_euclid(2) as usize;
9865                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9866                {
9867                    *index = next;
9868                }
9869            }
9870            MarketUiMode::ListPick { source, index } => {
9871                let opts = self.state.market_list_item_options(source);
9872                let n = opts.len();
9873                if n == 0 {
9874                    return;
9875                }
9876                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9877                self.state.market_ui_mode = MarketUiMode::ListPick {
9878                    source: source.clone(),
9879                    index: next,
9880                };
9881            }
9882            _ => {}
9883        }
9884    }
9885
9886    pub fn market_list_amount_append_char(&mut self, c: char) {
9887        if !c.is_ascii_digit() {
9888            return;
9889        }
9890        match &mut self.state.market_ui_mode {
9891            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9892                if input.len() < 12 {
9893                    input.push(c);
9894                }
9895            }
9896            _ => {}
9897        }
9898    }
9899
9900    pub fn market_list_amount_backspace(&mut self) {
9901        match &mut self.state.market_ui_mode {
9902            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9903                input.pop();
9904            }
9905            _ => {}
9906        }
9907    }
9908
9909    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9910        match self.state.market_ui_mode.clone() {
9911            MarketUiMode::Browse => Ok(()),
9912            MarketUiMode::ListSource { index } => {
9913                let sources = self.state.market_list_source_options();
9914                let Some((source, _)) = sources.get(index).cloned() else {
9915                    return Ok(());
9916                };
9917                let opts = self.state.market_list_item_options(&source);
9918                if opts.is_empty() {
9919                    self.state.push_log("Nothing to list from that source.");
9920                    return Ok(());
9921                }
9922                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9923                Ok(())
9924            }
9925            MarketUiMode::ListPick { source, index } => {
9926                let opts = self.state.market_list_item_options(&source);
9927                let Some(opt) = opts.get(index) else {
9928                    self.state.push_log("Nothing to list.");
9929                    self.state.market_ui_mode = MarketUiMode::Browse;
9930                    return Ok(());
9931                };
9932                self.state.market_ui_mode = MarketUiMode::ListAmount {
9933                    source,
9934                    pick_index: index,
9935                    item_instance_id: opt.item_instance_id,
9936                    template_id: opt.template_id.clone(),
9937                    label: opt.label.clone(),
9938                    max_qty: opt.quantity.max(1),
9939                    input: String::new(),
9940                };
9941                Ok(())
9942            }
9943            MarketUiMode::ListAmount {
9944                source,
9945                pick_index,
9946                item_instance_id,
9947                template_id,
9948                label,
9949                max_qty,
9950                input,
9951                ..
9952            } => {
9953                let Some(qty_opt) = parse_storage_quantity(&input) else {
9954                    self.state.push_log("Enter a quantity (blank = all).");
9955                    return Ok(());
9956                };
9957                if let Some(q) = qty_opt {
9958                    if q > max_qty {
9959                        self.state.push_log(format!("Only {max_qty} available."));
9960                        return Ok(());
9961                    }
9962                }
9963                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9964                    source,
9965                    pick_index,
9966                    item_instance_id,
9967                    template_id,
9968                    label,
9969                    quantity: qty_opt,
9970                    max_qty,
9971                    index: 0,
9972                };
9973                Ok(())
9974            }
9975            MarketUiMode::ListPricingMode {
9976                source,
9977                pick_index,
9978                item_instance_id,
9979                template_id,
9980                label,
9981                quantity,
9982                max_qty,
9983                index,
9984            } => {
9985                if index == 0 {
9986                    if self
9987                        .state
9988                        .npc_market_dump_unit_estimate(&template_id)
9989                        .is_none()
9990                    {
9991                        self.state
9992                            .push_log("That item has no NPC value — use a fixed price instead.");
9993                        return Ok(());
9994                    }
9995                    return self
9996                        .submit_market_list_intent(
9997                            source,
9998                            item_instance_id,
9999                            quantity,
10000                            0,
10001                            true,
10002                            &label,
10003                        )
10004                        .await;
10005                }
10006                self.state.market_ui_mode = MarketUiMode::ListPrice {
10007                    source,
10008                    pick_index,
10009                    item_instance_id,
10010                    template_id,
10011                    label,
10012                    quantity,
10013                    max_qty,
10014                    input: String::new(),
10015                };
10016                Ok(())
10017            }
10018            MarketUiMode::ListPrice {
10019                source,
10020                item_instance_id,
10021                label,
10022                quantity,
10023                input,
10024                ..
10025            } => {
10026                let price = input.trim().parse::<u64>().unwrap_or(0);
10027                if price == 0 {
10028                    self.state
10029                        .push_log("Enter a unit price of at least 1 copper.");
10030                    return Ok(());
10031                }
10032                self.submit_market_list_intent(
10033                    source,
10034                    item_instance_id,
10035                    quantity,
10036                    price,
10037                    false,
10038                    &label,
10039                )
10040                .await
10041            }
10042        }
10043    }
10044
10045    async fn submit_market_list_intent(
10046        &mut self,
10047        source: MarketListSourceKind,
10048        item_instance_id: uuid::Uuid,
10049        quantity: Option<u32>,
10050        unit_price_copper: u64,
10051        npc_price: bool,
10052        label: &str,
10053    ) -> anyhow::Result<()> {
10054        let Some(panel) = self.state.market_panel.clone() else {
10055            self.state.market_ui_mode = MarketUiMode::Browse;
10056            return Ok(());
10057        };
10058        let goods = match source {
10059            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
10060            MarketListSourceKind::TownStorage { building_id } => {
10061                flatland_protocol::GoodsLocation::TownStorage { building_id }
10062            }
10063        };
10064        self.seq += 1;
10065        self.session
10066            .submit_intent(Intent::MarketList {
10067                entity_id: self.state.entity_id,
10068                npc_id: panel.npc_id,
10069                source: goods,
10070                item_instance_id,
10071                quantity,
10072                unit_price_copper,
10073                npc_price,
10074                seq: self.seq,
10075            })
10076            .await?;
10077        self.state.intents_sent += 1;
10078        if npc_price {
10079            self.state
10080                .push_log(format!("Listing {label} at NPC price…"));
10081        } else {
10082            self.state
10083                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
10084        }
10085        self.state.market_ui_mode = MarketUiMode::Browse;
10086        Ok(())
10087    }
10088
10089    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
10090    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
10091        let return_to_verbs = self.state.npc_verb_target.is_some();
10092        self.close_shop_menu().await?;
10093        if return_to_verbs {
10094            self.state.show_npc_verb_menu = true;
10095        }
10096        Ok(())
10097    }
10098
10099    pub fn shop_tab_toggle(&mut self) {
10100        self.state.shop_tab = match self.state.shop_tab {
10101            ShopTab::Buy => ShopTab::Sell,
10102            ShopTab::Sell => ShopTab::Buy,
10103        };
10104        self.state.shop_menu_index = 0;
10105        if self.state.shop_tab == ShopTab::Sell {
10106            self.state.shop_quantity_set_max();
10107        }
10108        self.state.clamp_shop_selection();
10109    }
10110
10111    pub fn shop_menu_move(&mut self, delta: i32) {
10112        self.state.shop_menu_move(delta);
10113    }
10114
10115    pub fn shop_quantity_adjust(&mut self, delta: i32) {
10116        self.state.shop_quantity_adjust(delta);
10117    }
10118
10119    pub fn shop_quantity_set_max(&mut self) {
10120        self.state.shop_quantity_set_max();
10121    }
10122
10123    pub fn shop_quantity_set_min(&mut self) {
10124        self.state.shop_quantity_set_min();
10125    }
10126
10127    pub fn toggle_quest_menu(&mut self) {
10128        self.state.show_quest_menu = !self.state.show_quest_menu;
10129        if self.state.show_quest_menu {
10130            self.state.quest_menu_index = 0;
10131            self.state.quest_withdraw_confirm = false;
10132            self.state.show_workers_menu = false;
10133        }
10134    }
10135
10136    pub fn toggle_workers_menu(&mut self) {
10137        if self.state.show_workers_menu {
10138            self.close_workers_menu_ui();
10139        } else {
10140            self.state.show_workers_menu = true;
10141            self.state.workers_menu_index = 0;
10142            self.state.show_quest_menu = false;
10143            self.close_worker_give_picker();
10144            self.close_worker_give_target_picker();
10145            self.close_worker_take_picker();
10146            self.close_worker_teach_picker();
10147            self.cancel_worker_rename();
10148        }
10149    }
10150
10151    /// Close workers UI layers without network (caller should release attend).
10152    pub fn close_workers_menu_ui(&mut self) {
10153        self.state.show_workers_menu = false;
10154        self.cancel_worker_dismissal();
10155        self.close_worker_give_picker();
10156        self.close_worker_give_target_picker();
10157        self.close_worker_take_picker();
10158        self.close_worker_teach_picker();
10159        self.cancel_worker_rename();
10160    }
10161
10162    /// Open the workers menu focused on `instance_id` and pause that worker's job.
10163    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
10164        let Some(idx) = self
10165            .state
10166            .hired_workers
10167            .iter()
10168            .position(|w| w.instance_id == instance_id)
10169        else {
10170            anyhow::bail!("worker not found");
10171        };
10172        let label = self.state.hired_workers[idx].label.clone();
10173        self.state.show_workers_menu = true;
10174        self.state.workers_menu_index = idx;
10175        self.state.show_quest_menu = false;
10176        self.close_worker_give_picker();
10177        self.close_worker_give_target_picker();
10178        self.close_worker_take_picker();
10179        self.close_worker_teach_picker();
10180        self.cancel_worker_rename();
10181        self.set_worker_attending(instance_id, true).await?;
10182        self.state
10183            .push_log(format!("Managing {label} — job paused while menu is open"));
10184        Ok(())
10185    }
10186
10187    /// Close workers menu and release any attend pause.
10188    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
10189        self.close_workers_menu_ui();
10190        self.release_worker_attend().await
10191    }
10192
10193    async fn set_worker_attending(
10194        &mut self,
10195        instance_id: &str,
10196        attending: bool,
10197    ) -> anyhow::Result<()> {
10198        if attending {
10199            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10200                return Ok(());
10201            }
10202            // Switch target: release previous first.
10203            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
10204                if prev != instance_id {
10205                    self.send_attend_hired_worker(&prev, false).await?;
10206                }
10207            }
10208            self.send_attend_hired_worker(instance_id, true).await?;
10209            self.state.attending_worker_instance_id = Some(instance_id.to_string());
10210        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10211            self.send_attend_hired_worker(instance_id, false).await?;
10212            self.state.attending_worker_instance_id = None;
10213        }
10214        Ok(())
10215    }
10216
10217    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
10218        let Some(id) = self.state.attending_worker_instance_id.take() else {
10219            return Ok(());
10220        };
10221        self.send_attend_hired_worker(&id, false).await
10222    }
10223
10224    async fn send_attend_hired_worker(
10225        &mut self,
10226        worker_instance_id: &str,
10227        attending: bool,
10228    ) -> anyhow::Result<()> {
10229        self.seq += 1;
10230        self.session
10231            .submit_intent(Intent::AttendHiredWorker {
10232                entity_id: self.state.entity_id,
10233                worker_instance_id: worker_instance_id.to_string(),
10234                attending,
10235                seq: self.seq,
10236            })
10237            .await?;
10238        self.state.intents_sent += 1;
10239        Ok(())
10240    }
10241
10242    pub fn workers_menu_move(&mut self, delta: i32) {
10243        let n = self.state.hired_workers.len();
10244        if n == 0 {
10245            return;
10246        }
10247        let idx = self.state.workers_menu_index as i32;
10248        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10249    }
10250
10251    pub fn toggle_workers_menu_compact(&mut self) {
10252        self.state.workers_menu_compact = !self.state.workers_menu_compact;
10253        let mut cfg = crate::client_config::ClientConfig::load();
10254        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
10255    }
10256
10257    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
10258        let Some(worker) = self
10259            .state
10260            .hired_workers
10261            .get(self.state.workers_menu_index)
10262            .cloned()
10263        else {
10264            anyhow::bail!("no worker selected");
10265        };
10266        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
10267            .await
10268    }
10269
10270    /// Open a confirmation dialog before permanently dismissing the selected worker.
10271    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
10272        let Some(worker) = self
10273            .state
10274            .hired_workers
10275            .get(self.state.workers_menu_index)
10276            .cloned()
10277        else {
10278            anyhow::bail!("no worker selected");
10279        };
10280        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
10281            worker_instance_id: worker.instance_id,
10282            worker_label: worker.label,
10283        });
10284        Ok(())
10285    }
10286
10287    pub fn cancel_worker_dismissal(&mut self) {
10288        self.state.worker_dismiss_confirmation = None;
10289    }
10290
10291    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
10292        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
10293            return Ok(());
10294        };
10295        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
10296            .await?;
10297        self.cancel_worker_dismissal();
10298        Ok(())
10299    }
10300
10301    async fn dismiss_worker_by_id(
10302        &mut self,
10303        worker_instance_id: &str,
10304        worker_label: &str,
10305    ) -> anyhow::Result<()> {
10306        self.seq += 1;
10307        self.session
10308            .submit_intent(Intent::DismissWorker {
10309                entity_id: self.state.entity_id,
10310                worker_instance_id: worker_instance_id.to_string(),
10311                seq: self.seq,
10312            })
10313            .await?;
10314        self.state.intents_sent += 1;
10315        self.state
10316            .hired_workers
10317            .retain(|w| w.instance_id != worker_instance_id);
10318        if self.state.workers_menu_index >= self.state.hired_workers.len() {
10319            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
10320        }
10321        self.state
10322            .push_log(format!("Dismissed {worker_label}"));
10323        Ok(())
10324    }
10325
10326    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
10327        let Some(worker) = self
10328            .state
10329            .hired_workers
10330            .get(self.state.workers_menu_index)
10331            .cloned()
10332        else {
10333            anyhow::bail!("no worker selected");
10334        };
10335        let mode = match worker.mode {
10336            flatland_protocol::WorkerModeView::Companion => "defender",
10337            flatland_protocol::WorkerModeView::Defender => "job_loop",
10338            flatland_protocol::WorkerModeView::JobLoop => "idle",
10339            flatland_protocol::WorkerModeView::Idle => "companion",
10340        };
10341        self.seq += 1;
10342        self.session
10343            .submit_intent(Intent::SetWorkerMode {
10344                entity_id: self.state.entity_id,
10345                worker_instance_id: worker.instance_id,
10346                mode: mode.into(),
10347                seq: self.seq,
10348            })
10349            .await?;
10350        self.state.intents_sent += 1;
10351        Ok(())
10352    }
10353
10354    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
10355        let Some(worker) = self
10356            .state
10357            .hired_workers
10358            .get(self.state.workers_menu_index)
10359            .cloned()
10360        else {
10361            anyhow::bail!("no worker selected");
10362        };
10363        if !matches!(
10364            worker.mode,
10365            flatland_protocol::WorkerModeView::Companion
10366        ) {
10367            anyhow::bail!("switch the worker to companion mode first");
10368        }
10369        if worker.step_label.starts_with("delivering to ")
10370            || worker.step_label == "returning to you"
10371        {
10372            anyhow::bail!("worker is already delivering to storage");
10373        }
10374        self.seq += 1;
10375        self.session
10376            .submit_intent(Intent::DeliverWorkerToNearestStorage {
10377                entity_id: self.state.entity_id,
10378                worker_instance_id: worker.instance_id.clone(),
10379                seq: self.seq,
10380            })
10381            .await?;
10382        self.state.intents_sent += 1;
10383        self.state.push_log(format!(
10384            "{} is delivering carried items to storage",
10385            worker.label
10386        ));
10387        Ok(())
10388    }
10389
10390    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
10391        let Some(worker) = self
10392            .state
10393            .hired_workers
10394            .get(self.state.workers_menu_index)
10395            .cloned()
10396        else {
10397            anyhow::bail!("no worker selected");
10398        };
10399        if !(worker.step_label.starts_with("delivering to ")
10400            || worker.step_label == "returning to you")
10401        {
10402            anyhow::bail!("worker has no active delivery");
10403        }
10404        self.seq += 1;
10405        self.session
10406            .submit_intent(Intent::CancelWorkerDelivery {
10407                entity_id: self.state.entity_id,
10408                worker_instance_id: worker.instance_id,
10409                seq: self.seq,
10410            })
10411            .await?;
10412        self.state.intents_sent += 1;
10413        self.state
10414            .push_log(format!("Canceled delivery for {}", worker.label));
10415        Ok(())
10416    }
10417
10418    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
10419        if self.state.hired_workers.is_empty() {
10420            return self.hire_worker_laborer().await;
10421        }
10422        self.workers_toggle_mode_selected().await
10423    }
10424
10425    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
10426    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
10427    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10428        let row = self
10429            .state
10430            .inventory_selected_row()
10431            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
10432            .clone();
10433        if row.from != flatland_protocol::InventoryLocation::Root {
10434            anyhow::bail!("select a carried item to give");
10435        }
10436        let Some(instance_id) = row.stack.item_instance_id else {
10437            anyhow::bail!("that stack can't be given");
10438        };
10439        let options = self.nearby_worker_give_targets();
10440        if options.is_empty() {
10441            anyhow::bail!(
10442                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10443            );
10444        }
10445        let item_label = row
10446            .stack
10447            .display_name
10448            .as_deref()
10449            .unwrap_or(&row.stack.template_id)
10450            .to_string();
10451        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10452            item_instance_id: instance_id,
10453            item_label,
10454            quantity: None,
10455            options,
10456        });
10457        self.state.worker_give_target_picker_index = 0;
10458        self.state.show_worker_give_target_picker = true;
10459        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
10460        self.state.show_inventory_menu = false;
10461        Ok(())
10462    }
10463
10464    /// Hired workers within give/take range, nearest first.
10465    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10466        let (px, py, _) = self.state.player_position_with_z();
10467        let mut options: Vec<WorkerGiveTargetOption> = self
10468            .state
10469            .hired_workers
10470            .iter()
10471            .filter_map(|w| {
10472                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10473                if dist > WORKER_GIVE_RANGE_M {
10474                    return None;
10475                }
10476                Some(WorkerGiveTargetOption {
10477                    instance_id: w.instance_id.clone(),
10478                    label: w.label.clone(),
10479                    distance_m: dist,
10480                })
10481            })
10482            .collect();
10483        options.sort_by(|a, b| {
10484            a.distance_m
10485                .partial_cmp(&b.distance_m)
10486                .unwrap_or(std::cmp::Ordering::Equal)
10487        });
10488        options
10489    }
10490
10491    pub fn close_worker_give_target_picker(&mut self) {
10492        self.state.show_worker_give_target_picker = false;
10493        self.state.worker_give_target_picker = None;
10494        self.state.worker_give_target_picker_index = 0;
10495    }
10496
10497    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10498        let Some(picker) = &self.state.worker_give_target_picker else {
10499            return;
10500        };
10501        let n = picker.options.len();
10502        if n == 0 {
10503            return;
10504        }
10505        let idx = self.state.worker_give_target_picker_index as i32;
10506        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10507    }
10508
10509    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10510        let Some(picker) = self.state.worker_give_target_picker.clone() else {
10511            anyhow::bail!("give target picker not open");
10512        };
10513        let Some(opt) = picker
10514            .options
10515            .get(self.state.worker_give_target_picker_index)
10516            .cloned()
10517        else {
10518            anyhow::bail!("no worker selected");
10519        };
10520        let Some(worker) = self
10521            .state
10522            .hired_workers
10523            .iter()
10524            .find(|w| w.instance_id == opt.instance_id)
10525            .cloned()
10526        else {
10527            self.close_worker_give_target_picker();
10528            anyhow::bail!("worker no longer hired");
10529        };
10530        self.give_item_to_worker(
10531            &worker.instance_id,
10532            &worker.label,
10533            worker.x,
10534            worker.y,
10535            picker.item_instance_id,
10536            &picker.item_label,
10537            picker.quantity,
10538        )
10539        .await?;
10540        self.close_worker_give_target_picker();
10541        Ok(())
10542    }
10543
10544    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
10545    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10546        self.open_worker_give_target_picker()
10547    }
10548
10549    /// Open the workers-menu give picker for the selected worker (must be in range).
10550    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10551        let Some(worker) = self
10552            .state
10553            .hired_workers
10554            .get(self.state.workers_menu_index)
10555            .cloned()
10556        else {
10557            anyhow::bail!("select a hired worker first");
10558        };
10559        let (px, py, _) = self.state.player_position_with_z();
10560        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10561        if dist > WORKER_GIVE_RANGE_M {
10562            anyhow::bail!(
10563                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10564                worker.label
10565            );
10566        }
10567        let options = self.state.giveable_inventory_options();
10568        if options.is_empty() {
10569            anyhow::bail!("nothing in inventory to give");
10570        }
10571        self.state.worker_give_picker = Some(WorkerGivePicker {
10572            worker_instance_id: worker.instance_id,
10573            worker_label: worker.label,
10574            options,
10575        });
10576        self.state.worker_give_picker_index = 0;
10577        self.state.show_worker_give_picker = true;
10578        Ok(())
10579    }
10580
10581    pub fn close_worker_give_picker(&mut self) {
10582        self.state.show_worker_give_picker = false;
10583        self.state.worker_give_picker = None;
10584        self.state.worker_give_picker_index = 0;
10585    }
10586
10587    pub fn worker_give_picker_move(&mut self, delta: i32) {
10588        let Some(picker) = &self.state.worker_give_picker else {
10589            return;
10590        };
10591        let n = picker.options.len();
10592        if n == 0 {
10593            return;
10594        }
10595        let idx = self.state.worker_give_picker_index as i32;
10596        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10597    }
10598
10599    /// Confirm the selected row in the workers-menu give picker.
10600    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10601        let Some(picker) = self.state.worker_give_picker.clone() else {
10602            anyhow::bail!("give picker not open");
10603        };
10604        let Some(opt) = picker
10605            .options
10606            .get(self.state.worker_give_picker_index)
10607            .cloned()
10608        else {
10609            anyhow::bail!("no item selected");
10610        };
10611        let Some(worker) = self
10612            .state
10613            .hired_workers
10614            .iter()
10615            .find(|w| w.instance_id == picker.worker_instance_id)
10616            .cloned()
10617        else {
10618            self.close_worker_give_picker();
10619            anyhow::bail!("worker no longer hired");
10620        };
10621        self.give_item_to_worker(
10622            &worker.instance_id,
10623            &worker.label,
10624            worker.x,
10625            worker.y,
10626            opt.item_instance_id,
10627            &opt.label,
10628            None,
10629        )
10630        .await?;
10631        // Refresh options (stack may be gone / reduced) or close when empty.
10632        let options = self.state.giveable_inventory_options();
10633        if options.is_empty() {
10634            self.close_worker_give_picker();
10635        } else {
10636            self.state.worker_give_picker = Some(WorkerGivePicker {
10637                worker_instance_id: picker.worker_instance_id,
10638                worker_label: picker.worker_label,
10639                options,
10640            });
10641            if self.state.worker_give_picker_index
10642                >= self
10643                    .state
10644                    .worker_give_picker
10645                    .as_ref()
10646                    .map(|p| p.options.len())
10647                    .unwrap_or(0)
10648            {
10649                self.state.worker_give_picker_index = self
10650                    .state
10651                    .worker_give_picker
10652                    .as_ref()
10653                    .map(|p| p.options.len().saturating_sub(1))
10654                    .unwrap_or(0);
10655            }
10656        }
10657        Ok(())
10658    }
10659
10660    /// Open the workers-menu teach picker for the selected worker (must be in range).
10661    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10662        let Some(worker) = self
10663            .state
10664            .hired_workers
10665            .get(self.state.workers_menu_index)
10666            .cloned()
10667        else {
10668            anyhow::bail!("select a hired worker first");
10669        };
10670        let (px, py, _) = self.state.player_position_with_z();
10671        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10672        if dist > WORKER_GIVE_RANGE_M {
10673            anyhow::bail!(
10674                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10675                worker.label
10676            );
10677        }
10678        let options = self.state.teachable_blueprint_options(&worker);
10679        if options.is_empty() {
10680            anyhow::bail!("no recipes you know that {} still needs", worker.label);
10681        }
10682        self.state.worker_teach_picker = Some(WorkerTeachPicker {
10683            worker_instance_id: worker.instance_id,
10684            worker_label: worker.label,
10685            worker_level: worker.level,
10686            options,
10687        });
10688        self.state.worker_teach_picker_index = 0;
10689        self.state.show_worker_teach_picker = true;
10690        Ok(())
10691    }
10692
10693    pub fn close_worker_teach_picker(&mut self) {
10694        self.state.show_worker_teach_picker = false;
10695        self.state.worker_teach_picker = None;
10696        self.state.worker_teach_picker_index = 0;
10697    }
10698
10699    pub fn worker_teach_picker_move(&mut self, delta: i32) {
10700        let Some(picker) = &self.state.worker_teach_picker else {
10701            return;
10702        };
10703        let n = picker.options.len();
10704        if n == 0 {
10705            return;
10706        }
10707        let idx = self.state.worker_teach_picker_index as i32;
10708        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10709    }
10710
10711    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10712        let Some(picker) = self.state.worker_teach_picker.clone() else {
10713            anyhow::bail!("teach picker not open");
10714        };
10715        let Some(opt) = picker
10716            .options
10717            .get(self.state.worker_teach_picker_index)
10718            .cloned()
10719        else {
10720            anyhow::bail!("nothing selected");
10721        };
10722        if !opt.level_ok {
10723            anyhow::bail!(
10724                "{} needs level {} (is level {})",
10725                picker.worker_label,
10726                opt.min_level,
10727                opt.worker_level
10728            );
10729        }
10730        if !opt.can_afford {
10731            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10732        }
10733        let Some(worker) = self
10734            .state
10735            .hired_workers
10736            .iter()
10737            .find(|w| w.instance_id == picker.worker_instance_id)
10738            .cloned()
10739        else {
10740            anyhow::bail!("worker gone");
10741        };
10742        let (px, py, _) = self.state.player_position_with_z();
10743        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10744        if dist > WORKER_GIVE_RANGE_M {
10745            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10746        }
10747        self.seq += 1;
10748        self.session
10749            .submit_intent(Intent::TeachWorkerBlueprint {
10750                entity_id: self.state.entity_id,
10751                worker_instance_id: picker.worker_instance_id.clone(),
10752                blueprint_id: opt.blueprint_id.clone(),
10753                seq: self.seq,
10754            })
10755            .await?;
10756        self.state.intents_sent += 1;
10757        self.state.push_log(format!(
10758            "Teaching {} to {} ({} cp)",
10759            opt.label, picker.worker_label, opt.cost_copper
10760        ));
10761        self.close_worker_teach_picker();
10762        Ok(())
10763    }
10764
10765    async fn give_item_to_worker(
10766        &mut self,
10767        worker_instance_id: &str,
10768        worker_label: &str,
10769        worker_x: f32,
10770        worker_y: f32,
10771        item_instance_id: uuid::Uuid,
10772        item_label: &str,
10773        quantity: Option<u32>,
10774    ) -> anyhow::Result<()> {
10775        let (px, py, _) = self.state.player_position_with_z();
10776        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10777        if dist > WORKER_GIVE_RANGE_M {
10778            anyhow::bail!("worker {worker_label} too far — stand next to them");
10779        }
10780        self.seq += 1;
10781        self.session
10782            .submit_intent(Intent::GiveWorkerItem {
10783                entity_id: self.state.entity_id,
10784                worker_instance_id: worker_instance_id.to_string(),
10785                item_instance_id,
10786                quantity,
10787                seq: self.seq,
10788            })
10789            .await?;
10790        self.state.intents_sent += 1;
10791        self.state
10792            .push_log(format!("Gave {item_label} to {worker_label}"));
10793        Ok(())
10794    }
10795
10796    /// Equip one item from the player's inventory on the selected worker.
10797    /// The server validates the requested hand/body slot and replaces the
10798    /// previous item into the worker's pack.
10799    pub async fn equip_item_on_worker(
10800        &mut self,
10801        worker_instance_id: &str,
10802        item_instance_id: uuid::Uuid,
10803        slot: &str,
10804    ) -> anyhow::Result<()> {
10805        let Some(worker) = self
10806            .state
10807            .hired_workers
10808            .iter()
10809            .find(|worker| worker.instance_id == worker_instance_id)
10810            .cloned()
10811        else {
10812            anyhow::bail!("worker not found");
10813        };
10814        let (px, py, _) = self.state.player_position_with_z();
10815        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
10816            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10817        }
10818        self.seq += 1;
10819        self.session
10820            .submit_intent(Intent::EquipWorkerItem {
10821                entity_id: self.state.entity_id,
10822                worker_instance_id: worker.instance_id.clone(),
10823                item_instance_id,
10824                slot: slot.to_string(),
10825                seq: self.seq,
10826            })
10827            .await?;
10828        self.state.intents_sent += 1;
10829        self.state
10830            .push_log(format!("Equipped {slot} on {}", worker.label));
10831        Ok(())
10832    }
10833
10834    /// Open the workers-menu take picker for the selected worker (must be in range).
10835    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10836        let Some(worker) = self
10837            .state
10838            .hired_workers
10839            .get(self.state.workers_menu_index)
10840            .cloned()
10841        else {
10842            anyhow::bail!("select a hired worker first");
10843        };
10844        let (px, py, _) = self.state.player_position_with_z();
10845        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10846        if dist > WORKER_GIVE_RANGE_M {
10847            anyhow::bail!(
10848                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10849                worker.label
10850            );
10851        }
10852        let options = Self::worker_inventory_options(&worker);
10853        if options.is_empty() {
10854            anyhow::bail!("{} isn't carrying anything", worker.label);
10855        }
10856        let initial_qty = options
10857            .first()
10858            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10859            .unwrap_or(1);
10860        self.state.worker_take_picker = Some(WorkerTakePicker {
10861            worker_instance_id: worker.instance_id,
10862            worker_label: worker.label,
10863            options,
10864            quantity: initial_qty,
10865        });
10866        self.state.worker_take_picker_index = 0;
10867        self.state.show_worker_take_picker = true;
10868        Ok(())
10869    }
10870
10871    fn worker_inventory_options(
10872        worker: &flatland_protocol::HiredWorkerView,
10873    ) -> Vec<WorkerGiveOption> {
10874        worker
10875            .inventory
10876            .iter()
10877            .filter_map(|stack| {
10878                let item_instance_id = stack.item_instance_id?;
10879                let label = stack
10880                    .display_name
10881                    .clone()
10882                    .unwrap_or_else(|| stack.template_id.clone());
10883                let label = if stack.quantity > 1 {
10884                    format!("{label} ×{}", stack.quantity)
10885                } else {
10886                    label
10887                };
10888                Some(WorkerGiveOption {
10889                    item_instance_id,
10890                    label,
10891                    quantity: stack.quantity,
10892                    template_id: stack.template_id.clone(),
10893                })
10894            })
10895            .collect()
10896    }
10897
10898    pub fn close_worker_take_picker(&mut self) {
10899        self.state.show_worker_take_picker = false;
10900        self.state.worker_take_picker = None;
10901        self.state.worker_take_picker_index = 0;
10902    }
10903
10904    pub fn worker_take_picker_move(&mut self, delta: i32) {
10905        let Some(picker) = &self.state.worker_take_picker else {
10906            return;
10907        };
10908        let n = picker.options.len();
10909        if n == 0 {
10910            return;
10911        }
10912        let idx = self.state.worker_take_picker_index as i32;
10913        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10914        self.clamp_worker_take_quantity();
10915    }
10916
10917    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10918        let Some(picker) = &mut self.state.worker_take_picker else {
10919            return;
10920        };
10921        let max = picker
10922            .options
10923            .get(self.state.worker_take_picker_index)
10924            .map(|o| o.quantity.max(1))
10925            .unwrap_or(1);
10926        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10927        picker.quantity = next as u32;
10928    }
10929
10930    pub fn worker_take_picker_set_quantity_max(&mut self) {
10931        let Some(picker) = &mut self.state.worker_take_picker else {
10932            return;
10933        };
10934        let max = picker
10935            .options
10936            .get(self.state.worker_take_picker_index)
10937            .map(|o| o.quantity.max(1))
10938            .unwrap_or(1);
10939        picker.quantity = max;
10940    }
10941
10942    pub fn worker_take_picker_set_quantity_min(&mut self) {
10943        let Some(picker) = &mut self.state.worker_take_picker else {
10944            return;
10945        };
10946        picker.quantity = 1;
10947        self.clamp_worker_take_quantity();
10948    }
10949
10950    fn clamp_worker_take_quantity(&mut self) {
10951        let Some(picker) = &mut self.state.worker_take_picker else {
10952            return;
10953        };
10954        let max = picker
10955            .options
10956            .get(self.state.worker_take_picker_index)
10957            .map(|o| o.quantity.max(1))
10958            .unwrap_or(1);
10959        if picker.quantity == 0 || picker.quantity > max {
10960            picker.quantity = if max > 1 { 1 } else { max };
10961        }
10962    }
10963
10964    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10965        let Some(picker) = self.state.worker_take_picker.clone() else {
10966            anyhow::bail!("take picker not open");
10967        };
10968        let Some(opt) = picker
10969            .options
10970            .get(self.state.worker_take_picker_index)
10971            .cloned()
10972        else {
10973            anyhow::bail!("no item selected");
10974        };
10975        let Some(worker) = self
10976            .state
10977            .hired_workers
10978            .iter()
10979            .find(|w| w.instance_id == picker.worker_instance_id)
10980            .cloned()
10981        else {
10982            self.close_worker_take_picker();
10983            anyhow::bail!("worker no longer hired");
10984        };
10985        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10986        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
10987        self.take_item_from_worker(
10988            &worker.instance_id,
10989            &worker.label,
10990            worker.x,
10991            worker.y,
10992            opt.item_instance_id,
10993            &opt.label,
10994            intent_qty,
10995        )
10996        .await?;
10997        // Leave the picker open — server Interaction / hired-workers sync refresh
10998        // options. Do not optimistic-strip stacks (rejects used to look like success).
10999        Ok(())
11000    }
11001
11002    async fn take_item_from_worker(
11003        &mut self,
11004        worker_instance_id: &str,
11005        worker_label: &str,
11006        worker_x: f32,
11007        worker_y: f32,
11008        item_instance_id: uuid::Uuid,
11009        item_label: &str,
11010        quantity: Option<u32>,
11011    ) -> anyhow::Result<()> {
11012        let (px, py, _) = self.state.player_position_with_z();
11013        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11014        if dist > WORKER_GIVE_RANGE_M {
11015            anyhow::bail!("worker {worker_label} too far — stand next to them");
11016        }
11017        self.seq += 1;
11018        self.session
11019            .submit_intent(Intent::TakeWorkerItem {
11020                entity_id: self.state.entity_id,
11021                worker_instance_id: worker_instance_id.to_string(),
11022                item_instance_id,
11023                quantity,
11024                seq: self.seq,
11025            })
11026            .await?;
11027        self.state.intents_sent += 1;
11028        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
11029        self.state.push_log(format!(
11030            "Taking {item_label}{qty_note} from {worker_label}…"
11031        ));
11032        Ok(())
11033    }
11034
11035    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
11036        if !self.state.has_worker_lodging() {
11037            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
11038        }
11039        self.seq += 1;
11040        self.session
11041            .submit_intent(Intent::HireWorker {
11042                entity_id: self.state.entity_id,
11043                def_id: "worker_laborer".into(),
11044                wage_copper_per_interval: 8,
11045                lodging_container_id: None,
11046                job_yaml: None,
11047                seq: self.seq,
11048            })
11049            .await?;
11050        self.state.intents_sent += 1;
11051        Ok(())
11052    }
11053
11054    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
11055        let Some(worker) = self
11056            .state
11057            .hired_workers
11058            .get(self.state.workers_menu_index)
11059            .cloned()
11060        else {
11061            anyhow::bail!("select a hired worker first");
11062        };
11063        let lodging = worker.lodging_container_id.clone().or_else(|| {
11064            crate::worker_route_editor::owned_lodging_container_ids(
11065                &self.state.placed_containers,
11066                self.state.character_id,
11067            )
11068            .into_iter()
11069            .next()
11070            .map(|(id, _)| id)
11071        });
11072        let label = worker.label.clone();
11073        let editor = if let Some(route) = &worker.route {
11074            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
11075                worker.instance_id,
11076                worker.label,
11077                route,
11078                lodging,
11079            )
11080        } else {
11081            crate::worker_route_editor::WorkerRouteEditorState::new(
11082                worker.instance_id,
11083                worker.label,
11084                lodging,
11085            )
11086        };
11087        self.state.worker_route_editor = Some(editor);
11088        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11089            if let Some(collapsed) =
11090                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
11091            {
11092                ed.panel_collapsed = collapsed;
11093            }
11094        }
11095        self.state.show_workers_menu = false;
11096        self.state.push_log(format!(
11097            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
11098        ));
11099        Ok(())
11100    }
11101
11102    pub fn close_worker_route_editor(&mut self) {
11103        self.state.worker_route_editor = None;
11104    }
11105
11106    pub fn worker_route_editor_toggle_panel(&mut self) {
11107        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11108            ed.toggle_panel_collapsed();
11109            let collapsed = ed.panel_collapsed;
11110            let mut cfg = crate::client_config::ClientConfig::load();
11111            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
11112        }
11113    }
11114
11115    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
11116        let n = {
11117            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11118                return;
11119            };
11120            ed.append_waypoint(x, y, z);
11121            ed.stop_count()
11122        };
11123        self.state
11124            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
11125    }
11126
11127    // ---- picker candidate snapshots ----------------------------------
11128
11129    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
11130        let (px, py, _) = self.state.player_position_with_z();
11131        let inside = self.state.effective_inside_building();
11132        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
11133            &self.state.placed_containers,
11134            &self.state.buildings,
11135            self.state.character_id,
11136            px,
11137            py,
11138            &self.state.hired_workers,
11139            inside.as_deref(),
11140        )
11141    }
11142
11143    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
11144        self.state.route_editor_node_candidates()
11145    }
11146
11147    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
11148        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
11149        let nodes = self.state.route_editor_node_candidates();
11150        let index = if nodes.is_empty() {
11151            ROUTE_PICKER_DONE_ROW
11152        } else {
11153            index.max(1).min(nodes.len())
11154        };
11155        self.re_open_sheet(S::HarvestPicker {
11156            index,
11157            picked,
11158            nodes,
11159        });
11160    }
11161
11162    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
11163        let (px, py, _) = self.state.player_position_with_z();
11164        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
11165    }
11166
11167    fn re_template_candidates(&self) -> Vec<String> {
11168        let mut extra = Vec::new();
11169        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11170            for stop in &ed.stops {
11171                match stop {
11172                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
11173                        filter: Some(filter),
11174                        ..
11175                    } => extra.extend(filter.iter().cloned()),
11176                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
11177                        extra.push(template.clone());
11178                    }
11179                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
11180                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
11181                        {
11182                            extra.push(bp.output.clone());
11183                            for input in &bp.inputs {
11184                                extra.push(input.template_id.clone());
11185                            }
11186                        }
11187                    }
11188                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
11189                        for it in items {
11190                            extra.push(it.template.clone());
11191                        }
11192                    }
11193                    _ => {}
11194                }
11195            }
11196            // Worker-known recipe outputs even if the player hasn't learned them yet.
11197            if let Some(worker) = self
11198                .state
11199                .hired_workers
11200                .iter()
11201                .find(|w| w.instance_id == ed.worker_instance_id)
11202            {
11203                for recipe in &worker.known_blueprint_ids {
11204                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
11205                        extra.push(bp.output.clone());
11206                    }
11207                }
11208            }
11209        }
11210        crate::worker_route_editor::route_item_template_candidates(
11211            &self.state.placed_containers,
11212            self.state.character_id,
11213            &self.state.inventory,
11214            &self.state.blueprints,
11215            &self.state.resource_nodes,
11216            &extra,
11217        )
11218    }
11219
11220    fn re_blueprint_ids(&self) -> Vec<String> {
11221        let worker_known: Option<&[String]> = self
11222            .state
11223            .worker_route_editor
11224            .as_ref()
11225            .and_then(|ed| {
11226                self.state
11227                    .hired_workers
11228                    .iter()
11229                    .find(|w| w.instance_id == ed.worker_instance_id)
11230            })
11231            .map(|w| w.known_blueprint_ids.as_slice());
11232        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
11233    }
11234
11235    fn re_bed_candidates(&self) -> Vec<(String, String)> {
11236        crate::worker_route_editor::owned_lodging_container_ids(
11237            &self.state.placed_containers,
11238            self.state.character_id,
11239        )
11240    }
11241
11242    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
11243        self.state
11244            .placed_containers
11245            .iter()
11246            .find(|c| c.id == container_id)
11247            .map(|c| c.contents.clone())
11248            .unwrap_or_default()
11249    }
11250
11251    // ---- sheet navigation (`plans/33`) --------------------------------
11252
11253    fn re_sheet_supports_filter(&self) -> bool {
11254        use crate::worker_route_editor::RouteEditorSheet as S;
11255        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11256            matches!(
11257                ed.sheet,
11258                S::HarvestPicker { .. }
11259                    | S::SellItem { .. }
11260                    | S::DepositFilter { .. }
11261                    | S::WithdrawItems { .. }
11262                    | S::WithdrawContainers { .. }
11263                    | S::DepositContainers { .. }
11264                    | S::SellNpcs { .. }
11265                    | S::CraftBlueprint { .. }
11266                    | S::BedPicker { .. }
11267            )
11268        })
11269    }
11270
11271    /// Whether a sheet row is shown under the current `/` filter.
11272    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
11273        use crate::worker_route_editor::{
11274            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
11275            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11276        };
11277        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11278            return false;
11279        };
11280        let filter = &ed.sheet_filter;
11281        match &ed.sheet {
11282            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
11283            S::SellItem { templates, .. } => {
11284                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
11285                    return true;
11286                }
11287                let slot = row.saturating_sub(2);
11288                templates.get(slot).is_some_and(|t| {
11289                    let label = self.state.template_display_name(t);
11290                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11291                })
11292            }
11293            S::DepositFilter { rows, .. } => {
11294                if row >= rows.len() {
11295                    return true;
11296                }
11297                rows.get(row).is_some_and(|(t, _)| {
11298                    let label = self.state.template_display_name(t);
11299                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11300                })
11301            }
11302            S::WithdrawItems { lines, .. } => {
11303                if row >= lines.len() {
11304                    return true;
11305                }
11306                lines.get(row).is_some_and(|l| {
11307                    let label = self.state.template_display_name(&l.template);
11308                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
11309                })
11310            }
11311            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11312                self.re_container_candidates().get(row).is_some_and(|c| {
11313                    list_filter_row_matches(
11314                        filter,
11315                        Some(c.dist),
11316                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
11317                    )
11318                })
11319            }
11320            S::SellNpcs { .. } => {
11321                if row == 0 {
11322                    return true;
11323                }
11324                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
11325                    list_filter_row_matches(
11326                        filter,
11327                        Some(n.dist),
11328                        &[n.label.as_str(), n.id.as_str()],
11329                    )
11330                })
11331            }
11332            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
11333                let label = self
11334                    .state
11335                    .blueprints
11336                    .iter()
11337                    .find(|b| &b.id == id)
11338                    .map(|b| {
11339                        if b.label.is_empty() {
11340                            id.as_str()
11341                        } else {
11342                            b.label.as_str()
11343                        }
11344                    })
11345                    .unwrap_or(id.as_str());
11346                list_filter_row_matches(filter, None, &[id.as_str(), label])
11347            }),
11348            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
11349                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
11350            }),
11351            _ => true,
11352        }
11353    }
11354
11355    fn re_sheet_clamp_index(&mut self) {
11356        let count = self.re_sheet_row_count();
11357        if count == 0 {
11358            return;
11359        }
11360        let cur = self.re_sheet_index();
11361        if self.re_sheet_row_visible(cur) {
11362            return;
11363        }
11364        for offset in 1..count {
11365            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
11366                self.re_sheet_set_index(cur + offset);
11367                return;
11368            }
11369            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
11370                self.re_sheet_set_index(cur - offset);
11371                return;
11372            }
11373        }
11374    }
11375
11376    fn re_sheet_set_index(&mut self, index: usize) {
11377        use crate::worker_route_editor::RouteEditorSheet as S;
11378        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11379            return;
11380        };
11381        match &mut ed.sheet {
11382            S::AddMenu { index: slot }
11383            | S::WaypointMenu { index: slot }
11384            | S::HarvestPicker { index: slot, .. }
11385            | S::WithdrawContainers { index: slot }
11386            | S::DepositContainers { index: slot }
11387            | S::SellNpcs { index: slot }
11388            | S::CraftBlueprint { index: slot }
11389            | S::BedPicker { index: slot }
11390            | S::FarmPlotPicker { index: slot, .. }
11391            | S::FarmPlantSeed { index: slot, .. }
11392            | S::WithdrawItems { index: slot, .. }
11393            | S::DepositFilter { index: slot, .. }
11394            | S::SellItem { index: slot, .. } => *slot = index,
11395            _ => {}
11396        }
11397    }
11398
11399    pub fn re_focus_sheet_filter(&mut self) {
11400        if !self.re_sheet_supports_filter() {
11401            return;
11402        }
11403        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11404            ed.sheet_filter_focused = true;
11405        }
11406    }
11407
11408    pub fn re_blur_sheet_filter_keep_text(&mut self) {
11409        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11410            return;
11411        };
11412        if !ed.sheet_filter_focused {
11413            return;
11414        }
11415        ed.sheet_filter_focused = false;
11416        self.re_sheet_clamp_index();
11417    }
11418
11419    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
11420        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11421            return false;
11422        };
11423        if ed.sheet_filter_focused {
11424            ed.sheet_filter_focused = false;
11425            self.re_sheet_clamp_index();
11426            return true;
11427        }
11428        if !ed.sheet_filter.is_empty() {
11429            ed.sheet_filter.clear();
11430            self.re_sheet_clamp_index();
11431            return true;
11432        }
11433        false
11434    }
11435
11436    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
11437        if ch.is_control() {
11438            return;
11439        }
11440        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11441            return;
11442        };
11443        if !ed.sheet_filter_focused {
11444            return;
11445        }
11446        ed.sheet_filter.push(ch);
11447        self.re_sheet_set_index(0);
11448        self.re_sheet_clamp_index();
11449    }
11450
11451    pub fn re_sheet_filter_backspace(&mut self) {
11452        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11453            return;
11454        };
11455        if !ed.sheet_filter_focused {
11456            return;
11457        }
11458        ed.sheet_filter.pop();
11459        self.re_sheet_set_index(0);
11460        self.re_sheet_clamp_index();
11461    }
11462
11463    /// Row count of the current sheet (for cursor wrapping).
11464    pub fn re_sheet_row_count(&self) -> usize {
11465        use crate::worker_route_editor::{
11466            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
11467        };
11468        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11469            return 0;
11470        };
11471        match &ed.sheet {
11472            S::Stops => ed.stops.len(),
11473            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
11474            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
11475            S::WaypointMapPick => 0,
11476            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
11477            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11478                self.re_container_candidates().len()
11479            }
11480            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
11481            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
11482            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
11483            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
11484            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11485            S::WaitEntry { .. } => 1,
11486            S::BedPicker { .. } => self.re_bed_candidates().len(),
11487            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11488            S::FarmPlantSeed { seeds, .. } => seeds.len(),
11489        }
11490    }
11491
11492    /// Current sheet cursor index (0 for sheets without one).
11493    pub fn re_sheet_index(&self) -> usize {
11494        use crate::worker_route_editor::RouteEditorSheet as S;
11495        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11496            return 0;
11497        };
11498        match &ed.sheet {
11499            S::AddMenu { index }
11500            | S::WaypointMenu { index }
11501            | S::HarvestPicker { index, .. }
11502            | S::WithdrawContainers { index }
11503            | S::DepositContainers { index }
11504            | S::SellNpcs { index }
11505            | S::CraftBlueprint { index }
11506            | S::BedPicker { index }
11507            | S::FarmPlotPicker { index, .. }
11508            | S::FarmPlantSeed { index, .. }
11509            | S::WithdrawItems { index, .. }
11510            | S::DepositFilter { index, .. }
11511            | S::SellItem { index, .. } => *index,
11512            _ => 0,
11513        }
11514    }
11515
11516    /// Move the current sheet's cursor, wrapping within its rows.
11517    pub fn re_sheet_move(&mut self, delta: i32) {
11518        let count = self.re_sheet_row_count();
11519        if count == 0 {
11520            return;
11521        }
11522        let cur = self.re_sheet_index();
11523        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11524        self.re_sheet_set_index(next);
11525    }
11526
11527    pub fn re_sheet_page(&mut self, pages: i32) {
11528        let count = self.re_sheet_row_count();
11529        if count == 0 {
11530            return;
11531        }
11532        let cur = self.re_sheet_index();
11533        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11534        self.re_sheet_set_index(next);
11535    }
11536
11537    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
11538    pub fn re_sheet_adjust(&mut self, delta: i32) {
11539        use crate::worker_route_editor::RouteEditorSheet as S;
11540        let index = self.re_sheet_index();
11541        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11542            return;
11543        };
11544        match &mut ed.sheet {
11545            S::WithdrawItems { lines, .. } => {
11546                if let Some(line) = lines.get_mut(index) {
11547                    line.adjust_qty(delta);
11548                }
11549            }
11550            S::WaitEntry { ticks } => {
11551                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11552            }
11553            _ => {}
11554        }
11555    }
11556
11557    pub fn re_sheet_back(&mut self) {
11558        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11559            return;
11560        };
11561        use crate::worker_route_editor::RouteEditorSheet as S;
11562        let was_editing = ed.editing_index.is_some();
11563        let from_top_picker = matches!(
11564            ed.sheet,
11565            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11566        );
11567        ed.sheet_back();
11568        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11569            // Chest retarget commits on pick; Esc here just ends the edit sheet.
11570            self.state
11571                .push_log("Route: left edit sheet — press s to save current stops".to_string());
11572        }
11573    }
11574
11575    /// True when the editor is at its root sheet (Esc should close it).
11576    pub fn re_at_root_sheet(&self) -> bool {
11577        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11578            matches!(
11579                ed.sheet,
11580                crate::worker_route_editor::RouteEditorSheet::Stops
11581            )
11582        })
11583    }
11584
11585    pub fn re_open_add_menu(&mut self) {
11586        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11587            ed.open_add_menu();
11588        }
11589    }
11590
11591    pub fn re_open_bed_picker(&mut self) {
11592        let beds = self.re_bed_candidates();
11593        if beds.is_empty() {
11594            self.state
11595                .push_log("Route: place a camp bed first".to_string());
11596            return;
11597        }
11598        let current = self
11599            .state
11600            .worker_route_editor
11601            .as_ref()
11602            .and_then(|ed| ed.lodging_container_id.clone());
11603        let index = current
11604            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11605            .unwrap_or(0);
11606        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11607    }
11608
11609    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11610        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11611            ed.open_sheet(sheet);
11612        }
11613    }
11614
11615    /// Confirm a sheet's stop and log the outcome.
11616    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
11617        let appended = self
11618            .state
11619            .worker_route_editor
11620            .as_mut()
11621            .is_some_and(|ed| ed.confirm_stop(stop));
11622        if appended {
11623            self.state.push_log(format!("Route: + {what}"));
11624        } else {
11625            self.state
11626                .push_log(format!("Route: {what} already in route — selected it"));
11627        }
11628    }
11629
11630    fn re_open_withdraw_items(&mut self, container_id: String) {
11631        use crate::worker_route_editor::{
11632            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11633        };
11634        let contents = self.re_container_contents(&container_id);
11635        // When editing, keep the stop's item picks even if the player retargets
11636        // to a different (possibly empty) chest — otherwise Done has nothing to
11637        // confirm and the edit appears to "revert".
11638        let existing = self
11639            .state
11640            .worker_route_editor
11641            .as_ref()
11642            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11643            .and_then(|stop| match stop {
11644                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11645                _ => None,
11646            })
11647            .unwrap_or_default();
11648        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11649        // Commit the new chest immediately while editing so Esc-before-Done still
11650        // keeps the retarget (items update only when Done is pressed).
11651        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11652            let _ = ed.retarget_withdraw_container(container_id.clone());
11653        }
11654        self.re_open_sheet(S::WithdrawItems {
11655            container_id,
11656            lines,
11657            index: 0,
11658        });
11659    }
11660
11661    fn re_withdraw_items_activate(&mut self, index: usize) {
11662        use crate::worker_route_editor::{
11663            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11664        };
11665        enum Outcome {
11666            Cycled,
11667            Confirmed(String),
11668            Empty,
11669        }
11670        let outcome = {
11671            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11672                return;
11673            };
11674            let S::WithdrawItems {
11675                container_id,
11676                lines,
11677                index: sheet_index,
11678            } = &mut ed.sheet
11679            else {
11680                return;
11681            };
11682            *sheet_index = index;
11683            if index < lines.len() {
11684                lines[index].cycle();
11685                Outcome::Cycled
11686            } else {
11687                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11688                if items.is_empty() {
11689                    Outcome::Empty
11690                } else {
11691                    let stop = WorkerRouteStop::WithdrawFrom {
11692                        container_id: container_id.clone(),
11693                        items,
11694                    };
11695                    let summary = stop.summary();
11696                    ed.confirm_stop(stop);
11697                    Outcome::Confirmed(summary)
11698                }
11699            }
11700        };
11701        match outcome {
11702            Outcome::Cycled => {}
11703            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11704            Outcome::Empty => self.state.push_log(
11705                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
11706            ),
11707        }
11708    }
11709
11710    fn re_open_deposit_filter(&mut self, container_id: String) {
11711        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11712        // Preserve filter when retargeting the deposit chest while editing.
11713        let existing_filter = self
11714            .state
11715            .worker_route_editor
11716            .as_ref()
11717            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11718            .and_then(|stop| match stop {
11719                WorkerRouteStop::DepositAt { filter, .. } => {
11720                    Some(filter.clone().unwrap_or_default())
11721                }
11722                _ => None,
11723            });
11724        let mut candidates = self.re_template_candidates();
11725        if let Some(ref chosen) = existing_filter {
11726            for t in chosen {
11727                if !candidates.iter().any(|c| c == t) {
11728                    candidates.push(t.clone());
11729                }
11730            }
11731            candidates.sort();
11732            candidates.dedup();
11733        }
11734        let rows: Vec<(String, bool)> = match existing_filter {
11735            Some(chosen) => candidates
11736                .iter()
11737                .map(|t| (t.clone(), chosen.contains(t)))
11738                .collect(),
11739            None => candidates.into_iter().map(|t| (t, false)).collect(),
11740        };
11741        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11742            let _ = ed.retarget_deposit_container(container_id.clone());
11743        }
11744        self.re_open_sheet(S::DepositFilter {
11745            container_id,
11746            rows,
11747            index: 0,
11748        });
11749    }
11750
11751    fn re_deposit_filter_activate(&mut self, index: usize) {
11752        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11753        let mut confirmed: Option<String> = None;
11754        {
11755            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11756                return;
11757            };
11758            let S::DepositFilter {
11759                container_id,
11760                rows,
11761                index: sheet_index,
11762            } = &mut ed.sheet
11763            else {
11764                return;
11765            };
11766            *sheet_index = index;
11767            if index < rows.len() {
11768                rows[index].1 = !rows[index].1;
11769            } else {
11770                // "Done" row.
11771                let chosen: Vec<String> = rows
11772                    .iter()
11773                    .filter(|(_, on)| *on)
11774                    .map(|(t, _)| t.clone())
11775                    .collect();
11776                let filter = if chosen.is_empty() {
11777                    None
11778                } else {
11779                    Some(chosen)
11780                };
11781                let stop = WorkerRouteStop::DepositAt {
11782                    container_id: container_id.clone(),
11783                    filter,
11784                };
11785                confirmed = Some(stop.summary());
11786                ed.confirm_stop(stop);
11787            }
11788        }
11789        if let Some(what) = confirmed {
11790            self.state.push_log(format!("Route: + {what}"));
11791        }
11792    }
11793
11794    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11795        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11796        // Prefill when editing an existing sell stop.
11797        let (pre_npc, pre_template, pre_all) = self
11798            .state
11799            .worker_route_editor
11800            .as_ref()
11801            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11802            .and_then(|stop| match stop {
11803                WorkerRouteStop::TradeWith {
11804                    npc_id,
11805                    template,
11806                    sell_all,
11807                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11808                _ => None,
11809            })
11810            .unwrap_or((None, None, true));
11811        let npc_id = npc_id.or(pre_npc);
11812        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
11813            &self.re_template_candidates(),
11814            &self.state.npcs,
11815            npc_id.as_deref(),
11816        );
11817        // Keep an existing invalid selection visible long enough to remove it
11818        // when editing a route after NPC buy lists change.
11819        if let Some(template) = pre_template.as_ref() {
11820            if !templates.iter().any(|candidate| candidate == template) {
11821                templates.push(template.clone());
11822                templates.sort();
11823            }
11824        }
11825        if templates.is_empty() {
11826            self.state.push_log(
11827                "Route: no sellable item templates for that merchant".to_string(),
11828            );
11829            return;
11830        }
11831        let mut picked = std::collections::BTreeSet::new();
11832        if let Some(t) = pre_template {
11833            picked.insert(t);
11834        }
11835        self.re_open_sheet(S::SellItem {
11836            npc_id,
11837            templates,
11838            index: if picked.is_empty() {
11839                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11840            } else {
11841                2
11842            },
11843            sell_all: pre_all,
11844            picked,
11845        });
11846    }
11847
11848    fn re_sell_item_activate(&mut self, index: usize) {
11849        use crate::worker_route_editor::{
11850            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11851        };
11852        let mut batch_log: Option<String> = None;
11853        {
11854            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11855                return;
11856            };
11857            let S::SellItem {
11858                npc_id,
11859                templates,
11860                index: sheet_index,
11861                sell_all,
11862                picked,
11863            } = &mut ed.sheet
11864            else {
11865                return;
11866            };
11867            *sheet_index = index;
11868            if index == ROUTE_PICKER_DONE_ROW {
11869                if picked.is_empty() {
11870                    batch_log =
11871                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
11872                } else {
11873                    let picks: Vec<String> = picked.iter().cloned().collect();
11874                    let npc = npc_id.clone();
11875                    let all = *sell_all;
11876                    let added = ed.confirm_trade_picks(npc, &picks, all);
11877                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
11878                }
11879            } else if index == SELL_ITEM_TOGGLE_ROW {
11880                *sell_all = !*sell_all;
11881            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11882                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
11883                    std::slice::from_ref(template),
11884                    &self.state.npcs,
11885                    npc_id.as_deref(),
11886                )
11887                .iter()
11888                .any(|candidate| candidate == template);
11889                if !sellable && !picked.contains(template) {
11890                    return;
11891                }
11892                if picked.contains(template) {
11893                    picked.remove(template);
11894                } else {
11895                    picked.insert(template.clone());
11896                }
11897            }
11898        }
11899        if let Some(msg) = batch_log {
11900            self.state.push_log(msg);
11901        }
11902    }
11903
11904    /// Enter on the stop list: open the selected stop's sheet, prefilled.
11905    pub fn re_edit_selected_stop(&mut self) {
11906        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11907        let Some(stop) = self
11908            .state
11909            .worker_route_editor
11910            .as_ref()
11911            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11912        else {
11913            self.state
11914                .push_log("Route: no stop selected — press a to add one".to_string());
11915            return;
11916        };
11917        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11918            ed.begin_edit_selected();
11919        }
11920        match stop {
11921            WorkerRouteStop::Waypoint { .. } => {
11922                self.re_open_sheet(S::WaypointMenu { index: 0 });
11923            }
11924            WorkerRouteStop::HarvestNode { node_id } => {
11925                let nodes = self.state.route_editor_node_candidates();
11926                if nodes.is_empty() {
11927                    self.re_cancel_edit();
11928                    self.state
11929                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
11930                } else {
11931                    let mut picked = std::collections::BTreeSet::new();
11932                    picked.insert(node_id.clone());
11933                    let index = nodes
11934                        .iter()
11935                        .position(|n| n.id == node_id)
11936                        .map(|i| i + 1)
11937                        .unwrap_or(1);
11938                    self.re_open_harvest_picker(index, picked);
11939                }
11940            }
11941            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11942                // Open the container picker so the player can retarget storage
11943                // (previously jumped straight to items on the same chest).
11944                let containers = self.re_container_candidates();
11945                if containers.is_empty() {
11946                    self.re_cancel_edit();
11947                    self.state
11948                        .push_log("Route: place a storage chest first".to_string());
11949                } else {
11950                    let index = containers
11951                        .iter()
11952                        .position(|c| c.id == container_id)
11953                        .unwrap_or(0);
11954                    self.re_open_sheet(S::WithdrawContainers { index });
11955                }
11956            }
11957            WorkerRouteStop::DepositAt { container_id, .. } => {
11958                let containers = self.re_container_candidates();
11959                if containers.is_empty() {
11960                    self.re_cancel_edit();
11961                    self.state
11962                        .push_log("Route: place a storage chest first".to_string());
11963                } else {
11964                    let index = containers
11965                        .iter()
11966                        .position(|c| c.id == container_id)
11967                        .unwrap_or(0);
11968                    self.re_open_sheet(S::DepositContainers { index });
11969                }
11970            }
11971            WorkerRouteStop::TradeWith { npc_id, .. } => {
11972                let npcs = self.re_npc_candidates();
11973                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
11974                let index = npc_id
11975                    .as_ref()
11976                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11977                    .unwrap_or(0);
11978                self.re_open_sheet(S::SellNpcs { index });
11979            }
11980            WorkerRouteStop::CraftAt { blueprint, .. } => {
11981                let bps = self.re_blueprint_ids();
11982                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11983                if bps.is_empty() {
11984                    self.re_cancel_edit();
11985                    self.state
11986                        .push_log("Route: no known blueprints to retarget".to_string());
11987                } else {
11988                    self.re_open_sheet(S::CraftBlueprint { index });
11989                }
11990            }
11991            WorkerRouteStop::CultivatePlot { .. } => {
11992                self.re_open_farm_plot_picker(
11993                    crate::worker_route_editor::FarmPlotAction::Cultivate,
11994                );
11995            }
11996            WorkerRouteStop::PlantPlot { .. } => {
11997                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11998            }
11999            WorkerRouteStop::HarvestPlot { .. } => {
12000                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
12001            }
12002            WorkerRouteStop::RestIfNeeded => {
12003                self.re_cancel_edit();
12004                self.state
12005                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
12006            }
12007            WorkerRouteStop::Wait { wait_ticks } => {
12008                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
12009            }
12010        }
12011    }
12012
12013    fn re_cancel_edit(&mut self) {
12014        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12015            ed.editing_index = None;
12016        }
12017    }
12018
12019    // ---- mouse clicks inside the overlay ------------------------------
12020
12021    pub fn worker_route_editor_ui_click(
12022        &mut self,
12023        click: crate::worker_route_editor::RouteEditorClick,
12024    ) {
12025        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
12026        match click {
12027            RouteEditorClick::SelectStop(i) => {
12028                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12029                    ed.sheet = S::Stops;
12030                    ed.select_stop(i);
12031                }
12032            }
12033            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
12034            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
12035            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
12036        }
12037    }
12038
12039    /// Activate (Enter / click) a row of the current sheet.
12040    pub fn re_sheet_row_activate(&mut self, row: usize) {
12041        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12042        let Some(sheet) = self
12043            .state
12044            .worker_route_editor
12045            .as_ref()
12046            .map(|ed| ed.sheet.clone())
12047        else {
12048            return;
12049        };
12050        match sheet {
12051            S::Stops => {
12052                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12053                    ed.select_stop(row);
12054                }
12055            }
12056            S::AddMenu { .. } => match row {
12057                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
12058                1 => {
12059                    if self.re_node_candidates().is_empty() {
12060                        self.state.push_log(
12061                            "Route: no harvestable nodes visible in this region".to_string(),
12062                        );
12063                    } else {
12064                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
12065                    }
12066                }
12067                2 | 3 => {
12068                    if self.re_container_candidates().is_empty() {
12069                        self.state
12070                            .push_log("Route: place a storage chest first".to_string());
12071                    } else if row == 2 {
12072                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
12073                    } else {
12074                        self.re_open_sheet(S::DepositContainers { index: 0 });
12075                    }
12076                }
12077                4 => {
12078                    if self.re_template_candidates().is_empty() {
12079                        self.state.push_log(
12080                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
12081                                .to_string(),
12082                        );
12083                    } else {
12084                        self.re_open_sheet(S::SellNpcs { index: 0 });
12085                    }
12086                }
12087                5 => {
12088                    if self.re_blueprint_ids().is_empty() {
12089                        self.state.push_log(
12090                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
12091                                .to_string(),
12092                        );
12093                    } else {
12094                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
12095                    }
12096                }
12097                6 => self.re_confirm_stop(
12098                    WorkerRouteStop::RestIfNeeded,
12099                    "rest at lodging (if needed)".into(),
12100                ),
12101                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
12102                8 => self.re_open_farm_plot_picker(
12103                    crate::worker_route_editor::FarmPlotAction::Cultivate,
12104                ),
12105                9 => {
12106                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
12107                }
12108                10 => self
12109                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
12110                _ => {}
12111            },
12112            S::WaypointMenu { .. } => match row {
12113                0 => {
12114                    let (x, y, z) = self.state.player_position_with_z();
12115                    let stop = WorkerRouteStop::Waypoint { x, y, z };
12116                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12117                }
12118                1 => {
12119                    self.re_open_sheet(S::WaypointMapPick);
12120                    self.state.push_log(
12121                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
12122                    );
12123                }
12124                _ => {}
12125            },
12126            S::HarvestPicker { .. } => {
12127                let mut log: Option<String> = None;
12128                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12129                    let S::HarvestPicker {
12130                        index: sheet_index,
12131                        picked,
12132                        nodes,
12133                    } = &mut ed.sheet
12134                    else {
12135                        return;
12136                    };
12137                    *sheet_index = row;
12138                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
12139                        if picked.is_empty() {
12140                            log = Some(
12141                                "Route: pick at least one node (Space toggles, Done confirms)"
12142                                    .into(),
12143                            );
12144                        } else {
12145                            let ids: Vec<String> = picked.iter().cloned().collect();
12146                            let added = ed.confirm_harvest_picks(&ids);
12147                            log = Some(format!("Route: + {added} harvest stop(s)"));
12148                        }
12149                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
12150                        if picked.contains(&n.id) {
12151                            picked.remove(&n.id);
12152                        } else {
12153                            picked.insert(n.id.clone());
12154                        }
12155                    }
12156                }
12157                if let Some(msg) = log {
12158                    self.state.push_log(msg);
12159                }
12160            }
12161            S::WithdrawContainers { .. } => {
12162                let containers = self.re_container_candidates();
12163                if let Some(c) = containers.get(row) {
12164                    let id = c.id.clone();
12165                    self.re_open_withdraw_items(id);
12166                }
12167            }
12168            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
12169            S::DepositContainers { .. } => {
12170                let containers = self.re_container_candidates();
12171                if let Some(c) = containers.get(row) {
12172                    let id = c.id.clone();
12173                    self.re_open_deposit_filter(id);
12174                }
12175            }
12176            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
12177            S::SellNpcs { .. } => {
12178                let npcs = self.re_npc_candidates();
12179                let npc_id = if row == 0 {
12180                    None
12181                } else {
12182                    npcs.get(row - 1).map(|n| n.id.clone())
12183                };
12184                if row == 0 || npc_id.is_some() {
12185                    self.re_open_sell_item(npc_id);
12186                }
12187            }
12188            S::SellItem { .. } => self.re_sell_item_activate(row),
12189            S::CraftBlueprint { .. } => {
12190                let bps = self.re_blueprint_ids();
12191                if let Some(bp) = bps.get(row) {
12192                    let stop = WorkerRouteStop::CraftAt {
12193                        device: "hand".into(),
12194                        blueprint: bp.clone(),
12195                        qty: None,
12196                    };
12197                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
12198                }
12199            }
12200            S::WaitEntry { ticks } => {
12201                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
12202                self.re_confirm_stop(stop, format!("wait {ticks}t"));
12203            }
12204            S::BedPicker { .. } => {
12205                let beds = self.re_bed_candidates();
12206                if let Some((id, name)) = beds.get(row) {
12207                    let (id, name) = (id.clone(), name.clone());
12208                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12209                        ed.lodging_container_id = Some(id.clone());
12210                        ed.sheet = S::Stops;
12211                    }
12212                    self.state
12213                        .push_log(format!("Route: rest bed set to {name}"));
12214                }
12215            }
12216            S::FarmPlotPicker { action, .. } => {
12217                let plots = self.re_farm_plot_candidates();
12218                let Some(plot) = plots.get(row).cloned() else {
12219                    return;
12220                };
12221                match action {
12222                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
12223                        let label = plot_route_label(&plot);
12224                        self.re_confirm_stop(
12225                            WorkerRouteStop::CultivatePlot {
12226                                plot_id: plot.plot_id,
12227                            },
12228                            format!("cultivate {label}"),
12229                        );
12230                    }
12231                    crate::worker_route_editor::FarmPlotAction::Harvest => {
12232                        let label = plot_route_label(&plot);
12233                        self.re_confirm_stop(
12234                            WorkerRouteStop::HarvestPlot {
12235                                plot_id: plot.plot_id,
12236                            },
12237                            format!("harvest {label}"),
12238                        );
12239                    }
12240                    crate::worker_route_editor::FarmPlotAction::Plant => {
12241                        let seeds = self.re_farm_seed_candidates();
12242                        if seeds.is_empty() {
12243                            self.state.push_log(
12244                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
12245                            );
12246                            return;
12247                        }
12248                        self.re_open_sheet(S::FarmPlantSeed {
12249                            plot_id: plot.plot_id,
12250                            seeds,
12251                            index: 0,
12252                        });
12253                    }
12254                }
12255            }
12256            S::FarmPlantSeed { plot_id, seeds, .. } => {
12257                if let Some(seed) = seeds.get(row).cloned() {
12258                    self.re_confirm_stop(
12259                        WorkerRouteStop::PlantPlot {
12260                            plot_id,
12261                            seed_template: seed.clone(),
12262                        },
12263                        format!("plant {seed}"),
12264                    );
12265                }
12266            }
12267            S::WaypointMapPick => {}
12268        }
12269    }
12270
12271    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
12272        use crate::worker_route_editor::RouteEditorSheet as S;
12273        if self.re_farm_plot_candidates().is_empty() {
12274            self.state
12275                .push_log("Route: no farmable plots visible — claim land or get farm access first");
12276            return;
12277        }
12278        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
12279    }
12280
12281    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
12282        self.state
12283            .property_plots
12284            .iter()
12285            .filter(|p| p.is_mine || p.may_farm)
12286            .cloned()
12287            .collect()
12288    }
12289
12290    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
12291    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
12292    /// and known crop seeds so routes can withdraw-then-plant.
12293    fn re_farm_seed_candidates(&self) -> Vec<String> {
12294        let mut set = std::collections::BTreeSet::new();
12295        let looks_like_seed =
12296            |id: &str| id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed";
12297        for (id, _, _) in self.state.farm_seed_entries() {
12298            set.insert(id);
12299        }
12300        for c in &self.state.placed_containers {
12301            let mine = match (self.state.character_id, c.owner_character_id) {
12302                (Some(a), Some(b)) => a == b,
12303                _ => false,
12304            };
12305            if !mine {
12306                continue;
12307            }
12308            for s in &c.contents {
12309                if s.quantity > 0
12310                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
12311                {
12312                    set.insert(s.template_id.clone());
12313                }
12314            }
12315        }
12316        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12317            for stop in &ed.stops {
12318                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
12319                    stop
12320                {
12321                    for it in items {
12322                        if looks_like_seed(&it.template) {
12323                            set.insert(it.template.clone());
12324                        }
12325                    }
12326                }
12327                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
12328                    seed_template,
12329                    ..
12330                } = stop
12331                {
12332                    if !seed_template.is_empty() {
12333                        set.insert(seed_template.clone());
12334                    }
12335                }
12336            }
12337        }
12338        for id in self.state.inventory_hints.keys() {
12339            if looks_like_seed(id) {
12340                set.insert(id.clone());
12341            }
12342        }
12343        // Authored crop seeds (always offer so withdraw-then-plant routes work).
12344        for id in ["potato_seed", "carrot_seed"] {
12345            set.insert(id.to_string());
12346        }
12347        set.into_iter().collect()
12348    }
12349
12350    // ---- map clicks (sheet-scoped accelerators) ------------------------
12351
12352    /// Map click while the route editor is open. When a picker sheet is open
12353    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
12354    /// container, NPC clicks the merchant, node clicks the harvest target);
12355    /// otherwise the click quick-adds the nearest target (context-aware).
12356    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
12357        use crate::worker_route_editor as wre;
12358        use wre::RouteEditorSheet as S;
12359        if self.state.worker_route_editor.is_none() {
12360            return;
12361        }
12362        let sheet = self
12363            .state
12364            .worker_route_editor
12365            .as_ref()
12366            .map(|ed| ed.sheet.clone())
12367            .unwrap_or(S::Stops);
12368        match sheet {
12369            S::WaypointMapPick => {
12370                let (_, _, z) = self.state.player_position_with_z();
12371                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
12372                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12373                // Stay in map-pick mode when adding (not editing) for fast pathing.
12374                let editing = self
12375                    .state
12376                    .worker_route_editor
12377                    .as_ref()
12378                    .is_some_and(|ed| ed.editing_index.is_some());
12379                if !editing {
12380                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12381                        ed.sheet = S::WaypointMapPick;
12382                    }
12383                }
12384            }
12385            S::HarvestPicker { .. } => {
12386                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12387                    let mut log: Option<String> = None;
12388                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12389                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
12390                            return;
12391                        };
12392                        let selected = if picked.contains(&node.id) {
12393                            picked.remove(&node.id);
12394                            false
12395                        } else {
12396                            picked.insert(node.id.clone());
12397                            true
12398                        };
12399                        log = Some(format!(
12400                            "Route: {} {}",
12401                            if selected { "selected" } else { "deselected" },
12402                            resource_node_route_label(node)
12403                        ));
12404                    }
12405                    if let Some(msg) = log {
12406                        self.state.push_log(msg);
12407                    }
12408                }
12409            }
12410            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
12411                // Chest click picks (or switches) the withdraw source.
12412                let inside = self.state.effective_inside_building();
12413                if let Some(cid) = wre::pick_storage_container_at(
12414                    &self.state.placed_containers,
12415                    self.state.character_id,
12416                    x,
12417                    y,
12418                    inside.as_deref(),
12419                ) {
12420                    self.re_open_withdraw_items(cid);
12421                }
12422            }
12423            S::DepositContainers { .. } | S::DepositFilter { .. } => {
12424                let inside = self.state.effective_inside_building();
12425                if let Some(cid) = wre::pick_storage_container_at(
12426                    &self.state.placed_containers,
12427                    self.state.character_id,
12428                    x,
12429                    y,
12430                    inside.as_deref(),
12431                ) {
12432                    self.re_open_deposit_filter(cid);
12433                }
12434            }
12435            S::SellNpcs { .. } => {
12436                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12437                    self.re_open_sell_item(Some(npc_id));
12438                }
12439            }
12440            S::SellItem { .. } => {
12441                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12442                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12443                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
12444                            *slot = Some(npc_id.clone());
12445                        }
12446                    }
12447                    self.state
12448                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12449                }
12450            }
12451            // Stop list / other sheets: context-aware quick add.
12452            _ => self.worker_route_editor_quick_add_click(x, y),
12453        }
12454    }
12455
12456    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
12457    /// node wins; duplicates select the existing stop; a click on a merchant
12458    /// while a sell stop is selected pins it.
12459    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
12460        use crate::worker_route_editor as wre;
12461        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
12462            let dx = ax - bx;
12463            let dy = ay - by;
12464            (dx * dx + dy * dy).sqrt()
12465        };
12466
12467        // 1. Retarget the selected stop when the click names its target:
12468        //    sell stop → pin the merchant; withdraw stop → set the source chest.
12469        let selected_stop_kind = self
12470            .state
12471            .worker_route_editor
12472            .as_ref()
12473            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
12474            .map(|s| match s {
12475                wre::WorkerRouteStop::TradeWith { .. } => 1,
12476                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
12477                _ => 0,
12478            })
12479            .unwrap_or(0);
12480        if selected_stop_kind == 1 {
12481            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12482                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12483                    ed.set_selected_trade_npc(npc_id.clone());
12484                }
12485                self.state
12486                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12487                return;
12488            }
12489        }
12490        if selected_stop_kind == 2 {
12491            let inside = self.state.effective_inside_building();
12492            if let Some(cid) = wre::pick_storage_container_at(
12493                &self.state.placed_containers,
12494                self.state.character_id,
12495                x,
12496                y,
12497                inside.as_deref(),
12498            ) {
12499                let name = self
12500                    .state
12501                    .placed_containers
12502                    .iter()
12503                    .find(|c| c.id == cid)
12504                    .map(|c| c.display_name.clone())
12505                    .unwrap_or_else(|| "container".into());
12506                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12507                    ed.set_selected_withdraw_container(cid.clone());
12508                }
12509                self.state
12510                    .push_log(format!("Route: withdraw source → {name}"));
12511                return;
12512            }
12513        }
12514
12515        // 2. Nearest candidate of any kind wins. Category order is the
12516        //    tie-break for exact overlaps (bed over chest over merchant over node).
12517        enum Target {
12518            Bed(String),
12519            Container(String),
12520            Npc(String, String),
12521            Node(String, String),
12522        }
12523        let mut best: Option<(f32, u8, Target)> = None;
12524        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12525            let better = match best {
12526                None => true,
12527                Some((bd, brank, _)) => {
12528                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
12529                }
12530            };
12531            if better {
12532                *best = Some((d, rank, t));
12533            }
12534        };
12535        let inside = self.state.effective_inside_building();
12536        if let Some(bed_id) = wre::pick_lodging_container_at(
12537            &self.state.placed_containers,
12538            self.state.character_id,
12539            x,
12540            y,
12541            inside.as_deref(),
12542        ) {
12543            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12544                // A bed that's already the rest bed doubles as a storage chest —
12545                // a second click on it means "deposit here", not "set bed again".
12546                let already_bed =
12547                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12548                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
12549                    });
12550                if already_bed {
12551                    consider(
12552                        dist(x, y, c.x, c.y),
12553                        1,
12554                        Target::Container(bed_id),
12555                        &mut best,
12556                    );
12557                } else {
12558                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12559                }
12560            }
12561        }
12562        if let Some(cid) = wre::pick_storage_container_at(
12563            &self.state.placed_containers,
12564            self.state.character_id,
12565            x,
12566            y,
12567            inside.as_deref(),
12568        ) {
12569            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12570                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12571            }
12572        }
12573        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12574            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12575                consider(
12576                    dist(x, y, n.x, n.y),
12577                    2,
12578                    Target::Npc(npc_id, label),
12579                    &mut best,
12580                );
12581            }
12582        }
12583        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12584            let d = dist(x, y, node.x, node.y);
12585            let label = resource_node_route_label(node);
12586            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
12587        }
12588
12589        match best.map(|(_, _, t)| t) {
12590            Some(Target::Bed(bed_id)) => {
12591                let name = self
12592                    .state
12593                    .placed_containers
12594                    .iter()
12595                    .find(|c| c.id == bed_id)
12596                    .map(|c| c.display_name.clone())
12597                    .unwrap_or_else(|| "camp bed".into());
12598                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12599                    ed.lodging_container_id = Some(bed_id.clone());
12600                }
12601                self.state
12602                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12603            }
12604            Some(Target::Container(cid)) => {
12605                let name = self
12606                    .state
12607                    .placed_containers
12608                    .iter()
12609                    .find(|c| c.id == cid)
12610                    .map(|c| c.display_name.clone())
12611                    .unwrap_or_else(|| "container".into());
12612                let added = self
12613                    .state
12614                    .worker_route_editor
12615                    .as_mut()
12616                    .is_some_and(|ed| ed.append_deposit_at(&cid));
12617                if added {
12618                    self.state
12619                        .push_log(format!("Route: + deposit at {name} ({cid})"));
12620                } else {
12621                    self.state.push_log(format!(
12622                        "Route: {name} already in route — selected it (d to remove)"
12623                    ));
12624                }
12625            }
12626            Some(Target::Npc(npc_id, label)) => {
12627                // Quick-add sell: first storage template (the full sell flow
12628                // lives in the a → Sell sheet).
12629                let template = self.re_template_candidates().into_iter().next();
12630                let Some(template) = template else {
12631                    self.state.push_log(
12632                        "Route: no items in your storage to sell — stock a chest first".to_string(),
12633                    );
12634                    return;
12635                };
12636                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12637                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
12638                });
12639                if added {
12640                    self.state
12641                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12642                } else {
12643                    self.state.push_log(format!(
12644                        "Route: {label} already sells {template} — selected it (d to remove)"
12645                    ));
12646                }
12647            }
12648            Some(Target::Node(id, label)) => {
12649                let added = self
12650                    .state
12651                    .worker_route_editor
12652                    .as_mut()
12653                    .is_some_and(|ed| ed.append_harvest_node(&id));
12654                if added {
12655                    self.state
12656                        .push_log(format!("Route: + harvest node {label}"));
12657                } else {
12658                    self.state.push_log(format!(
12659                        "Route: {label} already in route — selected it (d to remove)"
12660                    ));
12661                }
12662            }
12663            None => {}
12664        }
12665    }
12666
12667    pub fn worker_route_editor_select(&mut self, delta: i32) {
12668        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12669            return;
12670        };
12671        if ed.stops.is_empty() {
12672            return;
12673        }
12674        let n = ed.stops.len() as i32;
12675        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12676        ed.selected_stop_index = next;
12677    }
12678
12679    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12680        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12681            return;
12682        };
12683        if delta < 0 {
12684            ed.move_selected_up();
12685        } else if delta > 0 {
12686            ed.move_selected_down();
12687        }
12688    }
12689
12690    pub fn worker_route_editor_delete_selected(&mut self) {
12691        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12692            let before = ed.stop_count();
12693            ed.remove_selected_stop();
12694            ed.stop_count() < before
12695        });
12696        if removed {
12697            self.state.push_log("Route: removed selected stop");
12698        }
12699    }
12700
12701    /// Clear all stops. Saving afterwards parks the worker in idle mode
12702    /// instead of leaving it in a broken job loop.
12703    pub fn worker_route_editor_clear_stops(&mut self) {
12704        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12705            return;
12706        };
12707        if ed.stops.is_empty() {
12708            self.state
12709                .push_log("Route: already empty — s saves an idle worker".to_string());
12710            return;
12711        }
12712        ed.stops.clear();
12713        ed.selected_stop_index = 0;
12714        self.state.push_log(
12715            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
12716        );
12717    }
12718
12719    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12720        if self.state.pending_worker_job_ack.is_some() {
12721            anyhow::bail!("route save still pending — wait for server ack");
12722        }
12723        let Some(ed) = self.state.worker_route_editor.clone() else {
12724            anyhow::bail!("route editor not open");
12725        };
12726        // Empty route = stand down: park the worker in idle mode rather than
12727        // erroring out or leaving it marching a ghost loop.
12728        let (job_yaml, idle) = if ed.stops.is_empty() {
12729            (ed.build_idle_job_yaml(), true)
12730        } else {
12731            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12732        };
12733        let worker_id = ed.worker_instance_id.clone();
12734        let route_view = if idle { None } else { Some(ed.to_route_view()) };
12735        let mode = if idle {
12736            flatland_protocol::WorkerModeView::Idle
12737        } else {
12738            flatland_protocol::WorkerModeView::JobLoop
12739        };
12740        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12741            .state
12742            .hired_workers
12743            .iter()
12744            .find(|w| w.instance_id == worker_id)
12745            .map(|w| {
12746                (
12747                    w.route.clone(),
12748                    w.mode,
12749                    w.step_label.clone(),
12750                    w.last_error.clone(),
12751                )
12752            })
12753            .unwrap_or((
12754                None,
12755                flatland_protocol::WorkerModeView::Idle,
12756                String::new(),
12757                None,
12758            ));
12759        self.seq += 1;
12760        let seq = self.seq;
12761        self.session
12762            .submit_intent(Intent::SetWorkerJob {
12763                entity_id: self.state.entity_id,
12764                worker_instance_id: worker_id.clone(),
12765                job_yaml,
12766                seq,
12767            })
12768            .await?;
12769        self.state.intents_sent += 1;
12770        if let Some(w) = self
12771            .state
12772            .hired_workers
12773            .iter_mut()
12774            .find(|w| w.instance_id == worker_id)
12775        {
12776            w.route = route_view;
12777            w.mode = mode;
12778            w.last_error = None;
12779            if idle {
12780                w.step_label.clear();
12781                w.route_stop_index = None;
12782            }
12783        }
12784        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12785            seq,
12786            worker_instance_id: worker_id,
12787            worker_label: ed.worker_label.clone(),
12788            idle,
12789            stop_count: ed.stops.len(),
12790            prev_route,
12791            prev_mode,
12792            prev_step_label,
12793            prev_last_error,
12794        });
12795        self.state.push_log(format!(
12796            "Route: saving for {}… (waiting for server)",
12797            ed.worker_label
12798        ));
12799        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
12800        Ok(())
12801    }
12802    pub fn quest_menu_move(&mut self, delta: i32) {
12803        let n = self.state.active_quest_entries().len();
12804        if n == 0 {
12805            return;
12806        }
12807        let idx = self.state.quest_menu_index as i32;
12808        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12809    }
12810
12811    pub fn quest_menu_page(&mut self, pages: i32) {
12812        let n = self.state.active_quest_entries().len();
12813        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12814    }
12815
12816    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12817        let Some(offer) = self.state.pending_quest_offer.clone() else {
12818            anyhow::bail!("no quest offer");
12819        };
12820        self.seq += 1;
12821        let seq = self.seq;
12822        self.session
12823            .submit_intent(Intent::AcceptQuest {
12824                entity_id: self.state.entity_id,
12825                quest_id: offer.quest_id,
12826                seq,
12827            })
12828            .await?;
12829        self.state.intents_sent += 1;
12830        Ok(())
12831    }
12832
12833    pub fn quest_offer_decline(&mut self) {
12834        self.state.show_quest_offer = false;
12835        self.state.pending_quest_offer = None;
12836        if !self.state.show_npc_chat
12837            && !self.state.show_shop_menu
12838            && self.state.npc_verb_target.is_some()
12839        {
12840            self.state.show_npc_verb_menu = true;
12841        }
12842    }
12843
12844    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12845        if !self.state.show_quest_menu {
12846            return Ok(());
12847        }
12848        let active: Vec<_> = self
12849            .state
12850            .active_quest_entries()
12851            .into_iter()
12852            .cloned()
12853            .collect();
12854        let Some(entry) = active.get(self.state.quest_menu_index) else {
12855            return Ok(());
12856        };
12857        if self.state.quest_withdraw_confirm {
12858            if !entry.can_withdraw {
12859                anyhow::bail!("quest cannot be withdrawn");
12860            }
12861            self.seq += 1;
12862            let seq = self.seq;
12863            self.session
12864                .submit_intent(Intent::WithdrawQuest {
12865                    entity_id: self.state.entity_id,
12866                    quest_id: entry.quest_id.clone(),
12867                    seq,
12868                })
12869                .await?;
12870            self.state.intents_sent += 1;
12871            self.state.quest_withdraw_confirm = false;
12872            return Ok(());
12873        }
12874        self.seq += 1;
12875        let seq = self.seq;
12876        self.session
12877            .submit_intent(Intent::TrackQuest {
12878                entity_id: self.state.entity_id,
12879                quest_id: entry.quest_id.clone(),
12880                seq,
12881            })
12882            .await?;
12883        self.state.intents_sent += 1;
12884        Ok(())
12885    }
12886
12887    pub fn quest_request_withdraw(&mut self) {
12888        if self.state.show_quest_menu {
12889            self.state.quest_withdraw_confirm = true;
12890        }
12891    }
12892
12893    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12894        if !self.state.is_alive() {
12895            anyhow::bail!("you are dead");
12896        }
12897        let Some(catalog) = self.state.shop_catalog.clone() else {
12898            anyhow::bail!("no shop open");
12899        };
12900        self.seq += 1;
12901        let seq = self.seq;
12902        match self.state.shop_tab {
12903            ShopTab::Buy => {
12904                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12905                    anyhow::bail!("nothing selected");
12906                };
12907                if offer.already_owned {
12908                    anyhow::bail!("already owned");
12909                }
12910                self.session
12911                    .submit_intent(Intent::ShopBuy {
12912                        entity_id: self.state.entity_id,
12913                        npc_id: catalog.npc_id.clone(),
12914                        offer_id: offer.offer_id.clone(),
12915                        quantity: self.state.shop_quantity,
12916                        seq,
12917                    })
12918                    .await?;
12919            }
12920            ShopTab::Sell => {
12921                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12922                    anyhow::bail!("nothing to sell");
12923                };
12924                if line.quantity == 0 {
12925                    anyhow::bail!("you have no {}", line.label);
12926                }
12927                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12928                self.session
12929                    .submit_intent(Intent::ShopSell {
12930                        entity_id: self.state.entity_id,
12931                        npc_id: catalog.npc_id.clone(),
12932                        template_id: line.template_id.clone(),
12933                        quantity,
12934                        seq,
12935                    })
12936                    .await?;
12937            }
12938        }
12939        self.state.intents_sent += 1;
12940        Ok(())
12941    }
12942
12943    pub fn craft_menu_move(&mut self, delta: i32) {
12944        let n = self.state.blueprints.len();
12945        if n == 0 {
12946            return;
12947        }
12948        let idx = self.state.craft_menu_index as i32;
12949        let next = (idx + delta).rem_euclid(n as i32);
12950        self.state.craft_menu_index = next as usize;
12951        self.state.clamp_craft_batch_quantity();
12952    }
12953
12954    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12955        self.state.craft_batch_adjust_quantity(delta);
12956    }
12957
12958    pub fn craft_batch_set_max(&mut self) {
12959        self.state.craft_batch_set_max();
12960    }
12961
12962    pub fn craft_batch_set_min(&mut self) {
12963        self.state.craft_batch_set_min();
12964    }
12965
12966    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12967        let Some(blueprint) = self
12968            .state
12969            .blueprints
12970            .get(self.state.craft_menu_index)
12971            .cloned()
12972        else {
12973            anyhow::bail!("no blueprints known");
12974        };
12975        if !self.state.can_craft_blueprint(&blueprint) {
12976            let hint = self
12977                .state
12978                .craft_missing_hint(&blueprint)
12979                .unwrap_or_else(|| "missing materials".into());
12980            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12981        }
12982        let count = self.state.craft_batch_quantity;
12983        let max = self.state.max_craft_batches(&blueprint);
12984        if max == 0 {
12985            anyhow::bail!("cannot craft {}", blueprint.label);
12986        }
12987        let batches = count.min(max);
12988        self.craft(&blueprint.id, Some(batches)).await?;
12989        self.state.show_craft_menu = false;
12990        Ok(())
12991    }
12992
12993    pub async fn move_by(
12994        &mut self,
12995        forward: f32,
12996        strafe: f32,
12997        vertical: f32,
12998        sprint: bool,
12999        sneak: bool,
13000    ) -> anyhow::Result<()> {
13001        if !self.state.is_alive() {
13002            anyhow::bail!("you are dead");
13003        }
13004        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
13005            self.last_move_forward = forward;
13006            self.last_move_strafe = strafe;
13007        }
13008        self.seq += 1;
13009        self.session
13010            .submit_intent(Intent::Move {
13011                entity_id: self.state.entity_id,
13012                forward,
13013                strafe,
13014                vertical,
13015                sprint: sprint && !sneak,
13016                sneak,
13017                seq: self.seq,
13018            })
13019            .await?;
13020        self.state.intents_sent += 1;
13021        Ok(())
13022    }
13023
13024    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
13025        if !self.state.connected {
13026            crate::harvest_trace!("harvest_nearest rejected: not connected");
13027            anyhow::bail!("not connected");
13028        }
13029        if !self.state.is_alive() {
13030            crate::harvest_trace!("harvest_nearest rejected: player dead");
13031            anyhow::bail!("you are dead");
13032        }
13033        if self.state.harvest_in_progress {
13034            if self.state.harvest_state_stale() {
13035                self.state.clear_harvest_state();
13036            } else {
13037                anyhow::bail!("already harvesting");
13038            }
13039        }
13040        let (px, py) = self
13041            .state
13042            .player
13043            .as_ref()
13044            .map(|p| (p.transform.position.x, p.transform.position.y))
13045            .unwrap_or((0.0, 0.0));
13046
13047        let available = self
13048            .state
13049            .resource_nodes
13050            .iter()
13051            .filter(|n| !n.harvest_off)
13052            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13053            .count();
13054        let node_id = self
13055            .state
13056            .resource_nodes
13057            .iter()
13058            .filter(|n| !n.harvest_off)
13059            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13060            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
13061            .min_by(|a, b| {
13062                let da = distance(px, py, a.x, a.y);
13063                let db = distance(px, py, b.x, b.y);
13064                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
13065            })
13066            .map(|n| n.id.clone());
13067
13068        let Some(node_id) = node_id else {
13069            let has_loot = self
13070                .state
13071                .ground_drops
13072                .iter()
13073                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13074            if has_loot {
13075                return self.pickup_nearest().await;
13076            }
13077            anyhow::bail!(
13078                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
13079            );
13080        };
13081
13082        self.seq += 1;
13083        let seq = self.seq;
13084        crate::harvest_trace!(
13085            entity_id = self.state.entity_id,
13086            node_id = %node_id,
13087            seq,
13088            px,
13089            py,
13090            available_nodes = available,
13091            "submitting harvest intent"
13092        );
13093        self.session
13094            .submit_intent(Intent::Harvest {
13095                entity_id: self.state.entity_id,
13096                node_id,
13097                seq,
13098            })
13099            .await?;
13100        self.state.intents_sent += 1;
13101        self.state.harvest_in_progress = true;
13102        self.state.harvest_started_at = Some(Instant::now());
13103        self.state.push_log("Harvesting…");
13104        crate::harvest_trace!(
13105            entity_id = self.state.entity_id,
13106            seq,
13107            "harvest intent queued to session"
13108        );
13109        Ok(())
13110    }
13111
13112    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
13113        if !self.state.is_alive() {
13114            anyhow::bail!("you are dead");
13115        }
13116        let blueprint_id = self
13117            .state
13118            .blueprints
13119            .iter()
13120            .find(|bp| self.state.can_craft_blueprint(bp))
13121            .map(|bp| bp.id.clone())
13122            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
13123        self.craft(&blueprint_id, None).await
13124    }
13125
13126    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
13127        if !self.state.is_alive() {
13128            anyhow::bail!("you are dead");
13129        }
13130        self.seq += 1;
13131        self.session
13132            .submit_intent(Intent::Craft {
13133                entity_id: self.state.entity_id,
13134                blueprint_id: blueprint_id.to_string(),
13135                count,
13136                seq: self.seq,
13137            })
13138            .await?;
13139        self.state.intents_sent += 1;
13140        let (label, batches) = self
13141            .state
13142            .blueprints
13143            .iter()
13144            .find(|b| b.id == blueprint_id)
13145            .map(|b| {
13146                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
13147                (b.label.as_str(), n)
13148            })
13149            .unwrap_or((blueprint_id, count.unwrap_or(1)));
13150        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
13151        Ok(())
13152    }
13153
13154    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
13155        if !self.state.is_alive() {
13156            anyhow::bail!("you are dead");
13157        }
13158        let target_id = match self.state.nearest_interact_target() {
13159            Some(id) => id,
13160            None => {
13161                anyhow::bail!("nothing to interact with nearby");
13162            }
13163        };
13164        if self.state.npcs.iter().any(|n| n.id == target_id) {
13165            self.state.show_npc_verb_menu = true;
13166            self.state.npc_verb_target = Some(target_id);
13167            self.state.npc_verb_index = 0;
13168            return Ok(());
13169        }
13170        if self
13171            .state
13172            .hired_workers
13173            .iter()
13174            .any(|w| w.instance_id == target_id)
13175        {
13176            return self.open_workers_menu_for(&target_id).await;
13177        }
13178        if let Ok(peer_id) = target_id.parse::<EntityId>() {
13179            if self
13180                .state
13181                .hired_workers
13182                .iter()
13183                .any(|w| w.entity_id == peer_id)
13184            {
13185                if let Some(w) = self
13186                    .state
13187                    .hired_workers
13188                    .iter()
13189                    .find(|w| w.entity_id == peer_id)
13190                {
13191                    let id = w.instance_id.clone();
13192                    return self.open_workers_menu_for(&id).await;
13193                }
13194            }
13195            if let Some(entity) = self
13196                .state
13197                .entities
13198                .iter()
13199                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
13200            {
13201                self.state.player_verbs.open_for(peer_id, &entity.label);
13202                return Ok(());
13203            }
13204        }
13205        self.seq += 1;
13206        self.session
13207            .submit_intent(Intent::Interact {
13208                entity_id: self.state.entity_id,
13209                target_id: target_id.clone(),
13210                seq: self.seq,
13211            })
13212            .await?;
13213        self.state.intents_sent += 1;
13214        Ok(())
13215    }
13216
13217    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
13218    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
13219        if !self.state.is_alive() {
13220            anyhow::bail!("you are dead");
13221        }
13222        let (px, py) = self.state.player_position();
13223        let has_loot = self
13224            .state
13225            .ground_drops
13226            .iter()
13227            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13228        if has_loot {
13229            return self.pickup_nearest().await;
13230        }
13231        if self
13232            .state
13233            .placed_containers
13234            .iter()
13235            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
13236        {
13237            return self.pickup_nearest_container().await;
13238        }
13239
13240        if let Some(plot) = self.state.my_plot_under_player().cloned() {
13241            // Double-tap f within 1.2s sells; otherwise farm the plot.
13242            const SELL_WINDOW: Duration = Duration::from_millis(1200);
13243            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
13244                && self
13245                    .state
13246                    .sell_plot_armed_at
13247                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
13248            if sell_armed {
13249                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
13250            }
13251            self.state.sell_plot_confirm = None;
13252            self.state.sell_plot_armed_at = None;
13253
13254            // Prefer farm actions on your own land unless an NPC/player/door/board
13255            // is in interact range (those still win).
13256            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
13257                self.state.npcs.iter().any(|n| n.id == id)
13258                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
13259                    || self.state.doors.iter().any(|d| d.id == id)
13260                    || self.state.interactables.iter().any(|i| {
13261                        i.id == id
13262                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
13263                    })
13264                    || id.parse::<EntityId>().is_ok_and(|eid| {
13265                        self.state
13266                            .entities
13267                            .iter()
13268                            .any(|e| e.id == eid && e.id != self.state.entity_id)
13269                    })
13270            });
13271            if !blocking_interact {
13272                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
13273                match self.harvest_nearest().await {
13274                    Ok(()) => return Ok(()),
13275                    Err(err) => {
13276                        let msg = err.to_string();
13277                        if !(msg.contains("no harvestable")
13278                            || msg.contains("press p")
13279                            || msg.contains("press f")
13280                            || msg.contains("nothing"))
13281                        {
13282                            return Err(err);
13283                        }
13284                    }
13285                }
13286                return Ok(());
13287            }
13288        }
13289        if self.state.nearest_interact_target().is_some() {
13290            return self.interact_nearest().await;
13291        }
13292        // Prefer a clear "move closer" when a board is visible but out of reach,
13293        // instead of silently falling through to harvest.
13294        if let Some((label, dist)) = self.state.nearest_quest_board() {
13295            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
13296                anyhow::bail!(
13297                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
13298                );
13299            }
13300        }
13301
13302        match self.harvest_nearest().await {
13303            Ok(()) => Ok(()),
13304            Err(err) => {
13305                let msg = err.to_string();
13306                if msg.contains("no harvestable")
13307                    || msg.contains("press p")
13308                    || msg.contains("press f")
13309                {
13310                    anyhow::bail!(
13311                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
13312                    );
13313                }
13314                Err(err)
13315            }
13316        }
13317    }
13318
13319    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
13320    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
13321        if !self.state.is_alive() {
13322            anyhow::bail!("you are dead");
13323        }
13324        if self.state.claim_mode.is_some() {
13325            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
13326        }
13327        let zone = self
13328            .state
13329            .free_property_zone_under_player()
13330            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
13331        let zone_id = zone.id.clone();
13332        let label = zone
13333            .label
13334            .as_deref()
13335            .filter(|s| !s.trim().is_empty())
13336            .unwrap_or(zone.id.as_str())
13337            .to_string();
13338        self.enter_claim_mode(&zone_id);
13339        self.state.push_log(format!(
13340            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
13341        ));
13342        Ok(())
13343    }
13344
13345    /// Enter claim mode over a property zone (default 4×4 or min size).
13346    pub fn enter_claim_mode(&mut self, zone_id: &str) {
13347        let Some(zone) = self
13348            .state
13349            .property_zones
13350            .iter()
13351            .find(|z| z.id == zone_id)
13352            .cloned()
13353        else {
13354            self.state.push_log("unknown property zone");
13355            return;
13356        };
13357        self.state.sell_plot_confirm = None;
13358        self.state.sell_plot_armed_at = None;
13359        let min_area = self
13360            .state
13361            .property_plot_settings
13362            .as_ref()
13363            .map(|s| s.min_plot_area_m2)
13364            .unwrap_or(4.0)
13365            .max(1.0);
13366        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
13367        let side = 4u32.max(min_side);
13368        let (px, py) = self.state.player_position();
13369        let anchor_x = px.floor();
13370        let anchor_y = py.floor();
13371        self.state.claim_mode = Some(ClaimModeState {
13372            zone_id: zone.id.clone(),
13373            width_m: side,
13374            height_m: side,
13375            anchor_x,
13376            anchor_y,
13377        });
13378        let label = zone
13379            .label
13380            .as_deref()
13381            .filter(|s| !s.trim().is_empty())
13382            .unwrap_or(zone.id.as_str());
13383        self.state.push_log(format!(
13384            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
13385        ));
13386    }
13387
13388    pub fn cancel_claim_mode(&mut self) {
13389        if self.state.claim_mode.take().is_some() {
13390            self.state.push_log("Claim cancelled");
13391        }
13392    }
13393
13394    /// Enter relocate mode for a placed container (1×1 ghost).
13395    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
13396        if !self.state.is_alive() {
13397            anyhow::bail!("you are dead");
13398        }
13399        if self.state.relocate_mode.is_some() {
13400            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
13401        }
13402        if self.state.claim_mode.is_some() {
13403            anyhow::bail!("finish or cancel claim mode first");
13404        }
13405        let chest = self
13406            .state
13407            .placed_containers
13408            .iter()
13409            .find(|c| c.id == container_id)
13410            .cloned()
13411            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
13412        let (px, py) = self.state.player_position();
13413        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
13414            anyhow::bail!("too far from {}", chest.display_name);
13415        }
13416        if chest.locked && !chest.accessible {
13417            anyhow::bail!(
13418                "need the matching key for {} before moving it",
13419                chest.display_name
13420            );
13421        }
13422        let label = if chest.display_name.trim().is_empty() {
13423            chest.template_id.clone()
13424        } else {
13425            chest.display_name.clone()
13426        };
13427        self.state.relocate_mode = Some(RelocateModeState {
13428            container_id: chest.id.clone(),
13429            label: label.clone(),
13430            cursor_x: chest.x.floor() + 0.5,
13431            cursor_y: chest.y.floor() + 0.5,
13432        });
13433        self.state.push_log(format!(
13434            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
13435        ));
13436        Ok(())
13437    }
13438
13439    /// World `Shift+m`: relocate nearest owned/accessible placed container.
13440    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
13441        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
13442            anyhow::bail!("no chest nearby to relocate");
13443        };
13444        if chest.locked && !chest.accessible {
13445            anyhow::bail!(
13446                "need the matching key for {} before moving it",
13447                chest.display_name
13448            );
13449        }
13450        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
13451        // Server enforces ownership; client starts on nearest accessible chest in range.
13452        self.begin_relocate_container(&chest.id)
13453    }
13454
13455    pub fn cancel_relocate_mode(&mut self) {
13456        if self.state.relocate_mode.take().is_some() {
13457            self.state.push_log("Relocate cancelled");
13458        }
13459    }
13460
13461    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
13462        let Some(mode) = self.state.relocate_mode.as_mut() else {
13463            return;
13464        };
13465        let max_x = self.state.world_width_m.max(1.0);
13466        let max_y = self.state.world_height_m.max(1.0);
13467        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
13468        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
13469        mode.cursor_x = nx.floor() + 0.5;
13470        mode.cursor_y = ny.floor() + 0.5;
13471    }
13472
13473    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
13474        let Some(mode) = self.state.relocate_mode.as_mut() else {
13475            return;
13476        };
13477        let max_x = self.state.world_width_m.max(1.0);
13478        let max_y = self.state.world_height_m.max(1.0);
13479        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
13480        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
13481    }
13482
13483    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
13484        if !self.state.is_alive() {
13485            anyhow::bail!("you are dead");
13486        }
13487        let Some(mode) = self.state.relocate_mode.clone() else {
13488            anyhow::bail!("not relocating");
13489        };
13490        let (px, py) = self.state.player_position();
13491        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
13492        if dist > 8.0 {
13493            anyhow::bail!("destination too far (max 8 m)");
13494        }
13495        self.seq += 1;
13496        self.session
13497            .submit_intent(Intent::MovePlacedContainer {
13498                entity_id: self.state.entity_id,
13499                container_id: mode.container_id.clone(),
13500                x: mode.cursor_x,
13501                y: mode.cursor_y,
13502                seq: self.seq,
13503            })
13504            .await?;
13505        self.state.intents_sent += 1;
13506        self.state.relocate_mode = None;
13507        self.state.push_log(format!("Moving {}…", mode.label));
13508        Ok(())
13509    }
13510
13511    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13512        let Some(mode) = self.state.claim_mode.as_mut() else {
13513            return;
13514        };
13515        mode.width_m = w.max(1);
13516        mode.height_m = h.max(1);
13517    }
13518
13519    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13520        let Some(mode) = self.state.claim_mode.as_mut() else {
13521            return;
13522        };
13523        let w = (mode.width_m as i32 + dw).max(1) as u32;
13524        let h = (mode.height_m as i32 + dh).max(1) as u32;
13525        mode.width_m = w;
13526        mode.height_m = h;
13527    }
13528
13529    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
13530    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13531        let Some(mode) = self.state.claim_mode.as_mut() else {
13532            return;
13533        };
13534        let max_x = self.state.world_width_m.max(1.0);
13535        let max_y = self.state.world_height_m.max(1.0);
13536        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13537        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13538        mode.anchor_x = nx.floor();
13539        mode.anchor_y = ny.floor();
13540    }
13541
13542    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13543        if !self.state.is_alive() {
13544            anyhow::bail!("you are dead");
13545        }
13546        let Some(mode) = self.state.claim_mode.clone() else {
13547            anyhow::bail!("not in claim mode");
13548        };
13549        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13550            self.state.claim_quote()
13551        else {
13552            anyhow::bail!("cannot quote claim");
13553        };
13554        if !valid {
13555            anyhow::bail!(reason);
13556        }
13557        if !can_afford {
13558            anyhow::bail!(
13559                "not enough copper (need {})",
13560                crate::currency::format_copper(purchase)
13561            );
13562        }
13563        let (x0, y0, x1, y1) = self
13564            .state
13565            .claim_footprint_rect()
13566            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13567        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13568        self.seq += 1;
13569        self.session
13570            .submit_intent(Intent::BuyPlot {
13571                entity_id: self.state.entity_id,
13572                zone_id: mode.zone_id,
13573                x0,
13574                y0,
13575                x1,
13576                y1,
13577                seq: self.seq,
13578            })
13579            .await?;
13580        self.state.intents_sent += 1;
13581        self.state.claim_mode = None;
13582        self.state.push_log(format!(
13583            "Buying plot for {}",
13584            crate::currency::format_copper(purchase)
13585        ));
13586        Ok(())
13587    }
13588
13589    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13590        if !self.state.is_alive() {
13591            anyhow::bail!("you are dead");
13592        }
13593        let zone_id = self
13594            .state
13595            .claim_mode
13596            .as_ref()
13597            .map(|m| m.zone_id.clone())
13598            .or_else(|| {
13599                self.state
13600                    .free_property_zone_under_player()
13601                    .map(|z| z.id.clone())
13602            })
13603            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13604        self.seq += 1;
13605        self.session
13606            .submit_intent(Intent::BuyPlotAllFree {
13607                entity_id: self.state.entity_id,
13608                zone_id,
13609                seq: self.seq,
13610            })
13611            .await?;
13612        self.state.intents_sent += 1;
13613        self.state.claim_mode = None;
13614        self.state.push_log("Claiming largest free plot…");
13615        Ok(())
13616    }
13617
13618    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
13619        if !self.state.is_alive() {
13620            anyhow::bail!("you are dead");
13621        }
13622        self.seq += 1;
13623        self.session
13624            .submit_intent(Intent::SellPlotToCrown {
13625                entity_id: self.state.entity_id,
13626                plot_id,
13627                seq: self.seq,
13628            })
13629            .await?;
13630        self.state.intents_sent += 1;
13631        self.state.sell_plot_confirm = None;
13632        self.state.sell_plot_armed_at = None;
13633        self.state.push_log("Selling plot to the crown…");
13634        Ok(())
13635    }
13636
13637    pub async fn set_plot_farm_public(
13638        &mut self,
13639        plot_id: uuid::Uuid,
13640        public: bool,
13641        public_tax_discount_bps: u32,
13642    ) -> anyhow::Result<()> {
13643        self.seq += 1;
13644        self.session
13645            .submit_intent(Intent::SetPlotFarmPublic {
13646                entity_id: self.state.entity_id,
13647                plot_id,
13648                public,
13649                public_tax_discount_bps,
13650                seq: self.seq,
13651            })
13652            .await?;
13653        self.state.intents_sent += 1;
13654        Ok(())
13655    }
13656
13657    pub async fn plot_farm_allow_upsert(
13658        &mut self,
13659        plot_id: uuid::Uuid,
13660        character_id: Option<uuid::Uuid>,
13661        character_name: String,
13662        tax_discount_bps: u32,
13663    ) -> anyhow::Result<()> {
13664        self.seq += 1;
13665        self.session
13666            .submit_intent(Intent::PlotFarmAllowUpsert {
13667                entity_id: self.state.entity_id,
13668                plot_id,
13669                character_id,
13670                character_name,
13671                tax_discount_bps,
13672                seq: self.seq,
13673            })
13674            .await?;
13675        self.state.intents_sent += 1;
13676        Ok(())
13677    }
13678
13679    pub async fn plot_farm_allow_remove(
13680        &mut self,
13681        plot_id: uuid::Uuid,
13682        character_id: uuid::Uuid,
13683    ) -> anyhow::Result<()> {
13684        self.seq += 1;
13685        self.session
13686            .submit_intent(Intent::PlotFarmAllowRemove {
13687                entity_id: self.state.entity_id,
13688                plot_id,
13689                character_id,
13690                seq: self.seq,
13691            })
13692            .await?;
13693        self.state.intents_sent += 1;
13694        Ok(())
13695    }
13696
13697    pub fn open_farm_access_panel(&mut self) {
13698        let Some(plot) = self.state.my_plot_under_player() else {
13699            self.state
13700                .push_log("Stand on your deed plot to manage farm access");
13701            return;
13702        };
13703        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13704        self.state.farm_access_index = 0;
13705        self.state.show_farm_access = true;
13706    }
13707
13708    pub fn close_farm_access_panel(&mut self) {
13709        self.state.show_farm_access = false;
13710        self.state.farm_access_name_draft.clear();
13711        self.state.farm_access_index = 0;
13712    }
13713
13714    pub fn farm_access_move(&mut self, delta: i32) {
13715        let n = self.farm_access_row_count().max(1);
13716        let idx = self.state.farm_access_index as i32 + delta;
13717        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13718    }
13719
13720    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13721        let Some(plot) = self.state.my_plot_under_player() else {
13722            return vec![FarmAccessRow::PublicToggle];
13723        };
13724        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13725        for g in &plot.farm_allow {
13726            rows.push(FarmAccessRow::AllowRemove {
13727                character_id: g.character_id,
13728                label: if g.character_label.trim().is_empty() {
13729                    g.character_id.to_string()[..8].to_string()
13730                } else {
13731                    g.character_label.clone()
13732                },
13733                tax_discount_bps: g.tax_discount_bps,
13734            });
13735        }
13736        for e in &self.state.entities {
13737            if e.id == self.state.entity_id || e.label.trim().is_empty() {
13738                continue;
13739            }
13740            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13741                continue;
13742            }
13743            if self
13744                .state
13745                .npcs
13746                .iter()
13747                .any(|n| n.id == e.label || n.label == e.label)
13748            {
13749                continue;
13750            }
13751            if plot
13752                .farm_allow
13753                .iter()
13754                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13755            {
13756                continue;
13757            }
13758            rows.push(FarmAccessRow::NearbyAdd {
13759                name: e.label.clone(),
13760            });
13761        }
13762        rows
13763    }
13764
13765    pub fn farm_access_row_count(&self) -> usize {
13766        self.farm_access_rows().len().max(1)
13767    }
13768
13769    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13770        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13771            self.close_farm_access_panel();
13772            return Ok(());
13773        };
13774        let rows = self.farm_access_rows();
13775        let Some(row) = rows.get(self.state.farm_access_index) else {
13776            return Ok(());
13777        };
13778        match row {
13779            FarmAccessRow::PublicToggle => {
13780                self.set_plot_farm_public(
13781                    plot.plot_id,
13782                    !plot.farm_public,
13783                    plot.public_tax_discount_bps,
13784                )
13785                .await
13786            }
13787            FarmAccessRow::PublicDiscount => Ok(()),
13788            FarmAccessRow::AllowRemove { character_id, .. } => {
13789                self.plot_farm_allow_remove(plot.plot_id, *character_id)
13790                    .await
13791            }
13792            FarmAccessRow::NearbyAdd { name } => {
13793                let disc = self
13794                    .state
13795                    .farm_access_discount_bps
13796                    .max(plot.public_tax_discount_bps);
13797                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13798                    .await
13799            }
13800        }
13801    }
13802
13803    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13804        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13805            return Ok(());
13806        };
13807        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13808        self.state.farm_access_discount_bps = next;
13809        self.state.farm_access_index = 1;
13810        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13811            .await
13812    }
13813
13814    /// Till the cell under you on a farmable plot (`c`).
13815    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13816        if self.state.farmable_plot_under_player().is_none() {
13817            anyhow::bail!("stand on a farmable plot to cultivate");
13818        }
13819        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13820            let (px, py) = self.state.player_position();
13821            if self
13822                .state
13823                .terrain_at(px, py)
13824                .is_some_and(|k| k == TerrainKindView::Tilled)
13825            {
13826                anyhow::bail!("already tilled — stand on bare soil and press c");
13827            }
13828            anyhow::bail!("cannot till this cell — move onto soil on your plot");
13829        };
13830        self.cultivate_at(tx, ty).await
13831    }
13832
13833    /// Plant seeds on empty tilled soil under you (`p`).
13834    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13835        if self.state.farmable_plot_under_player().is_none() {
13836            anyhow::bail!("stand on a farmable plot to plant");
13837        }
13838        if !self.state.underfoot_free_tilled_plant_slot() {
13839            anyhow::bail!("stand on empty tilled soil and press p");
13840        }
13841        let seeds = self.state.farm_seed_entries();
13842        if seeds.is_empty() {
13843            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13844        }
13845        if seeds.len() == 1 {
13846            return self.plant_seeds(seeds[0].0.clone(), 1).await;
13847        }
13848        self.open_plant_menu();
13849        Ok(())
13850    }
13851
13852    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
13853    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13854        let Some(plot) = self.state.my_plot_under_player() else {
13855            anyhow::bail!("stand on your plot to build");
13856        };
13857        if plot.building_id.is_some() {
13858            anyhow::bail!("this plot already has a building");
13859        }
13860        let building_now = self
13861            .state
13862            .timed_channel
13863            .as_ref()
13864            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13865        if !building_now && self.state.building_materials.is_empty() {
13866            anyhow::bail!("no building materials loaded — wait a moment and try again");
13867        }
13868        self.state.show_plot_build_menu = true;
13869        self.state.show_craft_menu = false;
13870        self.state.show_shop_menu = false;
13871        self.state.shop_catalog = None;
13872        self.state.show_stats = false;
13873        self.state.show_inventory_menu = false;
13874        self.state.plot_build_focus_wall = true;
13875        let walls = self.state.plot_build_wall_options().len();
13876        let roofs = self.state.plot_build_roof_options().len();
13877        if walls > 0 {
13878            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13879        } else {
13880            self.state.plot_build_wall_index = 0;
13881        }
13882        if roofs > 0 {
13883            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13884        } else {
13885            self.state.plot_build_roof_index = 0;
13886        }
13887        Ok(())
13888    }
13889
13890    pub fn close_plot_build_menu(&mut self) {
13891        self.state.show_plot_build_menu = false;
13892    }
13893
13894    pub fn plot_build_menu_move(&mut self, delta: i32) {
13895        let walls = self.state.plot_build_wall_options();
13896        let roofs = self.state.plot_build_roof_options();
13897        if self.state.plot_build_focus_wall {
13898            if walls.is_empty() {
13899                return;
13900            }
13901            let n = walls.len() as i32;
13902            let cur = self.state.plot_build_wall_index as i32;
13903            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13904        } else {
13905            if roofs.is_empty() {
13906                return;
13907            }
13908            let n = roofs.len() as i32;
13909            let cur = self.state.plot_build_roof_index as i32;
13910            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13911        }
13912    }
13913
13914    pub fn plot_build_menu_toggle_focus(&mut self) {
13915        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13916    }
13917
13918    /// Confirm selection from the B menu.
13919    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13920        let wall = self
13921            .state
13922            .plot_build_selected_wall()
13923            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13924            .id
13925            .clone();
13926        let roof = self
13927            .state
13928            .plot_build_selected_roof()
13929            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13930            .id
13931            .clone();
13932        // Keep the B menu open so it switches to the in-progress panel.
13933        self.start_plot_build(&wall, &roof).await
13934    }
13935
13936    /// Cancel an in-progress plot build (materials returned).
13937    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13938        self.seq += 1;
13939        self.session
13940            .submit_intent(Intent::CancelPlotBuild {
13941                entity_id: self.state.entity_id,
13942                seq: self.seq,
13943            })
13944            .await?;
13945        self.state.intents_sent += 1;
13946        Ok(())
13947    }
13948
13949    /// Start timed shell craft on the plot's solid tilled rectangle.
13950    pub async fn start_plot_build(
13951        &mut self,
13952        wall_material_id: &str,
13953        roof_material_id: &str,
13954    ) -> anyhow::Result<()> {
13955        let Some(plot) = self.state.my_plot_under_player() else {
13956            anyhow::bail!("stand on your plot to build");
13957        };
13958        if plot.building_id.is_some() {
13959            anyhow::bail!("this plot already has a building");
13960        }
13961        let plot_id = plot.plot_id;
13962        self.seq += 1;
13963        self.session
13964            .submit_intent(Intent::StartPlotBuild {
13965                entity_id: self.state.entity_id,
13966                plot_id,
13967                wall_material_id: wall_material_id.to_string(),
13968                roof_material_id: roof_material_id.to_string(),
13969                seq: self.seq,
13970            })
13971            .await?;
13972        self.state.intents_sent += 1;
13973        Ok(())
13974    }
13975
13976    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
13977    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13978        let (px, py) = self.state.player_position();
13979        let mut best: Option<(f32, String, bool)> = None;
13980        for d in &self.state.doors {
13981            if d.lock_id.is_none() {
13982                continue;
13983            }
13984            let dist = (d.x - px).hypot(d.y - py);
13985            if dist > DOOR_INTERACTION_RADIUS_M {
13986                continue;
13987            }
13988            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13989                best = Some((dist, d.id.clone(), d.locked));
13990            }
13991        }
13992        let Some((_, door_id, locked_now)) = best else {
13993            anyhow::bail!("no lockable door nearby");
13994        };
13995        let locked = !locked_now;
13996        self.seq += 1;
13997        self.session
13998            .submit_intent(Intent::SetDoorLocked {
13999                entity_id: self.state.entity_id,
14000                door_id,
14001                locked,
14002                seq: self.seq,
14003            })
14004            .await?;
14005        self.state.intents_sent += 1;
14006        Ok(())
14007    }
14008
14009    /// Enter through a nearby open player-building door (`Enter`).
14010    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
14011        if !self.state.is_alive() {
14012            anyhow::bail!("you are dead");
14013        }
14014        if self.state.effective_inside_building().is_some() {
14015            anyhow::bail!("already inside");
14016        }
14017        let (px, py) = self.state.player_position();
14018        let mut best: Option<(f32, String)> = None;
14019        for d in &self.state.doors {
14020            if !d.open || d.locked {
14021                continue;
14022            }
14023            let player_house = self
14024                .state
14025                .buildings
14026                .iter()
14027                .find(|b| b.id == d.building_id)
14028                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14029            if !player_house {
14030                continue;
14031            }
14032            let dist = (d.x - px).hypot(d.y - py);
14033            if dist > DOOR_INTERACTION_RADIUS_M {
14034                continue;
14035            }
14036            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14037                best = Some((dist, d.id.clone()));
14038            }
14039        }
14040        let Some((_, door_id)) = best else {
14041            anyhow::bail!("no open house door nearby — open with f first");
14042        };
14043        self.seq += 1;
14044        self.session
14045            .submit_intent(Intent::EnterBuildingDoor {
14046                entity_id: self.state.entity_id,
14047                door_id,
14048                seq: self.seq,
14049            })
14050            .await?;
14051        self.state.intents_sent += 1;
14052        Ok(())
14053    }
14054
14055    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
14056    /// Works even when the door is closed or locked.
14057    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
14058        if !self.state.is_alive() {
14059            anyhow::bail!("you are dead");
14060        }
14061        let Some(bid) = self.state.effective_inside_building() else {
14062            anyhow::bail!("not inside a building");
14063        };
14064        let (px, py) = self.state.player_position();
14065        let mut best: Option<(f32, String)> = None;
14066        for d in &self.state.doors {
14067            if d.building_id != bid || d.portal.is_none() {
14068                continue;
14069            }
14070            let player_house = self
14071                .state
14072                .buildings
14073                .iter()
14074                .find(|b| b.id == d.building_id)
14075                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14076            if !player_house {
14077                continue;
14078            }
14079            let dist = (d.x - px).hypot(d.y - py);
14080            if dist > 1.5 {
14081                continue;
14082            }
14083            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14084                best = Some((dist, d.id.clone()));
14085            }
14086        }
14087        let Some((_, door_id)) = best else {
14088            anyhow::bail!("stand by the door to exit");
14089        };
14090        self.seq += 1;
14091        self.session
14092            .submit_intent(Intent::ExitBuildingDoor {
14093                entity_id: self.state.entity_id,
14094                door_id,
14095                seq: self.seq,
14096            })
14097            .await?;
14098        self.state.intents_sent += 1;
14099        Ok(())
14100    }
14101
14102    /// Confirm interior layout for your player building (copper charged on server).
14103    pub async fn confirm_interior_edit(
14104        &mut self,
14105        building_id: String,
14106        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
14107        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
14108    ) -> anyhow::Result<()> {
14109        self.seq += 1;
14110        self.session
14111            .submit_intent(Intent::ConfirmInteriorEdit {
14112                entity_id: self.state.entity_id,
14113                building_id,
14114                rooms,
14115                room_doors,
14116                seq: self.seq,
14117            })
14118            .await?;
14119        self.state.intents_sent += 1;
14120        Ok(())
14121    }
14122
14123    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
14124        if !self.state.is_alive() {
14125            anyhow::bail!("you are dead");
14126        }
14127        self.seq += 1;
14128        self.session
14129            .submit_intent(Intent::Cultivate {
14130                entity_id: self.state.entity_id,
14131                x,
14132                y,
14133                seq: self.seq,
14134            })
14135            .await?;
14136        self.state.intents_sent += 1;
14137        Ok(())
14138    }
14139
14140    pub async fn plant_seeds(
14141        &mut self,
14142        seed_template_id: String,
14143        quantity: u32,
14144    ) -> anyhow::Result<()> {
14145        if !self.state.is_alive() {
14146            anyhow::bail!("you are dead");
14147        }
14148        self.seq += 1;
14149        self.session
14150            .submit_intent(Intent::PlantSeeds {
14151                entity_id: self.state.entity_id,
14152                seed_template_id: seed_template_id.clone(),
14153                quantity,
14154                seq: self.seq,
14155            })
14156            .await?;
14157        self.state.intents_sent += 1;
14158        self.state
14159            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
14160        Ok(())
14161    }
14162
14163    pub fn open_plant_menu(&mut self) {
14164        if self.state.farm_seed_entries().is_empty() {
14165            self.state.push_log("No seeds in inventory to plant");
14166            return;
14167        }
14168        self.state.show_plant_menu = true;
14169        self.state.plant_menu_index = 0;
14170        self.state.plant_quantity = 1;
14171        self.state.clamp_plant_menu();
14172    }
14173
14174    pub fn close_plant_menu(&mut self) {
14175        self.state.show_plant_menu = false;
14176    }
14177
14178    pub fn plant_menu_move(&mut self, delta: i32) {
14179        let n = self.state.farm_seed_entries().len();
14180        if n == 0 {
14181            return;
14182        }
14183        let idx = self.state.plant_menu_index as i32 + delta;
14184        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
14185        self.state.clamp_plant_menu();
14186    }
14187
14188    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
14189        let next = self.state.plant_quantity as i32 + delta;
14190        self.state.plant_quantity = next.max(1) as u32;
14191        self.state.clamp_plant_menu();
14192    }
14193
14194    pub fn plant_menu_set_quantity_max(&mut self) {
14195        if let Some((_, max, _)) = self.state.plant_menu_selection() {
14196            self.state.plant_quantity = max;
14197        }
14198        self.state.clamp_plant_menu();
14199    }
14200
14201    pub fn plant_menu_set_quantity_min(&mut self) {
14202        self.state.plant_quantity = 1;
14203        self.state.clamp_plant_menu();
14204    }
14205
14206    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
14207        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
14208            self.close_plant_menu();
14209            anyhow::bail!("no seeds to plant");
14210        };
14211        self.close_plant_menu();
14212        self.plant_seeds(seed, qty).await?;
14213        self.state.push_log(format!("Planted {qty}× {label}"));
14214        Ok(())
14215    }
14216
14217    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
14218    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
14219    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
14220        if !self.state.is_alive() {
14221            anyhow::bail!("you are dead");
14222        }
14223        let binding = self
14224            .state
14225            .hotbar_ability(slot)
14226            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
14227            .to_string();
14228        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
14229            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
14230            if qty == 0 {
14231                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
14232            }
14233            return self.use_item(template_id).await;
14234        }
14235        let ability_id = binding;
14236        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
14237            return self
14238                .cast_ability(&ability_id, Some(self.state.entity_id))
14239                .await;
14240        }
14241        let is_heal = ability_id == "heal_touch"
14242            || self
14243                .state
14244                .ability_meta
14245                .get(&ability_id)
14246                .map(|meta| meta.is_heal)
14247                .unwrap_or(false);
14248        let target = if is_heal {
14249            Some(
14250                self.state
14251                    .target_for_slot(2)
14252                    .unwrap_or(self.state.entity_id),
14253            )
14254        } else {
14255            self.state
14256                .target_for_slot(1)
14257                .or_else(|| self.state.target_for_slot(2))
14258        };
14259        let Some(target_id) = target else {
14260            anyhow::bail!("no target — Tab to select, then press the hotbar key");
14261        };
14262        self.cast_ability(&ability_id, Some(target_id)).await
14263    }
14264
14265    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
14266    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
14267    pub async fn set_hotbar_slot(
14268        &mut self,
14269        slot: u8,
14270        ability_id: Option<&str>,
14271    ) -> anyhow::Result<()> {
14272        if !self.state.is_alive() {
14273            anyhow::bail!("you are dead");
14274        }
14275        if !(1..=9).contains(&slot) {
14276            anyhow::bail!("hotbar slot must be 1–9");
14277        }
14278        let ability_id = ability_id
14279            .map(str::trim)
14280            .filter(|id| !id.is_empty())
14281            .map(str::to_string);
14282        self.seq += 1;
14283        self.session
14284            .submit_intent(Intent::SetHotbarSlot {
14285                entity_id: self.state.entity_id,
14286                slot,
14287                ability_id: ability_id.clone(),
14288                seq: self.seq,
14289            })
14290            .await?;
14291        self.state.intents_sent += 1;
14292        let idx = (slot - 1) as usize;
14293        if self.state.hotbar.len() < 9 {
14294            self.state.hotbar.resize(9, None);
14295        }
14296        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
14297            *slot_mut = ability_id.clone();
14298        }
14299        match ability_id {
14300            Some(id) => {
14301                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
14302                    format!("use {tid}")
14303                } else {
14304                    id
14305                };
14306                self.state.push_log(format!("Hotbar {slot} → {label}"))
14307            }
14308            None => self.state.push_log(format!("Hotbar {slot} cleared")),
14309        }
14310        Ok(())
14311    }
14312
14313    pub fn npc_verb_options(&self) -> Vec<&'static str> {
14314        self.state.npc_verb_options()
14315    }
14316
14317    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
14318        let Some(npc_id) = self.state.npc_verb_target.clone() else {
14319            return Ok(());
14320        };
14321        let options = self.npc_verb_options();
14322        let choice = options
14323            .get(self.state.npc_verb_index)
14324            .copied()
14325            .unwrap_or("Talk");
14326        self.seq += 1;
14327        match choice {
14328            "Trade" | "Bank" | "Storage" | "Market" => {
14329                self.session
14330                    .submit_intent(Intent::Interact {
14331                        entity_id: self.state.entity_id,
14332                        target_id: npc_id,
14333                        seq: self.seq,
14334                    })
14335                    .await?;
14336            }
14337            _ => {
14338                self.session
14339                    .submit_intent(Intent::NpcTalkOpen {
14340                        entity_id: self.state.entity_id,
14341                        npc_id,
14342                        seq: self.seq,
14343                    })
14344                    .await?;
14345            }
14346        }
14347        self.state.intents_sent += 1;
14348        Ok(())
14349    }
14350
14351    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
14352        let Some(chat) = self.state.npc_chat.clone() else {
14353            return Ok(());
14354        };
14355        let message = chat.input.trim().to_string();
14356        if message.is_empty() || chat.pending {
14357            return Ok(());
14358        }
14359        if let Some(c) = self.state.npc_chat.as_mut() {
14360            c.lines.push(format!("You: {message}"));
14361            c.input.clear();
14362            c.pending = true;
14363        }
14364        self.seq += 1;
14365        self.session
14366            .submit_intent(Intent::NpcTalkSay {
14367                entity_id: self.state.entity_id,
14368                npc_id: chat.npc_id,
14369                message,
14370                seq: self.seq,
14371            })
14372            .await?;
14373        self.state.intents_sent += 1;
14374        Ok(())
14375    }
14376
14377    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
14378        let topic = self
14379            .state
14380            .npc_chat
14381            .as_ref()
14382            .and_then(|c| c.suggested_topics.get(index))
14383            .cloned();
14384        let Some(topic) = topic else {
14385            return Ok(());
14386        };
14387        if let Some(c) = self.state.npc_chat.as_mut() {
14388            if c.pending {
14389                return Ok(());
14390            }
14391            c.input = topic;
14392        }
14393        self.npc_talk_send().await
14394    }
14395
14396    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
14397        let return_to_verbs = self.state.npc_verb_target.is_some();
14398        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
14399            self.state.show_npc_chat = false;
14400            if return_to_verbs {
14401                self.state.show_npc_verb_menu = true;
14402            }
14403            return Ok(());
14404        };
14405        self.seq += 1;
14406        self.session
14407            .submit_intent(Intent::NpcTalkClose {
14408                entity_id: self.state.entity_id,
14409                npc_id,
14410                seq: self.seq,
14411            })
14412            .await?;
14413        self.state.intents_sent += 1;
14414        self.state.show_npc_chat = false;
14415        self.state.npc_chat = None;
14416        if return_to_verbs {
14417            self.state.show_npc_verb_menu = true;
14418        }
14419        Ok(())
14420    }
14421
14422    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
14423    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
14424        if self.state.show_quest_offer
14425            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
14426        {
14427            self.quest_offer_decline();
14428            return Ok(());
14429        }
14430        if self.state.show_npc_chat {
14431            return self.npc_talk_close().await;
14432        }
14433        if self.state.show_shop_menu {
14434            return self.back_from_shop_menu().await;
14435        }
14436        if self.state.bank_panel.is_some() {
14437            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
14438                self.bank_transfer_back();
14439                return Ok(());
14440            }
14441            return self.close_bank_panel().await;
14442        }
14443        if self.state.storage_panel.is_some() {
14444            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
14445                self.storage_ui_back();
14446                return Ok(());
14447            }
14448            return self.close_storage_panel().await;
14449        }
14450        if self.state.market_panel.is_some() {
14451            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
14452                self.market_ui_back();
14453                return Ok(());
14454            }
14455            if self.state.market_buy_confirm.is_some() {
14456                self.state.market_buy_confirm = None;
14457                return Ok(());
14458            }
14459            return self.close_market_panel().await;
14460        }
14461        if self.state.show_npc_verb_menu {
14462            self.state.show_npc_verb_menu = false;
14463            self.state.npc_verb_target = None;
14464        }
14465        Ok(())
14466    }
14467
14468    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
14469        self.seq += 1;
14470        self.session
14471            .submit_intent(Intent::TestDamage {
14472                entity_id: self.state.entity_id,
14473                amount,
14474                seq: self.seq,
14475            })
14476            .await?;
14477        self.state.intents_sent += 1;
14478        Ok(())
14479    }
14480
14481    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
14482        self.cycle_combat_target_slot(1, reverse).await
14483    }
14484
14485    pub async fn cycle_combat_target_slot(
14486        &mut self,
14487        slot_index: u8,
14488        reverse: bool,
14489    ) -> anyhow::Result<()> {
14490        if !self.state.is_alive() {
14491            anyhow::bail!("you are dead");
14492        }
14493        let candidates = self.state.candidates_for_slot(slot_index);
14494        if candidates.is_empty() {
14495            anyhow::bail!("no targets nearby");
14496        }
14497        let current = self.state.target_for_slot(slot_index);
14498        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
14499        let next_idx = match idx {
14500            None => 0,
14501            Some(i) if reverse => {
14502                if i == 0 {
14503                    candidates.len() - 1
14504                } else {
14505                    i - 1
14506                }
14507            }
14508            Some(i) => (i + 1) % candidates.len(),
14509        };
14510        if idx == Some(next_idx) && candidates.len() == 1 {
14511            self.clear_combat_target_slot(slot_index).await?;
14512            return Ok(());
14513        }
14514        let (target_id, label) = candidates[next_idx].clone();
14515        self.set_combat_target_slot(slot_index, target_id, &label)
14516            .await
14517    }
14518
14519    pub async fn set_combat_target_slot(
14520        &mut self,
14521        slot_index: u8,
14522        target_id: EntityId,
14523        label: &str,
14524    ) -> anyhow::Result<()> {
14525        if !self.state.is_alive() {
14526            anyhow::bail!("you are dead");
14527        }
14528        self.seq += 1;
14529        self.session
14530            .submit_intent(Intent::SetTargetSlot {
14531                entity_id: self.state.entity_id,
14532                slot_index,
14533                target_id,
14534                seq: self.seq,
14535            })
14536            .await?;
14537        self.state.intents_sent += 1;
14538        if slot_index == 1 {
14539            self.state.combat_target = Some(target_id);
14540            self.state.combat_target_label = Some(label.to_string());
14541        }
14542        self.state
14543            .push_log(format!("Slot {slot_index} target: {label}"));
14544        Ok(())
14545    }
14546
14547    pub async fn set_combat_target(
14548        &mut self,
14549        target_id: EntityId,
14550        label: &str,
14551    ) -> anyhow::Result<()> {
14552        self.set_combat_target_slot(1, target_id, label).await
14553    }
14554
14555    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14556        if slot_index == 1 && self.state.combat_target.is_none() {
14557            return Ok(());
14558        }
14559        self.seq += 1;
14560        self.session
14561            .submit_intent(Intent::ClearTargetSlot {
14562                entity_id: self.state.entity_id,
14563                slot_index,
14564                seq: self.seq,
14565            })
14566            .await?;
14567        if slot_index == 1 {
14568            self.state.combat_target = None;
14569            self.state.combat_target_label = None;
14570        }
14571        self.state.intents_sent += 1;
14572        self.state
14573            .push_log(format!("Slot {slot_index} target cleared"));
14574        Ok(())
14575    }
14576
14577    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14578        self.clear_combat_target_slot(1).await
14579    }
14580
14581    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14582        if !self.state.is_alive() {
14583            anyhow::bail!("you are dead");
14584        }
14585        self.seq += 1;
14586        self.session
14587            .submit_intent(Intent::AdvanceRotation {
14588                entity_id: self.state.entity_id,
14589                slot_index,
14590                seq: self.seq,
14591            })
14592            .await?;
14593        self.state.intents_sent += 1;
14594        Ok(())
14595    }
14596
14597    pub async fn assign_slot_preset(
14598        &mut self,
14599        slot_index: u8,
14600        preset_id: &str,
14601    ) -> anyhow::Result<()> {
14602        if !self.state.is_alive() {
14603            anyhow::bail!("you are dead");
14604        }
14605        self.seq += 1;
14606        self.session
14607            .submit_intent(Intent::AssignSlotPreset {
14608                entity_id: self.state.entity_id,
14609                slot_index,
14610                preset_id: preset_id.to_string(),
14611                seq: self.seq,
14612            })
14613            .await?;
14614        self.state.intents_sent += 1;
14615        if let Some(slot) = self
14616            .state
14617            .combat_slots
14618            .iter_mut()
14619            .find(|s| s.slot_index == slot_index)
14620        {
14621            slot.preset_id = Some(preset_id.to_string());
14622            if let Some(preset) = self
14623                .state
14624                .rotation_presets
14625                .iter()
14626                .find(|p| p.id == preset_id)
14627            {
14628                slot.preset_label = Some(preset.label.clone());
14629                slot.rotation = preset.abilities.clone();
14630                slot.rotation_index = 0;
14631            }
14632        }
14633        self.state
14634            .push_log(format!("T{slot_index} loadout → {preset_id}"));
14635        Ok(())
14636    }
14637
14638    pub async fn cast_ability(
14639        &mut self,
14640        ability_id: &str,
14641        target_id: Option<EntityId>,
14642    ) -> anyhow::Result<()> {
14643        if !self.state.is_alive() {
14644            anyhow::bail!("you are dead");
14645        }
14646        let allows_ground = self.state.ability_allows_ground(ability_id);
14647        let requires_ground = self.state.ability_requires_ground(ability_id);
14648        if requires_ground && self.state.ground_target.is_none() {
14649            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14650        }
14651        let (resolved_target_id, target_point) = if allows_ground {
14652            if let Some((x, y, z)) = self.state.ground_target {
14653                (
14654                    target_id.unwrap_or(self.state.entity_id),
14655                    Some(flatland_protocol::AimPoint { x, y, z }),
14656                )
14657            } else {
14658                (
14659                    target_id
14660                        .or_else(|| self.state.target_for_slot(2))
14661                        .or_else(|| self.state.target_for_slot(1))
14662                        .unwrap_or(self.state.entity_id),
14663                    None,
14664                )
14665            }
14666        } else {
14667            (
14668                target_id
14669                    .or_else(|| self.state.target_for_slot(2))
14670                    .or_else(|| self.state.target_for_slot(1))
14671                    .unwrap_or(self.state.entity_id),
14672                None,
14673            )
14674        };
14675        self.seq += 1;
14676        self.session
14677            .submit_intent(Intent::Cast {
14678                entity_id: self.state.entity_id,
14679                ability_id: ability_id.to_string(),
14680                target_id: resolved_target_id,
14681                target_point,
14682                seq: self.seq,
14683            })
14684            .await?;
14685        self.state.intents_sent += 1;
14686        match target_point {
14687            Some(point) => self.state.push_log(format!(
14688                "Cast {ability_id} → ({:.1}, {:.1})",
14689                point.x, point.y
14690            )),
14691            None => self
14692                .state
14693                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14694        }
14695        Ok(())
14696    }
14697
14698    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14699        self.seq += 1;
14700        self.session
14701            .submit_intent(Intent::UpsertRotationPreset {
14702                entity_id: self.state.entity_id,
14703                preset: preset.clone(),
14704                seq: self.seq,
14705            })
14706            .await?;
14707        self.state.intents_sent += 1;
14708        if let Some(existing) = self
14709            .state
14710            .rotation_presets
14711            .iter_mut()
14712            .find(|p| p.id == preset.id)
14713        {
14714            *existing = preset.clone();
14715        } else {
14716            self.state.rotation_presets.push(preset.clone());
14717        }
14718        for slot in &mut self.state.combat_slots {
14719            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14720                slot.preset_label = Some(preset.label.clone());
14721                slot.rotation = preset.abilities.clone();
14722            }
14723        }
14724        self.state
14725            .push_log(format!("Saved rotation: {}", preset.label));
14726        Ok(())
14727    }
14728
14729    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14730        self.seq += 1;
14731        self.session
14732            .submit_intent(Intent::DeleteRotationPreset {
14733                entity_id: self.state.entity_id,
14734                preset_id: preset_id.to_string(),
14735                seq: self.seq,
14736            })
14737            .await?;
14738        self.state.intents_sent += 1;
14739        self.state.rotation_presets.retain(|p| p.id != preset_id);
14740        for slot in &mut self.state.combat_slots {
14741            if slot.preset_id.as_deref() == Some(preset_id) {
14742                slot.preset_id = None;
14743                slot.preset_label = None;
14744                slot.rotation.clear();
14745                slot.rotation_index = 0;
14746            }
14747        }
14748        self.state
14749            .push_log(format!("Deleted rotation: {preset_id}"));
14750        Ok(())
14751    }
14752
14753    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14754        if !self.state.is_alive() {
14755            anyhow::bail!("you are dead");
14756        }
14757        let enabled = !self
14758            .state
14759            .combat_slots
14760            .iter()
14761            .find(|s| s.slot_index == slot_index)
14762            .map(|s| s.auto_enabled)
14763            .unwrap_or(false);
14764        self.seq += 1;
14765        self.session
14766            .submit_intent(Intent::SetAutoAttack {
14767                entity_id: self.state.entity_id,
14768                slot_index,
14769                enabled,
14770                seq: self.seq,
14771            })
14772            .await?;
14773        if slot_index == 1 {
14774            self.state.auto_attack = enabled;
14775        }
14776        self.state.intents_sent += 1;
14777        self.state.push_log(format!(
14778            "T{slot_index} auto {}",
14779            if enabled { "ON" } else { "OFF" }
14780        ));
14781        Ok(())
14782    }
14783
14784    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14785        if !self.state.connected {
14786            anyhow::bail!("not connected");
14787        }
14788        if !self.state.is_alive() {
14789            anyhow::bail!("you are dead");
14790        }
14791        let (px, py) = self.state.player_position();
14792        if self
14793            .state
14794            .ground_drops
14795            .iter()
14796            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14797        {
14798            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14799        }
14800        self.seq += 1;
14801        self.session
14802            .submit_intent(Intent::Pickup {
14803                entity_id: self.state.entity_id,
14804                drop_id: None,
14805                seq: self.seq,
14806            })
14807            .await?;
14808        self.state.intents_sent += 1;
14809        self.state.push_audio(crate::social::AudioCue::LootPickup);
14810        Ok(())
14811    }
14812
14813    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14814        if !self.state.is_alive() {
14815            anyhow::bail!("you are dead");
14816        }
14817        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
14818        self.seq += 1;
14819        self.session
14820            .submit_intent(Intent::Dodge {
14821                entity_id: self.state.entity_id,
14822                forward,
14823                strafe,
14824                seq: self.seq,
14825            })
14826            .await?;
14827        self.state.intents_sent += 1;
14828        self.state.push_log("Dodge!");
14829        self.state.push_audio(crate::social::AudioCue::CombatDodge);
14830        Ok(())
14831    }
14832
14833    pub async fn lunge(&mut self) -> anyhow::Result<()> {
14834        if !self.state.is_alive() {
14835            anyhow::bail!("you are dead");
14836        }
14837        let (forward, strafe) = self.last_move_axes();
14838        self.seq += 1;
14839        self.session
14840            .submit_intent(Intent::Lunge {
14841                entity_id: self.state.entity_id,
14842                forward,
14843                strafe,
14844                seq: self.seq,
14845            })
14846            .await?;
14847        self.state.intents_sent += 1;
14848        self.state.push_log("Lunge!");
14849        Ok(())
14850    }
14851
14852    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14853        if !self.state.is_alive() {
14854            anyhow::bail!("you are dead");
14855        }
14856        self.seq += 1;
14857        self.session
14858            .submit_intent(Intent::DirectionalJump {
14859                entity_id: self.state.entity_id,
14860                forward,
14861                strafe,
14862                seq: self.seq,
14863            })
14864            .await?;
14865        self.state.intents_sent += 1;
14866        self.state.push_log("Jump!");
14867        Ok(())
14868    }
14869
14870    /// Remembered WASD axes for lunge when not currently moving.
14871    pub fn last_move_axes(&self) -> (f32, f32) {
14872        (self.last_move_forward, self.last_move_strafe)
14873    }
14874
14875    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14876        if !self.state.is_alive() {
14877            anyhow::bail!("you are dead");
14878        }
14879        self.seq += 1;
14880        self.session
14881            .submit_intent(Intent::Block {
14882                entity_id: self.state.entity_id,
14883                enabled,
14884                seq: self.seq,
14885            })
14886            .await?;
14887        self.state.intents_sent += 1;
14888        if enabled {
14889            self.state.push_log("Blocking");
14890            self.state.push_audio(crate::social::AudioCue::CombatBlock);
14891        }
14892        Ok(())
14893    }
14894
14895    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14896        if !self.state.is_alive() {
14897            anyhow::bail!("you are dead");
14898        }
14899        self.seq += 1;
14900        self.session
14901            .submit_intent(Intent::EquipMainhand {
14902                entity_id: self.state.entity_id,
14903                template_id,
14904                instance_id: None,
14905                seq: self.seq,
14906            })
14907            .await?;
14908        self.state.intents_sent += 1;
14909        Ok(())
14910    }
14911
14912    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
14913    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14914        let idx = self.state.equip_menu_index;
14915        let slots = equip_paperdoll_rows(&self.state);
14916        let Some(row) = slots.get(idx) else {
14917            return Ok(());
14918        };
14919        match row {
14920            EquipPaperdollRow::Body { slot, filled } => {
14921                if *filled {
14922                    self.equip_worn(*slot, None).await
14923                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14924                    self.equip_worn(*slot, Some(inst)).await
14925                } else {
14926                    self.state
14927                        .push_log(format!("No item for {}", body_slot_label(*slot)));
14928                    Ok(())
14929                }
14930            }
14931            EquipPaperdollRow::Mainhand { filled } => {
14932                if *filled {
14933                    self.unequip_mainhand().await
14934                } else if let Some(tid) = first_inventory_weapon(&self.state) {
14935                    self.equip_mainhand(Some(tid)).await
14936                } else {
14937                    self.state.push_log("No weapon in inventory".to_string());
14938                    Ok(())
14939                }
14940            }
14941            EquipPaperdollRow::Offhand { filled, locked } => {
14942                if *locked {
14943                    self.state
14944                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
14945                    Ok(())
14946                } else if *filled {
14947                    self.unequip_offhand().await
14948                } else if let Some(tid) = first_inventory_offhand(&self.state) {
14949                    self.equip_offhand(Some(tid)).await
14950                } else {
14951                    self.state
14952                        .push_log("No offhand item in inventory".to_string());
14953                    Ok(())
14954                }
14955            }
14956        }
14957    }
14958
14959    pub async fn say(
14960        &mut self,
14961        channel: flatland_protocol::ChatChannel,
14962        text: &str,
14963    ) -> anyhow::Result<()> {
14964        self.say_to(channel, text, None).await
14965    }
14966
14967    pub async fn say_to(
14968        &mut self,
14969        channel: flatland_protocol::ChatChannel,
14970        text: &str,
14971        to_entity: Option<EntityId>,
14972    ) -> anyhow::Result<()> {
14973        self.seq += 1;
14974        self.session
14975            .submit_intent(Intent::Say {
14976                entity_id: self.state.entity_id,
14977                channel,
14978                text: text.to_string(),
14979                to_entity,
14980                seq: self.seq,
14981            })
14982            .await?;
14983        self.state.intents_sent += 1;
14984        Ok(())
14985    }
14986
14987    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14988        let Some(peer) = self.state.player_verbs.target_entity else {
14989            return Ok(());
14990        };
14991        let label = self.state.player_verbs.target_label.clone();
14992        let choice = crate::social::PlayerVerbState::options()
14993            .get(self.state.player_verbs.index)
14994            .copied()
14995            .unwrap_or("Whisper");
14996        self.state.player_verbs.close();
14997        match choice {
14998            "Trade" => {
14999                // Only request — peer must accept (Y in chat). Do not auto-respond;
15000                // TradeRespond requires a pending inbound request from the peer.
15001                self.seq += 1;
15002                self.session
15003                    .submit_intent(Intent::TradeRequest {
15004                        entity_id: self.state.entity_id,
15005                        peer_entity_id: peer,
15006                        seq: self.seq,
15007                    })
15008                    .await?;
15009                self.state.intents_sent += 1;
15010                self.state.social_chat.push_system(format!(
15011                    "Trade request sent to {label} — waiting for accept"
15012                ));
15013            }
15014            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
15015            _ => self.state.social_chat.focus_nearby(),
15016        }
15017        Ok(())
15018    }
15019
15020    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
15021        let Some(pending) = self.state.social_chat.pending_trade.take() else {
15022            return Ok(());
15023        };
15024        self.seq += 1;
15025        self.session
15026            .submit_intent(Intent::TradeRespond {
15027                entity_id: self.state.entity_id,
15028                peer_entity_id: pending.from_entity,
15029                accept,
15030                seq: self.seq,
15031            })
15032            .await?;
15033        self.state.intents_sent += 1;
15034        if accept {
15035            self.state
15036                .social_chat
15037                .push_system(format!("Accepted trade with {}", pending.from_name));
15038        } else {
15039            self.state
15040                .social_chat
15041                .push_system(format!("Declined trade with {}", pending.from_name));
15042        }
15043        Ok(())
15044    }
15045
15046    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
15047        let text = self.state.social_chat.buffer.trim().to_string();
15048        if text.is_empty() {
15049            return Ok(());
15050        }
15051        self.state.social_chat.buffer.clear();
15052        if crate::social::is_chat_slash_line(&text) {
15053            match crate::social::parse_chat_slash(&text) {
15054                Some(cmd) => return self.apply_chat_slash(cmd).await,
15055                None => {
15056                    self.state.social_chat.push_system(format!(
15057                        "Unknown command — {}",
15058                        crate::social::chat_slash_help_text()
15059                    ));
15060                    return Ok(());
15061                }
15062            }
15063        }
15064        let thread = self.state.social_chat.thread;
15065        let channel = thread.channel();
15066        let to = thread.to_entity();
15067        if let Some(peer) = to {
15068            let label = self.state.social_chat.peer_label.clone();
15069            self.state
15070                .social_chat
15071                .remember_whisper_peer(peer, &label, channel);
15072        }
15073        self.say_to(channel, &text, to).await
15074    }
15075
15076    async fn apply_chat_slash(
15077        &mut self,
15078        cmd: crate::social::ChatSlashCommand,
15079    ) -> anyhow::Result<()> {
15080        use crate::social::{chat_slash_help_text, ChatSlashCommand};
15081        match cmd {
15082            ChatSlashCommand::Help => {
15083                self.state
15084                    .social_chat
15085                    .push_system(chat_slash_help_text().to_string());
15086                Ok(())
15087            }
15088            ChatSlashCommand::Nearby { message } => {
15089                self.state.social_chat.focus_nearby();
15090                self.state
15091                    .social_chat
15092                    .push_system("Nearby speech — everyone close can hear");
15093                if let Some(msg) = message {
15094                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
15095                        .await
15096                } else {
15097                    Ok(())
15098                }
15099            }
15100            ChatSlashCommand::Reply { message } => {
15101                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15102                    self.state
15103                        .social_chat
15104                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
15105                    return Ok(());
15106                };
15107                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
15108                self.state
15109                    .social_chat
15110                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
15111                self.state.social_chat.push_system(format!(
15112                    "Replying to {} — type and Enter · /nearby",
15113                    peer.label
15114                ));
15115                if let Some(msg) = message {
15116                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
15117                } else {
15118                    Ok(())
15119                }
15120            }
15121            ChatSlashCommand::Whisper { name, message } => {
15122                let (peer_id, label, stone) = if let Some(name) = name {
15123                    match self.resolve_whisper_target(&name) {
15124                        Ok(t) => t,
15125                        Err(err) => {
15126                            self.state.social_chat.push_system(err);
15127                            return Ok(());
15128                        }
15129                    }
15130                } else {
15131                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15132                        self.state.social_chat.push_system(
15133                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
15134                        );
15135                        return Ok(());
15136                    };
15137                    (
15138                        peer.entity_id,
15139                        peer.label,
15140                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
15141                    )
15142                };
15143                self.state
15144                    .social_chat
15145                    .set_whisper_thread(peer_id, &label, stone);
15146                let channel = if stone {
15147                    flatland_protocol::ChatChannel::WhisperStone
15148                } else {
15149                    flatland_protocol::ChatChannel::Whisper
15150                };
15151                if let Some(msg) = message {
15152                    self.state
15153                        .social_chat
15154                        .push_system(format!("Whisper → {label}"));
15155                    self.say_to(channel, &msg, Some(peer_id)).await
15156                } else {
15157                    self.state.social_chat.push_system(format!(
15158                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
15159                    ));
15160                    Ok(())
15161                }
15162            }
15163        }
15164    }
15165
15166    /// Resolve `/whisper Name` against AOI players (label match).
15167    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
15168        let needle = name.trim().to_ascii_lowercase();
15169        if needle.is_empty() {
15170            return Err("Usage: /whisper Name [message]".into());
15171        }
15172        let mut candidates: Vec<(EntityId, String)> = self
15173            .state
15174            .entities
15175            .iter()
15176            .filter(|e| e.id != self.state.entity_id)
15177            .filter(|e| !e.label.trim().is_empty())
15178            .filter(|e| e.vitals.is_some())
15179            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
15180            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
15181            .map(|e| (e.id, e.label.clone()))
15182            .collect();
15183
15184        // Also allow matching the last whisper peer by name even if they left AOI briefly.
15185        if let Some(last) = &self.state.social_chat.last_whisper_peer {
15186            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
15187                candidates.push((last.entity_id, last.label.clone()));
15188            }
15189        }
15190
15191        let exact: Vec<_> = candidates
15192            .iter()
15193            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
15194            .cloned()
15195            .collect();
15196        let pool = if exact.len() == 1 {
15197            exact
15198        } else if exact.len() > 1 {
15199            return Err(format!(
15200                "Several players named '{name}' nearby — move closer and try again"
15201            ));
15202        } else {
15203            let starts: Vec<_> = candidates
15204                .iter()
15205                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
15206                .cloned()
15207                .collect();
15208            if starts.len() == 1 {
15209                starts
15210            } else if starts.len() > 1 {
15211                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
15212                return Err(format!(
15213                    "Ambiguous name '{name}' — matches: {}",
15214                    names.join(", ")
15215                ));
15216            } else {
15217                let contains: Vec<_> = candidates
15218                    .iter()
15219                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
15220                    .cloned()
15221                    .collect();
15222                if contains.len() == 1 {
15223                    contains
15224                } else if contains.is_empty() {
15225                    return Err(format!(
15226                        "No player matching '{name}' in range — get closer or check the spelling"
15227                    ));
15228                } else {
15229                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
15230                    return Err(format!(
15231                        "Ambiguous name '{name}' — matches: {}",
15232                        names.join(", ")
15233                    ));
15234                }
15235            }
15236        };
15237
15238        let (id, label) = pool.into_iter().next().unwrap();
15239        let stone = self
15240            .state
15241            .social_chat
15242            .last_whisper_peer
15243            .as_ref()
15244            .is_some_and(|p| {
15245                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
15246            });
15247        Ok((id, label, stone))
15248    }
15249
15250    pub async fn trade_present_selected(
15251        &mut self,
15252        item_instance_id: uuid::Uuid,
15253    ) -> anyhow::Result<()> {
15254        self.trade_present_quantity(item_instance_id, None).await
15255    }
15256
15257    pub async fn trade_present_quantity(
15258        &mut self,
15259        item_instance_id: uuid::Uuid,
15260        quantity: Option<u32>,
15261    ) -> anyhow::Result<()> {
15262        self.seq += 1;
15263        self.session
15264            .submit_intent(Intent::TradePresent {
15265                entity_id: self.state.entity_id,
15266                item_instance_id,
15267                quantity,
15268                seq: self.seq,
15269            })
15270            .await?;
15271        self.state.intents_sent += 1;
15272        self.state.trade_ui.qty_entry = None;
15273        self.state.trade_ui.picking_inventory = false;
15274        Ok(())
15275    }
15276
15277    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
15278    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
15279        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
15280            let qty = self.state.trade_ui.present_quantity();
15281            return self
15282                .trade_present_quantity(entry.item_instance_id, qty)
15283                .await;
15284        }
15285        if !self.state.trade_ui.picking_inventory {
15286            return Ok(());
15287        }
15288        let stacks = self.state.trade_presentable_stacks();
15289        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
15290            return Ok(());
15291        };
15292        let Some(id) = stack.item_instance_id else {
15293            return Ok(());
15294        };
15295        let label = stack
15296            .display_name
15297            .clone()
15298            .unwrap_or_else(|| stack.template_id.clone());
15299        if stack.quantity <= 1 {
15300            self.trade_present_quantity(id, Some(1)).await
15301        } else {
15302            self.state
15303                .trade_ui
15304                .begin_qty_entry(id, label, stack.quantity);
15305            Ok(())
15306        }
15307    }
15308
15309    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
15310        self.seq += 1;
15311        self.session
15312            .submit_intent(Intent::TradeSetReady {
15313                entity_id: self.state.entity_id,
15314                ready,
15315                seq: self.seq,
15316            })
15317            .await?;
15318        self.state.intents_sent += 1;
15319        Ok(())
15320    }
15321
15322    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
15323        self.seq += 1;
15324        self.session
15325            .submit_intent(Intent::TradeCancel {
15326                entity_id: self.state.entity_id,
15327                seq: self.seq,
15328            })
15329            .await?;
15330        self.state.intents_sent += 1;
15331        self.state.trade_ui.close();
15332        Ok(())
15333    }
15334
15335    pub async fn destroy_whisper_stone(
15336        &mut self,
15337        item_instance_id: uuid::Uuid,
15338    ) -> anyhow::Result<()> {
15339        self.seq += 1;
15340        self.session
15341            .submit_intent(Intent::DestroyWhisperStone {
15342                entity_id: self.state.entity_id,
15343                item_instance_id,
15344                seq: self.seq,
15345            })
15346            .await?;
15347        self.state.intents_sent += 1;
15348        Ok(())
15349    }
15350
15351    pub async fn stop(&mut self) -> anyhow::Result<()> {
15352        self.seq += 1;
15353        self.session
15354            .submit_intent(Intent::Stop {
15355                entity_id: self.state.entity_id,
15356                seq: self.seq,
15357            })
15358            .await?;
15359        self.state.intents_sent += 1;
15360        Ok(())
15361    }
15362
15363    pub fn disconnect(&self) {
15364        self.session.disconnect();
15365    }
15366}
15367
15368fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
15369    let dx = ax - bx;
15370    let dy = ay - by;
15371    (dx * dx + dy * dy).sqrt()
15372}
15373
15374#[cfg(test)]
15375mod tests {
15376    use std::collections::BTreeMap;
15377
15378    use super::*;
15379    use flatland_protocol::{
15380        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
15381    };
15382
15383    fn sample_state() -> GameState {
15384        let mut state = GameState {
15385            session_id: 1,
15386            entity_id: 1,
15387            character_id: None,
15388            tick: 0,
15389            chunk_rev: 0,
15390            content_rev: 0,
15391            publish_rev: 0,
15392            entities: vec![EntityState {
15393                id: 1,
15394                label: "You".into(),
15395                transform: Transform {
15396                    position: WorldCoord::surface(128.0, 128.0),
15397                    yaw: 0.0,
15398                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15399                },
15400                vitals: None,
15401                attributes: None,
15402                skills: None,
15403                inside_building: None,
15404                tile_id: None,
15405                paperdoll_ref: None,
15406                draw_scale: 1.0,
15407                presentation_state: None,
15408                sprite_mode: None,
15409                progression_xp: None,
15410                combat_cues: vec![],
15411                statuses: vec![],
15412            }],
15413            player: None,
15414            resource_nodes: vec![ResourceNodeView {
15415                id: "oak-1".into(),
15416                label: "Oak".into(),
15417                x: 130.0,
15418                y: 128.0,
15419                z: 0.0,
15420                item_template: "oak_log".into(),
15421                state: ResourceNodeState::Available,
15422                blocking: true,
15423                blocking_radius_m: 0.8,
15424                harvest_off: false,
15425                tile_id: None,
15426                yaw: 0.0,
15427                pitch: 0.0,
15428                roll: 0.0,
15429                draw_scale: 1.0,
15430                sprite_mode: None,
15431                growth_progress: None,
15432                presentation_state: None,
15433                channel_start_tick: None,
15434                channel_end_tick: None,
15435                harvest_drop_templates: vec![],
15436            }],
15437            ground_drops: vec![],
15438            placed_containers: vec![],
15439            buildings: vec![BuildingView {
15440                id: "broker-hut".into(),
15441                label: "Broker".into(),
15442                x: 148.0,
15443                y: 118.0,
15444                width_m: 8.0,
15445                depth_m: 6.0,
15446                interior_blueprint: Some("broker_hut".into()),
15447                tags: vec![],
15448                market_boundary_zone_ids: vec![],
15449                market_max_volume: None,
15450                wall_set: None,
15451                roof_set: None,
15452            }],
15453            doors: vec![flatland_protocol::DoorView {
15454                id: "door-1".into(),
15455                building_id: "broker-hut".into(),
15456                x: 148.0,
15457                y: 118.0,
15458                open: false,
15459                portal: Some("front".into()),
15460                locked: false,
15461                accessible: true,
15462                lock_id: None,
15463            }],
15464            interior_map: None,
15465            npcs: vec![],
15466            blueprints: vec![],
15467            building_materials: vec![],
15468            world_x0: 0.0,
15469            world_y0: 0.0,
15470            world_width_m: 256.0,
15471            world_height_m: 256.0,
15472            terrain_zones: Vec::new(),
15473            z_platforms: Vec::new(),
15474            z_transitions: Vec::new(),
15475            z_bands_outdoor_backup: None,
15476            world_clock: flatland_protocol::WorldClock::default(),
15477            inventory: std::collections::HashMap::new(),
15478            inventory_hints: std::collections::HashMap::new(),
15479            logs: VecDeque::new(),
15480            intents_sent: 0,
15481            ticks_received: 0,
15482            connected: true,
15483            disconnect_reason: None,
15484            show_stats: false,
15485            hud_log_hidden: false,
15486            show_equip_menu: false,
15487            equip_menu_index: 0,
15488            show_craft_menu: false,
15489            show_plot_build_menu: false,
15490            plot_build_focus_wall: true,
15491            plot_build_wall_index: 0,
15492            plot_build_roof_index: 0,
15493            craft_menu_index: 0,
15494            craft_batch_quantity: 1,
15495            show_shop_menu: false,
15496            shop_catalog: None,
15497            bank_panel: None,
15498            bank_menu_index: 0,
15499            bank_ui_mode: BankUiMode::Menu,
15500            storage_panel: None,
15501            market_panel: None,
15502            market_menu_index: 0,
15503            market_filter: String::new(),
15504            market_filter_focused: false,
15505            market_category_filter: None,
15506            market_buy_confirm: None,
15507            market_ui_mode: MarketUiMode::Browse,
15508            storage_menu_index: 0,
15509            storage_ui_mode: StorageUiMode::Menu,
15510            shop_tab: ShopTab::default(),
15511            shop_menu_index: 0,
15512            shop_quantity: 1,
15513            shop_trade_log: VecDeque::new(),
15514            show_npc_verb_menu: false,
15515            npc_verb_target: None,
15516            npc_verb_index: 0,
15517            player_verbs: crate::social::PlayerVerbState::default(),
15518            social_chat: crate::social::SocialChatState::default(),
15519            trade_ui: crate::social::TradeUiState::default(),
15520            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15521            show_npc_chat: false,
15522            npc_chat: None,
15523            show_inventory_menu: false,
15524            inventory_menu_index: 0,
15525            inventory_tab: InventoryTab::OnPerson,
15526            inventory_filter: String::new(),
15527            inventory_filter_focused: false,
15528            show_move_picker: false,
15529            show_rename_prompt: false,
15530            rename_plot_id: None,
15531            highlighted_plot_id: None,
15532            show_worker_rename: false,
15533            rename_buffer: String::new(),
15534            move_picker_index: 0,
15535            move_picker: None,
15536            show_grant_picker: false,
15537            grant_picker_index: 0,
15538            grant_picker: None,
15539            show_destroy_picker: false,
15540            destroy_confirm_pending: false,
15541            destroy_picker: None,
15542            combat_target: None,
15543            combat_target_label: None,
15544            ground_target: None,
15545            combat_fx: Vec::new(),
15546            ground_hazards: Vec::new(),
15547            property_zones: Vec::new(),
15548            tax_zones: Vec::new(),
15549            growth_zones: Vec::new(),
15550            biome_zones: Vec::new(),
15551            terrain_kind_nav: Vec::new(),
15552            property_plots: Vec::new(),
15553            property_plot_settings: None,
15554            claim_mode: None,
15555            relocate_mode: None,
15556            sell_plot_confirm: None,
15557            sell_plot_armed_at: None,
15558            show_plant_menu: false,
15559            plant_menu_index: 0,
15560            show_farm_access: false,
15561            farm_access_name_draft: String::new(),
15562            farm_access_discount_bps: 0,
15563            farm_access_index: 0,
15564            plant_quantity: 1,
15565            in_combat: false,
15566            auto_attack: true,
15567            combat_has_los: false,
15568            attack_cd_ticks: 0,
15569            gcd_ticks: 0,
15570            weapon_ability_id: "unarmed".into(),
15571            mainhand_template_id: None,
15572            mainhand_label: None,
15573            mainhand_instance_id: None,
15574            offhand_template_id: None,
15575            offhand_label: None,
15576            offhand_instance_id: None,
15577            mainhand_hand_slots: 1,
15578            defense: None,
15579            worn: BTreeMap::new(),
15580            carry_mass: 0.0,
15581            carry_mass_max: 0.0,
15582            encumbrance: flatland_protocol::EncumbranceState::Light,
15583            inventory_stacks: Vec::new(),
15584            keychain_stacks: Vec::new(),
15585            whisper_pouch_stacks: Vec::new(),
15586            combat_target_detail: None,
15587            statuses: Vec::new(),
15588            cast_progress: None,
15589            timed_channel: None,
15590            plot_build_offer: None,
15591            ability_cooldowns: Vec::new(),
15592            blocking_active: false,
15593            max_target_slots: 1,
15594            combat_slots: Vec::new(),
15595            rotation_presets: Vec::new(),
15596            known_abilities: Vec::new(),
15597            ability_meta: std::collections::HashMap::new(),
15598            ability_mastery: std::collections::HashMap::new(),
15599            hotbar: vec![None; 9],
15600            max_abilities_per_rotation: 0,
15601            show_loadout_menu: false,
15602            show_keychain_menu: false,
15603            keychain_menu_index: 0,
15604            show_rotation_editor: false,
15605            loadout_menu_index: 0,
15606            loadout_hotbar_slot: 1,
15607            loadout_ability_index: 0,
15608            loadout_focus_presets: false,
15609            rotation_editor: RotationEditorState::default(),
15610            harvest_in_progress: false,
15611            harvest_started_at: None,
15612            pending_craft_ack: None,
15613            pending_worker_job_ack: None,
15614            attending_worker_instance_id: None,
15615            quest_log: Vec::new(),
15616            interactables: Vec::new(),
15617            ledger: None,
15618            career: None,
15619            character_sheet_tab: CharacterSheetTab::Character,
15620            ledger_period: LedgerPeriod::Day,
15621            show_quest_offer: false,
15622            pending_quest_offer: None,
15623            show_quest_menu: false,
15624            quest_menu_index: 0,
15625            quest_withdraw_confirm: false,
15626            hired_workers: Vec::new(),
15627            show_workers_menu: false,
15628            workers_menu_index: 0,
15629            worker_dismiss_confirmation: None,
15630            workers_menu_compact: false,
15631            worker_step_display: BTreeMap::new(),
15632            worker_error_display: BTreeMap::new(),
15633            worker_health_ring_until: BTreeMap::new(),
15634            show_worker_give_picker: false,
15635            worker_give_picker_index: 0,
15636            worker_give_picker: None,
15637            show_worker_give_target_picker: false,
15638            worker_give_target_picker_index: 0,
15639            worker_give_target_picker: None,
15640            show_worker_take_picker: false,
15641            worker_take_picker_index: 0,
15642            worker_take_picker: None,
15643            show_worker_teach_picker: false,
15644            worker_teach_picker_index: 0,
15645            worker_teach_picker: None,
15646            worker_route_editor: None,
15647            progression_curve: None,
15648        };
15649        state.player = state.entities.first().cloned();
15650        state
15651    }
15652
15653    #[test]
15654    fn whisper_cancels_when_peer_walks_out_of_range() {
15655        let mut state = sample_state();
15656        state.player = state.entities.first().cloned();
15657        let mut peer = state.entities[0].clone();
15658        peer.id = 2;
15659        peer.label = "Ada".into();
15660        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
15661        state.entities.push(peer.clone());
15662        state.social_chat.focus_whisper(2, "Ada");
15663        state.refresh_whisper_range();
15664        assert!(matches!(
15665            state.social_chat.thread,
15666            crate::social::ChatThreadKind::Whisper { peer: 2 }
15667        ));
15668
15669        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
15670        state.entities[1] = peer;
15671        state.refresh_whisper_range();
15672        assert_eq!(
15673            state.social_chat.thread,
15674            crate::social::ChatThreadKind::Nearby
15675        );
15676        assert!(!state.social_chat.input_focused);
15677    }
15678
15679    #[test]
15680    fn probe_use_world_hired_worker_manage() {
15681        let mut state = sample_state();
15682        state
15683            .hired_workers
15684            .push(flatland_protocol::HiredWorkerView {
15685                instance_id: "worker-1".into(),
15686                entity_id: 42,
15687                def_id: "worker_laborer".into(),
15688                label: "Sam".into(),
15689                x: 129.0,
15690                y: 128.0,
15691                z: 0.0,
15692                mode: flatland_protocol::WorkerModeView::JobLoop,
15693                state: flatland_protocol::WorkerStateView::Working,
15694                step_label: "cultivate".into(),
15695                vitals: flatland_protocol::WorkerVitalsSummary {
15696                    health_pct: 100.0,
15697                    stamina_pct: 100.0,
15698                },
15699                carry_pct: 0.0,
15700                last_error: None,
15701                wage_copper_per_interval: 1,
15702                effective_wage_copper: 1,
15703                wage_meters_walked: 0.0,
15704                lodging_container_id: None,
15705                route: None,
15706                route_stop_index: None,
15707                known_blueprint_ids: Vec::new(),
15708                level: 1,
15709                worker_xp: 0.0,
15710                inventory: Vec::new(),
15711                equipment: flatland_protocol::WorkerEquipmentView::default(),
15712                issue_hint: None,
15713            });
15714        let probe = state.probe_use_world();
15715        let primary = probe.primary.expect("primary");
15716        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15717        assert_eq!(primary.id, "worker-1");
15718        assert!(primary.hint_line().contains("Manage"));
15719        assert!(primary.hint_line().contains("Sam"));
15720        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
15721    }
15722
15723    #[test]
15724    fn market_clerk_verb_options_include_market() {
15725        let mut state = sample_state();
15726        state.npcs.push(flatland_protocol::NpcView {
15727            id: "mira_market".into(),
15728            label: "Mira".into(),
15729            role: "market_clerk".into(),
15730            x: 129.0,
15731            y: 128.0,
15732            building_id: Some("town_market".into()),
15733            entity_id: None,
15734            life_state: None,
15735            hp_pct: None,
15736            can_trade: false,
15737            buy_templates: vec![],
15738            tile_id: None,
15739            behavior_state: None,
15740            presentation_state: None,
15741            sprite_mode: None,
15742            paperdoll_ref: None,
15743            draw_scale: 1.0,
15744            yaw: None,
15745            perception_fov_deg: None,
15746            perception_sight_m: None,
15747            perception_hear_m: None,
15748        });
15749        state.npc_verb_target = Some("mira_market".into());
15750        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15751    }
15752
15753    #[test]
15754    fn market_list_excludes_currency_stacks() {
15755        let mut state = sample_state();
15756        state.inventory_stacks = vec![
15757            flatland_protocol::ItemStack {
15758                template_id: "copper_coin".into(),
15759                quantity: 50,
15760                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15761                display_name: Some("Copper Coin".into()),
15762                ..Default::default()
15763            },
15764            flatland_protocol::ItemStack {
15765                template_id: "oak_log".into(),
15766                quantity: 2,
15767                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15768                display_name: Some("Oak Log".into()),
15769                ..Default::default()
15770            },
15771            flatland_protocol::ItemStack {
15772                template_id: "whisper_stone".into(),
15773                quantity: 1,
15774                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15775                display_name: Some("Whisper Stone".into()),
15776                category: Some("quest".into()),
15777                listable: Some(false),
15778                ..Default::default()
15779            },
15780        ];
15781        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15782        assert_eq!(opts.len(), 1);
15783        assert!(opts[0].label.contains("Oak"));
15784    }
15785
15786    #[test]
15787    fn market_browse_filters_by_category_and_search() {
15788        let mut state = sample_state();
15789        state.market_panel = Some(flatland_protocol::MarketPanel {
15790            npc_id: "mira_market".into(),
15791            npc_label: "Mira".into(),
15792            building_id: "town_market".into(),
15793            building_label: "Town Market".into(),
15794            used_volume: 0.0,
15795            max_volume: 100.0,
15796            listings: vec![
15797                flatland_protocol::MarketListingView {
15798                    listing_id: uuid::Uuid::from_u128(1),
15799                    seller_character_id: uuid::Uuid::from_u128(2),
15800                    seller_label: "Ada".into(),
15801                    hall_building_id: "town_market".into(),
15802                    hall_label: "Town Market".into(),
15803                    template_id: "oak_log".into(),
15804                    display_name: "Oak Log".into(),
15805                    category: "resource".into(),
15806                    quantity: 3,
15807                    unit_price_copper: 10,
15808                    line_total_copper: 30,
15809                    npc_price: false,
15810                    npc_dump_unit_copper: None,
15811                    mine: false,
15812                },
15813                flatland_protocol::MarketListingView {
15814                    listing_id: uuid::Uuid::from_u128(3),
15815                    seller_character_id: uuid::Uuid::from_u128(2),
15816                    seller_label: "Ada".into(),
15817                    hall_building_id: "town_market".into(),
15818                    hall_label: "Town Market".into(),
15819                    template_id: "short_sword".into(),
15820                    display_name: "Short Sword".into(),
15821                    category: "weapon".into(),
15822                    quantity: 1,
15823                    unit_price_copper: 100,
15824                    line_total_copper: 100,
15825                    npc_price: false,
15826                    npc_dump_unit_copper: None,
15827                    mine: false,
15828                },
15829            ],
15830            tax_bps: 0,
15831            tax_flat_copper: 0,
15832            list_vaults: vec![],
15833        });
15834        assert_eq!(state.market_filtered_listing_indices().len(), 2);
15835        state.market_category_filter = Some("Weapons");
15836        let weapons = state.market_filtered_listing_indices();
15837        assert_eq!(weapons.len(), 1);
15838        assert_eq!(
15839            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15840            "Short Sword"
15841        );
15842        state.market_category_filter = None;
15843        state.market_filter = "oak".into();
15844        let oak = state.market_filtered_listing_indices();
15845        assert_eq!(oak.len(), 1);
15846        assert_eq!(
15847            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15848            "Oak Log"
15849        );
15850    }
15851
15852    #[test]
15853    fn market_list_source_includes_person_and_vaults() {
15854        let mut state = sample_state();
15855        let item_id = uuid::Uuid::from_u128(1);
15856        state.inventory_stacks = vec![flatland_protocol::ItemStack {
15857            template_id: "oak_log".into(),
15858            quantity: 2,
15859            item_instance_id: Some(item_id),
15860            display_name: Some("Oak Log".into()),
15861            ..Default::default()
15862        }];
15863        state.market_panel = Some(flatland_protocol::MarketPanel {
15864            npc_id: "mira_market".into(),
15865            npc_label: "Mira".into(),
15866            building_id: "town_market".into(),
15867            building_label: "Town Market".into(),
15868            used_volume: 0.0,
15869            max_volume: 100.0,
15870            listings: vec![],
15871            tax_bps: 0,
15872            tax_flat_copper: 0,
15873            list_vaults: vec![flatland_protocol::MarketListVault {
15874                building_id: "town_storage".into(),
15875                building_label: "Town Storage".into(),
15876                contents: vec![flatland_protocol::ItemStack {
15877                    template_id: "lumber".into(),
15878                    quantity: 1,
15879                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
15880                    display_name: Some("Lumber".into()),
15881                    ..Default::default()
15882                }],
15883            }],
15884        });
15885        let sources = state.market_list_source_options();
15886        assert_eq!(sources.len(), 2);
15887        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15888        assert!(matches!(
15889            sources[1].0,
15890            MarketListSourceKind::TownStorage { .. }
15891        ));
15892        assert!(sources[1].1.contains("Town Storage"));
15893    }
15894
15895    #[test]
15896    fn npc_market_dump_estimate_from_town_storage_vault() {
15897        let mut state = sample_state();
15898        state.market_panel = Some(flatland_protocol::MarketPanel {
15899            npc_id: "mira_market".into(),
15900            npc_label: "Mira".into(),
15901            building_id: "town_market".into(),
15902            building_label: "Town Market".into(),
15903            used_volume: 0.0,
15904            max_volume: 100.0,
15905            listings: vec![],
15906            tax_bps: 0,
15907            tax_flat_copper: 0,
15908            list_vaults: vec![flatland_protocol::MarketListVault {
15909                building_id: "town_storage".into(),
15910                building_label: "Town Storage".into(),
15911                contents: vec![flatland_protocol::ItemStack {
15912                    template_id: "lumber".into(),
15913                    quantity: 3,
15914                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
15915                    display_name: Some("Lumber".into()),
15916                    base_value_copper: Some(20),
15917                    ..Default::default()
15918                }],
15919            }],
15920        });
15921        assert_eq!(
15922            state.npc_market_dump_unit_estimate("lumber"),
15923            Some(9),
15924            "vault stack base_value should enable NPC price estimate"
15925        );
15926    }
15927
15928    #[test]
15929    fn probe_use_world_npc_beats_nearby_loot() {
15930        let mut state = sample_state();
15931        state.npcs.push(flatland_protocol::NpcView {
15932            id: "ada".into(),
15933            label: "Ada".into(),
15934            role: "broker".into(),
15935            x: 129.0,
15936            y: 128.0,
15937            building_id: None,
15938            entity_id: None,
15939            life_state: None,
15940            hp_pct: None,
15941            can_trade: true,
15942            buy_templates: vec!["lumber".into()],
15943            tile_id: None,
15944            behavior_state: None,
15945            presentation_state: None,
15946            sprite_mode: None,
15947            paperdoll_ref: None,
15948            draw_scale: 1.0,
15949            yaw: None,
15950            perception_fov_deg: None,
15951            perception_sight_m: None,
15952            perception_hear_m: None,
15953        });
15954        state.ground_drops.push(flatland_protocol::GroundDropView {
15955            id: "d1".into(),
15956            template_id: "lumber".into(),
15957            quantity: 1,
15958            x: 128.5,
15959            y: 128.0,
15960            z: 0.0,
15961            tile_id: None,
15962            display_name: None,
15963            yaw: 0.0,
15964            pitch: 0.0,
15965            roll: 0.0,
15966            draw_scale: 1.0,
15967        });
15968        let probe = state.probe_use_world();
15969        let primary = probe.primary.expect("primary");
15970        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15971        assert_eq!(primary.id, "ada");
15972    }
15973
15974    #[test]
15975    fn probe_use_world_harvest_when_in_range() {
15976        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
15977        let probe = state.probe_use_world();
15978        assert!(
15979            probe.primary.is_none(),
15980            "oak is 2m away, out of harvest range"
15981        );
15982        assert!(probe
15983            .candidates
15984            .iter()
15985            .any(|c| c.kind == crate::UseWorldKind::Harvest));
15986
15987        let mut state = sample_state();
15988        state.resource_nodes[0].x = 129.0;
15989        let probe = state.probe_use_world();
15990        let primary = probe.primary.expect("primary");
15991        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15992    }
15993
15994    #[test]
15995    fn probe_use_world_door_uses_building_label() {
15996        let mut state = sample_state();
15997        state.doors[0].x = 129.0;
15998        state.doors[0].y = 128.0;
15999        let probe = state.probe_use_world();
16000        let primary = probe.primary.expect("primary");
16001        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
16002        assert_eq!(primary.label, "Broker");
16003        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
16004    }
16005
16006    #[test]
16007    fn empty_entity_tick_preserves_welcome_snapshot() {
16008        let mut state = sample_state();
16009        state.inventory.insert("carrot".into(), 3);
16010        let delta = TickDelta {
16011            tick: 1,
16012            entities: vec![],
16013            resource_nodes: vec![],
16014            ground_drops: vec![],
16015            placed_containers: vec![],
16016            buildings: vec![],
16017            doors: vec![],
16018            interior_map: None,
16019            npcs: vec![],
16020            inventory: vec![],
16021            blueprints: vec![],
16022            building_materials: vec![],
16023            world_clock: flatland_protocol::WorldClock::default(),
16024            combat: None,
16025            quest_log: vec![],
16026            hired_workers: Vec::new(),
16027            interactables: vec![],
16028            ledger: None,
16029            career: None,
16030            combat_fx: Vec::new(),
16031            ground_hazards: Vec::new(),
16032            property_plots: Vec::new(),
16033            terrain_overlays: Vec::new(),
16034        };
16035
16036        state.apply_tick_fields(&delta, 1);
16037
16038        assert_eq!(state.entities.len(), 1);
16039        assert!(state.player.is_some());
16040        assert_eq!(state.inventory.get("carrot"), Some(&3));
16041        assert_eq!(state.resource_nodes.len(), 1);
16042    }
16043
16044    #[test]
16045    fn tick_preserves_world_layers_when_delta_omits_them() {
16046        let mut state = sample_state();
16047        let delta = TickDelta {
16048            tick: 1,
16049            entities: state.entities.clone(),
16050            resource_nodes: vec![],
16051            ground_drops: vec![],
16052            placed_containers: vec![],
16053            buildings: vec![],
16054            doors: vec![],
16055            interior_map: None,
16056            npcs: vec![],
16057            inventory: vec![],
16058            blueprints: vec![],
16059            building_materials: vec![],
16060            world_clock: flatland_protocol::WorldClock::default(),
16061            combat: None,
16062            quest_log: vec![],
16063            hired_workers: Vec::new(),
16064            interactables: vec![],
16065            ledger: None,
16066            career: None,
16067            combat_fx: Vec::new(),
16068            ground_hazards: Vec::new(),
16069            property_plots: Vec::new(),
16070            terrain_overlays: Vec::new(),
16071        };
16072
16073        state.apply_tick_fields(&delta, 1);
16074
16075        assert_eq!(state.resource_nodes.len(), 1);
16076        assert_eq!(state.buildings.len(), 1);
16077        assert_eq!(state.doors.len(), 1);
16078    }
16079
16080    #[test]
16081    fn tick_updates_resource_nodes_when_server_sends_them() {
16082        let mut state = sample_state();
16083        let delta = TickDelta {
16084            tick: 1,
16085            entities: state.entities.clone(),
16086            resource_nodes: vec![ResourceNodeView {
16087                id: "oak-1".into(),
16088                label: "Oak".into(),
16089                x: 130.0,
16090                y: 128.0,
16091                z: 0.0,
16092                item_template: "oak_log".into(),
16093                state: ResourceNodeState::Cooldown,
16094                blocking: true,
16095                blocking_radius_m: 0.8,
16096                harvest_off: false,
16097                tile_id: None,
16098                yaw: 0.0,
16099                pitch: 0.0,
16100                roll: 0.0,
16101                draw_scale: 1.0,
16102                sprite_mode: None,
16103                growth_progress: None,
16104                presentation_state: None,
16105                channel_start_tick: None,
16106                channel_end_tick: None,
16107                harvest_drop_templates: vec![],
16108            }],
16109            buildings: vec![],
16110            doors: vec![],
16111            interior_map: None,
16112            npcs: vec![],
16113            inventory: vec![],
16114            blueprints: vec![],
16115            building_materials: vec![],
16116            world_clock: flatland_protocol::WorldClock::default(),
16117            ground_drops: vec![],
16118            placed_containers: vec![],
16119            combat: None,
16120            quest_log: vec![],
16121            hired_workers: Vec::new(),
16122            interactables: vec![],
16123            ledger: None,
16124            career: None,
16125            combat_fx: Vec::new(),
16126            ground_hazards: Vec::new(),
16127            property_plots: Vec::new(),
16128            terrain_overlays: Vec::new(),
16129        };
16130
16131        state.apply_tick_fields(&delta, 1);
16132
16133        assert!(matches!(
16134            state.resource_nodes[0].state,
16135            ResourceNodeState::Cooldown
16136        ));
16137    }
16138
16139    #[test]
16140    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
16141        let mut state = GameState {
16142            session_id: 1,
16143            entity_id: 1,
16144            character_id: None,
16145            tick: 0,
16146            chunk_rev: 0,
16147            content_rev: 0,
16148            publish_rev: 0,
16149            entities: vec![EntityState {
16150                id: 1,
16151                label: "You".into(),
16152                transform: Transform {
16153                    position: WorldCoord::surface(4.5, 2.0),
16154                    yaw: 0.0,
16155                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16156                },
16157                vitals: None,
16158                attributes: None,
16159                skills: None,
16160                inside_building: Some("broker_hut".into()),
16161                tile_id: None,
16162                paperdoll_ref: None,
16163                draw_scale: 1.0,
16164                presentation_state: None,
16165                sprite_mode: None,
16166                progression_xp: None,
16167                combat_cues: vec![],
16168                statuses: vec![],
16169            }],
16170            player: None,
16171            resource_nodes: vec![],
16172            ground_drops: vec![],
16173            placed_containers: vec![],
16174            buildings: vec![BuildingView {
16175                id: "broker_hut".into(),
16176                label: "Broker".into(),
16177                x: 158.0,
16178                y: 124.0,
16179                width_m: 8.0,
16180                depth_m: 6.0,
16181                interior_blueprint: Some("broker_hut".into()),
16182                tags: vec![],
16183                market_boundary_zone_ids: vec![],
16184                market_max_volume: None,
16185                wall_set: None,
16186                roof_set: None,
16187            }],
16188            doors: vec![flatland_protocol::DoorView {
16189                id: "broker_hut_exit".into(),
16190                building_id: "broker_hut".into(),
16191                x: 4.3,
16192                y: 0.9,
16193                open: true,
16194                portal: Some("front".into()),
16195                locked: false,
16196                accessible: true,
16197                lock_id: None,
16198            }],
16199            interior_map: None,
16200            npcs: vec![flatland_protocol::NpcView {
16201                id: "ada_broker".into(),
16202                label: "Ada".into(),
16203                x: 4.5,
16204                y: 2.0,
16205                building_id: Some("broker_hut".into()),
16206                role: "broker".into(),
16207                entity_id: None,
16208                life_state: None,
16209                hp_pct: None,
16210                can_trade: true,
16211                buy_templates: vec!["lumber".into()],
16212                tile_id: None,
16213                behavior_state: None,
16214                presentation_state: None,
16215                sprite_mode: None,
16216                paperdoll_ref: None,
16217                draw_scale: 1.0,
16218                yaw: None,
16219                perception_fov_deg: None,
16220                perception_sight_m: None,
16221                perception_hear_m: None,
16222            }],
16223            blueprints: vec![],
16224            building_materials: vec![],
16225            world_x0: 0.0,
16226            world_y0: 0.0,
16227            world_width_m: 256.0,
16228            world_height_m: 256.0,
16229            terrain_zones: Vec::new(),
16230            z_platforms: Vec::new(),
16231            z_transitions: Vec::new(),
16232            z_bands_outdoor_backup: None,
16233            world_clock: flatland_protocol::WorldClock::default(),
16234            inventory: std::collections::HashMap::new(),
16235            inventory_hints: std::collections::HashMap::new(),
16236            logs: VecDeque::new(),
16237            intents_sent: 0,
16238            ticks_received: 0,
16239            connected: true,
16240            disconnect_reason: None,
16241            show_stats: false,
16242            hud_log_hidden: false,
16243            show_equip_menu: false,
16244            equip_menu_index: 0,
16245            show_craft_menu: false,
16246            show_plot_build_menu: false,
16247            plot_build_focus_wall: true,
16248            plot_build_wall_index: 0,
16249            plot_build_roof_index: 0,
16250            craft_menu_index: 0,
16251            craft_batch_quantity: 1,
16252            show_shop_menu: false,
16253            shop_catalog: None,
16254            bank_panel: None,
16255            bank_menu_index: 0,
16256            bank_ui_mode: BankUiMode::Menu,
16257            storage_panel: None,
16258            market_panel: None,
16259            market_menu_index: 0,
16260            market_filter: String::new(),
16261            market_filter_focused: false,
16262            market_category_filter: None,
16263            market_buy_confirm: None,
16264            market_ui_mode: MarketUiMode::Browse,
16265            storage_menu_index: 0,
16266            storage_ui_mode: StorageUiMode::Menu,
16267            shop_tab: ShopTab::default(),
16268            shop_menu_index: 0,
16269            shop_quantity: 1,
16270            shop_trade_log: VecDeque::new(),
16271            show_npc_verb_menu: false,
16272            npc_verb_target: None,
16273            npc_verb_index: 0,
16274            player_verbs: crate::social::PlayerVerbState::default(),
16275            social_chat: crate::social::SocialChatState::default(),
16276            trade_ui: crate::social::TradeUiState::default(),
16277            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16278            show_npc_chat: false,
16279            npc_chat: None,
16280            show_inventory_menu: false,
16281            inventory_menu_index: 0,
16282            inventory_tab: InventoryTab::OnPerson,
16283            inventory_filter: String::new(),
16284            inventory_filter_focused: false,
16285            show_move_picker: false,
16286            show_rename_prompt: false,
16287            rename_plot_id: None,
16288            highlighted_plot_id: None,
16289            show_worker_rename: false,
16290            rename_buffer: String::new(),
16291            move_picker_index: 0,
16292            move_picker: None,
16293            show_grant_picker: false,
16294            grant_picker_index: 0,
16295            grant_picker: None,
16296            show_destroy_picker: false,
16297            destroy_confirm_pending: false,
16298            destroy_picker: None,
16299            combat_target: None,
16300            combat_target_label: None,
16301            ground_target: None,
16302            combat_fx: Vec::new(),
16303            ground_hazards: Vec::new(),
16304            property_zones: Vec::new(),
16305            tax_zones: Vec::new(),
16306            growth_zones: Vec::new(),
16307            biome_zones: Vec::new(),
16308            terrain_kind_nav: Vec::new(),
16309            property_plots: Vec::new(),
16310            property_plot_settings: None,
16311            claim_mode: None,
16312            relocate_mode: None,
16313            sell_plot_confirm: None,
16314            sell_plot_armed_at: None,
16315            show_plant_menu: false,
16316            plant_menu_index: 0,
16317            show_farm_access: false,
16318            farm_access_name_draft: String::new(),
16319            farm_access_discount_bps: 0,
16320            farm_access_index: 0,
16321            plant_quantity: 1,
16322            in_combat: false,
16323            auto_attack: true,
16324            combat_has_los: false,
16325            attack_cd_ticks: 0,
16326            gcd_ticks: 0,
16327            weapon_ability_id: "unarmed".into(),
16328            mainhand_template_id: None,
16329            mainhand_label: None,
16330            mainhand_instance_id: None,
16331            offhand_template_id: None,
16332            offhand_label: None,
16333            offhand_instance_id: None,
16334            mainhand_hand_slots: 1,
16335            defense: None,
16336            worn: BTreeMap::new(),
16337            carry_mass: 0.0,
16338            carry_mass_max: 0.0,
16339            encumbrance: flatland_protocol::EncumbranceState::Light,
16340            inventory_stacks: Vec::new(),
16341            keychain_stacks: Vec::new(),
16342            whisper_pouch_stacks: Vec::new(),
16343            combat_target_detail: None,
16344            statuses: Vec::new(),
16345            cast_progress: None,
16346            timed_channel: None,
16347            plot_build_offer: None,
16348            ability_cooldowns: Vec::new(),
16349            blocking_active: false,
16350            max_target_slots: 1,
16351            combat_slots: Vec::new(),
16352            rotation_presets: Vec::new(),
16353            known_abilities: Vec::new(),
16354            ability_meta: std::collections::HashMap::new(),
16355            ability_mastery: std::collections::HashMap::new(),
16356            hotbar: vec![None; 9],
16357            max_abilities_per_rotation: 0,
16358            show_loadout_menu: false,
16359            show_keychain_menu: false,
16360            keychain_menu_index: 0,
16361            show_rotation_editor: false,
16362            loadout_menu_index: 0,
16363            loadout_hotbar_slot: 1,
16364            loadout_ability_index: 0,
16365            loadout_focus_presets: false,
16366            rotation_editor: RotationEditorState::default(),
16367            harvest_in_progress: false,
16368            harvest_started_at: None,
16369            pending_craft_ack: None,
16370            pending_worker_job_ack: None,
16371            attending_worker_instance_id: None,
16372            quest_log: Vec::new(),
16373            interactables: Vec::new(),
16374            ledger: None,
16375            career: None,
16376            character_sheet_tab: CharacterSheetTab::Character,
16377            ledger_period: LedgerPeriod::Day,
16378            show_quest_offer: false,
16379            pending_quest_offer: None,
16380            show_quest_menu: false,
16381            quest_menu_index: 0,
16382            quest_withdraw_confirm: false,
16383            hired_workers: Vec::new(),
16384            show_workers_menu: false,
16385            workers_menu_index: 0,
16386            worker_dismiss_confirmation: None,
16387            workers_menu_compact: false,
16388            worker_step_display: BTreeMap::new(),
16389            worker_error_display: BTreeMap::new(),
16390            worker_health_ring_until: BTreeMap::new(),
16391            show_worker_give_picker: false,
16392            worker_give_picker_index: 0,
16393            worker_give_picker: None,
16394            show_worker_give_target_picker: false,
16395            worker_give_target_picker_index: 0,
16396            worker_give_target_picker: None,
16397            show_worker_take_picker: false,
16398            worker_take_picker_index: 0,
16399            worker_take_picker: None,
16400            show_worker_teach_picker: false,
16401            worker_teach_picker_index: 0,
16402            worker_teach_picker: None,
16403            worker_route_editor: None,
16404            progression_curve: None,
16405        };
16406        state.player = state.entities.first().cloned();
16407        assert_eq!(
16408            state.nearest_interact_target().as_deref(),
16409            Some("ada_broker")
16410        );
16411    }
16412
16413    #[test]
16414    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
16415        let mut state = sample_state();
16416        // Player is at (128, 128) per sample_state(). One chest just inside
16417        // CONTAINER_RANGE_M, one clearly beyond it.
16418        state.placed_containers = vec![
16419            flatland_protocol::PlacedContainerView {
16420                id: "near".into(),
16421                template_id: "wooden_chest_small".into(),
16422                display_name: "Wooden Chest".into(),
16423                x: 130.0,
16424                y: 128.0,
16425                z: 0.0,
16426                locked: true,
16427                accessible: true,
16428                owner_character_id: None,
16429                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
16430                lock_id: None,
16431                capacity_volume: None,
16432                item_instance_id: Some(uuid::Uuid::from_u128(1)),
16433                tile_id: None,
16434                worker_lodging_capacity: None,
16435                blocking: false,
16436                blocking_radius_m: 0.0,
16437                building_id: None,
16438            },
16439            flatland_protocol::PlacedContainerView {
16440                id: "far".into(),
16441                template_id: "wooden_chest_small".into(),
16442                display_name: "Distant Chest".into(),
16443                x: 128.0 + CONTAINER_RANGE_M + 5.0,
16444                y: 128.0,
16445                z: 0.0,
16446                locked: false,
16447                accessible: true,
16448                owner_character_id: None,
16449                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
16450                lock_id: None,
16451                capacity_volume: None,
16452                item_instance_id: Some(uuid::Uuid::from_u128(2)),
16453                tile_id: None,
16454                worker_lodging_capacity: None,
16455                blocking: false,
16456                blocking_radius_m: 0.0,
16457                building_id: None,
16458            },
16459        ];
16460
16461        let nearby = state.nearby_containers();
16462        assert_eq!(
16463            nearby.len(),
16464            1,
16465            "far chest must not appear once out of range"
16466        );
16467        assert_eq!(nearby[0].view.id, "near");
16468        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
16469        assert!(nearby[0].rows[0].is_chest_shell);
16470
16471        // The same chest, but locked and inaccessible (no key held), must hide
16472        // contents but still show the selectable chest shell row.
16473        state.placed_containers[0].accessible = false;
16474        let nearby = state.nearby_containers();
16475        assert_eq!(nearby.len(), 1);
16476        assert_eq!(nearby[0].rows.len(), 1);
16477        assert!(nearby[0].rows[0].is_chest_shell);
16478    }
16479
16480    #[test]
16481    fn chest_pickup_destinations_offer_person_and_worn_bag() {
16482        let mut state = sample_state();
16483        let back_id = uuid::Uuid::from_u128(42);
16484        state.worn.insert(
16485            BodySlot::Back,
16486            flatland_protocol::ItemStack {
16487                template_id: "travel_backpack".into(),
16488                quantity: 1,
16489                item_instance_id: Some(back_id),
16490                props: Default::default(),
16491                status_bindings: Vec::new(),
16492                contents: Vec::new(),
16493                display_name: Some("Travel Backpack".into()),
16494                category: Some("container".into()),
16495                base_mass: Some(2.5),
16496                base_volume: Some(12.0),
16497                capacity_volume: Some(80.0),
16498                stackable: Some(false),
16499                world_placeable: Some(false),
16500                worker_lodging_capacity: None,
16501                equip_slot: None,
16502                armor_physical: None,
16503                resists: vec![],
16504                hand_slots: None,
16505                listable: None,
16506                ..Default::default()
16507            },
16508        );
16509        let opts = state.chest_pickup_destinations("chest-1");
16510        assert!(matches!(
16511            opts.first().map(|o| &o.kind),
16512            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
16513        ));
16514        assert!(opts.iter().any(|o| matches!(
16515            &o.kind,
16516            MoveOptionKind::PickupPlaced {
16517                nest_parent_instance_id: None,
16518                ..
16519            }
16520        )));
16521        assert!(opts.iter().any(|o| matches!(
16522            &o.kind,
16523            MoveOptionKind::PickupPlaced {
16524                nest_parent_instance_id: Some(id),
16525                ..
16526            } if *id == back_id
16527        )));
16528        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16529    }
16530
16531    #[test]
16532    fn placed_container_public_label_hides_owner_custom_name() {
16533        let owner = uuid::Uuid::from_u128(99);
16534        let mut state = sample_state();
16535        state.character_id = Some(uuid::Uuid::from_u128(1));
16536        state.inventory_hints.insert(
16537            "wooden_chest_medium".into(),
16538            InventoryHint {
16539                display_name: "Medium Wooden Chest".into(),
16540                category: "container".into(),
16541                base_mass: None,
16542                base_volume: None,
16543                capacity_volume: None,
16544                stackable: false,
16545                listable: true,
16546                base_value_copper: None,
16547            },
16548        );
16549        let chest = flatland_protocol::PlacedContainerView {
16550            id: "c1".into(),
16551            template_id: "wooden_chest_medium".into(),
16552            display_name: "Barry's Loot #a3f2".into(),
16553            x: 128.0,
16554            y: 128.0,
16555            z: 0.0,
16556            locked: false,
16557            accessible: true,
16558            owner_character_id: Some(owner),
16559            contents: vec![],
16560            lock_id: None,
16561            capacity_volume: None,
16562            item_instance_id: None,
16563            tile_id: None,
16564            worker_lodging_capacity: None,
16565            blocking: false,
16566            blocking_radius_m: 0.0,
16567            building_id: None,
16568        };
16569        assert_eq!(
16570            state.placed_container_public_label(&chest),
16571            "Medium Wooden Chest"
16572        );
16573        state.character_id = Some(owner);
16574        assert_eq!(
16575            state.placed_container_public_label(&chest),
16576            "Barry's Loot #a3f2"
16577        );
16578    }
16579
16580    #[test]
16581    fn location_context_shows_crop_growth_percent_not_depleted() {
16582        let mut state = sample_state();
16583        state.player = state.entities.first().cloned();
16584        state.resource_nodes[0].label = "Carrot (growing)".into();
16585        state.resource_nodes[0].x = 128.2;
16586        state.resource_nodes[0].y = 128.0;
16587        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16588        state.resource_nodes[0].growth_progress = Some(0.47);
16589        let lines = state.location_context_lines();
16590        let line = lines
16591            .iter()
16592            .find(|l| l.text.contains("Carrot"))
16593            .map(|l| l.text.as_str())
16594            .unwrap_or("");
16595        assert!(
16596            line.contains("(growing, 47%)"),
16597            "expected growth percent, got: {line}"
16598        );
16599        assert!(
16600            !line.contains("depleted"),
16601            "growing crop should not show depleted: {line}"
16602        );
16603    }
16604
16605    #[test]
16606    fn resource_node_near_action_suffix_prefers_growth() {
16607        let node = ResourceNodeView {
16608            id: "crop".into(),
16609            label: "Wheat".into(),
16610            x: 0.0,
16611            y: 0.0,
16612            z: 0.0,
16613            item_template: "wheat".into(),
16614            state: ResourceNodeState::Cooldown,
16615            blocking: false,
16616            blocking_radius_m: 0.0,
16617            harvest_off: false,
16618            tile_id: None,
16619            yaw: 0.0,
16620            pitch: 0.0,
16621            roll: 0.0,
16622            draw_scale: 1.0,
16623            sprite_mode: None,
16624            growth_progress: Some(0.12),
16625            presentation_state: None,
16626            channel_start_tick: None,
16627            channel_end_tick: None,
16628            harvest_drop_templates: vec![],
16629        };
16630        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
16631    }
16632
16633    #[test]
16634    fn location_context_lists_nearby_resource_node() {
16635        let mut state = sample_state();
16636        state.player = state.entities.first().cloned();
16637        state.resource_nodes[0].x = 128.2;
16638        state.resource_nodes[0].y = 128.0;
16639        let lines = state.location_context_lines();
16640        assert!(
16641            lines
16642                .iter()
16643                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16644            "expected resource node in context: {:?}",
16645            lines
16646        );
16647    }
16648
16649    #[test]
16650    fn quest_board_usable_within_board_radius() {
16651        let mut state = sample_state();
16652        state.player = state.entities.first().cloned();
16653        state.interactables = vec![flatland_protocol::InteractableView {
16654            id: "board-1".into(),
16655            kind: "quest_board".into(),
16656            label: "Town Quest Board".into(),
16657            x: 130.5,
16658            y: 128.0,
16659            z: 0.0,
16660            board_id: Some("starter_town_board".into()),
16661        }];
16662        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
16663        assert_eq!(
16664            state.nearest_interact_target().as_deref(),
16665            Some("board-1"),
16666            "quest board should be selectable at ~2.5m"
16667        );
16668        let lines = state.location_context_lines();
16669        assert!(
16670            lines
16671                .iter()
16672                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16673            "HUD should advertise f when board is in range: {:?}",
16674            lines
16675        );
16676    }
16677
16678    #[test]
16679    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
16680        let mut state = sample_state();
16681        state.worn.insert(
16682            BodySlot::Back,
16683            flatland_protocol::ItemStack {
16684                template_id: "travel_backpack".into(),
16685                quantity: 1,
16686                item_instance_id: Some(uuid::Uuid::from_u128(3)),
16687                props: Default::default(),
16688                status_bindings: Vec::new(),
16689                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16690                display_name: None,
16691                category: None,
16692                base_mass: None,
16693                base_volume: None,
16694                capacity_volume: None,
16695                stackable: None,
16696                world_placeable: None,
16697                worker_lodging_capacity: None,
16698                equip_slot: None,
16699                armor_physical: None,
16700                resists: vec![],
16701                hand_slots: None,
16702                listable: None,
16703                ..Default::default()
16704            },
16705        );
16706        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16707        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16708            id: "chest-1".into(),
16709            template_id: "wooden_chest_small".into(),
16710            display_name: "Wooden Chest".into(),
16711            x: 129.0,
16712            y: 128.0,
16713            z: 0.0,
16714            locked: false,
16715            accessible: true,
16716            owner_character_id: None,
16717            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16718            lock_id: None,
16719            capacity_volume: None,
16720            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16721            tile_id: None,
16722            worker_lodging_capacity: None,
16723            blocking: false,
16724            blocking_radius_m: 0.0,
16725            building_id: None,
16726        }];
16727
16728        state.inventory_tab = InventoryTab::OnPerson;
16729        let rows = state.inventory_selectable_rows();
16730        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16731        assert_eq!(
16732            sections,
16733            vec![
16734                InventorySection::Worn,   // backpack shell
16735                InventorySection::Worn,   // iron_ore nested in backpack
16736                InventorySection::Person, // lumber
16737            ]
16738        );
16739        assert_eq!(rows[0].stack.template_id, "travel_backpack");
16740        assert!(rows[0].is_equip_shell);
16741        assert_eq!(rows[1].stack.template_id, "iron_ore");
16742        assert_eq!(rows[1].depth, 1);
16743        assert_eq!(rows[2].stack.template_id, "lumber");
16744
16745        let lines = state.inventory_browser_lines();
16746        assert!(lines.iter().any(|l| matches!(
16747            l,
16748            InventoryBrowserLine::Section(s) if s.contains("Worn")
16749        )));
16750        assert!(lines.iter().any(|l| matches!(
16751            l,
16752            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16753                || text.contains("backpack")
16754        )));
16755        assert!(!lines.iter().any(|l| matches!(
16756            l,
16757            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16758        )));
16759
16760        state.inventory_tab = InventoryTab::Nearby;
16761        let nearby_rows = state.inventory_selectable_rows();
16762        assert_eq!(nearby_rows.len(), 2);
16763        assert!(nearby_rows[0].is_chest_shell);
16764        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16765        let nearby_lines = state.inventory_browser_lines();
16766        assert!(nearby_lines.iter().any(|l| matches!(
16767            l,
16768            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16769        )));
16770    }
16771
16772    #[test]
16773    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16774        let mut state = sample_state();
16775        let back_id = uuid::Uuid::from_u128(5);
16776        state.worn.insert(
16777            BodySlot::Back,
16778            flatland_protocol::ItemStack {
16779                template_id: "travel_backpack".into(),
16780                quantity: 1,
16781                item_instance_id: Some(back_id),
16782                props: Default::default(),
16783                status_bindings: Vec::new(),
16784                contents: Vec::new(),
16785                display_name: None,
16786                category: Some("container".into()),
16787                base_mass: None,
16788                base_volume: None,
16789                capacity_volume: Some(80.0),
16790                stackable: None,
16791                world_placeable: None,
16792                worker_lodging_capacity: None,
16793                equip_slot: None,
16794                armor_physical: None,
16795                resists: vec![],
16796                hand_slots: None,
16797                listable: None,
16798                ..Default::default()
16799            },
16800        );
16801        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16802            id: "chest-1".into(),
16803            template_id: "wooden_chest_small".into(),
16804            display_name: "Wooden Chest".into(),
16805            x: 129.0,
16806            y: 128.0,
16807            z: 0.0,
16808            locked: false,
16809            accessible: true,
16810            owner_character_id: None,
16811            contents: Vec::new(),
16812            lock_id: None,
16813            capacity_volume: None,
16814            item_instance_id: Some(uuid::Uuid::from_u128(6)),
16815            tile_id: None,
16816            worker_lodging_capacity: None,
16817            blocking: false,
16818            blocking_radius_m: 0.0,
16819            building_id: None,
16820        }];
16821
16822        // Item currently sitting loose on the person (Root): backpack + nearby
16823        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
16824        let opts = state.move_destinations_for(
16825            &flatland_protocol::InventoryLocation::Root,
16826            None,
16827            None,
16828            "lumber",
16829        );
16830        assert!(!opts.iter().any(|o| matches!(
16831            &o.kind,
16832            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16833        )));
16834        assert!(opts.iter().any(|o| matches!(
16835            &o.kind,
16836            MoveOptionKind::Move { location, parent_instance_id, .. }
16837                if *location == flatland_protocol::InventoryLocation::Worn {
16838                    slot: BodySlot::Back,
16839                } && *parent_instance_id == Some(back_id)
16840        )));
16841        assert!(opts.iter().any(|o| matches!(
16842            &o.kind,
16843            MoveOptionKind::Move { location, .. }
16844                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16845        )));
16846        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16847        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16848
16849        // Item currently inside the worn backpack: the backpack itself must be
16850        // excluded from its own destination list (can't move an item into the
16851        // container it's already in).
16852        let from_backpack = flatland_protocol::InventoryLocation::Worn {
16853            slot: BodySlot::Back,
16854        };
16855        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16856        assert!(!opts.iter().any(|o| matches!(
16857            &o.kind,
16858            MoveOptionKind::Move { location, parent_instance_id, .. }
16859                if *location == from_backpack && *parent_instance_id == Some(back_id)
16860        )));
16861        assert!(opts.iter().any(|o| matches!(
16862            &o.kind,
16863            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16864        )));
16865    }
16866
16867    #[test]
16868    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16869        let mut state = sample_state();
16870        // Insert out of display order — BTreeMap iteration must still yield the
16871        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
16872        state.worn.insert(
16873            BodySlot::Waist,
16874            flatland_protocol::ItemStack {
16875                template_id: "simple_belt".into(),
16876                quantity: 1,
16877                item_instance_id: Some(uuid::Uuid::from_u128(10)),
16878                props: Default::default(),
16879                status_bindings: Vec::new(),
16880                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16881                display_name: None,
16882                category: Some("container".into()),
16883                base_mass: None,
16884                base_volume: None,
16885                capacity_volume: None,
16886                stackable: None,
16887                world_placeable: None,
16888                worker_lodging_capacity: None,
16889                equip_slot: None,
16890                armor_physical: None,
16891                resists: vec![],
16892                hand_slots: None,
16893                listable: None,
16894                ..Default::default()
16895            },
16896        );
16897        state.worn.insert(
16898            BodySlot::Head,
16899            flatland_protocol::ItemStack {
16900                template_id: "cloth_cap".into(),
16901                quantity: 1,
16902                item_instance_id: Some(uuid::Uuid::from_u128(11)),
16903                props: Default::default(),
16904                status_bindings: Vec::new(),
16905                contents: Vec::new(),
16906                display_name: None,
16907                category: Some("armor".into()),
16908                base_mass: None,
16909                base_volume: None,
16910                capacity_volume: None,
16911                stackable: None,
16912                world_placeable: None,
16913                worker_lodging_capacity: None,
16914                equip_slot: None,
16915                armor_physical: None,
16916                resists: vec![],
16917                hand_slots: None,
16918                listable: None,
16919                ..Default::default()
16920            },
16921        );
16922        state.worn.insert(
16923            BodySlot::Back,
16924            flatland_protocol::ItemStack {
16925                template_id: "travel_backpack".into(),
16926                quantity: 1,
16927                item_instance_id: Some(uuid::Uuid::from_u128(12)),
16928                props: Default::default(),
16929                status_bindings: Vec::new(),
16930                contents: Vec::new(),
16931                display_name: None,
16932                category: Some("container".into()),
16933                base_mass: None,
16934                base_volume: None,
16935                capacity_volume: None,
16936                stackable: None,
16937                world_placeable: None,
16938                worker_lodging_capacity: None,
16939                equip_slot: None,
16940                armor_physical: None,
16941                resists: vec![],
16942                hand_slots: None,
16943                listable: None,
16944                ..Default::default()
16945            },
16946        );
16947
16948        let rows = state.worn_rows();
16949        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
16950        assert_eq!(rows.len(), 4);
16951        assert_eq!(rows[0].stack.template_id, "cloth_cap");
16952        assert!(rows[0].is_equip_shell);
16953        assert_eq!(rows[1].stack.template_id, "travel_backpack");
16954        assert!(rows[1].is_equip_shell);
16955        assert_eq!(rows[2].stack.template_id, "simple_belt");
16956        assert!(rows[2].is_equip_shell);
16957        assert_eq!(rows[3].stack.template_id, "leather_pouch");
16958        assert_eq!(rows[3].depth, 1);
16959        assert!(!rows[3].is_equip_shell);
16960    }
16961
16962    #[test]
16963    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16964        let mut state = sample_state();
16965        state.worn.insert(
16966            BodySlot::Waist,
16967            flatland_protocol::ItemStack {
16968                template_id: "simple_belt".into(),
16969                quantity: 1,
16970                item_instance_id: Some(uuid::Uuid::from_u128(20)),
16971                props: Default::default(),
16972                status_bindings: Vec::new(),
16973                contents: Vec::new(),
16974                display_name: Some("Simple Belt".into()),
16975                category: Some("container".into()),
16976                base_mass: None,
16977                base_volume: None,
16978                capacity_volume: None,
16979                stackable: None,
16980                world_placeable: None,
16981                worker_lodging_capacity: None,
16982                equip_slot: None,
16983                armor_physical: None,
16984                resists: vec![],
16985                hand_slots: None,
16986                listable: None,
16987                ..Default::default()
16988            },
16989        );
16990        state.worn.insert(
16991            BodySlot::Head,
16992            flatland_protocol::ItemStack {
16993                template_id: "cloth_cap".into(),
16994                quantity: 1,
16995                item_instance_id: Some(uuid::Uuid::from_u128(21)),
16996                props: Default::default(),
16997                status_bindings: Vec::new(),
16998                contents: Vec::new(),
16999                display_name: Some("Cloth Cap".into()),
17000                category: Some("armor".into()),
17001                base_mass: None,
17002                base_volume: None,
17003                capacity_volume: None,
17004                stackable: None,
17005                world_placeable: None,
17006                worker_lodging_capacity: None,
17007                equip_slot: None,
17008                armor_physical: None,
17009                resists: vec![],
17010                hand_slots: None,
17011                listable: None,
17012                ..Default::default()
17013            },
17014        );
17015
17016        let opts = state.move_destinations_for(
17017            &flatland_protocol::InventoryLocation::Root,
17018            None,
17019            None,
17020            "leather_pouch",
17021        );
17022        assert!(
17023            opts.iter().any(|o| matches!(
17024                &o.kind,
17025                MoveOptionKind::Move { location, .. }
17026                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17027            )),
17028            "belt loop must be offered when moving a pouch"
17029        );
17030        assert!(
17031            !opts.iter().any(|o| matches!(
17032                &o.kind,
17033                MoveOptionKind::Move { location, .. }
17034                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
17035            )),
17036            "armor slots can't hold other items and must not appear as move destinations"
17037        );
17038        let belt_opt = opts
17039            .iter()
17040            .find(|o| matches!(
17041                &o.kind,
17042                MoveOptionKind::Move { location, .. }
17043                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17044            ))
17045            .unwrap();
17046        assert!(belt_opt.label.contains("belt loop"));
17047
17048        let opts = state.move_destinations_for(
17049            &flatland_protocol::InventoryLocation::Root,
17050            None,
17051            None,
17052            "lumber",
17053        );
17054        assert!(
17055            !opts.iter().any(|o| o.label.contains("belt loop")),
17056            "loose materials must not target the belt shell — only nested pouches"
17057        );
17058    }
17059
17060    #[test]
17061    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
17062        let mut state = sample_state();
17063        let belt_id = uuid::Uuid::from_u128(30);
17064        let pouch_id = uuid::Uuid::from_u128(31);
17065        state.worn.insert(
17066            BodySlot::Waist,
17067            flatland_protocol::ItemStack {
17068                template_id: "simple_belt".into(),
17069                quantity: 1,
17070                item_instance_id: Some(belt_id),
17071                props: Default::default(),
17072                status_bindings: Vec::new(),
17073                world_placeable: None,
17074                worker_lodging_capacity: None,
17075                equip_slot: None,
17076                armor_physical: None,
17077                resists: vec![],
17078                hand_slots: None,
17079                contents: vec![flatland_protocol::ItemStack {
17080                    template_id: "dimensional_pouch".into(),
17081                    quantity: 1,
17082                    item_instance_id: Some(pouch_id),
17083                    props: Default::default(),
17084                    status_bindings: Vec::new(),
17085                    contents: Vec::new(),
17086                    display_name: Some("Dimensional Pouch".into()),
17087                    category: Some("container".into()),
17088                    base_mass: None,
17089                    base_volume: None,
17090                    capacity_volume: Some(200.0),
17091                    stackable: None,
17092                    world_placeable: None,
17093                    worker_lodging_capacity: None,
17094                    equip_slot: None,
17095                    armor_physical: None,
17096                    resists: vec![],
17097                    hand_slots: None,
17098                    listable: None,
17099                    ..Default::default()
17100                }],
17101                display_name: Some("Simple Belt".into()),
17102                category: Some("container".into()),
17103                base_mass: None,
17104                base_volume: None,
17105                capacity_volume: None,
17106                stackable: None,
17107                listable: None,
17108                ..Default::default()
17109            },
17110        );
17111
17112        let opts = state.move_destinations_for(
17113            &flatland_protocol::InventoryLocation::Root,
17114            None,
17115            None,
17116            "iron_ore",
17117        );
17118        assert!(
17119            opts.iter().any(|o| matches!(
17120                &o.kind,
17121                MoveOptionKind::Move {
17122                    location,
17123                    parent_instance_id,
17124                    ..
17125                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17126                    && *parent_instance_id == Some(pouch_id)
17127            )),
17128            "dimensional pouch clipped on belt must accept loose items"
17129        );
17130        assert!(
17131            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
17132            "destination label should name the pouch"
17133        );
17134    }
17135
17136    #[test]
17137    fn container_volume_label_on_placed_chest_shell() {
17138        let mut state = sample_state();
17139        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17140            id: "chest-1".into(),
17141            template_id: "wooden_chest_small".into(),
17142            display_name: "Camp Chest".into(),
17143            x: 129.0,
17144            y: 128.0,
17145            z: 0.0,
17146            locked: false,
17147            accessible: true,
17148            owner_character_id: None,
17149            contents: vec![flatland_protocol::ItemStack {
17150                template_id: "iron_ore".into(),
17151                quantity: 2,
17152                item_instance_id: None,
17153                props: Default::default(),
17154                status_bindings: Vec::new(),
17155                contents: Vec::new(),
17156                display_name: None,
17157                category: None,
17158                base_mass: None,
17159                base_volume: Some(2.0),
17160                capacity_volume: None,
17161                stackable: None,
17162                world_placeable: None,
17163                worker_lodging_capacity: None,
17164                equip_slot: None,
17165                armor_physical: None,
17166                resists: vec![],
17167                hand_slots: None,
17168                listable: None,
17169                ..Default::default()
17170            }],
17171            lock_id: None,
17172            capacity_volume: Some(60.0),
17173            item_instance_id: Some(uuid::Uuid::from_u128(4)),
17174            tile_id: None,
17175            worker_lodging_capacity: None,
17176            blocking: false,
17177            blocking_radius_m: 0.0,
17178            building_id: None,
17179        }];
17180        let nearby = state.nearby_containers();
17181        let label = state.container_volume_label(&nearby[0].rows[0]);
17182        assert!(
17183            label.contains("vol 4/60"),
17184            "expected used/cap in label, got {label}"
17185        );
17186        assert!(
17187            label.contains("56 free"),
17188            "expected free space, got {label}"
17189        );
17190    }
17191
17192    #[test]
17193    fn key_pair_chest_label_from_placed_lock_id() {
17194        let mut state = sample_state();
17195        let owner = uuid::Uuid::from_u128(77);
17196        state.character_id = Some(owner);
17197        let lock = uuid::Uuid::from_u128(99).to_string();
17198        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17199            id: "chest-1".into(),
17200            template_id: "wooden_chest_small".into(),
17201            display_name: "Barry's Loot #a3f2".into(),
17202            x: 129.0,
17203            y: 128.0,
17204            z: 0.0,
17205            locked: true,
17206            accessible: true,
17207            owner_character_id: Some(owner),
17208            contents: Vec::new(),
17209            lock_id: Some(lock.clone()),
17210            capacity_volume: None,
17211            item_instance_id: Some(uuid::Uuid::from_u128(4)),
17212            tile_id: None,
17213            worker_lodging_capacity: None,
17214            blocking: false,
17215            blocking_radius_m: 0.0,
17216            building_id: None,
17217        }];
17218        let key_id = uuid::Uuid::from_u128(5);
17219        let key = flatland_protocol::ItemStack {
17220            template_id: KEY_TEMPLATE.into(),
17221            quantity: 1,
17222            item_instance_id: Some(key_id),
17223            props: BTreeMap::from([
17224                (PROP_OPENS_LOCK_ID.into(), lock),
17225                (
17226                    PROP_OPENS_CONTAINER_NAME.into(),
17227                    "Barry's Loot #a3f2".into(),
17228                ),
17229            ]),
17230            status_bindings: Vec::new(),
17231            contents: Vec::new(),
17232            display_name: Some("Container Key".into()),
17233            category: Some("key".into()),
17234            base_mass: None,
17235            base_volume: None,
17236            capacity_volume: None,
17237            stackable: None,
17238            world_placeable: None,
17239            worker_lodging_capacity: None,
17240            equip_slot: None,
17241            armor_physical: None,
17242            resists: vec![],
17243            hand_slots: None,
17244            listable: None,
17245            ..Default::default()
17246        };
17247        state.inventory_stacks = vec![key.clone()];
17248        assert_eq!(
17249            state.key_pair_chest_label(&key).as_deref(),
17250            Some("Barry's Loot #a3f2")
17251        );
17252        assert!(state.key_drop_blocked(&key));
17253    }
17254
17255    #[test]
17256    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
17257        let mut state = sample_state();
17258        let lock = uuid::Uuid::from_u128(101).to_string();
17259        let key = flatland_protocol::ItemStack {
17260            template_id: KEY_TEMPLATE.into(),
17261            quantity: 1,
17262            item_instance_id: Some(uuid::Uuid::from_u128(7)),
17263            props: BTreeMap::from([
17264                (PROP_OPENS_LOCK_ID.into(), lock),
17265                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
17266            ]),
17267            status_bindings: Vec::new(),
17268            contents: Vec::new(),
17269            display_name: None,
17270            category: Some("key".into()),
17271            base_mass: None,
17272            base_volume: None,
17273            capacity_volume: None,
17274            stackable: None,
17275            world_placeable: None,
17276            worker_lodging_capacity: None,
17277            equip_slot: None,
17278            armor_physical: None,
17279            resists: vec![],
17280            hand_slots: None,
17281            listable: None,
17282            ..Default::default()
17283        };
17284        state.placed_containers.clear();
17285        assert_eq!(
17286            state.key_pair_chest_label(&key).as_deref(),
17287            Some("Camp Stash")
17288        );
17289    }
17290
17291    #[test]
17292    fn key_drop_allowed_when_paired_chest_unlocked() {
17293        let mut state = sample_state();
17294        let lock = uuid::Uuid::from_u128(100).to_string();
17295        let key_id = uuid::Uuid::from_u128(6);
17296        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17297            id: "chest-1".into(),
17298            template_id: "wooden_chest_small".into(),
17299            display_name: "Camp Chest".into(),
17300            x: 129.0,
17301            y: 128.0,
17302            z: 0.0,
17303            locked: false,
17304            accessible: true,
17305            owner_character_id: None,
17306            contents: Vec::new(),
17307            lock_id: Some(lock.clone()),
17308            capacity_volume: None,
17309            item_instance_id: None,
17310            tile_id: None,
17311            worker_lodging_capacity: None,
17312            blocking: false,
17313            blocking_radius_m: 0.0,
17314            building_id: None,
17315        }];
17316        let key = flatland_protocol::ItemStack {
17317            template_id: KEY_TEMPLATE.into(),
17318            quantity: 1,
17319            item_instance_id: Some(key_id),
17320            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
17321            status_bindings: Vec::new(),
17322            contents: Vec::new(),
17323            display_name: None,
17324            category: Some("key".into()),
17325            base_mass: None,
17326            base_volume: None,
17327            capacity_volume: None,
17328            stackable: None,
17329            world_placeable: None,
17330            worker_lodging_capacity: None,
17331            equip_slot: None,
17332            armor_physical: None,
17333            resists: vec![],
17334            hand_slots: None,
17335            listable: None,
17336            ..Default::default()
17337        };
17338        state.inventory_stacks = vec![key.clone()];
17339        assert!(!state.key_drop_blocked(&key));
17340        let opts = state.move_destinations_for(
17341            &flatland_protocol::InventoryLocation::Root,
17342            None,
17343            Some(key_id),
17344            KEY_TEMPLATE,
17345        );
17346        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
17347    }
17348
17349    #[test]
17350    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
17351        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
17352
17353        let mut state = sample_state();
17354        let curve = ProgressionCurve::default();
17355        let bootstrap =
17356            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
17357        let mut fresh = bootstrap.clone();
17358        fresh.strength += 0.08;
17359        if let Some(player) = state.player.as_mut() {
17360            player.progression_xp = Some(bootstrap);
17361        }
17362
17363        let combat = CombatHud {
17364            progression_xp: Some(fresh.clone()),
17365            progression_baseline: curve.baseline_display,
17366            progression_xp_base: curve.xp_base,
17367            progression_xp_growth: curve.xp_growth,
17368            attributes: state.player.as_ref().and_then(|p| p.attributes),
17369            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
17370            ..CombatHud::default()
17371        };
17372        state.apply_combat_hud(&combat);
17373
17374        let xp = state
17375            .player
17376            .as_ref()
17377            .and_then(|p| p.progression_xp.as_ref())
17378            .expect("xp");
17379        assert!((xp.strength - fresh.strength).abs() < 0.001);
17380        assert!(state.progression_curve.is_some());
17381    }
17382
17383    #[test]
17384    fn combat_hud_syncs_known_abilities_and_hotbar() {
17385        use flatland_protocol::CombatHud;
17386
17387        let mut state = sample_state();
17388        let combat = CombatHud {
17389            known_abilities: vec!["unarmed".into(), "fireball".into()],
17390            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
17391            max_abilities_per_rotation: 4,
17392            ability_id: "short_sword_slash".into(),
17393            ..CombatHud::default()
17394        };
17395        state.apply_combat_hud(&combat);
17396
17397        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
17398        assert_eq!(state.hotbar_ability(1), Some("fireball"));
17399        assert_eq!(state.hotbar_ability(2), None);
17400        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
17401        assert_eq!(state.max_abilities_per_rotation, 4);
17402        let choices = state.loadout_ability_choices();
17403        assert!(choices.iter().any(|a| a == "short_sword_slash"));
17404        assert!(choices.iter().any(|a| a == "fireball"));
17405    }
17406
17407    #[test]
17408    fn loadout_hotbar_choices_include_inventory_consumables() {
17409        let mut state = sample_state();
17410        state.known_abilities = vec!["unarmed".into()];
17411        state.weapon_ability_id = "unarmed".into();
17412        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17413            template_id: "bottle_of_water".into(),
17414            quantity: 3,
17415            item_instance_id: Some(uuid::Uuid::from_u128(9)),
17416            display_name: Some("Bottle of Water".into()),
17417            category: Some("consumable".into()),
17418            ..Default::default()
17419        }];
17420        state.inventory.insert("bottle_of_water".into(), 3);
17421        state.inventory_hints.insert(
17422            "bottle_of_water".into(),
17423            InventoryHint {
17424                display_name: "Bottle of Water".into(),
17425                category: "consumable".into(),
17426                ..Default::default()
17427            },
17428        );
17429
17430        let choices = state.loadout_hotbar_choices();
17431        assert!(choices.iter().any(|c| c.binding == "unarmed"));
17432        let water = choices
17433            .iter()
17434            .find(|c| c.binding == "item:bottle_of_water")
17435            .expect("water binding");
17436        assert_eq!(water.meta.as_deref(), Some("use"));
17437        assert!(water.label.contains("Water"));
17438        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
17439        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
17440        assert_eq!(
17441            state.hotbar_slot_label(5).as_deref(),
17442            Some("Bottle of Water×3")
17443        );
17444    }
17445
17446    #[test]
17447    fn storage_store_options_excludes_hand_equipped() {
17448        let mut state = sample_state();
17449        let sword_id = uuid::Uuid::from_u128(11);
17450        let ore_id = uuid::Uuid::from_u128(22);
17451        state.inventory_stacks = vec![
17452            flatland_protocol::ItemStack {
17453                template_id: "short_sword".into(),
17454                quantity: 1,
17455                item_instance_id: Some(sword_id),
17456                display_name: Some("Short Sword".into()),
17457                category: Some("weapon".into()),
17458                ..Default::default()
17459            },
17460            flatland_protocol::ItemStack {
17461                template_id: "iron_ore".into(),
17462                quantity: 5,
17463                item_instance_id: Some(ore_id),
17464                display_name: Some("Iron Ore".into()),
17465                category: Some("resource".into()),
17466                ..Default::default()
17467            },
17468        ];
17469        state.mainhand_template_id = Some("short_sword".into());
17470        state.mainhand_instance_id = Some(sword_id);
17471
17472        let opts = state.storage_store_options();
17473        assert_eq!(opts.len(), 1);
17474        assert_eq!(opts[0].item_instance_id, ore_id);
17475        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
17476    }
17477
17478    #[test]
17479    fn loose_consumable_move_picker_offers_use_and_storage() {
17480        let mut state = sample_state();
17481        let inst = uuid::Uuid::from_u128(77);
17482        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17483            template_id: "carrot".into(),
17484            quantity: 2,
17485            item_instance_id: Some(inst),
17486            props: Default::default(),
17487            status_bindings: Vec::new(),
17488            contents: Vec::new(),
17489            display_name: Some("Wild Carrot".into()),
17490            category: Some("consumable".into()),
17491            base_mass: None,
17492            base_volume: None,
17493            capacity_volume: None,
17494            stackable: Some(true),
17495            world_placeable: None,
17496            worker_lodging_capacity: None,
17497            equip_slot: None,
17498            armor_physical: None,
17499            resists: vec![],
17500            hand_slots: None,
17501            listable: None,
17502            ..Default::default()
17503        }];
17504        state.inventory_hints.insert(
17505            "carrot".into(),
17506            InventoryHint {
17507                display_name: "Wild Carrot".into(),
17508                category: "consumable".into(),
17509                base_mass: Some(0.15),
17510                base_volume: Some(0.3),
17511                capacity_volume: None,
17512                stackable: true,
17513                listable: true,
17514                base_value_copper: None,
17515            },
17516        );
17517        state.show_inventory_menu = true;
17518        state.inventory_menu_index = 0;
17519
17520        let row = state.inventory_selected_row().expect("carrot row");
17521        let mut options = state.move_destinations_for(
17522            &row.from,
17523            row.from_parent_instance_id,
17524            row.stack.item_instance_id,
17525            &row.stack.template_id,
17526        );
17527        if row.from == flatland_protocol::InventoryLocation::Root
17528            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
17529        {
17530            options.insert(
17531                0,
17532                MoveOption {
17533                    label: "Use (eat / drink)".into(),
17534                    kind: MoveOptionKind::Use,
17535                },
17536            );
17537        }
17538
17539        assert_eq!(
17540            options.first().map(|o| &o.label),
17541            Some(&"Use (eat / drink)".into())
17542        );
17543        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
17544        assert!(options
17545            .iter()
17546            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
17547    }
17548
17549    #[test]
17550    fn inventory_category_group_order_is_stable() {
17551        assert_eq!(inventory_category_group("weapon").0, "Weapons");
17552        assert_eq!(inventory_category_group("armor").0, "Armor");
17553        assert_eq!(inventory_category_group("consumable").0, "Consumables");
17554        assert_eq!(inventory_category_group("resource").0, "Resources");
17555        assert_eq!(inventory_category_group("container").0, "Containers");
17556        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
17557        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
17558    }
17559
17560    #[test]
17561    fn page_list_index_clamps_without_wrap() {
17562        assert_eq!(page_list_index(0, -1, 25), 0);
17563        assert_eq!(page_list_index(0, 1, 25), 10);
17564        assert_eq!(page_list_index(12, 1, 25), 22);
17565        assert_eq!(page_list_index(22, 1, 25), 24);
17566        assert_eq!(page_list_index(5, 1, 0), 0);
17567        assert_eq!(page_list_index(3, -1, 8), 0);
17568    }
17569
17570    #[test]
17571    fn inventory_filter_hides_non_matching_person_items() {
17572        let mut state = sample_state();
17573        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17574        sword.display_name = Some("Iron Sword".into());
17575        sword.category = Some("weapon".into());
17576        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
17577        herb.display_name = Some("Wild Herb".into());
17578        herb.category = Some("consumable".into());
17579        state.inventory_stacks = vec![sword, herb];
17580        state.inventory_tab = InventoryTab::OnPerson;
17581        state.inventory_filter = "sword".into();
17582
17583        let rows = state.inventory_selectable_rows();
17584        assert_eq!(rows.len(), 1);
17585        assert_eq!(rows[0].stack.template_id, "iron_sword");
17586
17587        let lines = state.inventory_browser_lines();
17588        assert!(lines.iter().any(|l| matches!(
17589            l,
17590            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17591        )));
17592        assert!(!lines.iter().any(|l| matches!(
17593            l,
17594            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17595        )));
17596    }
17597
17598    #[test]
17599    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17600        let mut state = sample_state();
17601        let id_a = uuid::Uuid::from_u128(0xa1);
17602        let id_b = uuid::Uuid::from_u128(0xb2);
17603        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17604        sword_a.display_name = Some("Iron Sword".into());
17605        sword_a.category = Some("weapon".into());
17606        sword_a.item_instance_id = Some(id_a);
17607        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17608        sword_b.display_name = Some("Iron Sword".into());
17609        sword_b.category = Some("weapon".into());
17610        sword_b.item_instance_id = Some(id_b);
17611        state.inventory_stacks = vec![sword_a, sword_b];
17612        state.inventory_tab = InventoryTab::OnPerson;
17613
17614        let lines = state.inventory_browser_lines();
17615        let items: Vec<_> = lines
17616            .iter()
17617            .filter_map(|l| match l {
17618                InventoryBrowserLine::Item {
17619                    title,
17620                    instance_tooltip,
17621                    ..
17622                } => Some((title.clone(), instance_tooltip.clone())),
17623                _ => None,
17624            })
17625            .collect();
17626        assert_eq!(items.len(), 2);
17627        for (title, tip) in &items {
17628            assert!(
17629                !title.contains('#'),
17630                "title should not show instance suffix: {title}"
17631            );
17632            assert!(
17633                tip.is_some(),
17634                "two identical rows should expose instance on hover"
17635            );
17636        }
17637
17638        state.inventory_stacks.pop();
17639        let lines = state.inventory_browser_lines();
17640        let one = lines.iter().find_map(|l| match l {
17641            InventoryBrowserLine::Item {
17642                title,
17643                instance_tooltip,
17644                ..
17645            } => Some((title.clone(), instance_tooltip.clone())),
17646            _ => None,
17647        });
17648        let (title, tip) = one.expect("one sword row");
17649        assert!(!title.contains('#'));
17650        assert!(tip.is_none(), "single row should not need instance tooltip");
17651    }
17652
17653    #[test]
17654    fn inventory_person_rows_group_by_category() {
17655        let mut state = sample_state();
17656        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17657        sword.category = Some("weapon".into());
17658        sword.display_name = Some("Iron Sword".into());
17659        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17660        ore.category = Some("resource".into());
17661        ore.display_name = Some("Iron Ore".into());
17662        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17663        potion.category = Some("consumable".into());
17664        potion.display_name = Some("Health Potion".into());
17665        state.inventory_stacks = vec![ore, potion, sword];
17666        state.inventory_tab = InventoryTab::OnPerson;
17667
17668        let lines = state.inventory_browser_lines();
17669        let labels: Vec<&str> = lines
17670            .iter()
17671            .filter_map(|l| match l {
17672                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17673                _ => None,
17674            })
17675            .collect();
17676        assert!(
17677            labels.iter().any(|s| s.contains("Weapons")),
17678            "expected Weapons group: {labels:?}"
17679        );
17680        assert!(labels.iter().any(|s| s.contains("Consumables")));
17681        assert!(labels.iter().any(|s| s.contains("Resources")));
17682
17683        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17684        let consumable_pos = labels
17685            .iter()
17686            .position(|s| s.contains("Consumables"))
17687            .unwrap();
17688        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17689        assert!(weapon_pos < consumable_pos);
17690        assert!(consumable_pos < resource_pos);
17691    }
17692
17693    #[test]
17694    fn inventory_tab_cycle_resets_selection() {
17695        let mut state = sample_state();
17696        state.inventory_tab = InventoryTab::OnPerson;
17697        state.inventory_menu_index = 3;
17698        state.inventory_tab = state.inventory_tab.cycle(true);
17699        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17700        // Client method resets index; enum cycle alone does not — verify cycle labels.
17701        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17702        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17703        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17704        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17705    }
17706
17707    #[test]
17708    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17709        assert_eq!(parse_bank_copper_amount(""), Some(0));
17710        assert_eq!(parse_bank_copper_amount("  "), Some(0));
17711        assert_eq!(parse_bank_copper_amount("0"), Some(0));
17712        assert_eq!(parse_bank_copper_amount("250"), Some(250));
17713        assert_eq!(parse_bank_copper_amount("nope"), None);
17714    }
17715
17716    #[test]
17717    fn parse_storage_quantity_blank_and_zero_mean_all() {
17718        assert_eq!(parse_storage_quantity(""), Some(None));
17719        assert_eq!(parse_storage_quantity("  "), Some(None));
17720        assert_eq!(parse_storage_quantity("0"), Some(None));
17721        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17722        assert_eq!(parse_storage_quantity("nope"), None);
17723    }
17724
17725    #[test]
17726    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17727        assert!(worker_error_is_hud_noise("path stuck — repathing"));
17728        assert!(worker_error_is_hud_noise(
17729            "path stuck — nudged clear, repathing"
17730        ));
17731        assert!(worker_error_is_hud_noise(
17732            "returned to lodging after path failures"
17733        ));
17734        // Real problem: player may need to place lodging / fix assignment.
17735        assert!(!worker_error_is_hud_noise(
17736            "path stuck — no lodging to reset to"
17737        ));
17738    }
17739
17740    #[test]
17741    fn leaving_building_restores_outdoor_z_bands() {
17742        use flatland_protocol::{InteriorMapView, ZPlatformView};
17743
17744        let mut state = sample_state();
17745        state.z_platforms.clear();
17746        state.z_transitions.clear();
17747        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
17748        state.interior_map = Some(InteriorMapView {
17749            building_id: "broker_hut".into(),
17750            blueprint_id: "broker_hut".into(),
17751            background_color: "#000".into(),
17752            default_floor_color: None,
17753            floor_height_m: 3.0,
17754            z_platforms: vec![ZPlatformView {
17755                id: "floor_0".into(),
17756                z: 0.0,
17757                x0: 0.0,
17758                y0: 0.0,
17759                x1: 8.0,
17760                y1: 8.0,
17761            }],
17762            z_transitions: vec![],
17763            rooms: vec![],
17764            room_doors: vec![],
17765        });
17766        state.sync_interior_map_context();
17767        assert_eq!(
17768            state.z_platforms.len(),
17769            1,
17770            "indoors installs interior platforms"
17771        );
17772        assert!(state.z_bands_outdoor_backup.is_some());
17773
17774        state.player.as_mut().unwrap().inside_building = None;
17775        state.sync_interior_map_context();
17776        assert!(
17777            state.z_platforms.is_empty(),
17778            "leaving must restore outdoor bands (empty), not leave interior platforms"
17779        );
17780        assert!(state.z_bands_outdoor_backup.is_none());
17781        assert!(state.interior_map.is_none());
17782    }
17783
17784    #[test]
17785    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
17786        let node = ResourceNodeView {
17787            id: "crop-carrot-1_copy10".into(),
17788            label: "crop-carrot-1_copy10".into(),
17789            x: 0.0,
17790            y: 0.0,
17791            z: 0.0,
17792            item_template: "carrot".into(),
17793            state: ResourceNodeState::Available,
17794            blocking: false,
17795            blocking_radius_m: 0.5,
17796            harvest_off: false,
17797            tile_id: None,
17798            yaw: 0.0,
17799            pitch: 0.0,
17800            roll: 0.0,
17801            draw_scale: 1.0,
17802            sprite_mode: None,
17803            growth_progress: None,
17804            presentation_state: None,
17805            channel_start_tick: None,
17806            channel_end_tick: None,
17807            harvest_drop_templates: vec![],
17808        };
17809        let label = super::resource_node_route_label(&node);
17810        assert!(label.starts_with("Carrot ("), "got {label}");
17811        assert!(label.ends_with(')'), "got {label}");
17812
17813        let mut named = node;
17814        named.label = "Sweet Pad".into();
17815        named.id = "crop-carrot-a3f2b1c0".into();
17816        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
17817    }
17818
17819    #[test]
17820    fn plot_public_label_uses_owner_zone_and_label() {
17821        let plot = flatland_protocol::PropertyPlotView {
17822            plot_id: uuid::Uuid::nil(),
17823            property_zone_id: "zone_a".into(),
17824            zone_label: Some("Starter Town East 1".into()),
17825            deed_instance_id: uuid::Uuid::nil(),
17826            x0: 0.0,
17827            y0: 0.0,
17828            x1: 4.0,
17829            y1: 4.0,
17830            upkeep_copper_per_day: 1,
17831            arrears_days: 0,
17832            is_mine: true,
17833            may_farm: true,
17834            purchase_basis_copper: 0,
17835            farm_public: false,
17836            public_tax_discount_bps: 0,
17837            farm_allow: vec![],
17838            owner_character_id: None,
17839            owner_label: Some("Madsin".into()),
17840            building_id: None,
17841            plot_code: "xyz1234a".into(),
17842            label: "Food Pad".into(),
17843        };
17844        assert_eq!(
17845            super::plot_public_label(&plot),
17846            "Madsin — Starter Town East 1 — Food Pad"
17847        );
17848    }
17849}