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/// Prevent repeated hire-key presses while the new worker roster is in flight.
120const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
121
122/// Catalog hints synced from server `ItemStack` wire rows.
123#[derive(Debug, Clone, Default)]
124pub struct InventoryHint {
125    pub display_name: String,
126    pub category: String,
127    pub base_mass: Option<f32>,
128    pub base_volume: Option<f32>,
129    pub capacity_volume: Option<f32>,
130    pub stackable: bool,
131    /// Market-hall list eligibility (from catalog enrichment).
132    pub listable: bool,
133    /// NPC trade base for dump-queue payout hints.
134    pub base_value_copper: Option<u32>,
135}
136
137/// One row in the loadout hotbar picker (ability or inventory consumable).
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct LoadoutHotbarChoice {
140    /// Value sent via [`Intent::SetHotbarSlot`] (`ability_id` or `item:<template>`).
141    pub binding: String,
142    /// Display label (ability id, or "Carrot ×3").
143    pub label: String,
144    /// Optional meta badge (`weapon`, `use`, …).
145    pub meta: Option<String>,
146}
147
148/// Rotation editor overlay mode (`plans/26` §C2.5).
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum RotationEditorMode {
151    #[default]
152    List,
153    EditSequence,
154    PickAbility,
155    EditLabel,
156}
157
158/// Local rotation editor UI state (not persisted).
159#[derive(Debug, Clone, Default)]
160pub struct RotationEditorState {
161    pub mode: RotationEditorMode,
162    pub list_index: usize,
163    pub ability_index: usize,
164    pub picker_index: usize,
165    pub draft: Option<RotationPreset>,
166    pub label_buffer: String,
167}
168
169impl RotationEditorState {
170    pub fn reset(&mut self) {
171        *self = Self::default();
172    }
173}
174
175/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
176/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
177/// client only ever shows chests the server will actually let you use — this is
178/// what makes a chest disappear from the menu as soon as you walk away.
179pub const CONTAINER_RANGE_M: f32 = 3.0;
180
181/// Broad section of the inventory browser a row belongs to (drives the grouped
182/// "Worn" / "On you" / "Nearby chest" headers in the UI).
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum InventorySection {
185    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
186    Worn,
187    /// Loose on your person — not worn, not inside a placed chest.
188    Person,
189    /// Inside a placed chest within reach.
190    Nearby,
191}
192
193/// Top-level inventory browser tab (`b` menu).
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195pub enum InventoryTab {
196    #[default]
197    OnPerson,
198    Nearby,
199}
200
201impl InventoryTab {
202    pub fn label(self) -> &'static str {
203        match self {
204            Self::OnPerson => "On person",
205            Self::Nearby => "Nearby storage",
206        }
207    }
208
209    pub fn cycle(self, forward: bool) -> Self {
210        match (self, forward) {
211            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
212            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
213        }
214    }
215}
216
217/// Rows jumped by PageUp / PageDown in list UIs.
218pub const LIST_PAGE_SIZE: usize = 10;
219
220/// Case-insensitive substring match for list filters (name / template / label).
221pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
222    if filter.is_empty() {
223        return true;
224    }
225    haystack
226        .to_ascii_lowercase()
227        .contains(&filter.to_ascii_lowercase())
228}
229
230/// Clamp a list selection after a page jump (`pages` is typically ±1).
231pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
232    if len == 0 {
233        return 0;
234    }
235    let page = LIST_PAGE_SIZE as i32;
236    let next = index as i32 + pages * page;
237    next.clamp(0, (len as i32) - 1) as usize
238}
239
240/// Advance `index` by `delta` among indices where `pred` is true (wraps).
241pub fn step_filtered_index(
242    index: usize,
243    delta: i32,
244    len: usize,
245    pred: impl Fn(usize) -> bool,
246) -> usize {
247    if len == 0 {
248        return 0;
249    }
250    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
251    if matching.is_empty() {
252        return index.min(len - 1);
253    }
254    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
255    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
256    matching[next]
257}
258
259/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
260pub fn page_filtered_index(
261    index: usize,
262    pages: i32,
263    len: usize,
264    pred: impl Fn(usize) -> bool,
265) -> usize {
266    if len == 0 {
267        return 0;
268    }
269    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
270    if matching.is_empty() {
271        return index.min(len - 1);
272    }
273    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
274    let next = page_list_index(pos, pages, matching.len());
275    matching[next]
276}
277
278/// Category group for On-person loose items (stable order).
279pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
280    match category {
281        "weapon" | "ammo" => ("Weapons", 0),
282        "armor" | "shield" | "offhand" => ("Armor", 1),
283        "consumable" => ("Consumables", 2),
284        "resource" | "harvest_node" | "seed" => ("Resources", 3),
285        "container" | "lodging" => ("Containers", 4),
286        "currency" | "key" => ("Currency & keys", 5),
287        "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
288        _ => ("Other", 7),
289    }
290}
291
292/// Whether a template category is listable when the wire/hint omits an explicit flag.
293pub fn category_default_listable(category: &str) -> bool {
294    !matches!(
295        category,
296        "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
297    )
298}
299
300/// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
301pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
302    if base_value == 0 {
303        return None;
304    }
305    let unit = ((base_value as f32) * 0.5).floor() as u32;
306    if unit == 0 {
307        return None;
308    }
309    Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
310}
311
312/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
313fn parse_bank_copper_amount(input: &str) -> Option<u64> {
314    let s = input.trim();
315    if s.is_empty() {
316        return Some(0);
317    }
318    s.parse::<u64>().ok()
319}
320
321/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
322fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
323    let s = input.trim();
324    if s.is_empty() || s == "0" {
325        return Some(None);
326    }
327    let n = s.parse::<u32>().ok()?;
328    if n == 0 {
329        return Some(None);
330    }
331    Some(Some(n))
332}
333
334fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
335    let name = stack
336        .display_name
337        .as_deref()
338        .unwrap_or(stack.template_id.as_str());
339    if stack.quantity > 1 {
340        format!("{name} ×{}", stack.quantity)
341    } else {
342        name.to_string()
343    }
344}
345
346/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
347/// browser section headers and the move-destination picker.
348pub fn body_slot_label(slot: BodySlot) -> &'static str {
349    match slot {
350        BodySlot::Head => "Head",
351        BodySlot::Chest => "Chest",
352        BodySlot::Forearms => "Forearms",
353        BodySlot::Legs => "Legs",
354        BodySlot::Feet => "Feet",
355        BodySlot::Cloak => "Cloak",
356        BodySlot::Back => "Back",
357        BodySlot::Waist => "Waist",
358        BodySlot::Earrings => "Earrings",
359        BodySlot::Necklace => "Necklace",
360        BodySlot::Eyeglasses => "Eyeglasses",
361        BodySlot::RingLeft1 => "Ring L1",
362        BodySlot::RingLeft2 => "Ring L2",
363        BodySlot::RingRight1 => "Ring R1",
364        BodySlot::RingRight2 => "Ring R2",
365    }
366}
367
368fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
369    let cat = stack.category.as_deref().unwrap_or("");
370    match mode {
371        "while_equipped" => {
372            stack.equip_slot.is_some()
373                || cat == "weapon"
374                || cat == "shield"
375                || cat == "offhand"
376                || cat == "armor"
377        }
378        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
379    }
380}
381
382fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
383    if grant_tags.is_empty() {
384        return true;
385    }
386    let target_tags: Vec<&str> = stack
387        .props
388        .get("allowed_enchant_tags")
389        .map(|s| {
390            s.split(',')
391                .map(str::trim)
392                .filter(|t| !t.is_empty())
393                .collect()
394        })
395        .unwrap_or_default();
396    if target_tags.is_empty() {
397        return true;
398    }
399    grant_tags.iter().any(|t| target_tags.contains(t))
400}
401
402/// Default sim tick rate when the client has no settings (matches server-settings).
403pub const DEFAULT_TICK_HZ: u32 = 30;
404
405/// Human-readable remaining time for an item status binding.
406pub fn format_binding_ttl(
407    binding: &flatland_protocol::ItemStatusBinding,
408    tick: u64,
409    tick_hz: u32,
410) -> String {
411    let Some(expires) = binding.expires_at_tick else {
412        return "permanent".into();
413    };
414    let hz = tick_hz.max(1) as f32;
415    let remaining = expires.saturating_sub(tick) as f32 / hz;
416    if remaining <= 0.0 {
417        return "expired".into();
418    }
419    if remaining >= 120.0 {
420        format!("{:.0}m left", remaining / 60.0)
421    } else if remaining >= 10.0 {
422        format!("{remaining:.0}s left")
423    } else {
424        format!("{remaining:.1}s left")
425    }
426}
427
428pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
429    match mode {
430        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
431        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
432    }
433}
434
435/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
436pub fn format_status_bindings_suffix(
437    bindings: &[flatland_protocol::ItemStatusBinding],
438    tick: u64,
439    tick_hz: u32,
440) -> String {
441    if bindings.is_empty() {
442        return String::new();
443    }
444    let parts: Vec<String> = bindings
445        .iter()
446        .map(|b| {
447            format!(
448                "{} ({}, {})",
449                b.effect_id,
450                format_binding_mode(b.mode),
451                format_binding_ttl(b, tick, tick_hz)
452            )
453        })
454        .collect();
455    format!(" · {}", parts.join("; "))
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum EquipPaperdollRow {
460    Body { slot: BodySlot, filled: bool },
461    Mainhand { filled: bool },
462    Offhand { filled: bool, locked: bool },
463}
464
465pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
466    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
467        .iter()
468        .map(|slot| EquipPaperdollRow::Body {
469            slot: *slot,
470            filled: state.worn.contains_key(slot),
471        })
472        .collect();
473    let two_hand = state.mainhand_hand_slots >= 2;
474    rows.push(EquipPaperdollRow::Mainhand {
475        filled: state.mainhand_template_id.is_some(),
476    });
477    rows.push(EquipPaperdollRow::Offhand {
478        filled: state.offhand_template_id.is_some(),
479        locked: two_hand,
480    });
481    rows
482}
483
484fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
485    for stack in &state.inventory_stacks {
486        let matches = stack
487            .equip_slot
488            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
489            .unwrap_or(false)
490            || guess_body_slot(&stack.template_id) == Some(slot);
491        if matches {
492            return stack.item_instance_id;
493        }
494    }
495    None
496}
497
498fn is_client_ring(slot: BodySlot) -> bool {
499    matches!(
500        slot,
501        BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
502    )
503}
504
505fn first_inventory_weapon(state: &GameState) -> Option<String> {
506    for stack in &state.inventory_stacks {
507        if stack.category.as_deref() == Some("weapon") {
508            return Some(stack.template_id.clone());
509        }
510    }
511    None
512}
513
514fn first_inventory_offhand(state: &GameState) -> Option<String> {
515    for stack in &state.inventory_stacks {
516        let cat = stack.category.as_deref().unwrap_or("");
517        if matches!(cat, "shield" | "offhand") {
518            return Some(stack.template_id.clone());
519        }
520    }
521    None
522}
523
524/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
525/// on the stack when present; otherwise guesses from `template_id`.
526fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
527    if template_id.contains("backpack") {
528        Some(BodySlot::Back)
529    } else if template_id.contains("belt") {
530        Some(BodySlot::Waist)
531    } else if template_id.contains("cloak") || template_id.contains("cape") {
532        Some(BodySlot::Cloak)
533    } else if template_id.contains("cap")
534        || template_id.contains("hat")
535        || template_id.contains("helm")
536    {
537        Some(BodySlot::Head)
538    } else if template_id.contains("shirt")
539        || template_id.contains("robe")
540        || template_id.contains("vest")
541        || template_id.contains("chest")
542        || template_id.contains("jerkin")
543    {
544        Some(BodySlot::Chest)
545    } else if template_id.contains("sleeves")
546        || template_id.contains("gloves")
547        || template_id.contains("gauntlets")
548    {
549        Some(BodySlot::Forearms)
550    } else if template_id.contains("pants") || template_id.contains("leggings") {
551        Some(BodySlot::Legs)
552    } else if template_id.contains("boots") || template_id.contains("shoes") {
553        Some(BodySlot::Feet)
554    } else if template_id.contains("earring") {
555        Some(BodySlot::Earrings)
556    } else if template_id.contains("necklace") || template_id.contains("amulet") {
557        Some(BodySlot::Necklace)
558    } else if template_id.contains("glass")
559        || template_id.contains("spectacles")
560        || template_id.contains("goggles")
561    {
562        Some(BodySlot::Eyeglasses)
563    } else if template_id.contains("ring") {
564        Some(BodySlot::RingLeft1)
565    } else {
566        None
567    }
568}
569
570/// One row in the inventory browser tree.
571#[derive(Debug, Clone)]
572pub struct InventoryRow {
573    pub depth: usize,
574    pub stack: flatland_protocol::ItemStack,
575    /// `MoveItem` source location for this stack.
576    pub from: flatland_protocol::InventoryLocation,
577    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
578    pub from_parent_instance_id: Option<uuid::Uuid>,
579    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
580    pub is_equip_shell: bool,
581    /// Placed world chest shell — lock/unlock via Enter or `l`.
582    pub is_chest_shell: bool,
583    pub section: InventorySection,
584}
585
586/// Formatted inventory row text shared by TUI and gfx browsers.
587#[derive(Debug, Clone)]
588pub struct InventoryRowView {
589    pub depth: usize,
590    /// Dense single-line label (legacy / TUI).
591    pub text: String,
592    /// Primary label for redesigned gfx rows (name, qty, slot).
593    pub title: String,
594    pub mass_kg: Option<f32>,
595    pub volume: Option<(f32, f32)>,
596    /// Hover hint when multiple rows share the same visible identity (template + label + mods).
597    pub instance_tooltip: Option<String>,
598}
599
600/// One line in the sectioned inventory browser (headers are non-selectable).
601#[derive(Debug, Clone)]
602pub enum InventoryBrowserLine {
603    Section(String),
604    SlotLabel(String),
605    Hint(String),
606    Blank,
607    Item {
608        selectable_index: usize,
609        selected: bool,
610        depth: usize,
611        text: String,
612        title: String,
613        mass_kg: Option<f32>,
614        volume: Option<(f32, f32)>,
615        instance_tooltip: Option<String>,
616    },
617}
618
619/// Bank teller panel focus (action list vs amount / transfer prompts).
620#[derive(Debug, Clone, PartialEq, Eq, Default)]
621pub enum BankUiMode {
622    #[default]
623    Menu,
624    DepositAmount {
625        input: String,
626    },
627    WithdrawAmount {
628        input: String,
629    },
630    TransferName {
631        input: String,
632    },
633    TransferAmount {
634        to_name: String,
635        input: String,
636    },
637}
638
639/// Town storage manager focus (action list vs item pickers vs quantity).
640#[derive(Debug, Clone, PartialEq, Eq, Default)]
641pub enum StorageUiMode {
642    #[default]
643    Menu,
644    /// Pick a loose on-person stack to store.
645    StorePick { index: usize },
646    /// Quantity for a chosen store stack (blank/0 = all).
647    StoreAmount {
648        pick_index: usize,
649        item_instance_id: uuid::Uuid,
650        label: String,
651        max_qty: u32,
652        input: String,
653    },
654    /// Pick a vault stack to take.
655    TakePick { index: usize },
656    /// Quantity for a chosen take stack (blank/0 = all).
657    TakeAmount {
658        pick_index: usize,
659        item_instance_id: uuid::Uuid,
660        label: String,
661        max_qty: u32,
662        input: String,
663    },
664    /// Pick a vault stack to ship to `dest_building_id`.
665    ShipPick {
666        dest_building_id: String,
667        dest_label: String,
668        index: usize,
669    },
670    /// Quantity for a chosen ship stack (blank/0 = all).
671    ShipAmount {
672        dest_building_id: String,
673        dest_label: String,
674        pick_index: usize,
675        item_instance_id: uuid::Uuid,
676        label: String,
677        max_qty: u32,
678        input: String,
679    },
680}
681
682/// Where market list goods are taken from (`GoodsLocation` on submit).
683#[derive(Debug, Clone, PartialEq, Eq)]
684pub enum MarketListSourceKind {
685    Person,
686    TownStorage { building_id: String },
687}
688
689/// Market hall clerk focus (browse vs list wizard).
690#[derive(Debug, Clone, PartialEq, Eq, Default)]
691pub enum MarketUiMode {
692    #[default]
693    Browse,
694    /// Pick on-person vs an eligible town vault.
695    ListSource { index: usize },
696    /// Pick a stack from the chosen source.
697    ListPick {
698        source: MarketListSourceKind,
699        index: usize,
700    },
701    /// Quantity to list (blank/0 = all).
702    ListAmount {
703        source: MarketListSourceKind,
704        pick_index: usize,
705        item_instance_id: uuid::Uuid,
706        template_id: String,
707        label: String,
708        max_qty: u32,
709        input: String,
710    },
711    /// Choose Fixed copper vs NPC-price dump queue (`plans/53`).
712    ListPricingMode {
713        source: MarketListSourceKind,
714        pick_index: usize,
715        item_instance_id: uuid::Uuid,
716        template_id: String,
717        label: String,
718        quantity: Option<u32>,
719        max_qty: u32,
720        /// 0 = NPC price, 1 = Fixed.
721        index: usize,
722    },
723    /// Unit price in copper.
724    ListPrice {
725        source: MarketListSourceKind,
726        pick_index: usize,
727        item_instance_id: uuid::Uuid,
728        template_id: String,
729        label: String,
730        /// Resolved quantity to list (`None` = all / omit on wire).
731        quantity: Option<u32>,
732        max_qty: u32,
733        input: String,
734    },
735}
736
737/// One selectable stack in a storage store/take/ship picker.
738#[derive(Debug, Clone)]
739pub struct StoragePickOption {
740    pub item_instance_id: uuid::Uuid,
741    pub template_id: String,
742    pub label: String,
743    pub quantity: u32,
744    /// Catalog category when known (market list filters).
745    pub category: String,
746}
747
748/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
749/// the browser (empty when locked without the matching key).
750#[derive(Debug, Clone)]
751pub struct NearbyContainer {
752    pub view: flatland_protocol::PlacedContainerView,
753    pub distance_m: f32,
754    pub rows: Vec<InventoryRow>,
755}
756
757/// One key row in the keychain overlay (carried vs stowed).
758#[derive(Debug, Clone)]
759pub struct KeychainEntry {
760    pub stack: flatland_protocol::ItemStack,
761    pub stowed: bool,
762}
763
764/// A destination the currently-picked item could be moved to.
765#[derive(Debug, Clone)]
766pub struct MoveOption {
767    pub label: String,
768    pub kind: MoveOptionKind,
769}
770
771#[derive(Debug, Clone, PartialEq)]
772pub enum MoveOptionKind {
773    Move {
774        location: flatland_protocol::InventoryLocation,
775        parent_instance_id: Option<uuid::Uuid>,
776    },
777    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
778    PickupPlaced {
779        container_id: String,
780        nest_location: flatland_protocol::InventoryLocation,
781        nest_parent_instance_id: Option<uuid::Uuid>,
782    },
783    /// Enter map relocate mode for a placed chest (no pickup).
784    RelocatePlaced {
785        container_id: String,
786    },
787    /// Eat/drink a loose consumable (one unit per use).
788    Use,
789    /// Open grant-target picker for `grants_item_status` consumables.
790    GrantApply,
791    Drop,
792    /// Sell the plot tied to a property deed back to the crown.
793    SellPlotToCrown {
794        plot_id: uuid::Uuid,
795    },
796    Cancel,
797}
798
799/// One navigable row in the deed farm-access panel.
800#[derive(Debug, Clone, PartialEq)]
801pub enum FarmAccessRow {
802    PublicToggle,
803    PublicDiscount,
804    AllowRemove {
805        character_id: uuid::Uuid,
806        label: String,
807        tax_discount_bps: u32,
808    },
809    NearbyAdd {
810        name: String,
811    },
812}
813
814/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
815#[derive(Debug, Clone)]
816pub struct GrantTargetPicker {
817    pub grant_instance_id: uuid::Uuid,
818    pub grant_label: String,
819    pub effect_id: String,
820    pub mode: String,
821    pub options: Vec<GrantTargetOption>,
822    pub filter: String,
823    pub filter_focused: bool,
824}
825
826#[derive(Debug, Clone)]
827pub struct GrantTargetOption {
828    pub label: String,
829    pub target_instance_id: uuid::Uuid,
830}
831
832/// Active "move to…" destination picker state for the selected inventory item.
833#[derive(Debug, Clone)]
834pub struct MovePicker {
835    pub item_instance_id: uuid::Uuid,
836    pub from: flatland_protocol::InventoryLocation,
837    pub item_label: String,
838    pub template_id: String,
839    pub stack_quantity: u32,
840    pub quantity: u32,
841    pub options: Vec<MoveOption>,
842    pub filter: String,
843    pub filter_focused: bool,
844}
845
846/// Active permanent-delete picker for the selected inventory item.
847#[derive(Debug, Clone)]
848pub struct DestroyPicker {
849    pub item_instance_id: uuid::Uuid,
850    pub from: flatland_protocol::InventoryLocation,
851    pub item_label: String,
852    pub stack_quantity: u32,
853    pub quantity: u32,
854}
855
856/// One giveable stack in the workers-menu give picker.
857#[derive(Debug, Clone)]
858pub struct WorkerGiveOption {
859    pub item_instance_id: uuid::Uuid,
860    pub label: String,
861    pub quantity: u32,
862    pub template_id: String,
863}
864
865/// Give an on-person inventory stack to the selected hired worker.
866#[derive(Debug, Clone)]
867pub struct WorkerGivePicker {
868    pub worker_instance_id: String,
869    pub worker_label: String,
870    pub options: Vec<WorkerGiveOption>,
871}
872
873/// Nearby hired worker choice when giving a selected inventory stack (`g`).
874#[derive(Debug, Clone)]
875pub struct WorkerGiveTargetOption {
876    pub instance_id: String,
877    pub label: String,
878    pub distance_m: f32,
879}
880
881/// Pick which nearby worker receives the selected inventory item.
882#[derive(Debug, Clone)]
883pub struct WorkerGiveTargetPicker {
884    pub item_instance_id: uuid::Uuid,
885    pub item_label: String,
886    pub quantity: Option<u32>,
887    pub options: Vec<WorkerGiveTargetOption>,
888}
889
890/// Take an item from a hired worker back into the employer's inventory.
891#[derive(Debug, Clone)]
892pub struct WorkerTakePicker {
893    pub worker_instance_id: String,
894    pub worker_label: String,
895    pub options: Vec<WorkerGiveOption>,
896    /// How many of the selected stack to take (1..=stack quantity).
897    pub quantity: u32,
898}
899
900/// Max distance (m) to hand an item to a hired worker.
901pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
902
903/// One teachable blueprint in the workers-menu teach picker.
904#[derive(Debug, Clone)]
905pub struct WorkerTeachOption {
906    pub blueprint_id: String,
907    pub label: String,
908    pub cost_copper: u64,
909    pub min_level: u32,
910    pub worker_level: u32,
911    pub can_afford: bool,
912    pub level_ok: bool,
913}
914
915/// Teach a known blueprint to the selected hired worker.
916#[derive(Debug, Clone)]
917pub struct WorkerTeachPicker {
918    pub worker_instance_id: String,
919    pub worker_label: String,
920    pub worker_level: u32,
921    pub options: Vec<WorkerTeachOption>,
922}
923
924/// Selected worker awaiting confirmation before permanent dismissal.
925#[derive(Debug, Clone)]
926pub struct WorkerDismissConfirmation {
927    pub worker_instance_id: String,
928    pub worker_label: String,
929}
930
931/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
932/// do not thrash the UI.
933#[derive(Debug, Clone, Default)]
934pub struct StickyWorkerStep {
935    shown: String,
936    pending: String,
937    pending_since: Option<Instant>,
938}
939
940impl StickyWorkerStep {
941    fn from_label(label: String) -> Self {
942        Self {
943            shown: label.clone(),
944            pending: label,
945            pending_since: Some(Instant::now()),
946        }
947    }
948
949    fn observe(&mut self, label: &str, now: Instant) {
950        let pending_since = self.pending_since.unwrap_or(now);
951        if label == self.pending {
952            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
953                self.shown = self.pending.clone();
954            }
955            return;
956        }
957        self.pending = label.to_string();
958        self.pending_since = Some(now);
959        // Empty → first value, or first observation: show immediately.
960        if self.shown.is_empty() {
961            self.shown = self.pending.clone();
962        }
963    }
964}
965
966/// Last non-transient worker error — held so route flicker stays readable.
967/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
968#[derive(Debug, Clone, Default)]
969pub struct StickyWorkerError {
970    message: String,
971    last_seen: Option<Instant>,
972}
973
974impl StickyWorkerError {
975    fn observe(&mut self, err: Option<&str>, now: Instant) {
976        if let Some(e) = err {
977            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
978                self.message = e.to_string();
979                self.last_seen = Some(now);
980            }
981            return;
982        }
983        if let Some(seen) = self.last_seen {
984            if now.duration_since(seen) > WORKER_ERROR_HOLD {
985                self.message.clear();
986                self.last_seen = None;
987            }
988        }
989    }
990
991    pub fn shown(&self, now: Instant) -> Option<&str> {
992        if self.message.is_empty() {
993            return None;
994        }
995        let seen = self.last_seen?;
996        if now.duration_since(seen) > WORKER_ERROR_HOLD {
997            return None;
998        }
999        Some(self.message.as_str())
1000    }
1001}
1002
1003/// First non-transient worker issue for the status bar (strike, route error, etc.).
1004/// Routine recovery noise (path failures → lodging) stays in the game log only.
1005pub fn worker_attention_line(state: &GameState) -> Option<String> {
1006    use flatland_protocol::WorkerStateView;
1007    let now = Instant::now();
1008    for w in &state.hired_workers {
1009        if matches!(w.state, WorkerStateView::Strike) {
1010            return Some(format!(
1011                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1012                w.label
1013            ));
1014        }
1015        let sticky = state
1016            .worker_error_display
1017            .get(&w.instance_id)
1018            .and_then(|s| s.shown(now))
1019            .filter(|e| !worker_error_is_hud_noise(e));
1020        let live = w
1021            .last_error
1022            .as_deref()
1023            .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1024        if let Some(err) = sticky.or(live) {
1025            if let Some(hint) = w
1026                .issue_hint
1027                .as_deref()
1028                .filter(|h| !h.is_empty())
1029                .or_else(|| worker_issue_fix_hint(err))
1030            {
1031                return Some(format!("Worker {}: {err} — {hint}", w.label));
1032            }
1033            return Some(format!("Worker {}: {err}", w.label));
1034        }
1035        // Plan issue with hint but cleared last_error (sticky already shown above).
1036        if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1037            return Some(format!("Worker {}: {hint}", w.label));
1038        }
1039    }
1040    None
1041}
1042
1043/// Fallback fix copy when the server did not send `issue_hint`.
1044pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1045    let e = err.to_ascii_lowercase();
1046    if e.contains("missing")
1047        || e.contains("container not found")
1048        || e.contains("lodging container not found")
1049    {
1050        return Some("edit route (e): replace the missing chest/bed");
1051    }
1052    if e.contains("stranded at interior") || e.contains("interior map coords") {
1053        return Some("recovered — continuing route");
1054    }
1055    if e.contains("stuck inside")
1056        || e.contains("sent outside")
1057        || e.contains("sent to door")
1058        || e.contains("left building")
1059    {
1060        return Some("auto-exit for outdoor work — restart after update if it still loops");
1061    }
1062    if e.contains("collapsed") || e.contains("need food") {
1063        return Some("stock lodging bed with food and drink");
1064    }
1065    if e.contains("overburdened") {
1066        return Some("add a deposit/sell stop, or empty their pack");
1067    }
1068    if e.contains("need a hoe") || e.contains("need a dibber") {
1069        return Some("give them the tool or withdraw it on the route");
1070    }
1071    None
1072}
1073
1074/// True when a worker `last_error` is transient noise (soft-skip chatter).
1075pub fn worker_error_is_transient(err: &str) -> bool {
1076    let e = err.to_ascii_lowercase();
1077    e.contains("continuing route")
1078        || e.contains("storage full")
1079        || e.starts_with("nothing to withdraw")
1080}
1081
1082/// True for routine worker recoveries that should not paint the status bar.
1083/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1084pub fn worker_error_is_hud_noise(err: &str) -> bool {
1085    let e = err.to_ascii_lowercase();
1086    e.contains("returned to lodging after path")
1087        || e.contains("path failure")
1088        || e.contains("no path to")
1089        || e.contains("pathfinding")
1090        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1091        || e.contains("repathing")
1092        || e.contains("nudged clear")
1093        // Auto space rescue — informative in the log, not a red alert.
1094        || e.contains("auto-recovery")
1095        || e.contains("stranded at interior map coords")
1096}
1097
1098/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1099#[derive(Debug, Clone)]
1100pub struct PendingWorkerJobAck {
1101    pub seq: u32,
1102    pub worker_instance_id: String,
1103    pub worker_label: String,
1104    pub idle: bool,
1105    pub stop_count: usize,
1106    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1107    pub prev_mode: flatland_protocol::WorkerModeView,
1108    pub prev_step_label: String,
1109    pub prev_last_error: Option<String>,
1110}
1111
1112fn push_inventory_rows(
1113    rows: &mut Vec<InventoryRow>,
1114    depth: usize,
1115    stack: &flatland_protocol::ItemStack,
1116    from: &flatland_protocol::InventoryLocation,
1117    from_parent_instance_id: Option<uuid::Uuid>,
1118    section: InventorySection,
1119) {
1120    push_inventory_rows_filtered(
1121        rows,
1122        depth,
1123        stack,
1124        from,
1125        from_parent_instance_id,
1126        section,
1127        "",
1128    );
1129}
1130
1131fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1132    if filter.is_empty() {
1133        return true;
1134    }
1135    let f = filter.to_ascii_lowercase();
1136    let name = stack
1137        .display_name
1138        .as_deref()
1139        .unwrap_or("")
1140        .to_ascii_lowercase();
1141    let tid = stack.template_id.to_ascii_lowercase();
1142    name.contains(&f)
1143        || tid.contains(&f)
1144        || stack
1145            .contents
1146            .iter()
1147            .any(|c| stack_matches_filter(c, filter))
1148}
1149
1150fn push_inventory_rows_filtered(
1151    rows: &mut Vec<InventoryRow>,
1152    depth: usize,
1153    stack: &flatland_protocol::ItemStack,
1154    from: &flatland_protocol::InventoryLocation,
1155    from_parent_instance_id: Option<uuid::Uuid>,
1156    section: InventorySection,
1157    filter: &str,
1158) {
1159    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1160        return;
1161    }
1162    let self_hit = filter.is_empty() || {
1163        let f = filter.to_ascii_lowercase();
1164        let name = stack
1165            .display_name
1166            .as_deref()
1167            .unwrap_or("")
1168            .to_ascii_lowercase();
1169        let tid = stack.template_id.to_ascii_lowercase();
1170        name.contains(&f) || tid.contains(&f)
1171    };
1172    rows.push(InventoryRow {
1173        depth,
1174        stack: stack.clone(),
1175        from: from.clone(),
1176        from_parent_instance_id,
1177        is_equip_shell: false,
1178        is_chest_shell: false,
1179        section,
1180    });
1181    for child in &stack.contents {
1182        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1183            push_inventory_rows_filtered(
1184                rows,
1185                depth + 1,
1186                child,
1187                from,
1188                stack.item_instance_id,
1189                section,
1190                if self_hit { "" } else { filter },
1191            );
1192        }
1193    }
1194}
1195
1196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1197pub enum ShopTab {
1198    #[default]
1199    Buy,
1200    Sell,
1201}
1202
1203#[derive(Debug, Clone)]
1204pub struct NpcChatState {
1205    pub npc_id: String,
1206    pub npc_label: String,
1207    pub lines: Vec<String>,
1208    pub input: String,
1209    pub pending: bool,
1210    pub talk_depth: flatland_protocol::NpcTalkDepth,
1211    pub trade_allowed: bool,
1212    pub banner: Option<String>,
1213    pub suggested_topics: Vec<String>,
1214}
1215
1216impl Default for NpcChatState {
1217    fn default() -> Self {
1218        Self {
1219            npc_id: String::new(),
1220            npc_label: String::new(),
1221            lines: Vec::new(),
1222            input: String::new(),
1223            pending: false,
1224            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1225            trade_allowed: true,
1226            banner: None,
1227            suggested_topics: Vec::new(),
1228        }
1229    }
1230}
1231
1232/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1233pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1234    npc.entity_id
1235        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1236        .map(|e| (e.transform.position.x, e.transform.position.y))
1237        .unwrap_or((npc.x, npc.y))
1238}
1239
1240#[derive(Debug, Clone)]
1241pub struct GameState {
1242    pub session_id: SessionId,
1243    pub entity_id: EntityId,
1244    /// Logged-in character — used to show owner-only container labels.
1245    pub character_id: Option<uuid::Uuid>,
1246    pub tick: Tick,
1247    pub chunk_rev: u64,
1248    pub content_rev: u64,
1249    pub publish_rev: u64,
1250    pub entities: Vec<EntityState>,
1251    pub player: Option<EntityState>,
1252    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1253    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1254    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1255    pub buildings: Vec<BuildingView>,
1256    pub doors: Vec<DoorView>,
1257    pub interior_map: Option<InteriorMapView>,
1258    pub npcs: Vec<NpcView>,
1259    pub blueprints: Vec<BlueprintView>,
1260    /// Wall/roof packs for the B plot-build menu.
1261    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1262    /// Outdoor play AABB origin (meters).
1263    pub world_x0: f32,
1264    pub world_y0: f32,
1265    pub world_width_m: f32,
1266    pub world_height_m: f32,
1267    pub terrain_zones: Vec<TerrainZoneView>,
1268    pub z_platforms: Vec<ZPlatformView>,
1269    pub z_transitions: Vec<ZTransitionView>,
1270    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1271    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1272    #[doc(hidden)]
1273    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1274    pub world_clock: flatland_protocol::WorldClock,
1275    pub inventory: std::collections::HashMap<String, u32>,
1276    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1277    pub logs: VecDeque<String>,
1278    pub intents_sent: u64,
1279    pub ticks_received: u64,
1280    pub connected: bool,
1281    pub disconnect_reason: Option<String>,
1282    pub show_stats: bool,
1283    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1284    pub hud_log_hidden: bool,
1285    pub show_equip_menu: bool,
1286    pub equip_menu_index: usize,
1287    pub show_craft_menu: bool,
1288    pub craft_menu_index: usize,
1289    /// How many timed crafts to queue when confirming the craft menu.
1290    pub craft_batch_quantity: u32,
1291    /// B — wall/roof material picker for plot buildings.
1292    pub show_plot_build_menu: bool,
1293    /// True = navigate walls; false = navigate roofs (Tab switches).
1294    pub plot_build_focus_wall: bool,
1295    pub plot_build_wall_index: usize,
1296    pub plot_build_roof_index: usize,
1297    pub show_shop_menu: bool,
1298    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1299    pub bank_panel: Option<flatland_protocol::BankPanel>,
1300    pub bank_menu_index: usize,
1301    pub bank_ui_mode: BankUiMode,
1302    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1303    pub market_panel: Option<flatland_protocol::MarketPanel>,
1304    /// Selected row among filtered market browse listings.
1305    pub market_menu_index: usize,
1306    /// Browse / list-pick text filter (`/` to focus).
1307    pub market_filter: String,
1308    pub market_filter_focused: bool,
1309    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1310    pub market_category_filter: Option<&'static str>,
1311    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1312    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1313    pub market_ui_mode: MarketUiMode,
1314    pub storage_menu_index: usize,
1315    pub storage_ui_mode: StorageUiMode,
1316    pub shop_tab: ShopTab,
1317    pub shop_menu_index: usize,
1318    pub shop_quantity: u32,
1319    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1320    pub shop_trade_log: VecDeque<String>,
1321    pub show_npc_verb_menu: bool,
1322    pub npc_verb_target: Option<String>,
1323    pub npc_verb_index: usize,
1324    /// Nearby player Speak / Whisper / Trade dock.
1325    pub player_verbs: crate::social::PlayerVerbState,
1326    pub social_chat: crate::social::SocialChatState,
1327    pub trade_ui: crate::social::TradeUiState,
1328    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1329    pub show_npc_chat: bool,
1330    pub npc_chat: Option<NpcChatState>,
1331    pub show_inventory_menu: bool,
1332    pub inventory_menu_index: usize,
1333    pub inventory_tab: InventoryTab,
1334    pub inventory_filter: String,
1335    pub inventory_filter_focused: bool,
1336    pub show_move_picker: bool,
1337    pub move_picker_index: usize,
1338    pub move_picker: Option<MovePicker>,
1339    pub show_grant_picker: bool,
1340    pub grant_picker_index: usize,
1341    pub grant_picker: Option<GrantTargetPicker>,
1342    pub show_destroy_picker: bool,
1343    pub destroy_confirm_pending: bool,
1344    pub destroy_picker: Option<DestroyPicker>,
1345    /// Rename prompt for a selected container (`n` in inventory).
1346    pub show_rename_prompt: bool,
1347    /// When set, the rename prompt targets a property plot (deed or on-plot).
1348    pub rename_plot_id: Option<uuid::Uuid>,
1349    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1350    pub highlighted_plot_id: Option<uuid::Uuid>,
1351    /// Rename prompt for a hired worker (`n` in workers menu).
1352    pub show_worker_rename: bool,
1353    pub rename_buffer: String,
1354    /// Slot-1 combat target (mirrors server after SetTarget).
1355    pub combat_target: Option<EntityId>,
1356    pub combat_target_label: Option<String>,
1357    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1358    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1359    pub ground_target: Option<(f32, f32, f32)>,
1360    /// Active combat footprints from the server (`plans/39`).
1361    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1362    /// Lingering ground hazards (boss puddles) from the server.
1363    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1364    /// Authored crown property zones (claimable land) — plan 40.
1365    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1366    /// Tax overlays for claim cost premium preview.
1367    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1368    /// Growth / fertility overlays (farming, respawn).
1369    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1370    /// Climate / biome overlays.
1371    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1372    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1373    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1374    /// Claimed property plots near the observer.
1375    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1376    /// Server knobs for client-side claim quotes.
1377    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1378    /// Local claim-footprint editor (not server state).
1379    pub claim_mode: Option<ClaimModeState>,
1380    /// Local relocate ghost for a placed chest/lodging (not server state).
1381    pub relocate_mode: Option<RelocateModeState>,
1382    /// Second `f` within a short window confirms selling this plot back to the crown.
1383    pub sell_plot_confirm: Option<uuid::Uuid>,
1384    /// When `sell_plot_confirm` was armed (for double-tap window).
1385    pub sell_plot_armed_at: Option<Instant>,
1386    /// Choose seed type when planting on owned tilled soil (`f`).
1387    pub show_plant_menu: bool,
1388    pub plant_menu_index: usize,
1389    /// Deed-holder farm access panel (public toggle + allow-list).
1390    pub show_farm_access: bool,
1391    /// Draft name for adding a tenant from the farm-access panel.
1392    pub farm_access_name_draft: String,
1393    /// Public / grant discount draft (bps) while editing farm access.
1394    pub farm_access_discount_bps: u32,
1395    /// Selected row in the farm-access panel (0 = public toggle).
1396    pub farm_access_index: usize,
1397    pub plant_quantity: u32,
1398    pub in_combat: bool,
1399    pub auto_attack: bool,
1400    pub combat_has_los: bool,
1401    pub attack_cd_ticks: u64,
1402    pub gcd_ticks: u64,
1403    pub weapon_ability_id: String,
1404    pub mainhand_template_id: Option<String>,
1405    pub mainhand_label: Option<String>,
1406    pub mainhand_instance_id: Option<uuid::Uuid>,
1407    pub offhand_template_id: Option<String>,
1408    pub offhand_label: Option<String>,
1409    pub offhand_instance_id: Option<uuid::Uuid>,
1410    pub mainhand_hand_slots: u8,
1411    pub defense: Option<flatland_protocol::DefenseHud>,
1412    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1413    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1414    pub carry_mass: f32,
1415    pub carry_mass_max: f32,
1416    pub encumbrance: flatland_protocol::EncumbranceState,
1417    /// Full nested inventory stacks from the server (root only; worn are separate).
1418    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1419    /// Keys stowed on the virtual keychain (zero carry mass).
1420    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1421    /// Whisper stones in the pouch (zero carry mass).
1422    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1423    /// Active status effects on the local player (buff/debuff strip).
1424    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1425    pub combat_target_detail: Option<CombatTargetHud>,
1426    pub cast_progress: Option<CastProgressHud>,
1427    /// Till / plant / other non-combat timed channels on the local player.
1428    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1429    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1430    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1431    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1432    pub blocking_active: bool,
1433    pub max_target_slots: u8,
1434    pub combat_slots: Vec<CombatSlotHud>,
1435    pub rotation_presets: Vec<RotationPreset>,
1436    /// Learned abilities from combat HUD (usable casts).
1437    pub known_abilities: Vec<String>,
1438    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1439    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1440    /// Per-ability mastery from combat HUD (tier / XP).
1441    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1442    /// Server-persisted hotbar bindings (index 0 = key 1).
1443    pub hotbar: Vec<Option<String>>,
1444    /// Max abilities allowed in one rotation (mind score).
1445    pub max_abilities_per_rotation: u8,
1446    pub show_loadout_menu: bool,
1447    pub show_keychain_menu: bool,
1448    pub keychain_menu_index: usize,
1449    pub show_rotation_editor: bool,
1450    /// Selected rotation preset in the loadout menu.
1451    pub loadout_menu_index: usize,
1452    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1453    pub loadout_hotbar_slot: u8,
1454    /// Selected known-ability row in the loadout menu.
1455    pub loadout_ability_index: usize,
1456    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1457    pub loadout_focus_presets: bool,
1458    pub rotation_editor: RotationEditorState,
1459    /// True after a harvest intent is accepted until result/reject/disconnect.
1460    pub harvest_in_progress: bool,
1461    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1462    pub harvest_started_at: Option<Instant>,
1463    /// Craft log deferred until the server acks the craft intent.
1464    pub pending_craft_ack: Option<(u32, String, u32)>,
1465    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1466    pub interactables: Vec<flatland_protocol::InteractableView>,
1467    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1468    pub career: Option<flatland_protocol::PlayerCareerView>,
1469    pub character_sheet_tab: CharacterSheetTab,
1470    pub ledger_period: LedgerPeriod,
1471    pub show_quest_offer: bool,
1472    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1473    pub show_quest_menu: bool,
1474    pub quest_menu_index: usize,
1475    pub quest_withdraw_confirm: bool,
1476    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1477    pub show_workers_menu: bool,
1478    pub workers_menu_index: usize,
1479    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1480    /// Workers panel: one-line rows instead of full cards (more on screen).
1481    pub workers_menu_compact: bool,
1482    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1483    /// Public so out-of-crate tests can construct [`GameState`].
1484    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1485    /// Held worker route errors (readable when server clears `last_error` each tick).
1486    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1487    /// Wall-clock expiry for defender HP rings after their last observed hit.
1488    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1489    /// Set after a hire request until its worker appears in a roster update.
1490    pub pending_worker_hire_since: Option<Instant>,
1491    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1492    pub show_worker_give_picker: bool,
1493    pub worker_give_picker_index: usize,
1494    pub worker_give_picker: Option<WorkerGivePicker>,
1495    /// Inventory `g` — pick which nearby worker receives the selected stack.
1496    pub show_worker_give_target_picker: bool,
1497    pub worker_give_target_picker_index: usize,
1498    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1499    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1500    pub show_worker_take_picker: bool,
1501    pub worker_take_picker_index: usize,
1502    pub worker_take_picker: Option<WorkerTakePicker>,
1503    /// Teach-blueprint sheet opened from the workers menu (`t`).
1504    pub show_worker_teach_picker: bool,
1505    pub worker_teach_picker_index: usize,
1506    pub worker_teach_picker: Option<WorkerTeachPicker>,
1507    /// Active harvest-route editor (`h` → `e` on a worker).
1508    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1509    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1510    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1511    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1512    pub attending_worker_instance_id: Option<String>,
1513    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1514    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1515}
1516
1517impl GameState {
1518    pub fn push_log(&mut self, line: impl Into<String>) {
1519        self.logs.push_back(line.into());
1520        while self.logs.len() > MAX_LOG_LINES {
1521            self.logs.pop_front();
1522        }
1523    }
1524
1525    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1526        self.shop_trade_log.push_back(line.into());
1527        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1528            self.shop_trade_log.pop_front();
1529        }
1530    }
1531
1532    pub fn clear_shop_trade_log(&mut self) {
1533        self.shop_trade_log.clear();
1534    }
1535
1536    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1537        if !self.show_shop_menu {
1538            return;
1539        }
1540        let msg = notice.message.trim();
1541        if msg.is_empty() {
1542            return;
1543        }
1544        if notice.coins_delta != 0
1545            || msg.starts_with("Bought ")
1546            || msg.starts_with("Sold ")
1547            || msg.contains("taught you how to craft")
1548            || msg.starts_with("need ")
1549        {
1550            self.push_shop_trade_log(msg);
1551        }
1552    }
1553
1554    pub fn is_alive(&self) -> bool {
1555        self.player
1556            .as_ref()
1557            .and_then(|p| p.vitals)
1558            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1559            .unwrap_or(true)
1560    }
1561
1562    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1563        self.social_chat.push_cue(cue);
1564    }
1565
1566    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1567    fn sync_gameplay_audio(&mut self) {
1568        use crate::social::AudioCue;
1569        use flatland_protocol::PrimaryAttributes;
1570
1571        let alive = self.is_alive();
1572        let casting = self.cast_progress.is_some();
1573        let telegraph = self.focus_attack_telegraph_active();
1574        let in_aoe = self.player_inside_spatial_telegraph();
1575        let quest_sig = self.quest_audio_signature();
1576        let entity_id = self.entity_id;
1577        let char_level = self
1578            .player
1579            .as_ref()
1580            .and_then(|p| p.attributes)
1581            .map(|a| {
1582                PrimaryAttributes::display(a.strength)
1583                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1584                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1585                    .saturating_add(PrimaryAttributes::display(a.stamina))
1586                    .saturating_add(PrimaryAttributes::display(a.vitality))
1587                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1588                    .saturating_add(PrimaryAttributes::display(a.charisma))
1589            })
1590            .unwrap_or(0);
1591
1592        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1593        let mut hit_cues = Vec::new();
1594        {
1595            let seen = &self.social_chat.audio_seen_fx_ids;
1596            for fx in &self.combat_fx {
1597                if seen.contains(&fx.id) {
1598                    continue;
1599                }
1600                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1601                    continue;
1602                };
1603                if hit.outcome == CombatFxHitOutcome::Blocked {
1604                    hit_cues.push(AudioCue::CombatBlock);
1605                } else {
1606                    let heavy = matches!(
1607                        fx.kind,
1608                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1609                    );
1610                    hit_cues.push(if heavy {
1611                        AudioCue::CombatHitHeavy
1612                    } else {
1613                        AudioCue::CombatHitLight
1614                    });
1615                }
1616            }
1617        }
1618
1619        let audio = &mut self.social_chat;
1620        if !audio.audio_bootstrapped {
1621            audio.audio_was_alive = alive;
1622            audio.audio_was_casting = casting;
1623            audio.audio_had_target_telegraph = telegraph;
1624            audio.audio_was_in_aoe = in_aoe;
1625            audio.audio_quest_sig = quest_sig;
1626            audio.audio_char_level = char_level;
1627            audio.audio_seen_fx_ids = fx_ids;
1628            audio.audio_bootstrapped = true;
1629            return;
1630        }
1631
1632        if telegraph && !audio.audio_had_target_telegraph {
1633            audio.push_cue(AudioCue::CombatTelegraphStart);
1634        } else if !telegraph && audio.audio_had_target_telegraph {
1635            audio.push_cue(AudioCue::CombatTelegraphImpact);
1636        }
1637        audio.audio_had_target_telegraph = telegraph;
1638
1639        if in_aoe && !audio.audio_was_in_aoe {
1640            audio.push_cue(AudioCue::CombatAoeWarn);
1641        }
1642        audio.audio_was_in_aoe = in_aoe;
1643
1644        if casting && !audio.audio_was_casting {
1645            audio.push_cue(AudioCue::AbilityCastSelf);
1646        }
1647        audio.audio_was_casting = casting;
1648
1649        if !alive && audio.audio_was_alive {
1650            audio.push_cue(AudioCue::PlayerDeath);
1651        }
1652        audio.audio_was_alive = alive;
1653
1654        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1655            audio.push_cue(AudioCue::QuestUpdate);
1656        }
1657        audio.audio_quest_sig = quest_sig;
1658
1659        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1660            audio.push_cue(AudioCue::LevelUp);
1661        }
1662        audio.audio_char_level = char_level;
1663
1664        for cue in hit_cues {
1665            audio.push_cue(cue);
1666        }
1667        audio.audio_seen_fx_ids = fx_ids;
1668    }
1669
1670    fn focus_attack_telegraph_active(&self) -> bool {
1671        let Some(tid) = self.combat_target else {
1672            return false;
1673        };
1674        self.entities
1675            .iter()
1676            .find(|e| e.id == tid)
1677            .map(|e| {
1678                e.combat_cues.iter().any(|c| {
1679                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1680                })
1681            })
1682            .unwrap_or(false)
1683    }
1684
1685    fn player_inside_spatial_telegraph(&self) -> bool {
1686        let (px, py) = self.player_position();
1687        for e in &self.entities {
1688            for cue in &e.combat_cues {
1689                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1690                    || cue.until_tick <= self.tick
1691                {
1692                    continue;
1693                }
1694                let Some(kind) = cue.telegraph_kind else {
1695                    continue;
1696                };
1697                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1698                    (Some(x), Some(y)) => (x, y),
1699                    _ => continue,
1700                };
1701                match kind {
1702                    CombatFxKind::Sphere => {
1703                        let r = cue.radius_m.unwrap_or(1.0);
1704                        let dx = px - ox;
1705                        let dy = py - oy;
1706                        if dx * dx + dy * dy <= r * r {
1707                            return true;
1708                        }
1709                    }
1710                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1711                        let reach = cue.reach_m.unwrap_or(2.0);
1712                        let yaw = cue.yaw.unwrap_or(0.0);
1713                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1714                        let dx = px - ox;
1715                        let dy = py - oy;
1716                        let dist = (dx * dx + dy * dy).sqrt();
1717                        if dist > reach || dist < 0.05 {
1718                            continue;
1719                        }
1720                        let ang = dx.atan2(dy);
1721                        let mut delta = ang - yaw;
1722                        while delta > std::f32::consts::PI {
1723                            delta -= std::f32::consts::TAU;
1724                        }
1725                        while delta < -std::f32::consts::PI {
1726                            delta += std::f32::consts::TAU;
1727                        }
1728                        if delta.abs() <= arc * 0.5 {
1729                            return true;
1730                        }
1731                    }
1732                    _ => {}
1733                }
1734            }
1735        }
1736        false
1737    }
1738
1739    fn quest_audio_signature(&self) -> u64 {
1740        use std::collections::hash_map::DefaultHasher;
1741        use std::hash::{Hash, Hasher};
1742        let mut h = DefaultHasher::new();
1743        for q in &self.quest_log {
1744            q.quest_id.hash(&mut h);
1745            format!("{:?}", q.status).hash(&mut h);
1746            q.current_step_id.hash(&mut h);
1747            for o in &q.objectives {
1748                o.done.hash(&mut h);
1749                o.current.hash(&mut h);
1750            }
1751        }
1752        h.finish()
1753    }
1754
1755    /// Verb menu entries for the current `npc_verb_target`.
1756    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1757        let Some(ref id) = self.npc_verb_target else {
1758            return vec![];
1759        };
1760        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1761            return self.with_quest_turn_in(id, vec!["Talk"]);
1762        };
1763        let role = npc.role.as_str();
1764        let rest = if Self::npc_role_is_bank(role) {
1765            vec!["Bank", "Talk"]
1766        } else if Self::npc_role_is_storage(role) {
1767            vec!["Storage", "Talk"]
1768        } else if Self::npc_role_is_market(role) {
1769            vec!["Market", "Talk"]
1770        } else if npc.can_trade || Self::npc_role_can_trade(role) {
1771            vec!["Talk", "Trade"]
1772        } else {
1773            vec!["Talk"]
1774        };
1775        self.with_quest_turn_in(id, rest)
1776    }
1777
1778    fn with_quest_turn_in(&self, npc_id: &str, rest: Vec<&'static str>) -> Vec<&'static str> {
1779        if self.npc_has_pending_give(npc_id) {
1780            let mut opts = vec!["Turn in"];
1781            opts.extend(rest);
1782            opts
1783        } else {
1784            rest
1785        }
1786    }
1787
1788    fn npc_has_pending_give(&self, npc_id: &str) -> bool {
1789        self.quest_log.iter().any(|q| {
1790            q.status == flatland_protocol::QuestStatusView::Active
1791                && q.objectives.iter().any(|o| {
1792                    !o.done && o.kind == "give_item" && o.npc_ref.as_deref() == Some(npc_id)
1793                })
1794        })
1795    }
1796
1797    fn count_inventory_template(&self, template: &str) -> u32 {
1798        self.inventory_stacks
1799            .iter()
1800            .filter(|s| s.template_id == template)
1801            .map(|s| s.quantity)
1802            .sum()
1803    }
1804
1805    fn npc_role_can_trade(role: &str) -> bool {
1806        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1807    }
1808
1809    fn npc_role_is_bank(role: &str) -> bool {
1810        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1811    }
1812
1813    fn npc_role_is_storage(role: &str) -> bool {
1814        role.eq_ignore_ascii_case("storage_manager")
1815    }
1816
1817    fn npc_role_is_market(role: &str) -> bool {
1818        role.eq_ignore_ascii_case("market_clerk")
1819    }
1820
1821    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1822        vec![
1823            "Deposit…",
1824            "Withdraw…",
1825            "Deposit all",
1826            "Withdraw all",
1827            "Transfer…",
1828        ]
1829    }
1830
1831    pub fn storage_menu_options(&self) -> Vec<String> {
1832        let mut opts = vec!["Store…".into(), "Take…".into()];
1833        if let Some(panel) = &self.storage_panel {
1834            for dest in &panel.ship_destinations {
1835                opts.push(format!(
1836                    "Ship → {} ({} cp / {} ticks)",
1837                    dest.label, dest.fee_copper, dest.travel_ticks
1838                ));
1839            }
1840        }
1841        opts
1842    }
1843
1844    /// Loose on-person stacks eligible for town-storage store.
1845    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
1846    /// already outside root inventory.
1847    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1848        let equipped = self.hand_equipped_instance_ids();
1849        self.person_rows()
1850            .into_iter()
1851            .filter(|r| r.depth == 0)
1852            .filter_map(|r| {
1853                let id = r.stack.item_instance_id?;
1854                if equipped.contains(&id) {
1855                    return None;
1856                }
1857                Some(StoragePickOption {
1858                    item_instance_id: id,
1859                    template_id: r.stack.template_id.clone(),
1860                    label: storage_stack_label(&r.stack),
1861                    quantity: r.stack.quantity,
1862                    category: r.stack.category.clone().unwrap_or_default(),
1863                })
1864            })
1865            .collect()
1866    }
1867
1868    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
1869    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1870        let mut ids = std::collections::HashSet::new();
1871        if let Some(id) = self.mainhand_instance_id {
1872            ids.insert(id);
1873        } else if let Some(tid) = &self.mainhand_template_id {
1874            if let Some(id) = self
1875                .inventory_stacks
1876                .iter()
1877                .find(|s| &s.template_id == tid)
1878                .and_then(|s| s.item_instance_id)
1879            {
1880                ids.insert(id);
1881            }
1882        }
1883        if let Some(id) = self.offhand_instance_id {
1884            ids.insert(id);
1885        } else if let Some(tid) = &self.offhand_template_id {
1886            if let Some(id) = self
1887                .inventory_stacks
1888                .iter()
1889                .find(|s| {
1890                    &s.template_id == tid
1891                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
1892                })
1893                .and_then(|s| s.item_instance_id)
1894            {
1895                ids.insert(id);
1896            }
1897        }
1898        ids
1899    }
1900
1901    /// Vault stacks eligible for take / ship.
1902    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1903        let Some(panel) = &self.storage_panel else {
1904            return Vec::new();
1905        };
1906        panel
1907            .contents
1908            .iter()
1909            .filter_map(|s| {
1910                let id = s.item_instance_id?;
1911                Some(StoragePickOption {
1912                    item_instance_id: id,
1913                    template_id: s.template_id.clone(),
1914                    label: storage_stack_label(s),
1915                    quantity: s.quantity,
1916                    category: s.category.clone().unwrap_or_default(),
1917                })
1918            })
1919            .collect()
1920    }
1921
1922    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
1923    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1924        let mut opts = Vec::new();
1925        if !self
1926            .market_list_item_options(&MarketListSourceKind::Person)
1927            .is_empty()
1928        {
1929            opts.push((MarketListSourceKind::Person, "On person".into()));
1930        }
1931        if let Some(panel) = &self.market_panel {
1932            for vault in &panel.list_vaults {
1933                let source = MarketListSourceKind::TownStorage {
1934                    building_id: vault.building_id.clone(),
1935                };
1936                if self.market_list_item_options(&source).is_empty() {
1937                    continue;
1938                }
1939                let label = if vault.building_label.is_empty() {
1940                    format!("Town storage ({})", vault.building_id)
1941                } else {
1942                    format!("Town storage — {}", vault.building_label)
1943                };
1944                opts.push((source, label));
1945            }
1946        }
1947        opts
1948    }
1949
1950    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
1951    pub fn market_list_item_options(
1952        &self,
1953        source: &MarketListSourceKind,
1954    ) -> Vec<StoragePickOption> {
1955        let filter = self.market_filter.as_str();
1956        let cat_filter = self.market_category_filter;
1957        let mut opts: Vec<StoragePickOption> = match source {
1958            MarketListSourceKind::Person => {
1959                let equipped = self.hand_equipped_instance_ids();
1960                self.person_rows()
1961                    .into_iter()
1962                    .filter(|r| r.depth == 0)
1963                    .filter(|r| self.stack_is_market_listable(&r.stack))
1964                    .filter_map(|r| {
1965                        let id = r.stack.item_instance_id?;
1966                        if equipped.contains(&id) {
1967                            return None;
1968                        }
1969                        Some(StoragePickOption {
1970                            item_instance_id: id,
1971                            template_id: r.stack.template_id.clone(),
1972                            label: storage_stack_label(&r.stack),
1973                            quantity: r.stack.quantity,
1974                            category: r
1975                                .stack
1976                                .category
1977                                .clone()
1978                                .or_else(|| {
1979                                    self.inventory_item_category(&r.stack.template_id)
1980                                        .map(str::to_string)
1981                                })
1982                                .unwrap_or_default(),
1983                        })
1984                    })
1985                    .collect()
1986            }
1987            MarketListSourceKind::TownStorage { building_id } => {
1988                let Some(panel) = &self.market_panel else {
1989                    return Vec::new();
1990                };
1991                let Some(vault) = panel
1992                    .list_vaults
1993                    .iter()
1994                    .find(|v| &v.building_id == building_id)
1995                else {
1996                    return Vec::new();
1997                };
1998                vault
1999                    .contents
2000                    .iter()
2001                    .filter(|s| self.stack_is_market_listable(s))
2002                    .filter_map(|s| {
2003                        let id = s.item_instance_id?;
2004                        Some(StoragePickOption {
2005                            item_instance_id: id,
2006                            template_id: s.template_id.clone(),
2007                            label: storage_stack_label(s),
2008                            quantity: s.quantity,
2009                            category: s
2010                                .category
2011                                .clone()
2012                                .or_else(|| {
2013                                    self.inventory_item_category(&s.template_id)
2014                                        .map(str::to_string)
2015                                })
2016                                .unwrap_or_default(),
2017                        })
2018                    })
2019                    .collect()
2020            }
2021        };
2022        opts.retain(|o| {
2023            if !list_label_matches(&o.label, filter) {
2024                return false;
2025            }
2026            if let Some(group) = cat_filter {
2027                inventory_category_group(&o.category).0 == group
2028            } else {
2029                true
2030            }
2031        });
2032        opts
2033    }
2034
2035    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2036    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2037        if let Some(hint) = self.inventory_hints.get(template_id) {
2038            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2039                return Some(v);
2040            }
2041        }
2042        if let Some(v) = self
2043            .inventory_stacks
2044            .iter()
2045            .find(|s| s.template_id == template_id)
2046            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2047        {
2048            return Some(v);
2049        }
2050        self.market_panel.as_ref().and_then(|panel| {
2051            panel.list_vaults.iter().find_map(|vault| {
2052                vault.contents.iter().find_map(|stack| {
2053                    (stack.template_id == template_id)
2054                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2055                        .flatten()
2056                })
2057            })
2058        })
2059    }
2060
2061    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2062    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2063        let base = self.item_base_value_copper_hint(template_id)?;
2064        npc_market_dump_unit_estimate_copper(base)
2065    }
2066
2067    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2068        if crate::currency::is_currency(&stack.template_id) {
2069            return false;
2070        }
2071        if let Some(flag) = stack.listable {
2072            return flag;
2073        }
2074        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2075            return hint.listable;
2076        }
2077        let cat = stack
2078            .category
2079            .as_deref()
2080            .or_else(|| self.inventory_item_category(&stack.template_id))
2081            .unwrap_or("");
2082        category_default_listable(cat)
2083    }
2084
2085    /// Category group labels present in the current browse book or list-pick source.
2086    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2087        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2088        match &self.market_ui_mode {
2089            MarketUiMode::ListPick { source, .. } => {
2090                let raw: Vec<_> = match source {
2091                    MarketListSourceKind::Person => self
2092                        .person_rows()
2093                        .into_iter()
2094                        .filter(|r| r.depth == 0)
2095                        .filter(|r| self.stack_is_market_listable(&r.stack))
2096                        .filter(|r| {
2097                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2098                        })
2099                        .map(|r| {
2100                            r.stack
2101                                .category
2102                                .clone()
2103                                .or_else(|| {
2104                                    self.inventory_item_category(&r.stack.template_id)
2105                                        .map(str::to_string)
2106                                })
2107                                .unwrap_or_default()
2108                        })
2109                        .collect(),
2110                    MarketListSourceKind::TownStorage { building_id } => self
2111                        .market_panel
2112                        .as_ref()
2113                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2114                        .map(|vault| {
2115                            vault
2116                                .contents
2117                                .iter()
2118                                .filter(|s| self.stack_is_market_listable(s))
2119                                .filter(|s| {
2120                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2121                                })
2122                                .map(|s| {
2123                                    s.category
2124                                        .clone()
2125                                        .or_else(|| {
2126                                            self.inventory_item_category(&s.template_id)
2127                                                .map(str::to_string)
2128                                        })
2129                                        .unwrap_or_default()
2130                                })
2131                                .collect::<Vec<_>>()
2132                        })
2133                        .unwrap_or_default(),
2134                };
2135                for category in raw {
2136                    let (label, ord) = inventory_category_group(&category);
2137                    seen.insert(ord, label);
2138                }
2139            }
2140            _ => {
2141                if let Some(panel) = &self.market_panel {
2142                    for listing in &panel.listings {
2143                        if !list_label_matches(&listing.display_name, &self.market_filter)
2144                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2145                        {
2146                            continue;
2147                        }
2148                        let (label, ord) = inventory_category_group(&listing.category);
2149                        seen.insert(ord, label);
2150                    }
2151                }
2152            }
2153        }
2154        seen.into_values().collect()
2155    }
2156
2157    /// Indices into `market_panel.listings` after category + text filter.
2158    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2159        let Some(panel) = &self.market_panel else {
2160            return Vec::new();
2161        };
2162        let filter = self.market_filter.as_str();
2163        let cat_filter = self.market_category_filter;
2164        panel
2165            .listings
2166            .iter()
2167            .enumerate()
2168            .filter(|(_, listing)| {
2169                if !list_label_matches(&listing.display_name, filter)
2170                    && !list_label_matches(&listing.seller_label, filter)
2171                    && !list_label_matches(&listing.template_id, filter)
2172                {
2173                    return false;
2174                }
2175                if let Some(group) = cat_filter {
2176                    inventory_category_group(&listing.category).0 == group
2177                } else {
2178                    true
2179                }
2180            })
2181            .map(|(i, _)| i)
2182            .collect()
2183    }
2184
2185    pub fn clear_harvest_state(&mut self) {
2186        self.harvest_in_progress = false;
2187        self.harvest_started_at = None;
2188    }
2189
2190    fn harvest_state_stale(&self) -> bool {
2191        match self.harvest_started_at {
2192            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2193            None => self.harvest_in_progress,
2194        }
2195    }
2196
2197    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2198        self.player.as_ref().and_then(|p| p.vitals)
2199    }
2200
2201    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2202        let materials_ok = blueprint.inputs.iter().all(|input| {
2203            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2204        });
2205        let tools_ok = blueprint
2206            .required_tools
2207            .iter()
2208            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
2209        let station_ok = match blueprint.station.as_deref() {
2210            None | Some("hand") => true,
2211            Some(tag) => self.player_at_station_tag(tag),
2212        };
2213        materials_ok && tools_ok && station_ok
2214    }
2215
2216    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2217        if !self.can_craft_blueprint(blueprint) {
2218            return 0;
2219        }
2220        let mut limit = u32::MAX;
2221        for input in &blueprint.inputs {
2222            if input.quantity == 0 {
2223                continue;
2224            }
2225            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2226            limit = limit.min(have / input.quantity);
2227        }
2228        for tool in &blueprint.required_tools {
2229            if tool.consumed {
2230                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2231                limit = limit.min(have);
2232            }
2233        }
2234        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2235        if CRAFT_STAMINA_COST > 0.0 {
2236            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2237        }
2238        limit
2239    }
2240
2241    pub fn clamp_craft_batch_quantity(&mut self) {
2242        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
2243            self.craft_batch_quantity = 1;
2244            return;
2245        };
2246        let max = self.max_craft_batches(bp).max(1);
2247        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2248    }
2249
2250    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2251        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2252            return;
2253        };
2254        let max = self.max_craft_batches(&bp).max(1);
2255        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
2256        self.craft_batch_quantity = next as u32;
2257    }
2258
2259    pub fn craft_batch_set_max(&mut self) {
2260        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2261            return;
2262        };
2263        let max = self.max_craft_batches(&bp);
2264        self.craft_batch_quantity = if max == 0 { 1 } else { max };
2265    }
2266
2267    pub fn craft_batch_set_min(&mut self) {
2268        self.craft_batch_quantity = 1;
2269    }
2270
2271    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
2272        let preserve_ui = self.show_shop_menu;
2273        let tab = self.shop_tab;
2274        let index = self.shop_menu_index;
2275        let qty = self.shop_quantity;
2276
2277        self.show_shop_menu = true;
2278        self.bank_panel = None;
2279        self.show_craft_menu = false;
2280        self.show_inventory_menu = false;
2281        self.show_stats = false;
2282        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
2283            self.npc_verb_target = Some(catalog.npc_id.clone());
2284        }
2285        self.shop_catalog = Some(catalog);
2286
2287        if preserve_ui {
2288            self.shop_tab = tab;
2289            self.shop_menu_index = index;
2290            self.shop_quantity = qty;
2291        } else {
2292            self.shop_tab = ShopTab::Buy;
2293            self.shop_menu_index = 0;
2294            self.shop_quantity = 1;
2295            self.clear_shop_trade_log();
2296        }
2297        self.show_npc_verb_menu = false;
2298        self.clamp_shop_selection();
2299    }
2300
2301    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
2302        let same_teller = self
2303            .bank_panel
2304            .as_ref()
2305            .is_some_and(|p| p.npc_id == panel.npc_id);
2306        self.bank_panel = Some(panel);
2307        self.storage_panel = None;
2308        self.market_panel = None;
2309        self.shop_catalog = None;
2310        self.show_shop_menu = false;
2311        self.show_craft_menu = false;
2312        self.show_inventory_menu = false;
2313        self.show_stats = false;
2314        self.show_npc_verb_menu = false;
2315        self.show_npc_chat = false;
2316        self.npc_chat = None;
2317        if !same_teller {
2318            self.bank_menu_index = 0;
2319            self.bank_ui_mode = BankUiMode::Menu;
2320        }
2321        if let Some(panel) = &self.bank_panel {
2322            if self.npc_verb_target.is_none() {
2323                self.npc_verb_target = Some(panel.npc_id.clone());
2324            }
2325        }
2326    }
2327
2328    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
2329        let same_manager = self
2330            .storage_panel
2331            .as_ref()
2332            .is_some_and(|p| p.npc_id == panel.npc_id);
2333        self.storage_panel = Some(panel);
2334        self.bank_panel = None;
2335        self.market_panel = None;
2336        self.bank_ui_mode = BankUiMode::Menu;
2337        self.shop_catalog = None;
2338        self.show_shop_menu = false;
2339        self.show_craft_menu = false;
2340        self.show_inventory_menu = false;
2341        self.show_stats = false;
2342        self.show_npc_verb_menu = false;
2343        self.show_npc_chat = false;
2344        self.npc_chat = None;
2345        if !same_manager {
2346            self.storage_menu_index = 0;
2347            self.storage_ui_mode = StorageUiMode::Menu;
2348        } else {
2349            self.clamp_storage_pick_index();
2350        }
2351        if let Some(panel) = &self.storage_panel {
2352            if self.npc_verb_target.is_none() {
2353                self.npc_verb_target = Some(panel.npc_id.clone());
2354            }
2355        }
2356    }
2357
2358    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2359        for vault in &panel.list_vaults {
2360            self.merge_stack_catalog_hints(&vault.contents);
2361        }
2362        self.market_panel = Some(panel);
2363        self.bank_panel = None;
2364        self.storage_panel = None;
2365        self.shop_catalog = None;
2366        self.show_shop_menu = false;
2367        self.show_craft_menu = false;
2368        self.show_inventory_menu = false;
2369        self.show_stats = false;
2370        self.show_npc_verb_menu = false;
2371        self.show_npc_chat = false;
2372        self.npc_chat = None;
2373        self.market_menu_index = 0;
2374        self.market_buy_confirm = None;
2375        self.market_ui_mode = MarketUiMode::Browse;
2376        self.market_filter.clear();
2377        self.market_filter_focused = false;
2378        self.market_category_filter = None;
2379        if let Some(panel) = &self.market_panel {
2380            if self.npc_verb_target.is_none() {
2381                self.npc_verb_target = Some(panel.npc_id.clone());
2382            }
2383        }
2384    }
2385
2386    pub fn clear_market_panel(&mut self) {
2387        self.market_panel = None;
2388        self.market_menu_index = 0;
2389        self.market_buy_confirm = None;
2390        self.market_ui_mode = MarketUiMode::Browse;
2391        self.market_filter.clear();
2392        self.market_filter_focused = false;
2393        self.market_category_filter = None;
2394    }
2395
2396    pub fn clear_bank_panel(&mut self) {
2397        self.bank_panel = None;
2398        self.bank_menu_index = 0;
2399        self.bank_ui_mode = BankUiMode::Menu;
2400    }
2401
2402    pub fn clear_storage_panel(&mut self) {
2403        self.storage_panel = None;
2404        self.storage_menu_index = 0;
2405        self.storage_ui_mode = StorageUiMode::Menu;
2406    }
2407
2408    fn clamp_storage_pick_index(&mut self) {
2409        match &self.storage_ui_mode {
2410            StorageUiMode::StorePick { index } => {
2411                let n = self.storage_store_options().len();
2412                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2413                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2414            }
2415            StorageUiMode::TakePick { index } => {
2416                let n = self.storage_vault_options().len();
2417                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2418                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2419            }
2420            StorageUiMode::ShipPick {
2421                dest_building_id,
2422                dest_label,
2423                index,
2424            } => {
2425                let n = self.storage_vault_options().len();
2426                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2427                self.storage_ui_mode = StorageUiMode::ShipPick {
2428                    dest_building_id: dest_building_id.clone(),
2429                    dest_label: dest_label.clone(),
2430                    index: next,
2431                };
2432            }
2433            StorageUiMode::Menu
2434            | StorageUiMode::StoreAmount { .. }
2435            | StorageUiMode::TakeAmount { .. }
2436            | StorageUiMode::ShipAmount { .. } => {}
2437        }
2438    }
2439
2440    pub fn shop_list_len(&self) -> usize {
2441        let Some(catalog) = &self.shop_catalog else {
2442            return 0;
2443        };
2444        match self.shop_tab {
2445            ShopTab::Buy => catalog.sells.len(),
2446            ShopTab::Sell => catalog.buys.len(),
2447        }
2448    }
2449
2450    pub fn shop_menu_move(&mut self, delta: i32) {
2451        let n = self.shop_list_len();
2452        if n == 0 {
2453            return;
2454        }
2455        let idx = self.shop_menu_index as i32;
2456        let next = (idx + delta).rem_euclid(n as i32);
2457        self.shop_menu_index = next as usize;
2458        self.clamp_shop_quantity();
2459    }
2460
2461    pub fn shop_quantity_adjust(&mut self, delta: i32) {
2462        let max = self.shop_quantity_max();
2463        if max == 0 {
2464            self.shop_quantity = 0;
2465            return;
2466        }
2467        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2468        self.shop_quantity = next as u32;
2469    }
2470
2471    pub(crate) fn clamp_shop_selection(&mut self) {
2472        let n = self.shop_list_len();
2473        if n == 0 {
2474            self.shop_menu_index = 0;
2475        } else {
2476            self.shop_menu_index = self.shop_menu_index.min(n - 1);
2477        }
2478        self.clamp_shop_quantity();
2479    }
2480
2481    fn shop_quantity_max(&self) -> u32 {
2482        let Some(catalog) = &self.shop_catalog else {
2483            return 1;
2484        };
2485        match self.shop_tab {
2486            ShopTab::Buy => {
2487                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2488                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2489                        return 1;
2490                    }
2491                }
2492                99
2493            }
2494            ShopTab::Sell => catalog
2495                .buys
2496                .get(self.shop_menu_index)
2497                .map(|l| l.quantity)
2498                .unwrap_or(0),
2499        }
2500    }
2501
2502    pub fn shop_quantity_set_max(&mut self) {
2503        self.shop_quantity = self.shop_quantity_max();
2504    }
2505
2506    pub fn shop_quantity_set_min(&mut self) {
2507        let max = self.shop_quantity_max();
2508        self.shop_quantity = if max == 0 { 0 } else { 1 };
2509    }
2510
2511    fn clamp_shop_quantity(&mut self) {
2512        let max = self.shop_quantity_max();
2513        if max == 0 {
2514            self.shop_quantity = 0;
2515        } else {
2516            self.shop_quantity = self.shop_quantity.max(1).min(max);
2517        }
2518    }
2519
2520    pub fn player_at_station_tag(&self, tag: &str) -> bool {
2521        let Some(id) = self.effective_inside_building() else {
2522            return false;
2523        };
2524        self.buildings
2525            .iter()
2526            .find(|b| b.id == id)
2527            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2528    }
2529
2530    /// Short hint for UI when a recipe cannot be started.
2531    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2532        if self.can_craft_blueprint(blueprint) {
2533            return None;
2534        }
2535        let mut missing = Vec::new();
2536        for input in &blueprint.inputs {
2537            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2538            if have < input.quantity {
2539                let name = self.blueprint_ingredient_label(input);
2540                missing.push(format!("{}×{} (have {have})", input.quantity, name));
2541            }
2542        }
2543        for tool in &blueprint.required_tools {
2544            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2545            if have < 1 {
2546                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2547            }
2548        }
2549        if let Some(station) = blueprint.station.as_deref() {
2550            if station != "hand" && !self.player_at_station_tag(station) {
2551                missing.push(format!("station: {station} (enter building)"));
2552            }
2553        }
2554        if missing.is_empty() {
2555            None
2556        } else {
2557            Some(missing.join(", "))
2558        }
2559    }
2560
2561    pub fn player_entity(&self) -> Option<&EntityState> {
2562        self.player
2563            .as_ref()
2564            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2565    }
2566
2567    /// Apply persisted HUD / workers UI prefs from `client.json`.
2568    pub fn apply_client_ui_prefs(&mut self) {
2569        let cfg = crate::client_config::ClientConfig::load();
2570        if let Some(hidden) = cfg.hud_log_hidden {
2571            self.hud_log_hidden = hidden;
2572        }
2573        if let Some(compact) = cfg.workers_menu_compact {
2574            self.workers_menu_compact = compact;
2575        }
2576    }
2577
2578    pub fn player_position(&self) -> (f32, f32) {
2579        let (x, y, _) = self.player_position_with_z();
2580        (x, y)
2581    }
2582
2583    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2584        if let Some(p) = self.player_entity() {
2585            (
2586                p.transform.position.x,
2587                p.transform.position.y,
2588                p.transform.position.z,
2589            )
2590        } else {
2591            (0.0, 0.0, 0.0)
2592        }
2593    }
2594
2595    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2596        let mut rows: Vec<(String, u32, String)> = self
2597            .inventory
2598            .iter()
2599            .filter(|(_, q)| **q > 0)
2600            .map(|(id, qty)| {
2601                let label = self
2602                    .inventory_hints
2603                    .get(id)
2604                    .map(|h| h.display_name.clone())
2605                    .unwrap_or_else(|| id.clone());
2606                (id.clone(), *qty, label)
2607            })
2608            .collect();
2609        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2610        rows
2611    }
2612
2613    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2614        self.inventory_hints
2615            .get(template_id)
2616            .map(|h| h.category.as_str())
2617            .filter(|c| !c.is_empty())
2618    }
2619
2620    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2621        stack
2622            .props
2623            .get("grants_item_status_effect")
2624            .map(|s| !s.is_empty())
2625            .unwrap_or(false)
2626    }
2627
2628    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2629        stack
2630            .props
2631            .get("grants_item_status_effect")
2632            .map(String::as_str)
2633            .filter(|s| !s.is_empty())
2634    }
2635
2636    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2637        stack
2638            .props
2639            .get("grants_item_status_mode")
2640            .map(String::as_str)
2641            .unwrap_or("on_hit")
2642    }
2643
2644    /// Candidate gear for a grant consumable (inventory + worn).
2645    pub fn grant_target_options(
2646        &self,
2647        grant: &flatland_protocol::ItemStack,
2648    ) -> Vec<GrantTargetOption> {
2649        let mode = Self::grant_mode(grant);
2650        let grant_tags: Vec<&str> = grant
2651            .props
2652            .get("grants_item_status_tags")
2653            .map(|s| {
2654                s.split(',')
2655                    .map(str::trim)
2656                    .filter(|t| !t.is_empty())
2657                    .collect()
2658            })
2659            .unwrap_or_default();
2660        let grant_id = grant.item_instance_id;
2661        let mut out = Vec::new();
2662        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2663            let Some(iid) = stack.item_instance_id else {
2664                return;
2665            };
2666            if Some(iid) == grant_id {
2667                return;
2668            }
2669            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2670                return;
2671            }
2672            if !grant_target_matches_mode(stack, mode) {
2673                return;
2674            }
2675            if !grant_tags_match(stack, &grant_tags) {
2676                return;
2677            }
2678            let name = stack
2679                .display_name
2680                .clone()
2681                .unwrap_or_else(|| stack.template_id.clone());
2682            let bindings = if stack.status_bindings.is_empty() {
2683                String::new()
2684            } else {
2685                format!(
2686                    " · {}",
2687                    stack
2688                        .status_bindings
2689                        .iter()
2690                        .map(|b| b.effect_id.as_str())
2691                        .collect::<Vec<_>>()
2692                        .join(", ")
2693                )
2694            };
2695            out.push(GrantTargetOption {
2696                label: format!("{where_label}: {name}{bindings}"),
2697                target_instance_id: iid,
2698            });
2699        };
2700        fn walk(
2701            stacks: &[flatland_protocol::ItemStack],
2702            where_label: &str,
2703            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2704        ) {
2705            for s in stacks {
2706                push(s, where_label);
2707                if !s.contents.is_empty() {
2708                    let nested = format!(
2709                        "{where_label}/{}",
2710                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
2711                    );
2712                    walk(&s.contents, &nested, push);
2713                }
2714            }
2715        }
2716        walk(&self.inventory_stacks, "Bag", &mut push);
2717        for (slot, stack) in &self.worn {
2718            push(stack, body_slot_label(*slot));
2719            let nest = format!(
2720                "{}/{}",
2721                body_slot_label(*slot),
2722                stack
2723                    .display_name
2724                    .as_deref()
2725                    .unwrap_or(stack.template_id.as_str())
2726            );
2727            walk(&stack.contents, &nest, &mut push);
2728        }
2729        out
2730    }
2731
2732    pub fn item_base_mass(&self, template_id: &str) -> f32 {
2733        self.inventory_hints
2734            .get(template_id)
2735            .and_then(|h| h.base_mass)
2736            .unwrap_or(0.5)
2737    }
2738
2739    pub fn item_base_volume(&self, template_id: &str) -> f32 {
2740        self.inventory_hints
2741            .get(template_id)
2742            .and_then(|h| h.base_volume)
2743            .unwrap_or(1.0)
2744    }
2745
2746    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2747        let unit = stack
2748            .base_mass
2749            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2750        unit * stack.quantity as f32
2751    }
2752
2753    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2754        let unit = stack.base_volume.unwrap_or(1.0);
2755        unit * stack.quantity as f32
2756            + stack
2757                .contents
2758                .iter()
2759                .map(Self::stack_tree_volume)
2760                .sum::<f32>()
2761    }
2762
2763    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2764        contents.iter().map(Self::stack_tree_volume).sum()
2765    }
2766
2767    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2768        self.inventory_hints
2769            .get(template_id)
2770            .and_then(|h| h.capacity_volume)
2771            .filter(|c| *c > 0.0)
2772    }
2773
2774    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2775        stack
2776            .capacity_volume
2777            .filter(|c| *c > 0.0)
2778            .or_else(|| self.template_capacity_volume(&stack.template_id))
2779    }
2780
2781    /// Volume used / capacity / free space label for storage containers in the inventory UI.
2782    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2783        let Some((used, cap)) = self.container_volume_stats(row) else {
2784            return String::new();
2785        };
2786        let free = (cap - used).max(0.0);
2787        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
2788    }
2789
2790    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2791        if row.is_chest_shell {
2792            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2793                return None;
2794            };
2795            let chest = self
2796                .placed_containers
2797                .iter()
2798                .find(|c| c.id == *container_id)?;
2799            let cap = self
2800                .stack_capacity_volume(&row.stack)
2801                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2802            let used = if chest.accessible {
2803                Self::contents_used_volume(&chest.contents)
2804            } else {
2805                0.0
2806            };
2807            return Some((used, cap));
2808        }
2809
2810        let cap = self.stack_capacity_volume(&row.stack)?;
2811        let used = Self::contents_used_volume(&row.stack.contents);
2812        Some((used, cap))
2813    }
2814
2815    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2816        if row.is_chest_shell {
2817            return true;
2818        }
2819        if row.is_equip_shell {
2820            return self.inventory_item_category(&row.stack.template_id) == Some("container");
2821        }
2822        self.inventory_item_category(&row.stack.template_id) == Some("container")
2823            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2824    }
2825
2826    fn container_stack_for(
2827        &self,
2828        location: &flatland_protocol::InventoryLocation,
2829        parent_instance_id: Option<uuid::Uuid>,
2830    ) -> Option<flatland_protocol::ItemStack> {
2831        match location {
2832            flatland_protocol::InventoryLocation::Root => {
2833                let pid = parent_instance_id?;
2834                self.find_stack_by_instance(&self.inventory_stacks, pid)
2835            }
2836            flatland_protocol::InventoryLocation::Worn { slot } => {
2837                let worn = self.worn.get(slot)?;
2838                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2839                    Some(worn.clone())
2840                } else {
2841                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2842                }
2843            }
2844            flatland_protocol::InventoryLocation::Placed { container_id } => {
2845                let chest = self
2846                    .placed_containers
2847                    .iter()
2848                    .find(|c| c.id == *container_id)?;
2849                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2850                    Some(flatland_protocol::ItemStack {
2851                        template_id: chest.template_id.clone(),
2852                        quantity: 1,
2853                        item_instance_id: chest.item_instance_id,
2854                        props: Default::default(),
2855                        status_bindings: Vec::new(),
2856                        contents: chest.contents.clone(),
2857                        display_name: Some(chest.display_name.clone()),
2858                        category: Some("container".into()),
2859                        capacity_volume: self
2860                            .inventory_hints
2861                            .get(&chest.template_id)
2862                            .and_then(|h| h.capacity_volume),
2863                        worker_lodging_capacity: chest.worker_lodging_capacity,
2864                        ..Default::default()
2865                    })
2866                } else {
2867                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2868                }
2869            }
2870            flatland_protocol::InventoryLocation::Keychain => None,
2871            flatland_protocol::InventoryLocation::WhisperPouch => None,
2872        }
2873    }
2874
2875    fn find_stack_by_instance(
2876        &self,
2877        stacks: &[flatland_protocol::ItemStack],
2878        instance_id: uuid::Uuid,
2879    ) -> Option<flatland_protocol::ItemStack> {
2880        for stack in stacks {
2881            if stack.item_instance_id == Some(instance_id) {
2882                return Some(stack.clone());
2883            }
2884            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2885                return Some(found);
2886            }
2887        }
2888        None
2889    }
2890
2891    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2892    pub fn max_movable_to(
2893        &self,
2894        template_id: &str,
2895        stack_qty: u32,
2896        from: &flatland_protocol::InventoryLocation,
2897        to: &flatland_protocol::InventoryLocation,
2898        parent_instance_id: Option<uuid::Uuid>,
2899    ) -> u32 {
2900        let unit_vol = self.item_base_volume(template_id);
2901        let unit_mass = self.item_base_mass(template_id);
2902        let mut limit = stack_qty;
2903
2904        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2905            let cap = parent
2906                .capacity_volume
2907                .or_else(|| {
2908                    self.inventory_hints
2909                        .get(&parent.template_id)
2910                        .and_then(|h| h.capacity_volume)
2911                })
2912                .unwrap_or(0.0);
2913            if cap > 0.0 && unit_vol > 0.0 {
2914                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2915                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2916            }
2917        }
2918
2919        let to_person = matches!(
2920            to,
2921            flatland_protocol::InventoryLocation::Root
2922                | flatland_protocol::InventoryLocation::Worn { .. }
2923        );
2924        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2925        if to_person && from_placed && unit_mass > 0.0 {
2926            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2927            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2928                limit = 0;
2929            } else {
2930                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2931            }
2932        }
2933
2934        limit.max(0).min(stack_qty)
2935    }
2936
2937    pub fn move_picker_max_at_selection(&self) -> u32 {
2938        let Some(picker) = &self.move_picker else {
2939            return 1;
2940        };
2941        let Some(opt) = picker.options.get(self.move_picker_index) else {
2942            return picker.stack_quantity;
2943        };
2944        match &opt.kind {
2945            MoveOptionKind::Cancel
2946            | MoveOptionKind::Drop
2947            | MoveOptionKind::Use
2948            | MoveOptionKind::GrantApply
2949            | MoveOptionKind::SellPlotToCrown { .. }
2950            | MoveOptionKind::PickupPlaced { .. }
2951            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2952            MoveOptionKind::Move {
2953                location,
2954                parent_instance_id,
2955            } => self.max_movable_to(
2956                &picker.template_id,
2957                picker.stack_quantity,
2958                &picker.from,
2959                location,
2960                *parent_instance_id,
2961            ),
2962        }
2963    }
2964
2965    pub fn clamp_move_picker_quantity(&mut self) {
2966        let max = self.move_picker_max_at_selection();
2967        if let Some(picker) = &mut self.move_picker {
2968            if max == 0 {
2969                picker.quantity = 1;
2970            } else {
2971                picker.quantity = picker.quantity.clamp(1, max);
2972            }
2973        }
2974    }
2975
2976    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2977        let max = self.move_picker_max_at_selection().max(1);
2978        if let Some(picker) = &mut self.move_picker {
2979            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2980            picker.quantity = next as u32;
2981        }
2982    }
2983
2984    pub fn move_picker_set_quantity_max(&mut self) {
2985        let max = self.move_picker_max_at_selection();
2986        if let Some(picker) = &mut self.move_picker {
2987            picker.quantity = if max == 0 {
2988                1
2989            } else {
2990                max.min(picker.stack_quantity)
2991            };
2992        }
2993    }
2994
2995    pub fn move_picker_set_quantity_min(&mut self) {
2996        if let Some(picker) = &mut self.move_picker {
2997            picker.quantity = 1;
2998        }
2999    }
3000
3001    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3002        if let Some(picker) = &mut self.destroy_picker {
3003            let max = picker.stack_quantity.max(1);
3004            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3005            picker.quantity = next as u32;
3006        }
3007    }
3008
3009    pub fn destroy_picker_set_quantity_max(&mut self) {
3010        if let Some(picker) = &mut self.destroy_picker {
3011            picker.quantity = picker.stack_quantity.max(1);
3012        }
3013    }
3014
3015    pub fn destroy_picker_set_quantity_min(&mut self) {
3016        if let Some(picker) = &mut self.destroy_picker {
3017            picker.quantity = 1;
3018        }
3019    }
3020
3021    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3022        let have = self.inventory.get(template_id).copied().unwrap_or(0);
3023        (have, have >= need)
3024    }
3025
3026    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
3027    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3028        let have = self
3029            .plot_build_offer
3030            .as_ref()
3031            .and_then(|o| {
3032                o.available
3033                    .iter()
3034                    .find(|s| s.template_id == template_id)
3035                    .map(|s| s.quantity)
3036            })
3037            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3038        (have, have >= need)
3039    }
3040
3041    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3042        self.building_materials
3043            .iter()
3044            .filter(|m| m.can_wall)
3045            .collect()
3046    }
3047
3048    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3049        self.building_materials
3050            .iter()
3051            .filter(|m| m.can_roof)
3052            .collect()
3053    }
3054
3055    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3056        self.plot_build_wall_options()
3057            .get(self.plot_build_wall_index)
3058            .copied()
3059    }
3060
3061    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3062        self.plot_build_roof_options()
3063            .get(self.plot_build_roof_index)
3064            .copied()
3065    }
3066
3067    /// BOM lines for the current wall/roof selection and tilled pad area.
3068    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3069        let Some(wall) = self.plot_build_selected_wall() else {
3070            return Vec::new();
3071        };
3072        let Some(roof) = self.plot_build_selected_roof() else {
3073            return Vec::new();
3074        };
3075        let area = self
3076            .plot_build_offer
3077            .as_ref()
3078            .filter(|o| o.pad_ok)
3079            .map(|o| o.pad_width_m * o.pad_depth_m)
3080            .unwrap_or(0.0);
3081        if area <= 0.0 {
3082            return Vec::new();
3083        }
3084        let mut map: std::collections::HashMap<String, (String, u32)> =
3085            std::collections::HashMap::new();
3086        for line in &wall.wall_bom {
3087            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3088            if qty == 0 {
3089                continue;
3090            }
3091            let name = if line.display_name.is_empty() {
3092                line.template_id.clone()
3093            } else {
3094                line.display_name.clone()
3095            };
3096            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3097            entry.1 = entry.1.saturating_add(qty);
3098        }
3099        for line in &roof.roof_bom {
3100            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3101            if qty == 0 {
3102                continue;
3103            }
3104            let name = if line.display_name.is_empty() {
3105                line.template_id.clone()
3106            } else {
3107                line.display_name.clone()
3108            };
3109            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3110            entry.1 = entry.1.saturating_add(qty);
3111        }
3112        let mut out: Vec<_> = map
3113            .into_iter()
3114            .map(|(id, (name, qty))| (id, name, qty))
3115            .collect();
3116        out.sort_by(|a, b| a.0.cmp(&b.0));
3117        out
3118    }
3119
3120    pub fn plot_build_duration_secs(&self) -> Option<f32> {
3121        let wall = self.plot_build_selected_wall()?;
3122        let roof = self.plot_build_selected_roof()?;
3123        let offer = self.plot_build_offer.as_ref()?;
3124        if !offer.pad_ok {
3125            return None;
3126        }
3127        let area = offer.pad_width_m * offer.pad_depth_m;
3128        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3129        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3130        Some(ticks.max(2.0) / 30.0)
3131    }
3132
3133    pub fn plot_build_can_afford(&self) -> bool {
3134        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3135            return false;
3136        }
3137        self.plot_build_bom_lines()
3138            .iter()
3139            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3140    }
3141
3142    pub fn currency_display(&self) -> String {
3143        crate::currency::currency_line(&self.inventory)
3144    }
3145
3146    /// True when standing in a shallow-water terrain zone from the segment snapshot.
3147    pub fn in_shallow_water(&self) -> bool {
3148        let (px, py) = self.player_position();
3149        self.terrain_at(px, py)
3150            .is_some_and(|k| k == TerrainKindView::ShallowWater)
3151    }
3152
3153    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3154        self.terrain_zone_at(x, y).map(|z| z.kind)
3155    }
3156
3157    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
3158    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3159        use std::cell::RefCell;
3160
3161        const CHUNK: i32 = 8;
3162        thread_local! {
3163            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
3164                RefCell::new(None);
3165        }
3166
3167        let zones = &self.terrain_zones;
3168        if zones.is_empty() {
3169            return None;
3170        }
3171        if zones.len() <= 48 {
3172            return zones
3173                .iter()
3174                .enumerate()
3175                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
3176                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
3177                .map(|(_, z)| z);
3178        }
3179
3180        let ptr = zones.as_ptr();
3181        let len = zones.len();
3182        INDEX.with(|cell| {
3183            let mut slot = cell.borrow_mut();
3184            let stale = match slot.as_ref() {
3185                Some((p, l, _)) => *p != ptr || *l != len,
3186                None => true,
3187            };
3188            if stale {
3189                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
3190                    std::collections::HashMap::new();
3191                for (zi, z) in zones.iter().enumerate() {
3192                    let x0 = z.x0.min(z.x1).floor() as i32;
3193                    let y0 = z.y0.min(z.y1).floor() as i32;
3194                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
3195                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
3196                    let cx0 = x0.div_euclid(CHUNK);
3197                    let cy0 = y0.div_euclid(CHUNK);
3198                    let cx1 = x1.div_euclid(CHUNK);
3199                    let cy1 = y1.div_euclid(CHUNK);
3200                    for cy in cy0..=cy1 {
3201                        for cx in cx0..=cx1 {
3202                            chunks.entry((cx, cy)).or_default().push(zi);
3203                        }
3204                    }
3205                }
3206                *slot = Some((ptr, len, chunks));
3207            }
3208            let chunks = &slot.as_ref().expect("index").2;
3209            let cx = (x.floor() as i32).div_euclid(CHUNK);
3210            let cy = (y.floor() as i32).div_euclid(CHUNK);
3211            let mut best: Option<(usize, &TerrainZoneView)> = None;
3212            if let Some(list) = chunks.get(&(cx, cy)) {
3213                for &zi in list {
3214                    let Some(z) = zones.get(zi) else { continue };
3215                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
3216                        continue;
3217                    }
3218                    best = match best {
3219                        None => Some((zi, z)),
3220                        Some((bi, bz)) => {
3221                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
3222                                Some((zi, z))
3223                            } else {
3224                                Some((bi, bz))
3225                            }
3226                        }
3227                    };
3228                }
3229            }
3230            best.map(|(_, z)| z)
3231        })
3232    }
3233
3234    /// Ground elevation from terrain zones (m).
3235    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
3236        self.terrain_zone_at(x, y)
3237            .map(|z| z.elevation)
3238            .unwrap_or(0.0)
3239    }
3240
3241    /// Walkable z levels at a map column (terrain + platforms).
3242    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
3243        const TOL: f32 = 0.35;
3244        let mut levels = vec![self.elevation_at(x, y)];
3245        for p in &self.z_platforms {
3246            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3247                levels.push(p.z);
3248            }
3249        }
3250        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3251        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
3252        levels
3253    }
3254
3255    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
3256        const TOL: f32 = 0.35;
3257        self.walkable_levels_at(x, y)
3258            .iter()
3259            .any(|&l| (l - z).abs() <= TOL)
3260    }
3261
3262    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
3263        let mut top = self.elevation_at(x, y);
3264        for p in &self.z_platforms {
3265            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3266                top = top.max(p.z);
3267            }
3268        }
3269        top
3270    }
3271
3272    /// Authoritative interior context from the server (`inside_building` flag).
3273    pub fn effective_inside_building(&self) -> Option<String> {
3274        self.player_entity().and_then(|p| p.inside_building.clone())
3275    }
3276
3277    /// True when a placed chest shares the player's outdoor/interior space.
3278    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
3279    /// labels); they must not be drawn or treated as nearby world props.
3280    pub fn placed_container_in_current_space(
3281        &self,
3282        c: &flatland_protocol::PlacedContainerView,
3283    ) -> bool {
3284        match (
3285            self.effective_inside_building().as_deref(),
3286            c.building_id.as_deref(),
3287        ) {
3288            (None, None) => true,
3289            (Some(a), Some(b)) => a == b,
3290            _ => false,
3291        }
3292    }
3293
3294    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3295        fn walk(
3296            stacks: &[flatland_protocol::ItemStack],
3297            hints: &mut std::collections::HashMap<String, InventoryHint>,
3298        ) {
3299            for stack in stacks {
3300                if stack.display_name.is_some()
3301                    || stack.category.is_some()
3302                    || stack.base_mass.is_some()
3303                    || stack.base_volume.is_some()
3304                    || stack.base_value_copper.is_some()
3305                {
3306                    hints.insert(
3307                        stack.template_id.clone(),
3308                        InventoryHint {
3309                            display_name: stack
3310                                .display_name
3311                                .clone()
3312                                .unwrap_or_else(|| stack.template_id.clone()),
3313                            category: stack.category.clone().unwrap_or_default(),
3314                            base_mass: stack.base_mass,
3315                            base_volume: stack.base_volume,
3316                            capacity_volume: stack.capacity_volume,
3317                            stackable: stack.stackable.unwrap_or(true),
3318                            listable: stack.listable.unwrap_or_else(|| {
3319                                category_default_listable(stack.category.as_deref().unwrap_or(""))
3320                            }),
3321                            base_value_copper: stack.base_value_copper,
3322                        },
3323                    );
3324                }
3325                walk(&stack.contents, hints);
3326            }
3327        }
3328        walk(stacks, &mut self.inventory_hints);
3329    }
3330
3331    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3332        self.inventory_stacks = stacks.to_vec();
3333        self.inventory.clear();
3334        self.inventory_hints.clear();
3335        fn walk(
3336            stacks: &[flatland_protocol::ItemStack],
3337            inventory: &mut std::collections::HashMap<String, u32>,
3338            hints: &mut std::collections::HashMap<String, InventoryHint>,
3339        ) {
3340            for stack in stacks {
3341                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
3342                if stack.display_name.is_some()
3343                    || stack.category.is_some()
3344                    || stack.base_mass.is_some()
3345                    || stack.base_volume.is_some()
3346                    || stack.base_value_copper.is_some()
3347                {
3348                    hints.insert(
3349                        stack.template_id.clone(),
3350                        InventoryHint {
3351                            display_name: stack
3352                                .display_name
3353                                .clone()
3354                                .unwrap_or_else(|| stack.template_id.clone()),
3355                            category: stack.category.clone().unwrap_or_default(),
3356                            base_mass: stack.base_mass,
3357                            base_volume: stack.base_volume,
3358                            capacity_volume: stack.capacity_volume,
3359                            stackable: stack.stackable.unwrap_or(true),
3360                            listable: stack.listable.unwrap_or_else(|| {
3361                                category_default_listable(stack.category.as_deref().unwrap_or(""))
3362                            }),
3363                            base_value_copper: stack.base_value_copper,
3364                        },
3365                    );
3366                }
3367                walk(&stack.contents, inventory, hints);
3368            }
3369        }
3370        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
3371        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
3372        for item in self.worn.values() {
3373            walk(
3374                std::slice::from_ref(item),
3375                &mut self.inventory,
3376                &mut self.inventory_hints,
3377            );
3378        }
3379    }
3380
3381    /// Remove a carried stack by instance id (root or nested in a worn bag).
3382    /// Used for optimistic give-to-worker so the bag UI does not keep the item
3383    /// until the next tick — and so a later "Gave …" interaction cannot add it back.
3384    fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
3385        fn take_from(
3386            stacks: &mut Vec<flatland_protocol::ItemStack>,
3387            instance_id: uuid::Uuid,
3388            qty: Option<u32>,
3389        ) -> bool {
3390            if let Some(i) = stacks
3391                .iter()
3392                .position(|s| s.item_instance_id == Some(instance_id))
3393            {
3394                let have = stacks[i].quantity;
3395                let take = qty.unwrap_or(have).min(have);
3396                if take >= have {
3397                    stacks.remove(i);
3398                } else {
3399                    stacks[i].quantity = have - take;
3400                }
3401                return true;
3402            }
3403            stacks
3404                .iter_mut()
3405                .any(|stack| take_from(&mut stack.contents, instance_id, qty))
3406        }
3407
3408        if take_from(&mut self.inventory_stacks, instance_id, quantity) {
3409            let stacks = self.inventory_stacks.clone();
3410            self.sync_inventory_from_stacks(&stacks);
3411            self.refresh_inventory_ui();
3412            return;
3413        }
3414        let slots: Vec<_> = self.worn.keys().copied().collect();
3415        for slot in slots {
3416            let Some(item) = self.worn.get_mut(&slot) else {
3417                continue;
3418            };
3419            if take_from(&mut item.contents, instance_id, quantity) {
3420                let stacks = self.inventory_stacks.clone();
3421                self.sync_inventory_from_stacks(&stacks);
3422                self.refresh_inventory_ui();
3423                return;
3424            }
3425        }
3426    }
3427
3428    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
3429    /// inventory UI updates before the next tick snapshot arrives.
3430    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3431        // Worker handoff notices reuse `inventory_delta` as a toast payload, not a
3432        // gain. The same tick already dropped the stack; applying it as an add put
3433        // the item back in the player bag.
3434        if notice.message.starts_with("Gave ") {
3435            if notice.coins_delta != 0 {
3436                crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3437                let stacks = self.inventory_stacks.clone();
3438                self.sync_inventory_from_stacks(&stacks);
3439            }
3440            self.record_shop_trade_notice(notice);
3441            return;
3442        }
3443        let subtract_items =
3444            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3445        for stack in &notice.inventory_delta {
3446            if stack.quantity == 0 {
3447                continue;
3448            }
3449            if subtract_items {
3450                crate::currency::drain_template_stacks(
3451                    &mut self.inventory_stacks,
3452                    &stack.template_id,
3453                    stack.quantity,
3454                );
3455                continue;
3456            }
3457            let stackable = self
3458                .inventory_hints
3459                .get(&stack.template_id)
3460                .map(|h| h.stackable)
3461                .or(stack.stackable)
3462                .unwrap_or(true);
3463            if stackable {
3464                if let Some(existing) = self
3465                    .inventory_stacks
3466                    .iter_mut()
3467                    .find(|s| s.template_id == stack.template_id)
3468                {
3469                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
3470                    if stack.display_name.is_some() {
3471                        existing.display_name = stack.display_name.clone();
3472                    }
3473                    if stack.category.is_some() {
3474                        existing.category = stack.category.clone();
3475                    }
3476                    continue;
3477                }
3478            }
3479            self.inventory_stacks.push(stack.clone());
3480        }
3481        if notice.coins_delta != 0 {
3482            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3483        }
3484        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3485            let stacks = self.inventory_stacks.clone();
3486            self.sync_inventory_from_stacks(&stacks);
3487        }
3488        self.record_shop_trade_notice(notice);
3489    }
3490
3491    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
3492    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
3493    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
3494    /// iteration alone gives a stable row order.
3495    pub fn worn_rows(&self) -> Vec<InventoryRow> {
3496        let mut rows = Vec::new();
3497        for (slot, item) in &self.worn {
3498            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3499            rows.push(InventoryRow {
3500                depth: 0,
3501                stack: item.clone(),
3502                from: from.clone(),
3503                from_parent_instance_id: None,
3504                is_equip_shell: true,
3505                is_chest_shell: false,
3506                section: InventorySection::Worn,
3507            });
3508            for child in &item.contents {
3509                push_inventory_rows(
3510                    &mut rows,
3511                    1,
3512                    child,
3513                    &from,
3514                    item.item_instance_id,
3515                    InventorySection::Worn,
3516                );
3517            }
3518        }
3519        rows
3520    }
3521
3522    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
3523    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3524        let equipped = self.hand_equipped_instance_ids();
3525        self.inventory_stacks
3526            .iter()
3527            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
3528            .collect()
3529    }
3530
3531    /// Root on-person stacks that can be handed to a hired worker.
3532    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3533        let equipped = self.hand_equipped_instance_ids();
3534        self.inventory_stacks
3535            .iter()
3536            .filter_map(|stack| {
3537                let item_instance_id = stack.item_instance_id?;
3538                if equipped.contains(&item_instance_id) {
3539                    return None;
3540                }
3541                let label = stack
3542                    .display_name
3543                    .clone()
3544                    .unwrap_or_else(|| stack.template_id.clone());
3545                let label = if stack.quantity > 1 {
3546                    format!("{label} ×{}", stack.quantity)
3547                } else {
3548                    label
3549                };
3550                Some(WorkerGiveOption {
3551                    item_instance_id,
3552                    label,
3553                    quantity: stack.quantity,
3554                    template_id: stack.template_id.clone(),
3555                })
3556            })
3557            .collect()
3558    }
3559
3560    /// Employer-known blueprints the selected worker does not yet know.
3561    pub fn teachable_blueprint_options(
3562        &self,
3563        worker: &flatland_protocol::HiredWorkerView,
3564    ) -> Vec<WorkerTeachOption> {
3565        let copper = crate::currency::copper_from_counts(&self.inventory);
3566        let mut options: Vec<WorkerTeachOption> = self
3567            .blueprints
3568            .iter()
3569            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3570            .map(|bp| {
3571                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3572                let cost = bp.worker_train_copper;
3573                WorkerTeachOption {
3574                    blueprint_id: bp.id.clone(),
3575                    label: if bp.label.is_empty() {
3576                        bp.id.clone()
3577                    } else {
3578                        bp.label.clone()
3579                    },
3580                    cost_copper: cost,
3581                    min_level,
3582                    worker_level: worker.level,
3583                    can_afford: copper >= cost,
3584                    level_ok: worker.level >= min_level,
3585                }
3586            })
3587            .collect();
3588        options.sort_by(|a, b| a.label.cmp(&b.label));
3589        options
3590    }
3591
3592    /// Loose on-person inventory (not worn, not inside a placed chest).
3593    /// Root stacks are ordered by category group; nested contents stay under their parent.
3594    pub fn person_rows(&self) -> Vec<InventoryRow> {
3595        self.person_rows_filtered("")
3596    }
3597
3598    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3599        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3600        roots.sort_by(|a, b| {
3601            let ca = a
3602                .category
3603                .as_deref()
3604                .or_else(|| self.inventory_item_category(&a.template_id))
3605                .unwrap_or("");
3606            let cb = b
3607                .category
3608                .as_deref()
3609                .or_else(|| self.inventory_item_category(&b.template_id))
3610                .unwrap_or("");
3611            let ga = inventory_category_group(ca).1;
3612            let gb = inventory_category_group(cb).1;
3613            ga.cmp(&gb).then_with(|| {
3614                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
3615                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
3616                na.cmp(nb)
3617            })
3618        });
3619        let mut rows = Vec::new();
3620        for stack in roots {
3621            push_inventory_rows_filtered(
3622                &mut rows,
3623                0,
3624                stack,
3625                &flatland_protocol::InventoryLocation::Root,
3626                None,
3627                InventorySection::Person,
3628                filter,
3629            );
3630        }
3631        rows
3632    }
3633
3634    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3635        if filter.is_empty() {
3636            return self.worn_rows();
3637        }
3638        let mut rows = Vec::new();
3639        for (slot, item) in &self.worn {
3640            if !stack_matches_filter(item, filter) {
3641                continue;
3642            }
3643            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3644            let self_hit = {
3645                let f = filter.to_ascii_lowercase();
3646                let name = item
3647                    .display_name
3648                    .as_deref()
3649                    .unwrap_or("")
3650                    .to_ascii_lowercase();
3651                let tid = item.template_id.to_ascii_lowercase();
3652                name.contains(&f) || tid.contains(&f)
3653            };
3654            rows.push(InventoryRow {
3655                depth: 0,
3656                stack: item.clone(),
3657                from: from.clone(),
3658                from_parent_instance_id: None,
3659                is_equip_shell: true,
3660                is_chest_shell: false,
3661                section: InventorySection::Worn,
3662            });
3663            for child in &item.contents {
3664                if self_hit || stack_matches_filter(child, filter) {
3665                    push_inventory_rows_filtered(
3666                        &mut rows,
3667                        1,
3668                        child,
3669                        &from,
3670                        item.item_instance_id,
3671                        InventorySection::Worn,
3672                        if self_hit { "" } else { filter },
3673                    );
3674                }
3675            }
3676        }
3677        rows
3678    }
3679
3680    /// Items carried inside equipped containers, without showing the equipped
3681    /// container shells themselves. Equipped gear belongs in the paperdoll;
3682    /// these rows are still movable because the contents are carried by the player.
3683    pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3684        let mut rows = Vec::new();
3685        for (slot, item) in &self.worn {
3686            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3687            for child in &item.contents {
3688                push_inventory_rows_filtered(
3689                    &mut rows,
3690                    0,
3691                    child,
3692                    &from,
3693                    item.item_instance_id,
3694                    InventorySection::Person,
3695                    filter,
3696                );
3697            }
3698        }
3699        rows
3700    }
3701
3702    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
3703    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3704        let mut rows = self.worn_rows();
3705        rows.extend(self.person_rows());
3706        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3707    }
3708
3709    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
3710    /// populated when `accessible` — this is what makes a chest's contents
3711    /// disappear the moment you walk away or it's locked without your key.
3712    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3713        let (px, py) = self.player_position();
3714        let mut list: Vec<NearbyContainer> = self
3715            .placed_containers
3716            .iter()
3717            .filter(|c| self.placed_container_in_current_space(c))
3718            .filter_map(|c| {
3719                let distance_m = (c.x - px).hypot(c.y - py);
3720                if distance_m > CONTAINER_RANGE_M {
3721                    return None;
3722                }
3723                let mut rows = Vec::new();
3724                let from = flatland_protocol::InventoryLocation::Placed {
3725                    container_id: c.id.clone(),
3726                };
3727                rows.push(InventoryRow {
3728                    depth: 0,
3729                    stack: flatland_protocol::ItemStack {
3730                        template_id: c.template_id.clone(),
3731                        quantity: 1,
3732                        item_instance_id: c.item_instance_id,
3733                        props: Default::default(),
3734                        status_bindings: Vec::new(),
3735                        contents: Vec::new(),
3736                        display_name: Some(c.display_name.clone()),
3737                        category: Some("container".into()),
3738                        capacity_volume: c.capacity_volume,
3739                        worker_lodging_capacity: c.worker_lodging_capacity,
3740                        ..Default::default()
3741                    },
3742                    from: from.clone(),
3743                    from_parent_instance_id: None,
3744                    is_equip_shell: false,
3745                    is_chest_shell: true,
3746                    section: InventorySection::Nearby,
3747                });
3748                if c.accessible {
3749                    for child in &c.contents {
3750                        push_inventory_rows(
3751                            &mut rows,
3752                            1,
3753                            child,
3754                            &from,
3755                            c.item_instance_id,
3756                            InventorySection::Nearby,
3757                        );
3758                    }
3759                }
3760                Some(NearbyContainer {
3761                    view: c.clone(),
3762                    distance_m,
3763                    rows,
3764                })
3765            })
3766            .collect();
3767        list.sort_by(|a, b| {
3768            a.distance_m
3769                .partial_cmp(&b.distance_m)
3770                .unwrap_or(std::cmp::Ordering::Equal)
3771        });
3772        list
3773    }
3774
3775    /// Nearest placed chest within `max_dist`, regardless of accessibility.
3776    pub fn nearest_placed_container(
3777        &self,
3778        max_dist: f32,
3779    ) -> Option<flatland_protocol::PlacedContainerView> {
3780        let (px, py) = self.player_position();
3781        self.placed_containers
3782            .iter()
3783            .filter(|c| self.placed_container_in_current_space(c))
3784            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3785            .min_by(|a, b| {
3786                let da = (a.x - px).hypot(a.y - py);
3787                let db = (b.x - px).hypot(b.y - py);
3788                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3789            })
3790            .cloned()
3791    }
3792
3793    /// Selectable rows for the **active inventory tab** (and current filter).
3794    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
3795    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3796        let filter = self.inventory_filter.as_str();
3797        match self.inventory_tab {
3798            InventoryTab::OnPerson => {
3799                let mut rows = self.carried_worn_rows_filtered(filter);
3800                rows.extend(self.person_rows_filtered(filter));
3801                rows
3802            }
3803            InventoryTab::Nearby => {
3804                let mut rows = Vec::new();
3805                for nc in self.nearby_containers() {
3806                    if filter.is_empty() {
3807                        rows.extend(nc.rows);
3808                        continue;
3809                    }
3810                    let shell = nc.rows.first().cloned();
3811                    let contents: Vec<_> = nc
3812                        .rows
3813                        .iter()
3814                        .skip(1)
3815                        .filter(|r| stack_matches_filter(&r.stack, filter))
3816                        .cloned()
3817                        .collect();
3818                    let shell_hit = shell
3819                        .as_ref()
3820                        .map(|s| stack_matches_filter(&s.stack, filter))
3821                        .unwrap_or(false);
3822                    if shell_hit || !contents.is_empty() {
3823                        if let Some(s) = shell {
3824                            rows.push(s);
3825                        }
3826                        if shell_hit {
3827                            rows.extend(nc.rows.into_iter().skip(1));
3828                        } else {
3829                            rows.extend(contents);
3830                        }
3831                    }
3832                }
3833                rows
3834            }
3835        }
3836    }
3837
3838    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3839        self.inventory_selectable_rows()
3840            .into_iter()
3841            .nth(self.inventory_menu_index)
3842    }
3843
3844    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3845        let cat = self
3846            .inventory_item_category(&row.stack.template_id)
3847            .unwrap_or("");
3848        if cat == "key" {
3849            self.key_inventory_label(&row.stack)
3850        } else {
3851            row.stack
3852                .display_name
3853                .clone()
3854                .unwrap_or_else(|| row.stack.template_id.clone())
3855        }
3856    }
3857
3858    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
3859    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3860        let bindings =
3861            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3862        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3863            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3864            let mode = Self::grant_mode(&row.stack);
3865            format!(" [grant {effect} · {mode} — e apply]")
3866        } else {
3867            String::new()
3868        };
3869        let qty = if row.stack.quantity > 1 {
3870            format!(" ×{}", row.stack.quantity)
3871        } else {
3872            String::new()
3873        };
3874        let worn_slot = if row.is_equip_shell {
3875            match row.from {
3876                flatland_protocol::InventoryLocation::Worn { slot } => {
3877                    format!(" ({})", body_slot_label(slot))
3878                }
3879                _ => String::new(),
3880            }
3881        } else {
3882            String::new()
3883        };
3884        format!("{grant_hint}{bindings}{qty}{worn_slot}")
3885    }
3886
3887    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3888        (
3889            row.stack.template_id.clone(),
3890            self.inventory_row_base_label(row),
3891            self.inventory_row_visible_mod_signature(row),
3892        )
3893    }
3894
3895    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
3896    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3897        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3898        for row in self.inventory_selectable_rows() {
3899            if row.stack.item_instance_id.is_none() {
3900                continue;
3901            }
3902            let key = self.inventory_row_instance_identity_key(&row);
3903            *counts.entry(key).or_default() += 1;
3904        }
3905        counts
3906            .into_iter()
3907            .filter(|(_, n)| *n > 1)
3908            .map(|(k, _)| k)
3909            .collect()
3910    }
3911
3912    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3913        let hex: String = id
3914            .as_simple()
3915            .to_string()
3916            .chars()
3917            .filter(|c| c.is_ascii_hexdigit())
3918            .collect();
3919        let short = if hex.len() >= 4 {
3920            &hex[hex.len() - 4..]
3921        } else {
3922            hex.as_str()
3923        };
3924        format!("Instance {id} (#{short})")
3925    }
3926
3927    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
3928    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3929        let cat = self
3930            .inventory_item_category(&row.stack.template_id)
3931            .unwrap_or("");
3932        let label = self.inventory_row_base_label(row);
3933        let hint: String = if row.is_equip_shell {
3934            " [worn — Enter to unequip]".into()
3935        } else if row.is_chest_shell {
3936            let (locked, lodging_note) = match &row.from {
3937                flatland_protocol::InventoryLocation::Placed { container_id } => {
3938                    let locked = self
3939                        .placed_containers
3940                        .iter()
3941                        .find(|c| c.id == *container_id)
3942                        .map(|c| c.locked)
3943                        .unwrap_or(false);
3944                    let lodging_note = self
3945                        .lodging_occupancy_label(container_id)
3946                        .map(|who| format!(" [lodging: {who}]"))
3947                        .unwrap_or_default();
3948                    (locked, lodging_note)
3949                }
3950                _ => (false, String::new()),
3951            };
3952            if locked {
3953                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3954            } else {
3955                format!(" [Enter pick up · l lock]{lodging_note}")
3956            }
3957        } else if cat == "key" {
3958            self.key_inventory_hint(&row.stack)
3959        } else {
3960            match cat {
3961                "weapon" => " [weapon]".into(),
3962                "container" => " [bag/chest/belt]".into(),
3963                "lodging" => " [worker lodging]".into(),
3964                "armor" => " [armor]".into(),
3965                _ => String::new(),
3966            }
3967        };
3968        let qty = if row.stack.quantity > 1 {
3969            format!(" ×{}", row.stack.quantity)
3970        } else {
3971            String::new()
3972        };
3973        let bindings =
3974            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3975        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3976            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3977            let mode = Self::grant_mode(&row.stack);
3978            format!(" [grant {effect} · {mode} — e apply]")
3979        } else {
3980            String::new()
3981        };
3982        let mass = self.stack_mass(&row.stack);
3983        let mass_kg = (mass >= 0.05).then_some(mass);
3984        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
3985        let volume = self.container_volume_stats(row);
3986        let vol_str = self.container_volume_label(row);
3987
3988        let mut title = label.clone();
3989        title.push_str(&qty);
3990        if row.is_equip_shell {
3991            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3992                title.push_str(&format!(" ({})", body_slot_label(slot)));
3993            }
3994        }
3995
3996        InventoryRowView {
3997            depth: row.depth,
3998            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3999            title: format!("{title}{grant_hint}{bindings}"),
4000            mass_kg,
4001            volume,
4002            instance_tooltip: None,
4003        }
4004    }
4005
4006    fn push_browser_item(
4007        &self,
4008        lines: &mut Vec<InventoryBrowserLine>,
4009        row: &InventoryRow,
4010        global_idx: &mut usize,
4011        target: usize,
4012        highlight: bool,
4013        ambiguous_instance_keys: &HashSet<(String, String, String)>,
4014    ) {
4015        let mut view = self.format_inventory_row(row);
4016        if let Some(id) = row.stack.item_instance_id {
4017            let key = self.inventory_row_instance_identity_key(row);
4018            if ambiguous_instance_keys.contains(&key) {
4019                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4020            }
4021        }
4022        lines.push(InventoryBrowserLine::Item {
4023            selectable_index: *global_idx,
4024            selected: highlight && *global_idx == target,
4025            depth: view.depth,
4026            text: view.text,
4027            title: view.title,
4028            mass_kg: view.mass_kg,
4029            volume: view.volume,
4030            instance_tooltip: view.instance_tooltip,
4031        });
4032        *global_idx += 1;
4033    }
4034
4035    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
4036    /// `selectable_index` matching `inventory_menu_index`.
4037    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4038        let mut lines = Vec::new();
4039        let target = self.inventory_menu_index;
4040        let highlight = !self.show_move_picker && !self.show_grant_picker;
4041        let filter = self.inventory_filter.as_str();
4042        let mut global_idx = 0usize;
4043        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4044
4045        match self.inventory_tab {
4046            InventoryTab::OnPerson => {
4047                lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4048                let carried = self.carried_worn_rows_filtered(filter);
4049                if carried.is_empty() {
4050                    lines.push(InventoryBrowserLine::Hint(
4051                        "  (no items in carried bags)".into(),
4052                    ));
4053                } else {
4054                    for row in &carried {
4055                        self.push_browser_item(
4056                            &mut lines,
4057                            row,
4058                            &mut global_idx,
4059                            target,
4060                            highlight,
4061                            &ambiguous_instance_keys,
4062                        );
4063                    }
4064                }
4065
4066                lines.push(InventoryBrowserLine::Blank);
4067                lines.push(InventoryBrowserLine::Section(
4068                    "— On you (loose, not worn) —".into(),
4069                ));
4070                let person = self.person_rows_filtered(filter);
4071                if person.is_empty() {
4072                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
4073                } else {
4074                    let mut last_group: Option<&'static str> = None;
4075                    for row in &person {
4076                        if row.depth == 0 {
4077                            let cat = row
4078                                .stack
4079                                .category
4080                                .as_deref()
4081                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
4082                                .unwrap_or("");
4083                            let (group, _) = inventory_category_group(cat);
4084                            if last_group != Some(group) {
4085                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
4086                                last_group = Some(group);
4087                            }
4088                        }
4089                        self.push_browser_item(
4090                            &mut lines,
4091                            row,
4092                            &mut global_idx,
4093                            target,
4094                            highlight,
4095                            &ambiguous_instance_keys,
4096                        );
4097                    }
4098                }
4099            }
4100            InventoryTab::Nearby => {
4101                let nearby = self.nearby_containers();
4102                if nearby.is_empty() {
4103                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4104                    lines.push(InventoryBrowserLine::Hint(
4105                        "  (none within reach — walk up to a chest)".into(),
4106                    ));
4107                    lines.push(InventoryBrowserLine::Hint(
4108                        "  Select an on-person item, then m / Enter → move into chest.".into(),
4109                    ));
4110                } else {
4111                    let mut any_visible = false;
4112                    for nc in &nearby {
4113                        let shell = nc.rows.first();
4114                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
4115                            nc.rows.iter().skip(1).collect()
4116                        } else {
4117                            let shell_hit = shell
4118                                .map(|s| {
4119                                    let f = filter.to_ascii_lowercase();
4120                                    let name = s
4121                                        .stack
4122                                        .display_name
4123                                        .as_deref()
4124                                        .unwrap_or("")
4125                                        .to_ascii_lowercase();
4126                                    let tid = s.stack.template_id.to_ascii_lowercase();
4127                                    name.contains(&f) || tid.contains(&f)
4128                                })
4129                                .unwrap_or(false);
4130                            if shell_hit {
4131                                nc.rows.iter().skip(1).collect()
4132                            } else {
4133                                nc.rows
4134                                    .iter()
4135                                    .skip(1)
4136                                    .filter(|r| stack_matches_filter(&r.stack, filter))
4137                                    .collect()
4138                            }
4139                        };
4140                        let shell_visible = filter.is_empty()
4141                            || shell
4142                                .map(|s| stack_matches_filter(&s.stack, filter))
4143                                .unwrap_or(false)
4144                            || !contents.is_empty();
4145                        if !shell_visible && shell.is_some() {
4146                            continue;
4147                        }
4148                        any_visible = true;
4149                        lines.push(InventoryBrowserLine::Blank);
4150                        let lock_note = if nc.view.locked && nc.view.accessible {
4151                            "  unlocked with your key"
4152                        } else if nc.view.locked {
4153                            "  locked"
4154                        } else {
4155                            ""
4156                        };
4157                        lines.push(InventoryBrowserLine::Section(format!(
4158                            "— {} ({:.0}m away){lock_note} —",
4159                            nc.view.display_name, nc.distance_m
4160                        )));
4161                        if !nc.view.accessible {
4162                            lines.push(InventoryBrowserLine::Hint(
4163                                "  locked — need the matching key (l to try)".into(),
4164                            ));
4165                        } else if nc.rows.is_empty() {
4166                            lines.push(InventoryBrowserLine::Hint(
4167                                "  (empty — switch to On person, select an item, m to move in)"
4168                                    .into(),
4169                            ));
4170                        } else if let Some(shell_row) = shell {
4171                            self.push_browser_item(
4172                                &mut lines,
4173                                shell_row,
4174                                &mut global_idx,
4175                                target,
4176                                highlight,
4177                                &ambiguous_instance_keys,
4178                            );
4179                            for row in contents {
4180                                self.push_browser_item(
4181                                    &mut lines,
4182                                    row,
4183                                    &mut global_idx,
4184                                    target,
4185                                    highlight,
4186                                    &ambiguous_instance_keys,
4187                                );
4188                            }
4189                        }
4190                    }
4191                    if !any_visible {
4192                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4193                        lines.push(InventoryBrowserLine::Hint(
4194                            "  (no matching items — clear filter with Esc)".into(),
4195                        ));
4196                    }
4197                }
4198            }
4199        }
4200        lines
4201    }
4202
4203    /// Destinations for picking up a placed chest/crate into inventory.
4204    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
4205        let mut opts = Vec::new();
4206        opts.push(MoveOption {
4207            label: "Relocate…".into(),
4208            kind: MoveOptionKind::RelocatePlaced {
4209                container_id: container_id.to_string(),
4210            },
4211        });
4212        opts.push(MoveOption {
4213            label: "On your person (loose)".into(),
4214            kind: MoveOptionKind::PickupPlaced {
4215                container_id: container_id.to_string(),
4216                nest_location: flatland_protocol::InventoryLocation::Root,
4217                nest_parent_instance_id: None,
4218            },
4219        });
4220        for (slot, item) in &self.worn {
4221            if item.category.as_deref() != Some("container") {
4222                continue;
4223            }
4224            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
4225                continue;
4226            }
4227            let Some(parent_id) = item.item_instance_id else {
4228                continue;
4229            };
4230            let shell_name = item
4231                .display_name
4232                .clone()
4233                .unwrap_or_else(|| item.template_id.clone());
4234            opts.push(MoveOption {
4235                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
4236                kind: MoveOptionKind::PickupPlaced {
4237                    container_id: container_id.to_string(),
4238                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
4239                    nest_parent_instance_id: Some(parent_id),
4240                },
4241            });
4242            // Nested pouches inside the worn bag.
4243            Self::append_chest_pickup_nested(
4244                &mut opts,
4245                container_id,
4246                flatland_protocol::InventoryLocation::Worn { slot: *slot },
4247                item,
4248                &format!("in {shell_name}"),
4249            );
4250        }
4251        opts.push(MoveOption {
4252            label: "Cancel".into(),
4253            kind: MoveOptionKind::Cancel,
4254        });
4255        opts
4256    }
4257
4258    fn append_chest_pickup_nested(
4259        opts: &mut Vec<MoveOption>,
4260        container_id: &str,
4261        location: flatland_protocol::InventoryLocation,
4262        parent: &flatland_protocol::ItemStack,
4263        context: &str,
4264    ) {
4265        for child in &parent.contents {
4266            if child.category.as_deref() != Some("container") {
4267                continue;
4268            }
4269            if !Self::is_volume_container_stack(child) {
4270                continue;
4271            }
4272            // Skip placeable nested chests — relocating into another chest is not useful.
4273            if child.world_placeable == Some(true) {
4274                continue;
4275            }
4276            let Some(child_id) = child.item_instance_id else {
4277                continue;
4278            };
4279            let name = child
4280                .display_name
4281                .clone()
4282                .unwrap_or_else(|| child.template_id.clone());
4283            opts.push(MoveOption {
4284                label: format!("{name} ({context})"),
4285                kind: MoveOptionKind::PickupPlaced {
4286                    container_id: container_id.to_string(),
4287                    nest_location: location.clone(),
4288                    nest_parent_instance_id: Some(child_id),
4289                },
4290            });
4291            Self::append_chest_pickup_nested(
4292                opts,
4293                container_id,
4294                location.clone(),
4295                child,
4296                &format!("in {name}"),
4297            );
4298        }
4299    }
4300
4301    /// Build the "move to…" destination list for an item currently at `from`.
4302    pub fn move_destinations_for(
4303        &self,
4304        from: &flatland_protocol::InventoryLocation,
4305        from_parent_instance_id: Option<uuid::Uuid>,
4306        moving_instance_id: Option<uuid::Uuid>,
4307        moving_template_id: &str,
4308    ) -> Vec<MoveOption> {
4309        let mut opts = Vec::new();
4310        if *from != flatland_protocol::InventoryLocation::Root {
4311            opts.push(MoveOption {
4312                label: "On your person (loose)".into(),
4313                kind: MoveOptionKind::Move {
4314                    location: flatland_protocol::InventoryLocation::Root,
4315                    parent_instance_id: None,
4316                },
4317            });
4318        }
4319        for (slot, item) in &self.worn {
4320            if item.category.as_deref() != Some("container") {
4321                continue;
4322            }
4323            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4324            let shell_name = item
4325                .display_name
4326                .clone()
4327                .unwrap_or_else(|| item.template_id.clone());
4328
4329            // Backpack and other worn volume containers — store directly inside the shell.
4330            if *slot != BodySlot::Waist
4331                && item.item_instance_id != moving_instance_id
4332                && Self::is_volume_container_stack(item)
4333            {
4334                Self::push_move_destination(
4335                    &mut opts,
4336                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
4337                    location.clone(),
4338                    item.item_instance_id,
4339                    from,
4340                    from_parent_instance_id,
4341                );
4342            }
4343
4344            // Belt loops only accept pouch attachments — not loose materials.
4345            if *slot == BodySlot::Waist
4346                && Self::attaches_to_belt_loop(moving_template_id)
4347                && item.item_instance_id != moving_instance_id
4348            {
4349                Self::push_move_destination(
4350                    &mut opts,
4351                    format!("{shell_name} (belt loop)"),
4352                    location.clone(),
4353                    item.item_instance_id,
4354                    from,
4355                    from_parent_instance_id,
4356                );
4357            }
4358
4359            let context = if *slot == BodySlot::Waist {
4360                format!("on {shell_name}")
4361            } else {
4362                format!("in {shell_name}")
4363            };
4364            Self::append_nested_container_destinations(
4365                &mut opts,
4366                location,
4367                item,
4368                &context,
4369                from,
4370                from_parent_instance_id,
4371                moving_instance_id,
4372            );
4373        }
4374        for nc in self.nearby_containers() {
4375            if !nc.view.accessible {
4376                continue;
4377            }
4378            let location = flatland_protocol::InventoryLocation::Placed {
4379                container_id: nc.view.id.clone(),
4380            };
4381            Self::push_move_destination(
4382                &mut opts,
4383                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
4384                location,
4385                nc.view.item_instance_id,
4386                from,
4387                from_parent_instance_id,
4388            );
4389        }
4390        let allow_drop = moving_instance_id
4391            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
4392            .unwrap_or(true)
4393            && moving_instance_id
4394                .and_then(|id| self.stack_for_instance(id))
4395                .map(|stack| {
4396                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
4397                })
4398                .unwrap_or(
4399                    moving_template_id != KEY_TEMPLATE
4400                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
4401                );
4402        if allow_drop {
4403            opts.push(MoveOption {
4404                label: "Drop on the ground".into(),
4405                kind: MoveOptionKind::Drop,
4406            });
4407        }
4408        opts.push(MoveOption {
4409            label: "Cancel".into(),
4410            kind: MoveOptionKind::Cancel,
4411        });
4412        opts
4413    }
4414
4415    fn is_same_container_dest(
4416        dest_location: &flatland_protocol::InventoryLocation,
4417        dest_parent: Option<uuid::Uuid>,
4418        from: &flatland_protocol::InventoryLocation,
4419        from_parent: Option<uuid::Uuid>,
4420    ) -> bool {
4421        dest_location == from && dest_parent == from_parent
4422    }
4423
4424    fn push_move_destination(
4425        opts: &mut Vec<MoveOption>,
4426        label: String,
4427        location: flatland_protocol::InventoryLocation,
4428        parent_instance_id: Option<uuid::Uuid>,
4429        from: &flatland_protocol::InventoryLocation,
4430        from_parent_instance_id: Option<uuid::Uuid>,
4431    ) {
4432        if Self::is_same_container_dest(
4433            &location,
4434            parent_instance_id,
4435            from,
4436            from_parent_instance_id,
4437        ) {
4438            return;
4439        }
4440        opts.push(MoveOption {
4441            label,
4442            kind: MoveOptionKind::Move {
4443                location,
4444                parent_instance_id,
4445            },
4446        });
4447    }
4448
4449    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4450        stack.capacity_volume.is_some_and(|c| c > 0.0)
4451    }
4452
4453    fn attaches_to_belt_loop(template_id: &str) -> bool {
4454        matches!(template_id, "leather_pouch" | "dimensional_pouch")
4455    }
4456
4457    fn append_nested_container_destinations(
4458        opts: &mut Vec<MoveOption>,
4459        location: flatland_protocol::InventoryLocation,
4460        container: &flatland_protocol::ItemStack,
4461        context: &str,
4462        from: &flatland_protocol::InventoryLocation,
4463        from_parent_instance_id: Option<uuid::Uuid>,
4464        moving_instance_id: Option<uuid::Uuid>,
4465    ) {
4466        for child in &container.contents {
4467            if Self::is_volume_container_stack(child)
4468                && child.item_instance_id != moving_instance_id
4469            {
4470                let name = child
4471                    .display_name
4472                    .clone()
4473                    .unwrap_or_else(|| child.template_id.clone());
4474                Self::push_move_destination(
4475                    opts,
4476                    format!("{name} ({context})"),
4477                    location.clone(),
4478                    child.item_instance_id,
4479                    from,
4480                    from_parent_instance_id,
4481                );
4482            }
4483            let nested_context = format!(
4484                "in {}",
4485                child.display_name.as_deref().unwrap_or(&child.template_id)
4486            );
4487            Self::append_nested_container_destinations(
4488                opts,
4489                location.clone(),
4490                child,
4491                &nested_context,
4492                from,
4493                from_parent_instance_id,
4494                moving_instance_id,
4495            );
4496        }
4497    }
4498
4499    fn clamp_inventory_indices(&mut self) {
4500        let n = self.inventory_selectable_rows().len();
4501        self.inventory_menu_index = if n == 0 {
4502            0
4503        } else {
4504            self.inventory_menu_index.min(n - 1)
4505        };
4506        if let Some(picker) = &self.move_picker {
4507            let pn = picker.options.len();
4508            self.move_picker_index = if pn == 0 {
4509                0
4510            } else {
4511                self.move_picker_index.min(pn - 1)
4512            };
4513        }
4514    }
4515
4516    /// Drop interior map layers whenever the player is outdoors.
4517    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
4518    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
4519    /// force `build_grid` into a full-map z pass on every click until client restart.
4520    fn sync_interior_map_context(&mut self) {
4521        if self.effective_inside_building().is_none() {
4522            self.interior_map = None;
4523            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4524                self.z_platforms = platforms;
4525                self.z_transitions = transitions;
4526            }
4527            return;
4528        }
4529        self.sync_interior_z_bands();
4530    }
4531
4532    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
4533    fn sync_interior_z_bands(&mut self) {
4534        if self.effective_inside_building().is_some() {
4535            if let Some(map) = &self.interior_map {
4536                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4537                    if self.z_bands_outdoor_backup.is_none() {
4538                        self.z_bands_outdoor_backup = Some((
4539                            std::mem::take(&mut self.z_platforms),
4540                            std::mem::take(&mut self.z_transitions),
4541                        ));
4542                    }
4543                    self.z_platforms = map.z_platforms.clone();
4544                    self.z_transitions = map.z_transitions.clone();
4545                }
4546            }
4547        }
4548    }
4549
4550    fn apply_snapshot_fields(
4551        &mut self,
4552        snapshot: &flatland_protocol::Snapshot,
4553        entity_id: EntityId,
4554    ) {
4555        self.tick = snapshot.tick;
4556        self.chunk_rev = snapshot.chunk_rev;
4557        self.content_rev = snapshot.content_rev;
4558        self.publish_rev = snapshot.publish_rev;
4559        self.resource_nodes = snapshot.resource_nodes.clone();
4560        self.ground_drops = snapshot.ground_drops.clone();
4561        self.placed_containers = snapshot.placed_containers.clone();
4562        self.world_x0 = snapshot.world_x0;
4563        self.world_y0 = snapshot.world_y0;
4564        self.world_width_m = snapshot.world_width_m;
4565        self.world_height_m = snapshot.world_height_m;
4566        self.world_clock = snapshot.world_clock;
4567        self.terrain_zones = snapshot.terrain_zones.clone();
4568        self.z_platforms = snapshot.z_platforms.clone();
4569        self.z_transitions = snapshot.z_transitions.clone();
4570        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
4571        self.z_bands_outdoor_backup = None;
4572        self.buildings = snapshot.buildings.clone();
4573        self.doors = snapshot.doors.clone();
4574        self.interior_map = snapshot.interior_map.clone();
4575        self.npcs = snapshot.npcs.clone();
4576        self.blueprints = snapshot.blueprints.clone();
4577        self.building_materials = snapshot.building_materials.clone();
4578        self.sync_inventory_from_stacks(&snapshot.inventory);
4579        self.player = snapshot
4580            .entities
4581            .iter()
4582            .find(|e| e.id == entity_id)
4583            .cloned();
4584        self.entities = snapshot.entities.clone();
4585        self.quest_log = snapshot.quest_log.clone();
4586        self.apply_hired_workers(snapshot.hired_workers.clone());
4587        self.interactables = snapshot.interactables.clone();
4588        self.ledger = snapshot.ledger.clone();
4589        self.career = snapshot.career.clone();
4590        self.combat_fx = snapshot.combat_fx.clone();
4591        self.ground_hazards = snapshot.ground_hazards.clone();
4592        self.property_zones = snapshot.property_zones.clone();
4593        self.tax_zones = snapshot.tax_zones.clone();
4594        self.growth_zones = snapshot.growth_zones.clone();
4595        self.biome_zones = snapshot.biome_zones.clone();
4596        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4597        self.property_plots = snapshot.property_plots.clone();
4598        self.property_plot_settings = snapshot.property_plot_settings.clone();
4599        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
4600        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
4601        if self.effective_inside_building().is_some() {
4602            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4603        }
4604        self.sync_interior_map_context();
4605        self.refresh_whisper_range();
4606        self.sync_gameplay_audio();
4607    }
4608
4609    /// Re-derive inventory selection state after a snapshot/tick — chests that
4610    /// went out of range or got locked simply vanish from the row list, and the
4611    /// move picker (if any) closes once its item is no longer reachable.
4612    fn refresh_inventory_ui(&mut self) {
4613        if let Some(picker) = &self.move_picker {
4614            let instance_id = picker.item_instance_id;
4615            let still_exists = self
4616                .inventory_selectable_rows()
4617                .iter()
4618                .any(|r| r.stack.item_instance_id == Some(instance_id));
4619            if !still_exists {
4620                self.move_picker = None;
4621                self.show_move_picker = false;
4622            }
4623        }
4624        if let Some(picker) = &self.destroy_picker {
4625            let instance_id = picker.item_instance_id;
4626            let still_exists = self
4627                .inventory_selectable_rows()
4628                .iter()
4629                .any(|r| r.stack.item_instance_id == Some(instance_id));
4630            if !still_exists {
4631                self.destroy_picker = None;
4632                self.show_destroy_picker = false;
4633                self.destroy_confirm_pending = false;
4634            }
4635        }
4636        self.clamp_inventory_indices();
4637    }
4638
4639    /// Replace the hired-worker list from a snapshot/delta.
4640    ///
4641    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
4642    /// selected worker (and its step line) does not jump when the server rebuilds
4643    /// the list.
4644    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4645        let selected_id = self
4646            .hired_workers
4647            .get(self.workers_menu_index)
4648            .map(|w| w.instance_id.clone());
4649        let previous_worker_ids: HashSet<String> = self
4650            .hired_workers
4651            .iter()
4652            .map(|worker| worker.instance_id.clone())
4653            .collect();
4654        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4655        let now = Instant::now();
4656        let saw_new_worker = workers
4657            .iter()
4658            .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
4659        for worker in &workers {
4660            let was_hit = self
4661                .hired_workers
4662                .iter()
4663                .find(|previous| previous.instance_id == worker.instance_id)
4664                .is_some_and(|previous| {
4665                    matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
4666                        && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
4667                });
4668            if was_hit {
4669                self.worker_health_ring_until
4670                    .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
4671            }
4672        }
4673        let worker_entity_ids: HashSet<EntityId> =
4674            workers.iter().map(|worker| worker.entity_id).collect();
4675        self.worker_health_ring_until
4676            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
4677        for w in &workers {
4678            let prev_err = self
4679                .hired_workers
4680                .iter()
4681                .find(|p| p.instance_id == w.instance_id)
4682                .and_then(|p| p.last_error.as_deref());
4683            let new_err = w.last_error.as_deref();
4684            if new_err != prev_err {
4685                if let Some(err) = new_err {
4686                    if !worker_error_is_transient(err) {
4687                        self.push_log(format!("Worker {}: {err}", w.label));
4688                    }
4689                }
4690            }
4691        }
4692        let mut next_display = BTreeMap::new();
4693        let mut next_errors = BTreeMap::new();
4694        for w in &workers {
4695            let mut sticky = self
4696                .worker_step_display
4697                .remove(&w.instance_id)
4698                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4699            sticky.observe(&w.step_label, now);
4700            next_display.insert(w.instance_id.clone(), sticky);
4701
4702            let mut err_sticky = self
4703                .worker_error_display
4704                .remove(&w.instance_id)
4705                .unwrap_or_default();
4706            err_sticky.observe(w.last_error.as_deref(), now);
4707            if err_sticky.shown(now).is_some() {
4708                next_errors.insert(w.instance_id.clone(), err_sticky);
4709            }
4710        }
4711        self.worker_step_display = next_display;
4712        self.worker_error_display = next_errors;
4713        self.hired_workers = workers;
4714        if saw_new_worker {
4715            self.pending_worker_hire_since = None;
4716        }
4717        self.sync_worker_take_picker_from_hired();
4718        if let Some(id) = selected_id {
4719            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4720                self.workers_menu_index = idx;
4721                return;
4722            }
4723        }
4724        if self.workers_menu_index >= self.hired_workers.len() {
4725            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4726        }
4727    }
4728
4729    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
4730    fn sync_worker_take_picker_from_hired(&mut self) {
4731        if !self.show_worker_take_picker {
4732            return;
4733        }
4734        let Some(picker) = self.worker_take_picker.clone() else {
4735            return;
4736        };
4737        let Some(worker) = self
4738            .hired_workers
4739            .iter()
4740            .find(|w| w.instance_id == picker.worker_instance_id)
4741            .cloned()
4742        else {
4743            self.show_worker_take_picker = false;
4744            self.worker_take_picker = None;
4745            self.worker_take_picker_index = 0;
4746            return;
4747        };
4748        let options: Vec<WorkerGiveOption> = worker
4749            .inventory
4750            .iter()
4751            .filter_map(|stack| {
4752                let item_instance_id = stack.item_instance_id?;
4753                let label = stack
4754                    .display_name
4755                    .clone()
4756                    .unwrap_or_else(|| stack.template_id.clone());
4757                let label = if stack.quantity > 1 {
4758                    format!("{label} ×{}", stack.quantity)
4759                } else {
4760                    label
4761                };
4762                Some(WorkerGiveOption {
4763                    item_instance_id,
4764                    label,
4765                    quantity: stack.quantity,
4766                    template_id: stack.template_id.clone(),
4767                })
4768            })
4769            .collect();
4770        if options.is_empty() {
4771            self.show_worker_take_picker = false;
4772            self.worker_take_picker = None;
4773            self.worker_take_picker_index = 0;
4774            return;
4775        }
4776        let prev_id = picker
4777            .options
4778            .get(self.worker_take_picker_index)
4779            .map(|o| o.item_instance_id);
4780        let idx = prev_id
4781            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4782            .unwrap_or(0)
4783            .min(options.len().saturating_sub(1));
4784        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4785        let quantity = picker.quantity.clamp(1, max_qty);
4786        self.worker_take_picker_index = idx;
4787        self.worker_take_picker = Some(WorkerTakePicker {
4788            worker_instance_id: picker.worker_instance_id,
4789            worker_label: picker.worker_label,
4790            options,
4791            quantity,
4792        });
4793    }
4794
4795    /// Held coarse step label for the workers menu (`step:` line).
4796    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4797        self.worker_step_display
4798            .get(worker_instance_id)
4799            .map(|s| s.shown.as_str())
4800            .or_else(|| {
4801                self.hired_workers
4802                    .iter()
4803                    .find(|w| w.instance_id == worker_instance_id)
4804                    .map(|w| w.step_label.as_str())
4805            })
4806            .unwrap_or("")
4807    }
4808
4809    /// Held error line for workers UI (detail + compact).
4810    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4811        let now = Instant::now();
4812        self.worker_error_display
4813            .get(worker_instance_id)
4814            .and_then(|s| s.shown(now))
4815            .or_else(|| {
4816                self.hired_workers
4817                    .iter()
4818                    .find(|w| w.instance_id == worker_instance_id)
4819                    .and_then(|w| w.last_error.as_deref())
4820                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4821            })
4822            .filter(|e| !worker_error_is_hud_noise(e))
4823    }
4824
4825    fn apply_combat_hud(&mut self, combat: &CombatHud) {
4826        self.in_combat = combat.in_combat;
4827        self.auto_attack = combat.auto_attack;
4828        self.combat_has_los = combat.has_los;
4829        self.attack_cd_ticks = combat.attack_cd_ticks;
4830        self.gcd_ticks = combat.gcd_ticks;
4831        self.weapon_ability_id = combat.ability_id.clone();
4832        self.mainhand_template_id = combat.mainhand_template_id.clone();
4833        self.mainhand_label = combat.mainhand_label.clone();
4834        self.mainhand_instance_id = combat.mainhand_instance_id;
4835        self.offhand_template_id = combat.offhand_template_id.clone();
4836        self.offhand_label = combat.offhand_label.clone();
4837        self.offhand_instance_id = combat.offhand_instance_id;
4838        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4839            1
4840        } else {
4841            combat.mainhand_hand_slots
4842        };
4843        self.defense = combat.defense.clone();
4844        self.worn = combat.worn.iter().cloned().collect();
4845        self.carry_mass = combat.carry_mass;
4846        self.carry_mass_max = combat.carry_mass_max;
4847        self.encumbrance = combat.encumbrance;
4848        self.cast_progress = combat.cast.clone();
4849        self.timed_channel = combat.timed_channel.clone();
4850        self.plot_build_offer = combat.plot_build.clone();
4851        self.ability_cooldowns = combat.ability_cooldowns.clone();
4852        self.blocking_active = combat.blocking_active;
4853        self.max_target_slots = combat.max_target_slots.max(1);
4854        self.combat_slots = combat.slots.clone();
4855        self.rotation_presets = combat.rotation_presets.clone();
4856        self.known_abilities = combat.known_abilities.clone();
4857        self.ability_meta = combat
4858            .ability_meta
4859            .iter()
4860            .cloned()
4861            .map(|meta| (meta.id.clone(), meta))
4862            .collect();
4863        self.ability_mastery = combat
4864            .ability_mastery
4865            .iter()
4866            .cloned()
4867            .map(|row| (row.ability_id.clone(), row))
4868            .collect();
4869        self.hotbar = combat.hotbar.clone();
4870        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4871        self.keychain_stacks = combat.keychain.clone();
4872        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4873        self.combat_target_detail = combat.target.clone();
4874        self.statuses = combat.statuses.clone();
4875        self.combat_target = combat.target_entity_id;
4876        if combat.progression_xp_base > 0.0 {
4877            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4878                baseline_display: combat.progression_baseline,
4879                xp_base: combat.progression_xp_base,
4880                xp_growth: combat.progression_xp_growth,
4881            });
4882        }
4883        if let Some(xp) = &combat.progression_xp {
4884            if let Some(player) = &mut self.player {
4885                player.progression_xp = Some(xp.clone());
4886                if let Some(attrs) = combat.attributes {
4887                    player.attributes = Some(attrs);
4888                }
4889                if let Some(skills) = &combat.skills {
4890                    player.skills = Some(skills.clone());
4891                }
4892            }
4893        }
4894        if let Some(label) = &combat.target_label {
4895            self.combat_target_label = Some(label.clone());
4896        } else if let Some(id) = combat.target_entity_id {
4897            self.combat_target_label = self
4898                .entities
4899                .iter()
4900                .find(|e| e.id == id)
4901                .map(|e| e.label.clone())
4902                .or_else(|| self.combat_target_label.clone());
4903        }
4904        self.refresh_inventory_ui();
4905    }
4906
4907    /// Entity id assigned to a combat slot (from server HUD).
4908    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4909        self.combat_slots
4910            .iter()
4911            .find(|s| s.slot_index == slot)
4912            .and_then(|s| s.target_entity_id)
4913            .or_else(|| if slot == 1 { self.combat_target } else { None })
4914    }
4915
4916    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
4917    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4918        self.ability_meta
4919            .get(ability_id)
4920            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4921            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
4922            // the server validates aim_mode.
4923            .unwrap_or(self.ground_target.is_some())
4924    }
4925
4926    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
4927    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4928        self.ability_meta
4929            .get(ability_id)
4930            .map(|meta| meta.aim_mode == "ground")
4931            .unwrap_or(false)
4932    }
4933
4934    /// True when the ability may be placed in an auto-rotation preset.
4935    /// Missing meta (pre-sync) defaults to eligible; server still validates.
4936    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4937        self.ability_meta
4938            .get(ability_id)
4939            .map(|meta| meta.auto_rotation_eligible)
4940            .unwrap_or(true)
4941    }
4942
4943    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
4944    pub fn set_ground_target(&mut self, x: f32, y: f32) {
4945        self.ground_target = Some((x, y, 0.0));
4946    }
4947
4948    /// Clear the local free-aim ground target.
4949    pub fn clear_ground_target(&mut self) {
4950        self.ground_target = None;
4951    }
4952
4953    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
4954    /// May be an ability id or `item:<template_id>` consumable binding.
4955    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4956        if !(1..=9).contains(&slot_1_to_9) {
4957            return None;
4958        }
4959        self.hotbar
4960            .get((slot_1_to_9 - 1) as usize)
4961            .and_then(|a| a.as_deref())
4962            .filter(|id| !id.is_empty())
4963    }
4964
4965    /// Short label for a hotbar slot (ability id, or consumable name × qty).
4966    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4967        let binding = self.hotbar_ability(slot_1_to_9)?;
4968        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4969            let name = self
4970                .inventory_hints
4971                .get(template_id)
4972                .map(|h| h.display_name.as_str())
4973                .unwrap_or(template_id);
4974            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4975            Some(format!("{name}×{qty}"))
4976        } else {
4977            Some(binding.to_string())
4978        }
4979    }
4980
4981    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
4982    pub fn loadout_ability_choices(&self) -> Vec<String> {
4983        let mut out = self.known_abilities.clone();
4984        let weapon = self.weapon_ability_id.trim();
4985        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4986            out.push(weapon.to_string());
4987        }
4988        out
4989    }
4990
4991    /// Hotbar bind candidates: learned abilities, then on-person consumables.
4992    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4993        let mut out = Vec::new();
4994        for ability in self.loadout_ability_choices() {
4995            let meta = if ability == self.weapon_ability_id {
4996                Some("weapon".into())
4997            } else {
4998                None
4999            };
5000            out.push(LoadoutHotbarChoice {
5001                binding: ability.clone(),
5002                label: ability,
5003                meta,
5004            });
5005        }
5006        let mut consumables: Vec<(String, String, u32)> = Vec::new();
5007        for stack in &self.inventory_stacks {
5008            if Self::stack_is_item_grant(stack) {
5009                continue;
5010            }
5011            if self.inventory_item_category(&stack.template_id) != Some("consumable") {
5012                continue;
5013            }
5014            let qty = stack.quantity.max(1);
5015            if let Some((_, _, existing)) = consumables
5016                .iter_mut()
5017                .find(|(id, _, _)| id == &stack.template_id)
5018            {
5019                *existing = existing.saturating_add(qty);
5020            } else {
5021                let label = stack
5022                    .display_name
5023                    .clone()
5024                    .or_else(|| {
5025                        self.inventory_hints
5026                            .get(&stack.template_id)
5027                            .map(|h| h.display_name.clone())
5028                    })
5029                    .unwrap_or_else(|| stack.template_id.clone());
5030                consumables.push((stack.template_id.clone(), label, qty));
5031            }
5032        }
5033        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5034        for (template_id, label, qty) in consumables {
5035            out.push(LoadoutHotbarChoice {
5036                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5037                label: format!("{label} ×{qty}"),
5038                meta: Some("use".into()),
5039            });
5040        }
5041        out
5042    }
5043
5044    /// Hostile wildlife / monsters for T1 (`Tab`).
5045    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5046        self.combat_candidates()
5047    }
5048
5049    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
5050    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5051        let (px, py) = self.player_position();
5052        let dist = |id: EntityId| {
5053            self.entities
5054                .iter()
5055                .find(|e| e.id == id)
5056                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5057                .unwrap_or(f32::MAX)
5058        };
5059
5060        let mut allies = Vec::new();
5061        // Self first — heal_touch on T2.
5062        if let Some(me) = self.player.as_ref() {
5063            let alive = me
5064                .vitals
5065                .as_ref()
5066                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5067                .unwrap_or(true);
5068            if alive {
5069                allies.push((self.entity_id, "Yourself".into()));
5070            }
5071        }
5072        for entity in &self.entities {
5073            if entity.id == self.entity_id {
5074                continue;
5075            }
5076            if entity.vitals.is_some() {
5077                let alive = entity
5078                    .vitals
5079                    .as_ref()
5080                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5081                    .unwrap_or(true);
5082                if alive {
5083                    allies.push((entity.id, entity.label.clone()));
5084                }
5085            }
5086        }
5087        allies.sort_by(|(a, _), (b, _)| {
5088            if *a == self.entity_id {
5089                return std::cmp::Ordering::Less;
5090            }
5091            if *b == self.entity_id {
5092                return std::cmp::Ordering::Greater;
5093            }
5094            dist(*a)
5095                .partial_cmp(&dist(*b))
5096                .unwrap_or(std::cmp::Ordering::Equal)
5097        });
5098
5099        let mut monsters = self.combat_candidates();
5100        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
5101        allies.into_iter().chain(monsters).collect()
5102    }
5103
5104    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
5105        match slot_index {
5106            2 => self.t2_candidates(),
5107            _ => self.t1_candidates(),
5108        }
5109    }
5110
5111    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
5112    pub fn pick_combat_target_at(
5113        &self,
5114        wx: f32,
5115        wy: f32,
5116        slot_index: u8,
5117        radius_m: f32,
5118    ) -> Option<(EntityId, String)> {
5119        let mut best: Option<(f32, EntityId, String)> = None;
5120        for (id, label) in self.candidates_for_slot(slot_index) {
5121            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5122                // NPCs may only appear in NpcView — fall back to npc list coords.
5123                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5124                    let d = distance(wx, wy, npc.x, npc.y);
5125                    if d <= radius_m {
5126                        best = match best {
5127                            Some((bd, _, _)) if bd <= d => best,
5128                            _ => Some((d, id, label)),
5129                        };
5130                    }
5131                }
5132                continue;
5133            };
5134            let d = distance(
5135                wx,
5136                wy,
5137                entity.transform.position.x,
5138                entity.transform.position.y,
5139            );
5140            if d <= radius_m {
5141                best = match best {
5142                    Some((bd, _, _)) if bd <= d => best,
5143                    _ => Some((d, id, label)),
5144                };
5145            }
5146        }
5147        best.map(|(_, id, label)| (id, label))
5148    }
5149
5150    /// Full client reset after Welcome (initial connect or reconnect).
5151    pub(crate) fn restore_from_welcome(
5152        &mut self,
5153        session_id: SessionId,
5154        entity_id: EntityId,
5155        snapshot: &flatland_protocol::Snapshot,
5156    ) {
5157        self.clear_harvest_state();
5158        self.disconnect_reason = None;
5159        self.show_stats = false;
5160        self.show_craft_menu = false;
5161        self.show_shop_menu = false;
5162        self.shop_catalog = None;
5163        self.show_inventory_menu = false;
5164        self.session_id = session_id;
5165        self.entity_id = entity_id;
5166        self.connected = true;
5167        self.apply_snapshot_fields(snapshot, entity_id);
5168        if let Some(combat) = &snapshot.combat {
5169            self.apply_combat_hud(combat);
5170            let stacks = self.inventory_stacks.clone();
5171            self.sync_inventory_from_stacks(&stacks);
5172        }
5173    }
5174
5175    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
5176        self.tick = delta.tick;
5177        self.world_clock = delta.world_clock;
5178
5179        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
5180        if delta.entities.is_empty() {
5181            self.ground_drops = delta.ground_drops.clone();
5182            self.combat_fx = delta.combat_fx.clone();
5183            self.ground_hazards = delta.ground_hazards.clone();
5184            self.property_plots = delta.property_plots.clone();
5185            self.apply_terrain_overlays(&delta.terrain_overlays);
5186            if let Some(combat) = &delta.combat {
5187                self.apply_combat_hud(combat);
5188                let stacks = self.inventory_stacks.clone();
5189                self.sync_inventory_from_stacks(&stacks);
5190            }
5191            // Peer vanished from AOI — drop directed whisper.
5192            self.refresh_whisper_range();
5193            self.sync_gameplay_audio();
5194            return;
5195        }
5196        if !delta.buildings.is_empty() {
5197            self.buildings = delta.buildings.clone();
5198        }
5199        if !delta.blueprints.is_empty() {
5200            self.blueprints = delta.blueprints.clone();
5201        }
5202        if !delta.building_materials.is_empty() {
5203            self.building_materials = delta.building_materials.clone();
5204        }
5205        self.sync_inventory_from_stacks(&delta.inventory);
5206
5207        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
5208            self.player = Some(updated.clone());
5209        }
5210        self.entities = delta.entities.clone();
5211        if self.player.is_none() {
5212            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
5213        }
5214
5215        self.sync_interior_map_context();
5216
5217        // Static world layers: ticks often omit these (indoors, or unchanged).
5218        // Never wipe the welcome snapshot with an empty vec — except when indoors,
5219        // where empty means "no nodes in this building" (clear outdoor leftovers).
5220        if !delta.resource_nodes.is_empty() {
5221            self.resource_nodes = delta.resource_nodes.clone();
5222        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
5223            self.resource_nodes = delta.resource_nodes.clone();
5224        }
5225        self.ground_drops = delta.ground_drops.clone();
5226        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
5227        self.placed_containers = delta.placed_containers.clone();
5228        if !delta.doors.is_empty() {
5229            self.doors = delta.doors.clone();
5230        }
5231        if self.effective_inside_building().is_some() {
5232            if let Some(map) = &delta.interior_map {
5233                self.interior_map = Some(map.clone());
5234            }
5235        } else {
5236            self.interior_map = None;
5237        }
5238        self.sync_interior_z_bands();
5239        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
5240        self.npcs = delta.npcs.clone();
5241        if !delta.quest_log.is_empty() {
5242            self.quest_log = delta.quest_log.clone();
5243        }
5244        self.apply_hired_workers(delta.hired_workers.clone());
5245        if !delta.interactables.is_empty() {
5246            self.interactables = delta.interactables.clone();
5247        }
5248        if delta.ledger.is_some() {
5249            self.ledger = delta.ledger.clone();
5250        }
5251        if delta.career.is_some() {
5252            self.career = delta.career.clone();
5253        }
5254        self.combat_fx = delta.combat_fx.clone();
5255        self.ground_hazards = delta.ground_hazards.clone();
5256        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
5257        if !delta.property_plots.is_empty() {
5258            self.property_plots = delta.property_plots.clone();
5259        }
5260        self.apply_terrain_overlays(&delta.terrain_overlays);
5261        if let Some(combat) = &delta.combat {
5262            self.apply_combat_hud(combat);
5263            let stacks = self.inventory_stacks.clone();
5264            self.sync_inventory_from_stacks(&stacks);
5265        } else {
5266            self.refresh_inventory_ui();
5267        }
5268        self.refresh_whisper_range();
5269        self.sync_gameplay_audio();
5270    }
5271
5272    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
5273    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
5274    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
5275        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
5276        self.terrain_zones.extend(overlays.iter().cloned());
5277    }
5278
5279    /// End directed whisper when the peer is farther than interact range (or missing).
5280    /// Each client runs this locally so both players drop whisper mode when either walks away.
5281    fn refresh_whisper_range(&mut self) {
5282        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
5283            return;
5284        };
5285        let (px, py) = self.player_position();
5286        let in_range = self.entities.iter().any(|e| {
5287            e.id == peer
5288                && distance(px, py, e.transform.position.x, e.transform.position.y)
5289                    <= INTERACTION_RADIUS_M
5290        });
5291        if !in_range {
5292            self.social_chat.cancel_whisper_out_of_range();
5293        }
5294    }
5295
5296    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
5297    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
5298        let (px, py) = self.player_position();
5299        let mut out = Vec::new();
5300        for npc in &self.npcs {
5301            let Some(eid) = npc.entity_id else {
5302                continue;
5303            };
5304            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
5305            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
5306            if alive && has_hp {
5307                out.push((eid, npc.label.clone()));
5308            }
5309        }
5310        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
5311            let dist = |id: EntityId| {
5312                self.entities
5313                    .iter()
5314                    .find(|e| e.id == id)
5315                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5316                    .unwrap_or(f32::MAX)
5317            };
5318            dist(*a_id)
5319                .partial_cmp(&dist(*b_id))
5320                .unwrap_or(std::cmp::Ordering::Equal)
5321                .then_with(|| a_label.cmp(b_label))
5322                .then_with(|| a_id.cmp(b_id))
5323        });
5324        out
5325    }
5326
5327    pub fn refresh_combat_target_label(&mut self) {
5328        let Some(id) = self.combat_target else {
5329            return;
5330        };
5331        if let Some((_, label)) = self
5332            .combat_candidates()
5333            .into_iter()
5334            .find(|(eid, _)| *eid == id)
5335        {
5336            self.combat_target_label = Some(label);
5337        } else if let Some(label) = self
5338            .entities
5339            .iter()
5340            .find(|e| e.id == id)
5341            .map(|e| e.label.clone())
5342        {
5343            self.combat_target_label = Some(label);
5344        }
5345    }
5346
5347    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
5348        self.quest_log
5349            .iter()
5350            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
5351            .collect()
5352    }
5353
5354    /// True when the observer has at least one free lodging slot (max workers = beds).
5355    pub fn has_worker_lodging(&self) -> bool {
5356        self.free_worker_lodging_slots() > 0
5357    }
5358
5359    /// Owned lodging capacity minus currently hired workers.
5360    pub fn free_worker_lodging_slots(&self) -> i64 {
5361        let slots: u32 = self
5362            .placed_containers
5363            .iter()
5364            .filter(|c| match (self.character_id, c.owner_character_id) {
5365                (Some(me), Some(owner)) => me == owner,
5366                (Some(_), None) => false,
5367                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
5368            })
5369            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
5370            .sum();
5371        let used = self.hired_workers.len() as u32;
5372        slots as i64 - used as i64
5373    }
5374
5375    /// Display names of hired workers assigned to this lodging container.
5376    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
5377        let mut names: Vec<String> = self
5378            .hired_workers
5379            .iter()
5380            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
5381            .map(|w| w.label.clone())
5382            .collect();
5383        names.sort();
5384        names
5385    }
5386
5387    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
5388    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
5389        let is_lodging = self
5390            .placed_containers
5391            .iter()
5392            .find(|c| c.id == container_id)
5393            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
5394        if !is_lodging {
5395            return None;
5396        }
5397        let names = self.lodging_occupant_labels(container_id);
5398        Some(if names.is_empty() {
5399            "vacant".into()
5400        } else {
5401            names.join(", ")
5402        })
5403    }
5404
5405    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
5406        self.quest_log
5407            .iter()
5408            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
5409            .or_else(|| {
5410                self.quest_log
5411                    .iter()
5412                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
5413            })
5414    }
5415
5416    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
5417    pub fn nearby_lockable_door(&self) -> bool {
5418        let (px, py) = self.player_position();
5419        self.doors
5420            .iter()
5421            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
5422    }
5423
5424    /// True when an open player-house door is in range for Enter-to-go-inside.
5425    pub fn nearby_open_player_door(&self) -> bool {
5426        if self.effective_inside_building().is_some() {
5427            return false;
5428        }
5429        let (px, py) = self.player_position();
5430        self.doors.iter().any(|d| {
5431            if !d.open || d.locked {
5432                return false;
5433            }
5434            let player_house = self
5435                .buildings
5436                .iter()
5437                .find(|b| b.id == d.building_id)
5438                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5439            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
5440        })
5441    }
5442
5443    /// True when inside a player house near the exterior portal (Enter exits freely).
5444    pub fn nearby_player_exit_door(&self) -> bool {
5445        let Some(bid) = self.effective_inside_building() else {
5446            return false;
5447        };
5448        let (px, py) = self.player_position();
5449        self.doors.iter().any(|d| {
5450            if d.building_id != bid || d.portal.is_none() {
5451                return false;
5452            }
5453            let player_house = self
5454                .buildings
5455                .iter()
5456                .find(|b| b.id == d.building_id)
5457                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5458            player_house && (d.x - px).hypot(d.y - py) <= 1.5
5459        })
5460    }
5461
5462    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
5463    pub fn nearest_interact_target(&self) -> Option<String> {
5464        let (px, py) = self.player_position();
5465        let inside = self.effective_inside_building();
5466
5467        #[derive(Clone, Copy, PartialEq, Eq)]
5468        enum Kind {
5469            Player,
5470            Npc,
5471            HiredWorker,
5472            QuestBoard,
5473            ExitDoor,
5474            EnterDoor,
5475            Well,
5476            Water,
5477        }
5478
5479        fn kind_priority(kind: Kind) -> u8 {
5480            match kind {
5481                Kind::Player => 0,
5482                Kind::Npc => 0,
5483                Kind::HiredWorker => 0,
5484                Kind::QuestBoard => 1,
5485                Kind::ExitDoor => 2,
5486                Kind::EnterDoor => 3,
5487                Kind::Well => 4,
5488                Kind::Water => 5,
5489            }
5490        }
5491
5492        let mut best: Option<(f32, Kind, String)> = None;
5493
5494        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5495            if dist > max {
5496                return;
5497            }
5498            let replace = match best {
5499                None => true,
5500                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5501                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5502                    kind_priority(kind) < kind_priority(bk)
5503                }
5504                _ => false,
5505            };
5506            if replace {
5507                best = Some((dist, kind, id));
5508            }
5509        };
5510
5511        for npc in &self.npcs {
5512            consider(
5513                distance(px, py, npc.x, npc.y),
5514                INTERACTION_RADIUS_M,
5515                Kind::Npc,
5516                npc.id.clone(),
5517            );
5518        }
5519
5520        for worker in &self.hired_workers {
5521            consider(
5522                distance(px, py, worker.x, worker.y),
5523                INTERACTION_RADIUS_M,
5524                Kind::HiredWorker,
5525                worker.instance_id.clone(),
5526            );
5527        }
5528
5529        for entity in &self.entities {
5530            if entity.id == self.entity_id
5531                || entity.vitals.is_none()
5532                || entity.label.trim().is_empty()
5533            {
5534                continue;
5535            }
5536            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
5537            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
5538                continue;
5539            }
5540            consider(
5541                distance(
5542                    px,
5543                    py,
5544                    entity.transform.position.x,
5545                    entity.transform.position.y,
5546                ),
5547                INTERACTION_RADIUS_M,
5548                Kind::Player,
5549                entity.id.to_string(),
5550            );
5551        }
5552
5553        for door in &self.doors {
5554            if let Some(ref bid) = inside {
5555                if door.building_id != *bid {
5556                    continue;
5557                }
5558                let is_exit = door.portal.is_some();
5559                let max = if is_exit {
5560                    INTERACTION_RADIUS_M
5561                } else {
5562                    DOOR_INTERACTION_RADIUS_M
5563                };
5564                let kind = if is_exit {
5565                    Kind::ExitDoor
5566                } else {
5567                    Kind::EnterDoor
5568                };
5569                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5570                continue;
5571            }
5572            consider(
5573                distance(px, py, door.x, door.y),
5574                DOOR_INTERACTION_RADIUS_M,
5575                Kind::EnterDoor,
5576                door.id.clone(),
5577            );
5578        }
5579
5580        if inside.is_none() {
5581            for inter in &self.interactables {
5582                if inter.kind == "quest_board" {
5583                    consider(
5584                        distance(px, py, inter.x, inter.y),
5585                        QUEST_BOARD_INTERACTION_RADIUS_M,
5586                        Kind::QuestBoard,
5587                        inter.id.clone(),
5588                    );
5589                }
5590            }
5591            for building in &self.buildings {
5592                if !building.tags.iter().any(|t| t == "well") {
5593                    continue;
5594                }
5595                consider(
5596                    distance(px, py, building.x, building.y),
5597                    INTERACTION_RADIUS_M,
5598                    Kind::Well,
5599                    building.id.clone(),
5600                );
5601            }
5602            if self.in_shallow_water() {
5603                consider(
5604                    0.0,
5605                    INTERACTION_RADIUS_M,
5606                    Kind::Water,
5607                    "water_source".into(),
5608                );
5609            }
5610        }
5611
5612        best.map(|(_, _, id)| id)
5613    }
5614
5615    /// Nearest quest board and distance (any distance), for out-of-range feedback.
5616    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5617        if self.effective_inside_building().is_some() {
5618            return None;
5619        }
5620        let (px, py) = self.player_position();
5621        self.interactables
5622            .iter()
5623            .filter(|i| i.kind == "quest_board")
5624            .map(|i| {
5625                let label = if i.label.is_empty() {
5626                    "Quest board".to_string()
5627                } else {
5628                    i.label.clone()
5629                };
5630                (label, distance(px, py, i.x, i.y))
5631            })
5632            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5633    }
5634
5635    /// Human-readable template name from synced catalog hints.
5636    pub fn template_display_name(&self, template_id: &str) -> String {
5637        self.inventory_hints
5638            .get(template_id)
5639            .map(|h| h.display_name.clone())
5640            .filter(|n| !n.is_empty())
5641            .unwrap_or_else(|| humanize_template_id(template_id))
5642    }
5643
5644    /// Prefer blueprint-view display name, then inventory hints / humanized id.
5645    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5646        if !display_name.is_empty() {
5647            display_name.to_string()
5648        } else {
5649            self.template_display_name(template_id)
5650        }
5651    }
5652
5653    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5654        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5655    }
5656
5657    pub fn blueprint_ingredient_label(
5658        &self,
5659        input: &flatland_protocol::BlueprintIngredientView,
5660    ) -> String {
5661        self.blueprint_item_label(&input.template_id, &input.display_name)
5662    }
5663
5664    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5665        self.blueprint_item_label(&tool.item, &tool.display_name)
5666    }
5667
5668    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
5669    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5670        use crate::worker_route_editor::{
5671            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5672        };
5673        let lodging = self
5674            .worker_route_editor
5675            .as_ref()
5676            .and_then(|ed| ed.lodging_container_id.as_deref());
5677        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5678            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5679            None => node_candidates_stable(&self.resource_nodes),
5680        }
5681    }
5682
5683    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5684        if dist_m.is_nan() {
5685            return "—".into();
5686        }
5687        let from_bed = self
5688            .worker_route_editor
5689            .as_ref()
5690            .and_then(|ed| ed.lodging_container_id.as_deref())
5691            .and_then(|id| {
5692                self.placed_containers
5693                    .iter()
5694                    .find(|c| c.id == id)
5695                    .map(|c| c.display_name.clone())
5696            });
5697        match from_bed {
5698            Some(bed) => format!("{dist_m:.0}m from {bed}"),
5699            None => format!("{dist_m:.0}m"),
5700        }
5701    }
5702
5703    /// Chest label for the location panel — generic type unless this player owns it.
5704    pub fn placed_container_public_label(
5705        &self,
5706        c: &flatland_protocol::PlacedContainerView,
5707    ) -> String {
5708        let is_owner = match (self.character_id, c.owner_character_id) {
5709            (Some(me), Some(owner)) => me == owner,
5710            _ => false,
5711        };
5712        if is_owner {
5713            c.display_name.clone()
5714        } else {
5715            self.template_display_name(&c.template_id)
5716        }
5717    }
5718
5719    /// Keys on person and stowed on the keychain for the keychain overlay.
5720    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5721        let mut out = Vec::new();
5722        for stack in &self.inventory_stacks {
5723            if stack.template_id == KEY_TEMPLATE {
5724                out.push(KeychainEntry {
5725                    stack: stack.clone(),
5726                    stowed: false,
5727                });
5728            }
5729        }
5730        for stack in &self.keychain_stacks {
5731            if stack.template_id == KEY_TEMPLATE {
5732                out.push(KeychainEntry {
5733                    stack: stack.clone(),
5734                    stowed: true,
5735                });
5736            }
5737        }
5738        out
5739    }
5740
5741    /// Display name of the chest a `container_key` opens, when known on the client.
5742    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5743        if stack.template_id != KEY_TEMPLATE {
5744            return None;
5745        }
5746        if let Some(name) = stack
5747            .props
5748            .get(PROP_OPENS_CONTAINER_NAME)
5749            .filter(|n| !n.is_empty())
5750        {
5751            return Some(name.clone());
5752        }
5753        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5754        self.container_name_for_lock_id(opens)
5755    }
5756
5757    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
5758    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5759        if stack.template_id == KEY_TEMPLATE {
5760            self.template_display_name(KEY_TEMPLATE)
5761        } else {
5762            stack
5763                .display_name
5764                .clone()
5765                .unwrap_or_else(|| stack.template_id.clone())
5766        }
5767    }
5768
5769    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
5770    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5771        if stack.template_id != KEY_TEMPLATE {
5772            return String::new();
5773        }
5774        match self.key_pair_chest_label(stack) {
5775            Some(chest) if self.key_drop_blocked(stack) => {
5776                format!(" [key for {chest} — can't drop while locked]")
5777            }
5778            Some(chest) => format!(" [key for {chest}]"),
5779            None => " [key — unpaired]".into(),
5780        }
5781    }
5782
5783    /// Resolve a lock id to a container label (placed chest or one still on your person).
5784    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5785        for c in &self.placed_containers {
5786            if c.lock_id.as_deref() == Some(lock) {
5787                return Some(c.display_name.clone());
5788            }
5789        }
5790        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5791            self.worn
5792                .values()
5793                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5794        })
5795    }
5796
5797    /// Keys cannot be dropped while their paired chest is locked.
5798    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5799        if stack.template_id != KEY_TEMPLATE {
5800            return false;
5801        }
5802        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5803            return false;
5804        };
5805        for c in &self.placed_containers {
5806            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5807                return true;
5808            }
5809        }
5810        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5811            return true;
5812        }
5813        self.worn
5814            .values()
5815            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5816    }
5817
5818    /// Property deeds cannot be dropped or destroyed (store or trade only).
5819    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5820        stack.template_id == PROPERTY_DEED_TEMPLATE
5821    }
5822
5823    pub fn is_property_deed_template(template_id: &str) -> bool {
5824        template_id == PROPERTY_DEED_TEMPLATE
5825    }
5826
5827    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5828        stack
5829            .props
5830            .get("plot_id")
5831            .and_then(|s| uuid::Uuid::parse_str(s).ok())
5832    }
5833
5834    /// World position (cell center) of the plot cell the player is standing on, if tillable.
5835    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5836        let (px, py) = self.player_position();
5837        let (cx, cy) = self.farm_plot_cell_under_player()?;
5838        let tx = cx as f32 + 0.5;
5839        let ty = cy as f32 + 0.5;
5840        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5841            return None;
5842        }
5843        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
5844        if kind == Some(TerrainKindView::Tilled) {
5845            return None;
5846        }
5847        if matches!(
5848            kind,
5849            Some(TerrainKindView::ShallowWater)
5850                | Some(TerrainKindView::DeepWater)
5851                | Some(TerrainKindView::Rock)
5852        ) {
5853            return None;
5854        }
5855        Some((tx, ty))
5856    }
5857
5858    fn container_name_in_stacks(
5859        stacks: &[flatland_protocol::ItemStack],
5860        lock: &str,
5861    ) -> Option<String> {
5862        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5863            for s in stacks {
5864                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5865                    return Some(GameState::stack_container_label(s));
5866                }
5867                if let Some(name) = walk(&s.contents, lock) {
5868                    return Some(name);
5869                }
5870            }
5871            None
5872        }
5873        walk(stacks, lock)
5874    }
5875
5876    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5877        stack
5878            .props
5879            .get(PROP_CUSTOM_NAME)
5880            .cloned()
5881            .or_else(|| stack.display_name.clone())
5882            .unwrap_or_else(|| stack.template_id.clone())
5883    }
5884
5885    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5886        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5887            for s in stacks {
5888                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5889                    return true;
5890                }
5891                if walk(&s.contents, lock) {
5892                    return true;
5893                }
5894            }
5895            false
5896        }
5897        walk(stacks, lock)
5898    }
5899
5900    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5901        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5902            return Some(stack.clone());
5903        }
5904        for worn in self.worn.values() {
5905            if worn.item_instance_id == Some(instance_id) {
5906                return Some(worn.clone());
5907            }
5908            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5909                return Some(stack.clone());
5910            }
5911        }
5912        None
5913    }
5914
5915    /// Highest-`z_order` property zone covering `(x, y)`.
5916    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5917        self.property_zones
5918            .iter()
5919            .enumerate()
5920            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5921            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5922            .map(|(_, z)| z)
5923    }
5924
5925    /// Highest-`z_order` tax zone covering `(x, y)`.
5926    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5927        self.tax_zones
5928            .iter()
5929            .enumerate()
5930            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5931            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5932            .map(|(_, z)| z)
5933    }
5934
5935    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
5936    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5937        let mut max_bps = 0u32;
5938        let mut y = y0 + 0.5;
5939        while y < y1 {
5940            let mut x = x0 + 0.5;
5941            while x < x1 {
5942                if let Some(tz) = self.tax_zone_at(x, y) {
5943                    max_bps = max_bps.max(tz.rate_bps);
5944                }
5945                x += 1.0;
5946            }
5947            y += 1.0;
5948        }
5949        max_bps
5950    }
5951
5952    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
5953    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5954        let mode = self.claim_mode.as_ref()?;
5955        let w = mode.width_m.max(1) as f32;
5956        let h = mode.height_m.max(1) as f32;
5957        Some((
5958            mode.anchor_x,
5959            mode.anchor_y,
5960            mode.anchor_x + w,
5961            mode.anchor_y + h,
5962        ))
5963    }
5964
5965    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
5966    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5967        let mode = self.relocate_mode.as_ref()?;
5968        let x0 = mode.cursor_x.floor();
5969        let y0 = mode.cursor_y.floor();
5970        Some((x0, y0, x0 + 1.0, y0 + 1.0))
5971    }
5972
5973    /// Client quote matching server `quote_plot`:
5974    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
5975    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5976        let mode = self.claim_mode.as_ref()?;
5977        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
5978        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5979        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5980        let zone_area = zone_view_area_m2(zone).max(1.0);
5981        let area_frac = (area / zone_area).clamp(0.0, 1.0);
5982        let weight = self
5983            .property_plot_settings
5984            .as_ref()
5985            .map(|s| s.tax_premium_weight)
5986            .unwrap_or(0.5)
5987            .max(0.0);
5988        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5989        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5990        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
5991            .ceil()
5992            .max(0.0) as u64;
5993        let upkeep = if zone.upkeep_copper_per_day == 0 {
5994            0
5995        } else {
5996            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5997                .ceil()
5998                .max(1.0) as u64
5999        };
6000        let copper = crate::currency::copper_from_counts(&self.inventory);
6001        let can_afford = copper >= purchase;
6002        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6003        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6004    }
6005
6006    fn validate_claim_footprint(
6007        &self,
6008        zone: &flatland_protocol::PropertyZoneView,
6009        x0: f32,
6010        y0: f32,
6011        x1: f32,
6012        y1: f32,
6013        area: f32,
6014    ) -> (bool, String) {
6015        let min_area = self
6016            .property_plot_settings
6017            .as_ref()
6018            .map(|s| s.min_plot_area_m2)
6019            .unwrap_or(4.0);
6020        if area + f32::EPSILON < min_area {
6021            return (false, "plot too small".into());
6022        }
6023        if zone.max_area_m2.is_some_and(|m| area > m) {
6024            return (false, "plot exceeds max area".into());
6025        }
6026        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6027            return (false, "plot must lie inside the property zone".into());
6028        }
6029        if self
6030            .property_plots
6031            .iter()
6032            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6033        {
6034            return (false, "plot overlaps an existing claim".into());
6035        }
6036        (true, String::new())
6037    }
6038
6039    /// Standing in a property zone on a free cell (not already claimed).
6040    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6041        let (px, py) = self.player_position();
6042        let zone = self.property_zone_at(px, py)?;
6043        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6044            return None;
6045        }
6046        Some(zone)
6047    }
6048
6049    /// Own plot covering the player (`is_mine`).
6050    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6051        let (px, py) = self.player_position();
6052        self.property_plots
6053            .iter()
6054            .find(|p| p.is_mine && point_in_plot(px, py, p))
6055    }
6056
6057    /// Plot under the player that they may farm (deed, grant, or public).
6058    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6059        let (px, py) = self.player_position();
6060        self.property_plots
6061            .iter()
6062            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6063    }
6064
6065    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
6066    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6067        if self.farmable_plot_under_player().is_none() {
6068            return None;
6069        }
6070        let (px, py) = self.player_position();
6071        Some((px.floor() as i32, py.floor() as i32))
6072    }
6073
6074    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6075        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6076        self.resource_nodes.iter().any(|n| {
6077            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6078            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6079        })
6080    }
6081
6082    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6083        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6084        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6085            || self
6086                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6087                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6088        if !tilled {
6089            return false;
6090        }
6091        !self.resource_node_occupies_farm_cell(cx, cy)
6092    }
6093
6094    /// Empty tilled soil on the cell the player is standing on (no crop node).
6095    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6096        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6097            return false;
6098        };
6099        self.free_tilled_plant_slot_at(cx, cy)
6100    }
6101
6102    /// True when a free tilled cell (no resource node) is within interact range.
6103    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6104        let (px, py) = self.player_position();
6105        for dy in -2..=2 {
6106            for dx in -2..=2 {
6107                let cx = px.floor() as i32 + dx;
6108                let cy = py.floor() as i32 + dy;
6109                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6110                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6111                    continue;
6112                }
6113                if self.free_tilled_plant_slot_at(cx, cy) {
6114                    return true;
6115                }
6116            }
6117        }
6118        false
6119    }
6120
6121    fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
6122        stack.quantity > 0
6123            && (stack.props.contains_key("seed_for")
6124                || stack.template_id.ends_with("_seed")
6125                || stack.template_id == "potato_seed"
6126                || stack.template_id == "carrot_seed")
6127    }
6128
6129    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
6130    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
6131        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
6132        fn walk(
6133            stacks: &[flatland_protocol::ItemStack],
6134            counts: &mut std::collections::HashMap<String, u32>,
6135        ) {
6136            for s in stacks {
6137                if GameState::stack_is_farm_seed(s) {
6138                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
6139                }
6140                walk(&s.contents, counts);
6141            }
6142        }
6143        walk(&self.inventory_stacks, &mut counts);
6144        for worn in self.worn.values() {
6145            walk(std::slice::from_ref(worn), &mut counts);
6146        }
6147        let mut out: Vec<_> = counts
6148            .into_iter()
6149            .map(|(template_id, quantity)| {
6150                let label = self
6151                    .inventory_hints
6152                    .get(&template_id)
6153                    .map(|h| h.display_name.clone())
6154                    .filter(|n| !n.trim().is_empty())
6155                    .unwrap_or_else(|| humanize_template_id(&template_id));
6156                (template_id, quantity, label)
6157            })
6158            .collect();
6159        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
6160        out
6161    }
6162
6163    /// First farm seed template in inventory (root + nested bags).
6164    pub fn first_farm_seed_template(&self) -> Option<String> {
6165        self.farm_seed_entries()
6166            .into_iter()
6167            .next()
6168            .map(|(id, _, _)| id)
6169    }
6170
6171    pub fn clamp_plant_menu(&mut self) {
6172        let n = self.farm_seed_entries().len();
6173        if n == 0 {
6174            self.plant_menu_index = 0;
6175            self.plant_quantity = 1;
6176            return;
6177        }
6178        self.plant_menu_index = self.plant_menu_index.min(n - 1);
6179        let max_qty = self
6180            .farm_seed_entries()
6181            .get(self.plant_menu_index)
6182            .map(|(_, q, _)| *q)
6183            .unwrap_or(1)
6184            .max(1);
6185        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
6186    }
6187
6188    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
6189        let entries = self.farm_seed_entries();
6190        let (id, max, label) = entries.get(self.plant_menu_index)?;
6191        let qty = self.plant_quantity.min(*max).max(1);
6192        Some((id.clone(), qty, label.clone()))
6193    }
6194
6195    /// Terrain, interactables, and map objects near the player for the HUD location panel.
6196    pub fn location_context_lines(&self) -> Vec<ContextLine> {
6197        let (px, py) = self.player_position();
6198        let inside = self.effective_inside_building();
6199        let mut lines = Vec::new();
6200
6201        if let Some(kind) = self.terrain_at(px, py) {
6202            lines.push(ContextLine {
6203                on_top: true,
6204                text: format!("Terrain: {}", terrain_kind_label(kind)),
6205            });
6206        }
6207
6208        if let Some(id) = inside.as_ref() {
6209            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
6210                lines.push(ContextLine {
6211                    on_top: true,
6212                    text: format!("Inside: {}", b.label),
6213                });
6214            }
6215        }
6216
6217        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
6218
6219        for node in &self.resource_nodes {
6220            if node.id.starts_with("preview:") {
6221                continue;
6222            }
6223            let dist = distance(px, py, node.x, node.y);
6224            if dist > NEARBY_SCAN_M {
6225                continue;
6226            }
6227            let on_top = dist <= ON_TOP_RADIUS_M;
6228            let prefix = if on_top { "On" } else { "Near" };
6229            let name = resource_node_near_display_label(&node.label);
6230            let action = resource_node_near_action_suffix(node);
6231            nearby.push((
6232                dist,
6233                ContextLine {
6234                    on_top,
6235                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
6236                },
6237            ));
6238        }
6239
6240        for drop in &self.ground_drops {
6241            let dist = distance(px, py, drop.x, drop.y);
6242            if dist > INTERACTION_RADIUS_M {
6243                continue;
6244            }
6245            let on_top = dist <= ON_TOP_RADIUS_M;
6246            let name = self.template_display_name(&drop.template_id);
6247            let prefix = if on_top { "On" } else { "Near" };
6248            let qty = if drop.quantity > 1 {
6249                format!(" ×{}", drop.quantity)
6250            } else {
6251                String::new()
6252            };
6253            nearby.push((
6254                dist,
6255                ContextLine {
6256                    on_top,
6257                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
6258                },
6259            ));
6260        }
6261
6262        for c in &self.placed_containers {
6263            if !self.placed_container_in_current_space(c) {
6264                continue;
6265            }
6266            let dist = distance(px, py, c.x, c.y);
6267            if dist > CONTAINER_RANGE_M {
6268                continue;
6269            }
6270            let on_top = dist <= ON_TOP_RADIUS_M;
6271            let name = self.placed_container_public_label(c);
6272            let lock = if c.locked { " [locked]" } else { "" };
6273            let prefix = if on_top { "On" } else { "Near" };
6274            nearby.push((
6275                dist,
6276                ContextLine {
6277                    on_top,
6278                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
6279                },
6280            ));
6281        }
6282
6283        for npc in &self.npcs {
6284            let dist = distance(px, py, npc.x, npc.y);
6285            if dist > NEARBY_SCAN_M {
6286                continue;
6287            }
6288            let on_top = dist <= ON_TOP_RADIUS_M;
6289            let prefix = if on_top { "On" } else { "Near" };
6290            nearby.push((
6291                dist,
6292                ContextLine {
6293                    on_top,
6294                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
6295                },
6296            ));
6297        }
6298
6299        for door in &self.doors {
6300            let dist = distance(px, py, door.x, door.y);
6301            if dist > DOOR_INTERACTION_RADIUS_M {
6302                continue;
6303            }
6304            let building = self
6305                .buildings
6306                .iter()
6307                .find(|b| b.id == door.building_id)
6308                .map(|b| b.label.as_str())
6309                .unwrap_or(door.building_id.as_str());
6310            let player_house = self
6311                .buildings
6312                .iter()
6313                .find(|b| b.id == door.building_id)
6314                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6315            let action = if inside.is_some() && door.portal.is_some() {
6316                if player_house {
6317                    if door.locked {
6318                        "locked — l unlock · Enter exit".to_string()
6319                    } else if door.open {
6320                        "close · Enter exit · l lock".to_string()
6321                    } else {
6322                        "open · Enter exit · l lock".to_string()
6323                    }
6324                } else {
6325                    "exit".to_string()
6326                }
6327            } else if player_house {
6328                if door.locked {
6329                    "locked — l unlock".to_string()
6330                } else if door.open {
6331                    "close · Enter go inside · l lock".to_string()
6332                } else {
6333                    "open · l lock".to_string()
6334                }
6335            } else {
6336                "enter".to_string()
6337            };
6338            nearby.push((
6339                dist,
6340                ContextLine {
6341                    on_top: dist <= ON_TOP_RADIUS_M,
6342                    text: format!("{building} door ({dist:.1}m) — f {action}"),
6343                },
6344            ));
6345        }
6346
6347        if inside.is_none() {
6348            for inter in &self.interactables {
6349                if inter.kind != "quest_board" {
6350                    continue;
6351                }
6352                let dist = distance(px, py, inter.x, inter.y);
6353                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
6354                    continue;
6355                }
6356                let on_top = dist <= ON_TOP_RADIUS_M;
6357                let prefix = if on_top { "On" } else { "Near" };
6358                let label = if inter.label.is_empty() {
6359                    "Quest board".to_string()
6360                } else {
6361                    inter.label.clone()
6362                };
6363                nearby.push((
6364                    dist,
6365                    ContextLine {
6366                        on_top,
6367                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
6368                    },
6369                ));
6370            }
6371        }
6372
6373        if self.in_shallow_water() {
6374            let already = self
6375                .terrain_at(px, py)
6376                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
6377            if !already {
6378                nearby.push((
6379                    0.0,
6380                    ContextLine {
6381                        on_top: true,
6382                        text: "Shallow water — f fill bottle".into(),
6383                    },
6384                ));
6385            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
6386                line.text.push_str(" — f fill bottle");
6387            }
6388        }
6389
6390        if self.claim_mode.is_some() {
6391            nearby.push((
6392                0.0,
6393                ContextLine {
6394                    on_top: true,
6395                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
6396                        .into(),
6397                },
6398            ));
6399        } else if let Some(plot) = self.my_plot_under_player() {
6400            let name = plot_public_label(plot);
6401            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
6402                format!("{name} — f again to sell to crown")
6403            } else {
6404                format!(
6405                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
6406                )
6407            };
6408            nearby.push((
6409                0.0,
6410                ContextLine {
6411                    on_top: true,
6412                    text: prompt,
6413                },
6414            ));
6415        } else if let Some(plot) = self.farmable_plot_under_player() {
6416            let name = plot_public_label(plot);
6417            let disc = if plot.farm_public {
6418                plot.public_tax_discount_bps / 100
6419            } else {
6420                plot.farm_allow
6421                    .iter()
6422                    .find(|g| Some(g.character_id) == self.character_id)
6423                    .map(|g| g.tax_discount_bps / 100)
6424                    .unwrap_or(0)
6425            };
6426            nearby.push((
6427                0.0,
6428                ContextLine {
6429                    on_top: true,
6430                    text: format!(
6431                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
6432                    ),
6433                },
6434            ));
6435        } else if let Some(zone) = self.free_property_zone_under_player() {
6436            let label = zone
6437                .label
6438                .as_deref()
6439                .filter(|s| !s.trim().is_empty())
6440                .unwrap_or(zone.id.as_str());
6441            nearby.push((
6442                0.0,
6443                ContextLine {
6444                    on_top: true,
6445                    text: format!("Claimable land: {label} — k buy plot"),
6446                },
6447            ));
6448        }
6449
6450        for entity in &self.entities {
6451            if entity.id == self.entity_id {
6452                continue;
6453            }
6454            let dist = distance(
6455                px,
6456                py,
6457                entity.transform.position.x,
6458                entity.transform.position.y,
6459            );
6460            if dist > NEARBY_SCAN_M {
6461                continue;
6462            }
6463            let label = if entity.label.is_empty() {
6464                format!("entity {}", entity.id)
6465            } else {
6466                entity.label.clone()
6467            };
6468            nearby.push((
6469                dist,
6470                ContextLine {
6471                    on_top: dist <= ON_TOP_RADIUS_M,
6472                    text: format!("Near: {label} ({dist:.1}m)"),
6473                },
6474            ));
6475        }
6476
6477        nearby.sort_by(|a, b| {
6478            a.0.partial_cmp(&b.0)
6479                .unwrap_or(std::cmp::Ordering::Equal)
6480                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6481        });
6482        lines.extend(nearby.into_iter().map(|(_, l)| l));
6483
6484        if lines.is_empty() {
6485            lines.push(ContextLine {
6486                on_top: false,
6487                text: "(nothing notable nearby)".into(),
6488            });
6489        }
6490
6491        lines
6492    }
6493}
6494
6495/// HUD line for the location / nearby panel.
6496#[derive(Debug, Clone)]
6497pub struct ContextLine {
6498    pub on_top: bool,
6499    pub text: String,
6500}
6501
6502const ON_TOP_RADIUS_M: f32 = 0.65;
6503const NEARBY_SCAN_M: f32 = 5.0;
6504
6505/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
6506pub fn resource_node_near_display_label(label: &str) -> String {
6507    label
6508        .strip_suffix(" (growing)")
6509        .unwrap_or(label)
6510        .to_string()
6511}
6512
6513fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6514    let t = label.trim();
6515    if t.is_empty() || t == id {
6516        return true;
6517    }
6518    let lower = t.to_ascii_lowercase();
6519    if lower.contains("_copy") {
6520        return true;
6521    }
6522    false
6523}
6524
6525fn humanize_item_template_label(template: &str) -> String {
6526    let base = template.rsplit('/').next().unwrap_or(template).trim();
6527    if base.is_empty() {
6528        return "Resource".into();
6529    }
6530    let stripped = base
6531        .strip_prefix("crop-")
6532        .or_else(|| base.strip_prefix("crop_"))
6533        .unwrap_or(base);
6534    stripped
6535        .split(|c: char| c == '-' || c == '_')
6536        .filter(|p| !p.is_empty())
6537        .map(|p| {
6538            let mut chars = p.chars();
6539            match chars.next() {
6540                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6541                None => String::new(),
6542            }
6543        })
6544        .collect::<Vec<_>>()
6545        .join(" ")
6546}
6547
6548/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
6549pub fn resource_node_id_suffix(id: &str) -> String {
6550    let chars: Vec<char> = id
6551        .chars()
6552        .rev()
6553        .filter(|c| c.is_ascii_alphanumeric())
6554        .take(4)
6555        .collect();
6556    chars.into_iter().rev().collect()
6557}
6558
6559/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
6560pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6561    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6562}
6563
6564pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6565    let cleaned = resource_node_near_display_label(label);
6566    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6567        cleaned
6568    } else if !item_template.trim().is_empty() {
6569        humanize_item_template_label(item_template)
6570    } else {
6571        id.to_string()
6572    };
6573    let suffix = resource_node_id_suffix(id);
6574    if suffix.is_empty() {
6575        friendly
6576    } else {
6577        format!("{friendly} ({suffix})")
6578    }
6579}
6580
6581/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
6582pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6583    use flatland_protocol::ResourceNodeState;
6584    if node.harvest_off {
6585        return " (decorative)".to_string();
6586    }
6587    if let Some(p) = node.growth_progress {
6588        if p < 1.0 - f32::EPSILON {
6589            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6590            return format!(" (growing, {pct}%)");
6591        }
6592        return " — f harvest".to_string();
6593    }
6594    match node.state {
6595        ResourceNodeState::Available => " — f harvest".to_string(),
6596        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6597        ResourceNodeState::Cooldown => " (depleted)".to_string(),
6598    }
6599}
6600
6601fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6602    use flatland_protocol::TerrainKindView;
6603    match kind {
6604        TerrainKindView::Grass => "Grass",
6605        TerrainKindView::Dirt => "Dirt",
6606        TerrainKindView::Tilled => "Tilled",
6607        TerrainKindView::Desert => "Desert",
6608        TerrainKindView::Hill => "Hills",
6609        TerrainKindView::Bog => "Bog",
6610        TerrainKindView::Beach => "Beach",
6611        TerrainKindView::ShallowWater => "Shallow water",
6612        TerrainKindView::DeepWater => "Deep water",
6613        TerrainKindView::Trail => "Trail",
6614        TerrainKindView::Road => "Road",
6615        TerrainKindView::Rock => "Rock",
6616    }
6617}
6618
6619fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6620    crate::world_zones::zone_rects_contain(rects, x, y)
6621}
6622
6623fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6624    zone.rects
6625        .iter()
6626        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6627        .sum()
6628}
6629
6630fn claim_rect_fully_inside_zone(
6631    zone: &flatland_protocol::PropertyZoneView,
6632    x0: f32,
6633    y0: f32,
6634    x1: f32,
6635    y1: f32,
6636) -> bool {
6637    let mut y = y0 + 0.5;
6638    while y < y1 {
6639        let mut x = x0 + 0.5;
6640        while x < x1 {
6641            if !zone_rects_contain(&zone.rects, x, y) {
6642                return false;
6643            }
6644            x += 1.0;
6645        }
6646        y += 1.0;
6647    }
6648    true
6649}
6650
6651fn rects_overlap_half_open(
6652    ax0: f32,
6653    ay0: f32,
6654    ax1: f32,
6655    ay1: f32,
6656    bx0: f32,
6657    by0: f32,
6658    bx1: f32,
6659    by1: f32,
6660) -> bool {
6661    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6662}
6663
6664fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6665    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6666}
6667
6668fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6669    plot_public_label(p)
6670}
6671
6672/// Canonical plot name: `{owner} — {zone} — {label}`.
6673pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6674    let zone = p
6675        .zone_label
6676        .as_deref()
6677        .filter(|s| !s.trim().is_empty())
6678        .unwrap_or_else(|| {
6679            if p.property_zone_id.is_empty() {
6680                "Homestead"
6681            } else {
6682                p.property_zone_id.as_str()
6683            }
6684        });
6685    let label = if p.label.trim().is_empty() {
6686        if p.plot_code.trim().is_empty() {
6687            p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6688        } else {
6689            p.plot_code.clone()
6690        }
6691    } else {
6692        p.label.clone()
6693    };
6694    match p
6695        .owner_label
6696        .as_deref()
6697        .map(str::trim)
6698        .filter(|s| !s.is_empty())
6699    {
6700        Some(owner) => format!("{owner} — {zone} — {label}"),
6701        None => format!("{zone} — {label}"),
6702    }
6703}
6704
6705/// Match `flatland_sim::economy_zones::snap_claim_rect`.
6706fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6707    let a = x0.min(x1).floor();
6708    let b = y0.min(y1).floor();
6709    let mut c = x0.max(x1).ceil();
6710    let mut d = y0.max(y1).ceil();
6711    if (c - a) < 1.0 {
6712        c = a + 1.0;
6713    }
6714    if (d - b) < 1.0 {
6715        d = b + 1.0;
6716    }
6717    (a, b, c, d)
6718}
6719
6720fn humanize_template_id(template_id: &str) -> String {
6721    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
6722    if looks_like_template_uuid(template_id) {
6723        return "Unknown item".into();
6724    }
6725    template_id
6726        .split('_')
6727        .map(|word| {
6728            let mut chars = word.chars();
6729            match chars.next() {
6730                None => String::new(),
6731                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6732            }
6733        })
6734        .collect::<Vec<_>>()
6735        .join(" ")
6736}
6737
6738fn looks_like_template_uuid(template_id: &str) -> bool {
6739    let bytes = template_id.as_bytes();
6740    if bytes.len() != 36 {
6741        return false;
6742    }
6743    let is_hex = |b: u8| b.is_ascii_hexdigit();
6744    let groups = [8usize, 4, 4, 4, 12];
6745    let mut i = 0;
6746    for (gi, &len) in groups.iter().enumerate() {
6747        if gi > 0 {
6748            if bytes.get(i) != Some(&b'-') {
6749                return false;
6750            }
6751            i += 1;
6752        }
6753        for _ in 0..len {
6754            if !bytes.get(i).copied().is_some_and(is_hex) {
6755                return false;
6756            }
6757            i += 1;
6758        }
6759    }
6760    true
6761}
6762
6763/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
6764const HARVEST_RANGE_M: f32 = 1.5;
6765
6766pub struct GameClient<S: PlayConnection> {
6767    session: S,
6768    seq: Seq,
6769    pub state: GameState,
6770    last_move_forward: f32,
6771    last_move_strafe: f32,
6772}
6773
6774impl<S: PlayConnection> GameClient<S> {
6775    pub fn new(session: S) -> Self {
6776        let session_id = session.session_id();
6777        let entity_id = session.entity_id();
6778        let mut client = Self {
6779            session,
6780            seq: 0,
6781            last_move_forward: 0.0,
6782            last_move_strafe: 0.0,
6783            state: GameState {
6784                session_id,
6785                entity_id,
6786                character_id: None,
6787                tick: 0,
6788                chunk_rev: 0,
6789                content_rev: 0,
6790                publish_rev: 0,
6791                entities: Vec::new(),
6792                player: None,
6793                resource_nodes: Vec::new(),
6794                ground_drops: Vec::new(),
6795                placed_containers: Vec::new(),
6796                buildings: Vec::new(),
6797                doors: Vec::new(),
6798                interior_map: None,
6799                npcs: Vec::new(),
6800                blueprints: Vec::new(),
6801                building_materials: Vec::new(),
6802                world_x0: 0.0,
6803                world_y0: 0.0,
6804                world_width_m: 0.0,
6805                world_height_m: 0.0,
6806                terrain_zones: Vec::new(),
6807                z_platforms: Vec::new(),
6808                z_transitions: Vec::new(),
6809                z_bands_outdoor_backup: None,
6810                world_clock: flatland_protocol::WorldClock::default(),
6811                inventory: std::collections::HashMap::new(),
6812                inventory_hints: std::collections::HashMap::new(),
6813                logs: VecDeque::new(),
6814                intents_sent: 0,
6815                ticks_received: 0,
6816                connected: false,
6817                disconnect_reason: None,
6818                show_stats: false,
6819                hud_log_hidden: false,
6820                show_equip_menu: false,
6821                equip_menu_index: 0,
6822                show_craft_menu: false,
6823                show_plot_build_menu: false,
6824                plot_build_focus_wall: true,
6825                plot_build_wall_index: 0,
6826                plot_build_roof_index: 0,
6827                craft_menu_index: 0,
6828                craft_batch_quantity: 1,
6829                show_shop_menu: false,
6830                shop_catalog: None,
6831                bank_panel: None,
6832                bank_menu_index: 0,
6833                bank_ui_mode: BankUiMode::Menu,
6834                storage_panel: None,
6835                market_panel: None,
6836                market_menu_index: 0,
6837                market_filter: String::new(),
6838                market_filter_focused: false,
6839                market_category_filter: None,
6840                market_buy_confirm: None,
6841                market_ui_mode: MarketUiMode::Browse,
6842                storage_menu_index: 0,
6843                storage_ui_mode: StorageUiMode::Menu,
6844                shop_tab: ShopTab::default(),
6845                shop_menu_index: 0,
6846                shop_quantity: 1,
6847                shop_trade_log: VecDeque::new(),
6848                show_npc_verb_menu: false,
6849                npc_verb_target: None,
6850                npc_verb_index: 0,
6851                player_verbs: crate::social::PlayerVerbState::default(),
6852                social_chat: crate::social::SocialChatState::default(),
6853                trade_ui: crate::social::TradeUiState::default(),
6854                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6855                show_npc_chat: false,
6856                npc_chat: None,
6857                show_inventory_menu: false,
6858                inventory_menu_index: 0,
6859                inventory_tab: InventoryTab::OnPerson,
6860                inventory_filter: String::new(),
6861                inventory_filter_focused: false,
6862                show_move_picker: false,
6863                show_rename_prompt: false,
6864                rename_plot_id: None,
6865                highlighted_plot_id: None,
6866                show_worker_rename: false,
6867                rename_buffer: String::new(),
6868                move_picker_index: 0,
6869                move_picker: None,
6870                show_grant_picker: false,
6871                grant_picker_index: 0,
6872                grant_picker: None,
6873                show_destroy_picker: false,
6874                destroy_confirm_pending: false,
6875                destroy_picker: None,
6876                combat_target: None,
6877                combat_target_label: None,
6878                ground_target: None,
6879                combat_fx: Vec::new(),
6880                ground_hazards: Vec::new(),
6881                property_zones: Vec::new(),
6882                tax_zones: Vec::new(),
6883                growth_zones: Vec::new(),
6884                biome_zones: Vec::new(),
6885                terrain_kind_nav: Vec::new(),
6886                property_plots: Vec::new(),
6887                property_plot_settings: None,
6888                claim_mode: None,
6889                relocate_mode: None,
6890                sell_plot_confirm: None,
6891                sell_plot_armed_at: None,
6892                show_plant_menu: false,
6893                plant_menu_index: 0,
6894                show_farm_access: false,
6895                farm_access_name_draft: String::new(),
6896                farm_access_discount_bps: 0,
6897                farm_access_index: 0,
6898                plant_quantity: 1,
6899                in_combat: false,
6900                auto_attack: true,
6901                combat_has_los: false,
6902                attack_cd_ticks: 0,
6903                gcd_ticks: 0,
6904                weapon_ability_id: "unarmed".into(),
6905                mainhand_template_id: None,
6906                mainhand_label: None,
6907                mainhand_instance_id: None,
6908                offhand_template_id: None,
6909                offhand_label: None,
6910                offhand_instance_id: None,
6911                mainhand_hand_slots: 1,
6912                defense: None,
6913                worn: BTreeMap::new(),
6914                carry_mass: 0.0,
6915                carry_mass_max: 0.0,
6916                encumbrance: flatland_protocol::EncumbranceState::Light,
6917                inventory_stacks: Vec::new(),
6918                keychain_stacks: Vec::new(),
6919                whisper_pouch_stacks: Vec::new(),
6920                combat_target_detail: None,
6921                statuses: Vec::new(),
6922                cast_progress: None,
6923                timed_channel: None,
6924                plot_build_offer: None,
6925                ability_cooldowns: Vec::new(),
6926                blocking_active: false,
6927                max_target_slots: 1,
6928                combat_slots: Vec::new(),
6929                rotation_presets: Vec::new(),
6930                known_abilities: Vec::new(),
6931                ability_meta: std::collections::HashMap::new(),
6932                ability_mastery: std::collections::HashMap::new(),
6933                hotbar: vec![None; 9],
6934                max_abilities_per_rotation: 0,
6935                show_loadout_menu: false,
6936                show_keychain_menu: false,
6937                keychain_menu_index: 0,
6938                show_rotation_editor: false,
6939                loadout_menu_index: 0,
6940                loadout_hotbar_slot: 1,
6941                loadout_ability_index: 0,
6942                loadout_focus_presets: false,
6943                rotation_editor: RotationEditorState::default(),
6944                harvest_in_progress: false,
6945                harvest_started_at: None,
6946                pending_craft_ack: None,
6947                pending_worker_job_ack: None,
6948                attending_worker_instance_id: None,
6949                quest_log: Vec::new(),
6950                interactables: Vec::new(),
6951                ledger: None,
6952                career: None,
6953                character_sheet_tab: CharacterSheetTab::Character,
6954                ledger_period: LedgerPeriod::Day,
6955                show_quest_offer: false,
6956                pending_quest_offer: None,
6957                show_quest_menu: false,
6958                quest_menu_index: 0,
6959                quest_withdraw_confirm: false,
6960                hired_workers: Vec::new(),
6961                show_workers_menu: false,
6962                workers_menu_index: 0,
6963                worker_dismiss_confirmation: None,
6964                workers_menu_compact: false,
6965                worker_step_display: BTreeMap::new(),
6966                worker_error_display: BTreeMap::new(),
6967                worker_health_ring_until: BTreeMap::new(),
6968                pending_worker_hire_since: None,
6969                show_worker_give_picker: false,
6970                worker_give_picker_index: 0,
6971                worker_give_picker: None,
6972                show_worker_give_target_picker: false,
6973                worker_give_target_picker_index: 0,
6974                worker_give_target_picker: None,
6975                show_worker_take_picker: false,
6976                worker_take_picker_index: 0,
6977                worker_take_picker: None,
6978                show_worker_teach_picker: false,
6979                worker_teach_picker_index: 0,
6980                worker_teach_picker: None,
6981                worker_route_editor: None,
6982                progression_curve: None,
6983            },
6984        };
6985        client.state.apply_client_ui_prefs();
6986        client
6987    }
6988
6989    pub fn entity_id(&self) -> EntityId {
6990        self.state.entity_id
6991    }
6992
6993    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6994        if self.state.connected {
6995            return Ok(());
6996        }
6997
6998        loop {
6999            match self.session.next_event().await {
7000                Some(SessionEvent::Welcome {
7001                    session_id,
7002                    entity_id,
7003                    snapshot,
7004                }) => {
7005                    self.state
7006                        .restore_from_welcome(session_id, entity_id, &snapshot);
7007                    self.state.apply_client_ui_prefs();
7008                    self.state.push_log(format!(
7009                        "Connected — session {session_id}, entity {entity_id}"
7010                    ));
7011                    return Ok(());
7012                }
7013                Some(SessionEvent::Disconnected { .. }) => {
7014                    anyhow::bail!("disconnected before welcome");
7015                }
7016                Some(_) => continue,
7017                None => anyhow::bail!("session closed before welcome"),
7018            }
7019        }
7020    }
7021
7022    /// Drain all pending server events (non-blocking).
7023    pub fn drain_events(&mut self) {
7024        while let Some(event) = self.session.try_next_event() {
7025            if self.handle_event_sync(event).is_err() {
7026                break;
7027            }
7028        }
7029    }
7030
7031    /// Wait for the next server event.
7032    pub async fn next_event(&mut self) -> Option<SessionEvent> {
7033        self.session.next_event().await
7034    }
7035
7036    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7037        self.handle_event_sync(event)
7038    }
7039
7040    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7041        match event {
7042            SessionEvent::Welcome {
7043                session_id,
7044                entity_id,
7045                snapshot,
7046            } => {
7047                let resumed = self.state.connected;
7048                self.state
7049                    .restore_from_welcome(session_id, entity_id, &snapshot);
7050                if resumed {
7051                    self.state.push_log(format!(
7052                        "Session restored — session {session_id}, entity {entity_id}"
7053                    ));
7054                }
7055            }
7056            SessionEvent::ContentUpdated { snapshot } => {
7057                self.state
7058                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
7059                self.state.push_log(format!(
7060                    "World updated (content rev {})",
7061                    snapshot.content_rev
7062                ));
7063            }
7064            SessionEvent::QuestCatalogUpdated(update) => {
7065                self.state.push_log(format!(
7066                    "Quest board updated (revision {}, {} new, {} retired)",
7067                    update.revision,
7068                    update.accepted.len(),
7069                    update.retired.len()
7070                ));
7071            }
7072            SessionEvent::Tick(delta) => {
7073                self.state.apply_tick_fields(&delta, self.state.entity_id);
7074                self.state.ticks_received += 1;
7075            }
7076            SessionEvent::IntentAck {
7077                entity_id,
7078                seq,
7079                tick,
7080            } => {
7081                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
7082                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
7083                    if *craft_seq == seq {
7084                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
7085                        if batches > 1 {
7086                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
7087                        } else {
7088                            self.state.push_log(format!("Crafting {label}…"));
7089                        }
7090                    }
7091                }
7092                if self
7093                    .state
7094                    .pending_worker_job_ack
7095                    .as_ref()
7096                    .is_some_and(|p| p.seq == seq)
7097                {
7098                    let pending = self.state.pending_worker_job_ack.take().unwrap();
7099                    if pending.idle {
7100                        self.state.push_log(format!(
7101                            "Route cleared for {} — worker idle",
7102                            pending.worker_label
7103                        ));
7104                    } else {
7105                        self.state.push_log(format!(
7106                            "Route saved for {} — {} stop(s), job loop active",
7107                            pending.worker_label, pending.stop_count
7108                        ));
7109                    }
7110                    if self
7111                        .state
7112                        .worker_route_editor
7113                        .as_ref()
7114                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
7115                    {
7116                        self.close_worker_route_editor();
7117                    }
7118                }
7119            }
7120            SessionEvent::Chat(msg) => {
7121                let label = match msg.channel {
7122                    flatland_protocol::ChatChannel::Nearby => "nearby",
7123                    flatland_protocol::ChatChannel::Direct => "speak",
7124                    flatland_protocol::ChatChannel::Whisper => "whisper",
7125                    flatland_protocol::ChatChannel::WhisperStone => "stone",
7126                };
7127                let clarity = match msg.clarity {
7128                    flatland_protocol::ChatClarity::Clear => "",
7129                    flatland_protocol::ChatClarity::Partial => "~",
7130                    flatland_protocol::ChatClarity::Heavy => "…",
7131                };
7132                self.state.push_log(format!(
7133                    "[{label}{clarity}] {}: {}",
7134                    msg.from_name, msg.text
7135                ));
7136                let now_ms = std::time::SystemTime::now()
7137                    .duration_since(std::time::UNIX_EPOCH)
7138                    .map(|d| d.as_millis() as u64)
7139                    .unwrap_or(0);
7140                self.state
7141                    .social_chat
7142                    .note_speech(&msg, self.state.entity_id, now_ms);
7143                self.state
7144                    .social_chat
7145                    .push(crate::social::ChatLogEntry::from_message(
7146                        msg,
7147                        self.state.entity_id,
7148                    ));
7149            }
7150            SessionEvent::TradeOpened(panel) => {
7151                self.state.social_chat.pending_trade = None;
7152                let peer = panel.peer_name.clone();
7153                self.state.trade_ui.open(panel);
7154                self.state.social_chat.push_system(format!(
7155                    "Trade open with {peer} — p present · r ready · Esc cancel"
7156                ));
7157                self.state
7158                    .social_chat
7159                    .push_cue(crate::social::AudioCue::TradeOpened);
7160            }
7161            SessionEvent::TradeClosed { reason } => {
7162                self.state.push_log(reason.clone());
7163                self.state.social_chat.push_system(reason);
7164                self.state.trade_ui.close();
7165            }
7166            SessionEvent::HarvestResult(result) => {
7167                self.state.clear_harvest_state();
7168                crate::harvest_trace!(
7169                    entity_id = self.state.entity_id,
7170                    node_id = %result.node_id,
7171                    template = %result.item_template,
7172                    quantity = result.quantity,
7173                    client_tick = self.state.tick,
7174                    "client applied harvest result"
7175                );
7176                let msg = if result.quantity == 0 {
7177                    format!(
7178                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
7179                        result.item_template
7180                    )
7181                } else {
7182                    format!(
7183                        "Harvested {} x{} (on the ground — press P to pick up)",
7184                        result.item_template, result.quantity
7185                    )
7186                };
7187                self.state.push_log(msg);
7188            }
7189            SessionEvent::CraftResult(result) => {
7190                for stack in &result.consumed {
7191                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
7192                        *qty = qty.saturating_sub(stack.quantity);
7193                        if *qty == 0 {
7194                            self.state.inventory.remove(&stack.template_id);
7195                        }
7196                    }
7197                }
7198                for stack in &result.outputs {
7199                    *self
7200                        .state
7201                        .inventory
7202                        .entry(stack.template_id.clone())
7203                        .or_insert(0) += stack.quantity;
7204                }
7205                if let Some(output) = result.outputs.first() {
7206                    if result.batch_total > 1 {
7207                        self.state.push_log(format!(
7208                            "Crafted {} x{} ({}/{})",
7209                            output.template_id,
7210                            output.quantity,
7211                            result.batch_index,
7212                            result.batch_total
7213                        ));
7214                    } else {
7215                        self.state.push_log(format!(
7216                            "Crafted {} x{}",
7217                            output.template_id, output.quantity
7218                        ));
7219                    }
7220                } else {
7221                    self.state
7222                        .push_log(format!("Craft finished: {}", result.blueprint_id));
7223                }
7224            }
7225            SessionEvent::Death(notice) => {
7226                self.state.clear_harvest_state();
7227                self.state.push_log(notice.message.clone());
7228                self.state.push_log(format!(
7229                    "Respawned at ({:.1}, {:.1})",
7230                    notice.respawn_x, notice.respawn_y
7231                ));
7232            }
7233            SessionEvent::Interaction(notice) => {
7234                if notice.message.starts_with("Harvest failed:") {
7235                    self.state.clear_harvest_state();
7236                }
7237                if notice.message.starts_with("Can't do that:") {
7238                    self.state.pending_worker_hire_since = None;
7239                    self.state.pending_craft_ack = None;
7240                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
7241                        if let Some(w) = self
7242                            .state
7243                            .hired_workers
7244                            .iter_mut()
7245                            .find(|w| w.instance_id == pending.worker_instance_id)
7246                        {
7247                            w.route = pending.prev_route;
7248                            w.mode = pending.prev_mode;
7249                            w.step_label = pending.prev_step_label;
7250                            w.last_error = pending.prev_last_error;
7251                        }
7252                        let reason = notice
7253                            .message
7254                            .strip_prefix("Can't do that:")
7255                            .unwrap_or(&notice.message)
7256                            .trim();
7257                        self.state.push_log(format!(
7258                            "Route save failed for {}: {reason}",
7259                            pending.worker_label
7260                        ));
7261                    }
7262                    let reason = notice
7263                        .message
7264                        .strip_prefix("Can't do that:")
7265                        .unwrap_or(&notice.message)
7266                        .trim();
7267                    if reason.contains("already tilled") {
7268                        if let Some(plot) = self.state.my_plot_under_player() {
7269                            self.state.sell_plot_confirm = Some(plot.plot_id);
7270                            self.state.sell_plot_armed_at = Some(Instant::now());
7271                        }
7272                    }
7273                }
7274                if notice.message.starts_with("Cast failed:") {
7275                    self.state.cast_progress = None;
7276                }
7277                if notice.message.contains("slain the") {
7278                    self.state.combat_target = None;
7279                    self.state.combat_target_label = None;
7280                }
7281                // Inbound trade request → inline Y/N in the CHAT column (no popup).
7282                if notice.message.contains("wants to trade") {
7283                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
7284                        let from_name = notice
7285                            .message
7286                            .split(" wants to trade")
7287                            .next()
7288                            .unwrap_or("Player")
7289                            .to_string();
7290                        self.state.social_chat.pending_trade =
7291                            Some(crate::social::PendingTradeRequest {
7292                                from_entity,
7293                                from_name: from_name.clone(),
7294                            });
7295                        self.state.social_chat.push_system(format!(
7296                            "{from_name} wants to trade — [Y] accept · [N] decline"
7297                        ));
7298                        self.state
7299                            .social_chat
7300                            .push_cue(crate::social::AudioCue::TradeOffer);
7301                    }
7302                }
7303                if notice.message.starts_with("trade request declined") {
7304                    self.state.social_chat.push_system(notice.message.clone());
7305                    self.state
7306                        .social_chat
7307                        .push_cue(crate::social::AudioCue::TradeDeclined);
7308                }
7309                self.state.apply_interaction_notice(&notice);
7310                self.state.push_log(notice.message.clone());
7311            }
7312            SessionEvent::ShopOpened(catalog) => {
7313                self.state.apply_shop_catalog(catalog);
7314            }
7315            SessionEvent::BankOpened(panel) => {
7316                self.state.apply_bank_panel(panel);
7317            }
7318            SessionEvent::StorageOpened(panel) => {
7319                self.state.apply_storage_panel(panel);
7320            }
7321            SessionEvent::MarketOpened(panel) => {
7322                self.state.apply_market_panel(panel);
7323            }
7324            SessionEvent::NpcTalkOpened(opened) => {
7325                self.state.show_npc_verb_menu = false;
7326                if self.state.npc_verb_target.is_none() {
7327                    self.state.npc_verb_target = Some(opened.npc_id.clone());
7328                }
7329                let label = opened.npc_label.clone();
7330                let banner = if !opened.trade_allowed {
7331                    Some("Trade is unavailable right now.".to_string())
7332                } else {
7333                    None
7334                };
7335                self.state.show_npc_chat = true;
7336                self.state.npc_chat = Some(NpcChatState {
7337                    npc_id: opened.npc_id,
7338                    npc_label: opened.npc_label,
7339                    lines: if opened.greeting.is_empty() {
7340                        vec![]
7341                    } else {
7342                        vec![format!("{label}: {}", opened.greeting)]
7343                    },
7344                    input: String::new(),
7345                    pending: opened.greeting.is_empty(),
7346                    talk_depth: opened.talk_depth,
7347                    trade_allowed: opened.trade_allowed,
7348                    banner,
7349                    suggested_topics: opened.suggested_topics,
7350                });
7351            }
7352            SessionEvent::NpcTalkPending(_) => {
7353                if let Some(chat) = self.state.npc_chat.as_mut() {
7354                    chat.pending = true;
7355                }
7356            }
7357            SessionEvent::NpcTalkReply(reply) => {
7358                if let Some(chat) = self.state.npc_chat.as_mut() {
7359                    if chat.npc_id == reply.npc_id {
7360                        chat.pending = false;
7361                        if reply.trade_disabled {
7362                            chat.trade_allowed = false;
7363                            chat.banner = Some("Trade is unavailable right now.".to_string());
7364                        }
7365                        if reply.wind_down {
7366                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
7367                            if chat.banner.is_none() {
7368                                chat.banner =
7369                                    Some("They're wrapping up — keep it brief.".to_string());
7370                            }
7371                        }
7372                        chat.lines
7373                            .push(format!("{}: {}", chat.npc_label, reply.line));
7374                    }
7375                }
7376            }
7377            SessionEvent::NpcTalkClosed(closed) => {
7378                if self
7379                    .state
7380                    .npc_chat
7381                    .as_ref()
7382                    .is_some_and(|c| c.npc_id == closed.npc_id)
7383                {
7384                    self.state.show_npc_chat = false;
7385                    self.state.npc_chat = None;
7386                }
7387            }
7388            SessionEvent::NpcTalkError(err) => {
7389                self.state.push_log(format!("Talk failed: {}", err.reason));
7390                if let Some(chat) = self.state.npc_chat.as_mut() {
7391                    chat.pending = false;
7392                }
7393            }
7394            SessionEvent::UseResult(result) => {
7395                // Inventory count hint only — the Interaction notice already logs
7396                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
7397                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
7398                    *qty = qty.saturating_sub(1);
7399                    if *qty == 0 {
7400                        self.state.inventory.remove(&result.template_id);
7401                    }
7402                }
7403            }
7404            SessionEvent::QuestOffer(offer) => {
7405                self.state.pending_quest_offer = Some(offer.clone());
7406                self.state.show_quest_offer = true;
7407                self.state
7408                    .push_log(format!("Quest offered: {}", offer.title));
7409            }
7410            SessionEvent::QuestAccepted(notice) => {
7411                self.state.show_quest_offer = false;
7412                self.state.pending_quest_offer = None;
7413                self.state.push_log(notice.message);
7414            }
7415            SessionEvent::QuestWithdrawn(notice) => {
7416                self.state.show_quest_menu = false;
7417                self.state.quest_withdraw_confirm = false;
7418                self.state.push_log(notice.message);
7419            }
7420            SessionEvent::QuestStepCompleted(notice) => {
7421                self.state.push_log(notice.message);
7422            }
7423            SessionEvent::QuestCompleted(notice) => {
7424                self.state.push_log(notice.message);
7425            }
7426            SessionEvent::Disconnected { reason } => {
7427                self.state.clear_harvest_state();
7428                self.state.connected = false;
7429                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
7430                if let Some(r) = &self.state.disconnect_reason {
7431                    self.state.push_log(format!("Disconnected: {r}"));
7432                } else {
7433                    self.state.push_log("Disconnected from server");
7434                }
7435            }
7436        }
7437        Ok(())
7438    }
7439
7440    pub fn is_connected(&self) -> bool {
7441        self.state.connected
7442    }
7443
7444    pub fn close_overlays(&mut self) {
7445        self.state.show_stats = false;
7446        self.state.show_craft_menu = false;
7447        self.state.show_plot_build_menu = false;
7448        self.state.show_shop_menu = false;
7449        self.state.shop_catalog = None;
7450        self.state.show_npc_verb_menu = false;
7451        self.state.npc_verb_target = None;
7452        self.state.show_npc_chat = false;
7453        self.state.npc_chat = None;
7454        self.state.show_inventory_menu = false;
7455        self.state.show_loadout_menu = false;
7456        self.state.show_rotation_editor = false;
7457        self.state.rotation_editor.reset();
7458        self.state.show_rename_prompt = false;
7459        self.state.show_worker_rename = false;
7460        self.state.rename_buffer.clear();
7461        self.state.show_move_picker = false;
7462        self.state.move_picker = None;
7463        self.state.show_destroy_picker = false;
7464        self.state.destroy_confirm_pending = false;
7465        self.state.destroy_picker = None;
7466        self.state.show_quest_offer = false;
7467        self.state.pending_quest_offer = None;
7468        self.state.show_quest_menu = false;
7469        self.state.quest_withdraw_confirm = false;
7470        self.state.show_workers_menu = false;
7471        self.close_worker_give_picker();
7472        self.close_worker_give_target_picker();
7473        self.close_worker_take_picker();
7474        self.close_worker_teach_picker();
7475        self.state.worker_route_editor = None;
7476        self.state.claim_mode = None;
7477        self.state.relocate_mode = None;
7478        self.state.sell_plot_confirm = None;
7479        self.state.sell_plot_armed_at = None;
7480        self.close_farm_access_panel();
7481        if self.state.show_plant_menu {
7482            self.close_plant_menu();
7483        }
7484    }
7485
7486    /// Esc / back — pop one UI layer instead of closing every overlay at once.
7487    pub fn back_on_esc(&mut self) -> bool {
7488        if self.state.social_chat.composer_open() {
7489            self.state.social_chat.close_composer();
7490            return true;
7491        }
7492        if self.state.player_verbs.open {
7493            self.state.player_verbs.close();
7494            return true;
7495        }
7496        if self.state.whisper_pouch_ui.open {
7497            self.state.whisper_pouch_ui.open = false;
7498            return true;
7499        }
7500        if self.state.trade_ui.panel.is_some() {
7501            // async cancel — caller should prefer trade_cancel; close UI optimistically
7502            self.state.trade_ui.close();
7503            return true;
7504        }
7505        if self.state.show_rename_prompt {
7506            self.cancel_rename_prompt();
7507            return true;
7508        }
7509        if self.state.show_worker_rename {
7510            self.cancel_worker_rename();
7511            return true;
7512        }
7513        if self.state.show_destroy_picker {
7514            if self.state.destroy_confirm_pending {
7515                self.cancel_destroy_confirm();
7516            } else {
7517                self.close_destroy_picker();
7518            }
7519            return true;
7520        }
7521        if self.state.claim_mode.is_some() {
7522            self.cancel_claim_mode();
7523            return true;
7524        }
7525        if self.state.relocate_mode.is_some() {
7526            self.cancel_relocate_mode();
7527            return true;
7528        }
7529        if self.state.show_plant_menu {
7530            self.close_plant_menu();
7531            return true;
7532        }
7533        if self.state.show_farm_access {
7534            self.close_farm_access_panel();
7535            return true;
7536        }
7537        if self.state.sell_plot_confirm.is_some() {
7538            self.state.sell_plot_confirm = None;
7539            self.state.sell_plot_armed_at = None;
7540            self.state.push_log("Sell cancelled");
7541            return true;
7542        }
7543        if self.state.show_move_picker {
7544            self.close_move_picker();
7545            return true;
7546        }
7547        if self.state.show_rotation_editor {
7548            match self.state.rotation_editor.mode {
7549                RotationEditorMode::List => {
7550                    self.state.show_rotation_editor = false;
7551                    self.state.rotation_editor.reset();
7552                }
7553                RotationEditorMode::EditLabel => {
7554                    self.state.rotation_editor.label_buffer.clear();
7555                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7556                }
7557                RotationEditorMode::PickAbility => {
7558                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7559                }
7560                RotationEditorMode::EditSequence => {
7561                    self.state.rotation_editor.draft = None;
7562                    self.state.rotation_editor.mode = RotationEditorMode::List;
7563                }
7564            }
7565            return true;
7566        }
7567        if self.state.show_inventory_menu {
7568            self.close_inventory_menu();
7569            return true;
7570        }
7571        if self.state.show_craft_menu {
7572            self.close_craft_menu();
7573            return true;
7574        }
7575        if self.state.show_plot_build_menu {
7576            self.close_plot_build_menu();
7577            return true;
7578        }
7579        if self.state.show_keychain_menu {
7580            self.close_keychain_menu();
7581            return true;
7582        }
7583        if self.state.show_quest_offer {
7584            self.quest_offer_decline();
7585            return true;
7586        }
7587        if self.state.show_shop_menu {
7588            // Caller must await shop close (npc_interaction_back / play-loop).
7589            return false;
7590        }
7591        if self.state.bank_panel.is_some() {
7592            return false;
7593        }
7594        if self.state.storage_panel.is_some() {
7595            return false;
7596        }
7597        if self.state.market_panel.is_some() {
7598            return false;
7599        }
7600        if self.state.show_npc_chat {
7601            // play.rs calls npc_interaction_back().await on Esc
7602            return false;
7603        }
7604        if self.state.show_npc_verb_menu {
7605            self.state.show_npc_verb_menu = false;
7606            self.state.npc_verb_target = None;
7607            return true;
7608        }
7609        if self.state.show_quest_menu {
7610            if self.state.quest_withdraw_confirm {
7611                self.state.quest_withdraw_confirm = false;
7612            } else {
7613                self.state.show_quest_menu = false;
7614            }
7615            return true;
7616        }
7617        if self.state.worker_route_editor.is_some() {
7618            // Pop one sheet level; only close the editor at the root stop list.
7619            if self.re_at_root_sheet() {
7620                let reopen = self.state.attending_worker_instance_id.clone();
7621                self.close_worker_route_editor();
7622                if let Some(id) = reopen {
7623                    if let Some(idx) = self
7624                        .state
7625                        .hired_workers
7626                        .iter()
7627                        .position(|w| w.instance_id == id)
7628                    {
7629                        self.state.workers_menu_index = idx;
7630                        self.state.show_workers_menu = true;
7631                    }
7632                }
7633            } else {
7634                self.re_sheet_back();
7635            }
7636            return true;
7637        }
7638        if self.state.show_worker_give_picker {
7639            self.close_worker_give_picker();
7640            return true;
7641        }
7642        if self.state.show_worker_give_target_picker {
7643            self.close_worker_give_target_picker();
7644            return true;
7645        }
7646        if self.state.show_worker_take_picker {
7647            self.close_worker_take_picker();
7648            return true;
7649        }
7650        if self.state.show_worker_teach_picker {
7651            self.close_worker_teach_picker();
7652            return true;
7653        }
7654        if self.state.show_workers_menu {
7655            self.close_workers_menu_ui();
7656            return true;
7657        }
7658        if self.state.show_loadout_menu {
7659            self.state.show_loadout_menu = false;
7660            return true;
7661        }
7662        if self.state.show_stats {
7663            self.state.show_stats = false;
7664            return true;
7665        }
7666        if self.state.show_equip_menu {
7667            self.state.show_equip_menu = false;
7668            return true;
7669        }
7670        false
7671    }
7672
7673    pub fn toggle_stats(&mut self) {
7674        self.state.show_stats = !self.state.show_stats;
7675        if self.state.show_stats {
7676            self.state.character_sheet_tab = CharacterSheetTab::Character;
7677            self.state.show_craft_menu = false;
7678            self.state.show_shop_menu = false;
7679            self.state.shop_catalog = None;
7680            self.state.show_inventory_menu = false;
7681            self.state.show_equip_menu = false;
7682        }
7683    }
7684
7685    pub fn toggle_equip_menu(&mut self) {
7686        self.state.show_equip_menu = !self.state.show_equip_menu;
7687        if self.state.show_equip_menu {
7688            self.state.show_stats = false;
7689            self.state.show_craft_menu = false;
7690            self.state.show_shop_menu = false;
7691            self.state.shop_catalog = None;
7692            self.state.show_inventory_menu = false;
7693            self.state.show_loadout_menu = false;
7694        }
7695    }
7696
7697    pub fn cycle_character_sheet_tab(&mut self) {
7698        if self.state.show_stats {
7699            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7700        }
7701    }
7702
7703    pub fn set_ledger_period_digit(&mut self, c: char) {
7704        if self.state.show_stats {
7705            if let Some(p) = LedgerPeriod::from_digit(c) {
7706                self.state.ledger_period = p;
7707                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7708            }
7709        }
7710    }
7711
7712    pub fn cycle_ledger_period(&mut self) {
7713        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
7714            self.state.ledger_period = self.state.ledger_period.cycle();
7715        }
7716    }
7717
7718    pub fn open_inventory_menu(&mut self) {
7719        self.state.show_inventory_menu = true;
7720        self.state.show_craft_menu = false;
7721        self.state.show_shop_menu = false;
7722        self.state.shop_catalog = None;
7723        self.state.show_stats = false;
7724        self.state.show_move_picker = false;
7725        self.state.move_picker = None;
7726        self.state.show_destroy_picker = false;
7727        self.state.destroy_confirm_pending = false;
7728        self.state.destroy_picker = None;
7729        self.state.show_rename_prompt = false;
7730        self.state.rename_plot_id = None;
7731        self.state.rename_buffer.clear();
7732        self.state.inventory_filter_focused = false;
7733        self.state.clamp_inventory_indices();
7734    }
7735
7736    pub fn close_inventory_menu(&mut self) {
7737        self.state.show_inventory_menu = false;
7738        self.state.show_move_picker = false;
7739        self.state.move_picker = None;
7740        self.close_grant_picker();
7741        self.state.show_destroy_picker = false;
7742        self.state.destroy_confirm_pending = false;
7743        self.state.destroy_picker = None;
7744        self.state.show_rename_prompt = false;
7745        self.state.rename_plot_id = None;
7746        self.state.rename_buffer.clear();
7747        self.state.inventory_filter_focused = false;
7748    }
7749
7750    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7751        let Some(row) = self.state.inventory_selected_row() else {
7752            anyhow::bail!("inventory empty");
7753        };
7754        if GameState::is_property_deed_template(&row.stack.template_id) {
7755            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7756                anyhow::bail!("deed has no plot id");
7757            };
7758            let label = self
7759                .state
7760                .property_plots
7761                .iter()
7762                .find(|p| p.plot_id == plot_id)
7763                .map(|p| {
7764                    if p.label.trim().is_empty() {
7765                        p.plot_code.clone()
7766                    } else {
7767                        p.label.clone()
7768                    }
7769                })
7770                .unwrap_or_else(|| {
7771                    row.stack
7772                        .display_name
7773                        .clone()
7774                        .unwrap_or_else(|| "plot".into())
7775                });
7776            self.state.rename_buffer = label;
7777            self.state.rename_plot_id = Some(plot_id);
7778            self.state.highlighted_plot_id = Some(plot_id);
7779            self.state.show_rename_prompt = true;
7780            self.state.show_worker_rename = false;
7781            self.state.show_move_picker = false;
7782            self.state.show_destroy_picker = false;
7783            self.state.destroy_confirm_pending = false;
7784            return Ok(());
7785        }
7786        if !self.state.row_is_renameable_container(&row) {
7787            anyhow::bail!("only storage containers or deeds can be renamed");
7788        }
7789        let current = row
7790            .stack
7791            .display_name
7792            .clone()
7793            .unwrap_or_else(|| row.stack.template_id.clone());
7794        self.state.rename_buffer = current;
7795        self.state.rename_plot_id = None;
7796        self.state.show_rename_prompt = true;
7797        self.state.show_worker_rename = false;
7798        self.state.show_move_picker = false;
7799        self.state.show_destroy_picker = false;
7800        self.state.destroy_confirm_pending = false;
7801        Ok(())
7802    }
7803
7804    /// Rename the owned plot under the player's feet (`n` in world).
7805    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7806        let Some(plot) = self.state.my_plot_under_player().cloned() else {
7807            anyhow::bail!("stand on your plot to rename it");
7808        };
7809        let label = if plot.label.trim().is_empty() {
7810            plot.plot_code.clone()
7811        } else {
7812            plot.label.clone()
7813        };
7814        self.state.rename_buffer = label;
7815        self.state.rename_plot_id = Some(plot.plot_id);
7816        self.state.highlighted_plot_id = Some(plot.plot_id);
7817        self.state.show_rename_prompt = true;
7818        self.state.show_worker_rename = false;
7819        Ok(())
7820    }
7821
7822    pub fn cancel_rename_prompt(&mut self) {
7823        self.state.show_rename_prompt = false;
7824        self.state.rename_plot_id = None;
7825        self.state.rename_buffer.clear();
7826    }
7827
7828    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7829        let name = self.state.rename_buffer.trim().to_string();
7830        if name.is_empty() {
7831            anyhow::bail!("name cannot be empty");
7832        }
7833        if let Some(plot_id) = self.state.rename_plot_id {
7834            if name.chars().count() > 48 {
7835                anyhow::bail!("label must be 1–48 characters");
7836            }
7837            self.seq += 1;
7838            self.session
7839                .submit_intent(Intent::RenamePropertyPlot {
7840                    entity_id: self.state.entity_id,
7841                    plot_id,
7842                    label: name,
7843                    seq: self.seq,
7844                })
7845                .await?;
7846            self.state.intents_sent += 1;
7847            self.state.show_rename_prompt = false;
7848            self.state.rename_plot_id = None;
7849            self.state.rename_buffer.clear();
7850            return Ok(());
7851        }
7852        if name.chars().count() > 32 {
7853            anyhow::bail!("name must be 1–32 characters");
7854        }
7855        let Some(row) = self.state.inventory_selected_row() else {
7856            anyhow::bail!("inventory empty");
7857        };
7858        let Some(instance_id) = row.stack.item_instance_id else {
7859            anyhow::bail!("item has no instance id");
7860        };
7861        self.seq += 1;
7862        self.session
7863            .submit_intent(Intent::RenameContainer {
7864                entity_id: self.state.entity_id,
7865                item_instance_id: instance_id,
7866                location: row.from.clone(),
7867                name,
7868                seq: self.seq,
7869            })
7870            .await?;
7871        self.state.intents_sent += 1;
7872        self.state.show_rename_prompt = false;
7873        self.state.rename_buffer.clear();
7874        Ok(())
7875    }
7876
7877    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7878        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7879            anyhow::bail!("no worker selected");
7880        };
7881        self.state.rename_buffer = worker.label.clone();
7882        self.state.show_worker_rename = true;
7883        self.state.show_rename_prompt = false;
7884        Ok(())
7885    }
7886
7887    pub fn cancel_worker_rename(&mut self) {
7888        self.state.show_worker_rename = false;
7889        self.state.rename_buffer.clear();
7890    }
7891
7892    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7893        let name = self.state.rename_buffer.trim().to_string();
7894        if name.is_empty() {
7895            anyhow::bail!("name cannot be empty");
7896        }
7897        if name.chars().count() > 32 {
7898            anyhow::bail!("name must be 1–32 characters");
7899        }
7900        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7901            anyhow::bail!("no worker selected");
7902        };
7903        let worker_instance_id = worker.instance_id.clone();
7904        self.seq += 1;
7905        self.session
7906            .submit_intent(Intent::RenameHiredWorker {
7907                entity_id: self.state.entity_id,
7908                worker_instance_id: worker_instance_id.clone(),
7909                name: name.clone(),
7910                seq: self.seq,
7911            })
7912            .await?;
7913        self.state.intents_sent += 1;
7914        if let Some(w) = self
7915            .state
7916            .hired_workers
7917            .iter_mut()
7918            .find(|w| w.instance_id == worker_instance_id)
7919        {
7920            w.label = name.clone();
7921        }
7922        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7923            if ed.worker_instance_id == worker_instance_id {
7924                ed.worker_label = name.clone();
7925            }
7926        }
7927        self.state.show_worker_rename = false;
7928        self.state.rename_buffer.clear();
7929        self.state.push_log(format!("Renamed worker to \"{name}\""));
7930        Ok(())
7931    }
7932
7933    pub fn toggle_inventory_menu(&mut self) {
7934        if self.state.show_inventory_menu {
7935            self.close_inventory_menu();
7936        } else {
7937            self.open_inventory_menu();
7938        }
7939    }
7940
7941    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
7942    pub fn inventory_menu_move(&mut self, delta: i32) {
7943        if self.state.show_grant_picker {
7944            let Some(picker) = self.state.grant_picker.as_ref() else {
7945                return;
7946            };
7947            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7948            let filter = picker.filter.clone();
7949            let n = labels.len();
7950            if n == 0 {
7951                return;
7952            }
7953            self.state.grant_picker_index =
7954                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
7955                    list_label_matches(&labels[i], &filter)
7956                });
7957            return;
7958        }
7959        if self.state.show_move_picker {
7960            let Some(picker) = self.state.move_picker.as_ref() else {
7961                return;
7962            };
7963            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7964            let filter = picker.filter.clone();
7965            let n = labels.len();
7966            if n == 0 {
7967                return;
7968            }
7969            self.state.move_picker_index =
7970                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
7971                    list_label_matches(&labels[i], &filter)
7972                });
7973            self.state.clamp_move_picker_quantity();
7974            return;
7975        }
7976        let n = self.state.inventory_selectable_rows().len();
7977        if n == 0 {
7978            return;
7979        }
7980        let idx = self.state.inventory_menu_index as i32;
7981        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7982    }
7983
7984    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
7985    pub fn inventory_menu_page(&mut self, pages: i32) {
7986        if self.state.show_grant_picker {
7987            let Some(picker) = self.state.grant_picker.as_ref() else {
7988                return;
7989            };
7990            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7991            let filter = picker.filter.clone();
7992            let n = labels.len();
7993            self.state.grant_picker_index =
7994                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
7995                    list_label_matches(&labels[i], &filter)
7996                });
7997            return;
7998        }
7999        if self.state.show_move_picker {
8000            let Some(picker) = self.state.move_picker.as_ref() else {
8001                return;
8002            };
8003            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8004            let filter = picker.filter.clone();
8005            let n = labels.len();
8006            self.state.move_picker_index =
8007                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8008                    list_label_matches(&labels[i], &filter)
8009                });
8010            self.state.clamp_move_picker_quantity();
8011            return;
8012        }
8013        let n = self.state.inventory_selectable_rows().len();
8014        self.state.inventory_menu_index =
8015            page_list_index(self.state.inventory_menu_index, pages, n);
8016    }
8017
8018    pub fn cycle_inventory_tab(&mut self, forward: bool) {
8019        if self.state.show_move_picker
8020            || self.state.show_grant_picker
8021            || self.state.show_destroy_picker
8022            || self.state.show_rename_prompt
8023            || self.state.inventory_filter_focused
8024        {
8025            return;
8026        }
8027        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8028        self.state.inventory_menu_index = 0;
8029        self.state.clamp_inventory_indices();
8030    }
8031
8032    pub fn focus_inventory_filter(&mut self) {
8033        if self.state.show_grant_picker {
8034            if let Some(p) = self.state.grant_picker.as_mut() {
8035                p.filter_focused = true;
8036            }
8037            return;
8038        }
8039        if self.state.show_move_picker {
8040            if let Some(p) = self.state.move_picker.as_mut() {
8041                p.filter_focused = true;
8042            }
8043            return;
8044        }
8045        self.state.inventory_filter_focused = true;
8046    }
8047
8048    pub fn set_inventory_filter(&mut self, filter: String) {
8049        self.state.inventory_filter = filter;
8050        self.state.inventory_menu_index = 0;
8051        self.state.clamp_inventory_indices();
8052    }
8053
8054    pub fn append_inventory_filter_char(&mut self, ch: char) {
8055        if ch.is_control() {
8056            return;
8057        }
8058        if self.state.show_grant_picker {
8059            if let Some(p) = self.state.grant_picker.as_mut() {
8060                if p.filter_focused {
8061                    p.filter.push(ch);
8062                    self.state.grant_picker_index = 0;
8063                }
8064            }
8065            return;
8066        }
8067        if self.state.show_move_picker {
8068            if let Some(p) = self.state.move_picker.as_mut() {
8069                if p.filter_focused {
8070                    p.filter.push(ch);
8071                    self.state.move_picker_index = 0;
8072                    self.state.clamp_move_picker_quantity();
8073                }
8074            }
8075            return;
8076        }
8077        if !self.state.inventory_filter_focused {
8078            return;
8079        }
8080        self.state.inventory_filter.push(ch);
8081        self.state.inventory_menu_index = 0;
8082        self.state.clamp_inventory_indices();
8083    }
8084
8085    pub fn inventory_filter_backspace(&mut self) {
8086        if self.state.show_grant_picker {
8087            if let Some(p) = self.state.grant_picker.as_mut() {
8088                if p.filter_focused {
8089                    p.filter.pop();
8090                    self.state.grant_picker_index = 0;
8091                }
8092            }
8093            return;
8094        }
8095        if self.state.show_move_picker {
8096            if let Some(p) = self.state.move_picker.as_mut() {
8097                if p.filter_focused {
8098                    p.filter.pop();
8099                    self.state.move_picker_index = 0;
8100                    self.state.clamp_move_picker_quantity();
8101                }
8102            }
8103            return;
8104        }
8105        if !self.state.inventory_filter_focused {
8106            return;
8107        }
8108        self.state.inventory_filter.pop();
8109        self.state.inventory_menu_index = 0;
8110        self.state.clamp_inventory_indices();
8111    }
8112
8113    /// Esc while filter focused: clear filter then blur. Returns true if handled.
8114    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
8115        if self.state.show_grant_picker {
8116            if let Some(p) = self.state.grant_picker.as_mut() {
8117                if p.filter_focused {
8118                    if !p.filter.is_empty() {
8119                        p.filter.clear();
8120                        self.state.grant_picker_index = 0;
8121                    } else {
8122                        p.filter_focused = false;
8123                    }
8124                    return true;
8125                }
8126                if !p.filter.is_empty() {
8127                    p.filter.clear();
8128                    self.state.grant_picker_index = 0;
8129                    return true;
8130                }
8131            }
8132            return false;
8133        }
8134        if self.state.show_move_picker {
8135            if let Some(p) = self.state.move_picker.as_mut() {
8136                if p.filter_focused {
8137                    if !p.filter.is_empty() {
8138                        p.filter.clear();
8139                        self.state.move_picker_index = 0;
8140                        self.state.clamp_move_picker_quantity();
8141                    } else {
8142                        p.filter_focused = false;
8143                    }
8144                    return true;
8145                }
8146                if !p.filter.is_empty() {
8147                    p.filter.clear();
8148                    self.state.move_picker_index = 0;
8149                    self.state.clamp_move_picker_quantity();
8150                    return true;
8151                }
8152            }
8153            return false;
8154        }
8155        if self.state.inventory_filter_focused {
8156            if !self.state.inventory_filter.is_empty() {
8157                self.state.inventory_filter.clear();
8158                self.state.inventory_menu_index = 0;
8159                self.state.clamp_inventory_indices();
8160            } else {
8161                self.state.inventory_filter_focused = false;
8162            }
8163            return true;
8164        }
8165        if !self.state.inventory_filter.is_empty() {
8166            self.state.inventory_filter.clear();
8167            self.state.inventory_menu_index = 0;
8168            self.state.clamp_inventory_indices();
8169            return true;
8170        }
8171        false
8172    }
8173
8174    pub fn craft_menu_page(&mut self, pages: i32) {
8175        let n = self.state.blueprints.len();
8176        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
8177        self.state.clamp_craft_batch_quantity();
8178    }
8179
8180    pub fn shop_menu_page(&mut self, pages: i32) {
8181        let n = self.state.shop_list_len();
8182        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
8183        self.state.clamp_shop_quantity();
8184    }
8185
8186    pub fn workers_menu_page(&mut self, pages: i32) {
8187        let n = self.state.hired_workers.len();
8188        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
8189    }
8190
8191    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
8192    /// chest — or fall back to the "move to…" destination picker (including
8193    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
8194    /// Placed chest shells open a pick-up destination picker (`l` still locks).
8195    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
8196        if self.state.show_destroy_picker {
8197            if self.state.destroy_confirm_pending {
8198                return self.confirm_destroy_item().await;
8199            }
8200            return self.request_destroy_confirm();
8201        }
8202        if self.state.show_grant_picker {
8203            return self.confirm_grant_picker().await;
8204        }
8205        if self.state.show_move_picker {
8206            return self.confirm_move_picker().await;
8207        }
8208        let Some(row) = self.state.inventory_selected_row() else {
8209            anyhow::bail!("inventory empty");
8210        };
8211        if row.is_equip_shell {
8212            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
8213                anyhow::bail!("not a worn item");
8214            };
8215            return self.equip_worn(slot, None).await;
8216        }
8217        if row.is_chest_shell {
8218            return self.open_chest_pickup_picker();
8219        }
8220        let template_id = row.stack.template_id.clone();
8221        let instance_id = row.stack.item_instance_id;
8222        let category = self.state.inventory_item_category(&template_id);
8223        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8224
8225        if category == Some("weapon") {
8226            return self.equip_mainhand(Some(template_id)).await;
8227        }
8228        if category == Some("lodging") && on_person {
8229            if let Some(inst) = instance_id {
8230                return self.place_container(inst).await;
8231            }
8232        }
8233        if (category == Some("container") || category == Some("armor")) && on_person {
8234            if let Some(inst) = instance_id {
8235                let world_placeable =
8236                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
8237                if world_placeable {
8238                    return self.place_container(inst).await;
8239                }
8240                // Pouches no longer equip directly — they clip onto a worn belt's loops
8241                // instead, so fall through to the move picker (offers "belt loop" when a
8242                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
8243                if let Some(slot) = guess_body_slot(&template_id) {
8244                    return self.equip_worn(slot, Some(inst)).await;
8245                }
8246            }
8247        }
8248        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
8249        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
8250        // instead of guessing.
8251        self.open_move_picker()
8252    }
8253
8254    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
8255    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
8256        let Some(row) = self.state.inventory_selected_row() else {
8257            anyhow::bail!("inventory empty");
8258        };
8259        if row.from != flatland_protocol::InventoryLocation::Root {
8260            anyhow::bail!("select a consumable on your person");
8261        }
8262        if GameState::stack_is_item_grant(&row.stack) {
8263            return self.open_grant_target_picker();
8264        }
8265        if GameState::is_property_deed_template(&row.stack.template_id) {
8266            return self.open_move_picker();
8267        }
8268        let category = self.state.inventory_item_category(&row.stack.template_id);
8269        if category != Some("consumable") {
8270            anyhow::bail!("selected item is not consumable");
8271        }
8272        self.use_item(&row.stack.template_id).await
8273    }
8274
8275    /// Open picker: apply selected grant item onto inventory / worn gear.
8276    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
8277        let Some(row) = self.state.inventory_selected_row() else {
8278            anyhow::bail!("inventory empty");
8279        };
8280        if row.from != flatland_protocol::InventoryLocation::Root {
8281            anyhow::bail!("select a grant item on your person");
8282        }
8283        if !GameState::stack_is_item_grant(&row.stack) {
8284            anyhow::bail!("selected item does not grant onto gear");
8285        }
8286        let Some(grant_instance_id) = row.stack.item_instance_id else {
8287            anyhow::bail!("grant has no instance id");
8288        };
8289        let effect_id = GameState::grant_effect_id(&row.stack)
8290            .unwrap_or("?")
8291            .to_string();
8292        let mode = GameState::grant_mode(&row.stack).to_string();
8293        let options = self.state.grant_target_options(&row.stack);
8294        if options.is_empty() {
8295            anyhow::bail!("no valid gear to apply {effect_id} to");
8296        }
8297        let grant_label = row
8298            .stack
8299            .display_name
8300            .clone()
8301            .unwrap_or_else(|| row.stack.template_id.clone());
8302        self.state.show_grant_picker = true;
8303        self.state.grant_picker_index = 0;
8304        self.state.grant_picker = Some(GrantTargetPicker {
8305            grant_instance_id,
8306            grant_label,
8307            effect_id,
8308            mode,
8309            options,
8310            filter: String::new(),
8311            filter_focused: false,
8312        });
8313        Ok(())
8314    }
8315
8316    pub fn close_grant_picker(&mut self) {
8317        self.state.show_grant_picker = false;
8318        self.state.grant_picker = None;
8319        self.state.grant_picker_index = 0;
8320    }
8321
8322    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
8323        let Some(picker) = self.state.grant_picker.clone() else {
8324            self.close_grant_picker();
8325            return Ok(());
8326        };
8327        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
8328            self.close_grant_picker();
8329            return Ok(());
8330        };
8331        self.close_grant_picker();
8332        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
8333            .await?;
8334        self.state
8335            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
8336        Ok(())
8337    }
8338
8339    /// `m`: always open the "move to…" picker for the selected item, even for
8340    /// weapons/wearables that Enter would otherwise equip/wear directly.
8341    /// Placed chests open the pick-up destination picker instead.
8342    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
8343        let Some(row) = self.state.inventory_selected_row() else {
8344            anyhow::bail!("inventory empty");
8345        };
8346        if row.is_equip_shell {
8347            anyhow::bail!("this is a worn bag — press Enter to unequip it");
8348        }
8349        if row.is_chest_shell {
8350            return self.open_chest_pickup_picker();
8351        }
8352        let Some(instance_id) = row.stack.item_instance_id else {
8353            anyhow::bail!("item has no instance id");
8354        };
8355        let mut options = self.state.move_destinations_for(
8356            &row.from,
8357            row.from_parent_instance_id,
8358            row.stack.item_instance_id,
8359            &row.stack.template_id,
8360        );
8361        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8362        let category = self.state.inventory_item_category(&row.stack.template_id);
8363        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
8364            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
8365                options.insert(
8366                    0,
8367                    MoveOption {
8368                        label: "Sell plot to crown…".into(),
8369                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
8370                    },
8371                );
8372            }
8373        }
8374        if on_person && category == Some("consumable") {
8375            if GameState::stack_is_item_grant(&row.stack) {
8376                options.insert(
8377                    0,
8378                    MoveOption {
8379                        label: "Apply onto gear…".into(),
8380                        kind: MoveOptionKind::GrantApply,
8381                    },
8382                );
8383            } else {
8384                options.insert(
8385                    0,
8386                    MoveOption {
8387                        label: "Use (eat / drink)".into(),
8388                        kind: MoveOptionKind::Use,
8389                    },
8390                );
8391            }
8392        }
8393        let item_label = row
8394            .stack
8395            .display_name
8396            .clone()
8397            .unwrap_or_else(|| row.stack.template_id.clone());
8398        // Default to 1 so withdrawing from storage is partial unless the player
8399        // presses `a` for max fit (full stack when the destination allows it).
8400        let initial_qty = if row.stack.quantity > 1 {
8401            1
8402        } else {
8403            row.stack.quantity
8404        };
8405        self.state.move_picker = Some(MovePicker {
8406            item_instance_id: instance_id,
8407            from: row.from,
8408            item_label,
8409            template_id: row.stack.template_id.clone(),
8410            stack_quantity: row.stack.quantity,
8411            quantity: initial_qty.max(1),
8412            options,
8413            filter: String::new(),
8414            filter_focused: false,
8415        });
8416        self.state.move_picker_index = 0;
8417        self.state.show_move_picker = true;
8418        self.state.show_destroy_picker = false;
8419        self.state.destroy_confirm_pending = false;
8420        self.state.destroy_picker = None;
8421        self.state.clamp_move_picker_quantity();
8422        Ok(())
8423    }
8424
8425    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
8426    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
8427        let Some(row) = self.state.inventory_selected_row() else {
8428            anyhow::bail!("inventory empty");
8429        };
8430        if !row.is_chest_shell {
8431            anyhow::bail!("not a placed chest");
8432        }
8433        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
8434            anyhow::bail!("not a placed chest");
8435        };
8436        let Some(instance_id) = row.stack.item_instance_id else {
8437            anyhow::bail!("chest has no instance id");
8438        };
8439        let chest = self
8440            .state
8441            .placed_containers
8442            .iter()
8443            .find(|c| c.id == *container_id)
8444            .cloned()
8445            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8446        let (px, py) = self.state.player_position();
8447        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8448            anyhow::bail!("too far from {}", chest.display_name);
8449        }
8450        if chest.locked && !chest.accessible {
8451            anyhow::bail!(
8452                "need the matching key for {} before picking it up",
8453                chest.display_name
8454            );
8455        }
8456        let options = self.state.chest_pickup_destinations(container_id);
8457        let item_label = row
8458            .stack
8459            .display_name
8460            .clone()
8461            .unwrap_or_else(|| row.stack.template_id.clone());
8462        self.state.move_picker = Some(MovePicker {
8463            item_instance_id: instance_id,
8464            from: row.from.clone(),
8465            item_label,
8466            template_id: row.stack.template_id.clone(),
8467            stack_quantity: 1,
8468            quantity: 1,
8469            options,
8470            filter: String::new(),
8471            filter_focused: false,
8472        });
8473        self.state.move_picker_index = 0;
8474        self.state.show_move_picker = true;
8475        self.state.show_destroy_picker = false;
8476        self.state.destroy_confirm_pending = false;
8477        self.state.destroy_picker = None;
8478        Ok(())
8479    }
8480
8481    pub fn close_move_picker(&mut self) {
8482        self.state.show_move_picker = false;
8483        self.state.move_picker = None;
8484    }
8485
8486    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8487        self.state.move_picker_adjust_quantity(delta);
8488    }
8489
8490    pub fn move_picker_set_quantity_max(&mut self) {
8491        self.state.move_picker_set_quantity_max();
8492    }
8493
8494    pub fn move_picker_set_quantity_min(&mut self) {
8495        self.state.move_picker_set_quantity_min();
8496    }
8497
8498    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8499        self.state.destroy_picker_adjust_quantity(delta);
8500    }
8501
8502    pub fn destroy_picker_set_quantity_max(&mut self) {
8503        self.state.destroy_picker_set_quantity_max();
8504    }
8505
8506    pub fn destroy_picker_set_quantity_min(&mut self) {
8507        self.state.destroy_picker_set_quantity_min();
8508    }
8509
8510    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8511        let Some(picker) = self.state.move_picker.clone() else {
8512            self.close_move_picker();
8513            return Ok(());
8514        };
8515        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8516            self.close_move_picker();
8517            return Ok(());
8518        };
8519        match option.kind {
8520            MoveOptionKind::Cancel => {
8521                self.close_move_picker();
8522            }
8523            MoveOptionKind::Use => {
8524                self.close_move_picker();
8525                self.use_item(&picker.template_id).await?;
8526            }
8527            MoveOptionKind::GrantApply => {
8528                self.close_move_picker();
8529                self.open_grant_target_picker()?;
8530            }
8531            MoveOptionKind::SellPlotToCrown { plot_id } => {
8532                self.close_move_picker();
8533                self.confirm_sell_plot_to_crown(plot_id).await?;
8534            }
8535            MoveOptionKind::RelocatePlaced { container_id } => {
8536                self.close_move_picker();
8537                self.state.show_inventory_menu = false;
8538                self.begin_relocate_container(&container_id)?;
8539            }
8540            MoveOptionKind::Drop => {
8541                self.close_move_picker();
8542                if self
8543                    .state
8544                    .hand_equipped_instance_ids()
8545                    .contains(&picker.item_instance_id)
8546                {
8547                    anyhow::bail!("unequip that item first");
8548                }
8549                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8550                    if self.state.deed_bound(&stack) {
8551                        anyhow::bail!(
8552                            "cannot drop a property deed — store it or trade it to another player"
8553                        );
8554                    }
8555                    if self.state.key_drop_blocked(&stack) {
8556                        anyhow::bail!("cannot drop the key while its chest is locked");
8557                    }
8558                }
8559                self.drop_item(picker.item_instance_id, picker.from).await?;
8560                self.state
8561                    .push_log(format!("Dropped {}", picker.item_label));
8562            }
8563            MoveOptionKind::PickupPlaced {
8564                container_id,
8565                nest_location,
8566                nest_parent_instance_id,
8567            } => {
8568                self.close_move_picker();
8569                self.pickup_container(container_id.clone()).await?;
8570                let nest_into_bag = nest_parent_instance_id.is_some()
8571                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
8572                if nest_into_bag {
8573                    self.move_item(
8574                        picker.item_instance_id,
8575                        flatland_protocol::InventoryLocation::Root,
8576                        nest_location,
8577                        nest_parent_instance_id,
8578                        None,
8579                    )
8580                    .await?;
8581                    self.state
8582                        .push_log(format!("Picked up {} into bag", picker.item_label));
8583                } else {
8584                    self.state
8585                        .push_log(format!("Picked up {}", picker.item_label));
8586                }
8587            }
8588            MoveOptionKind::Move {
8589                location,
8590                parent_instance_id,
8591            } => {
8592                self.close_move_picker();
8593                let qty = if picker.quantity >= picker.stack_quantity {
8594                    None
8595                } else {
8596                    Some(picker.quantity)
8597                };
8598                self.move_item(
8599                    picker.item_instance_id,
8600                    picker.from,
8601                    location,
8602                    parent_instance_id,
8603                    qty,
8604                )
8605                .await?;
8606                let moved = qty.unwrap_or(picker.stack_quantity);
8607                if moved >= picker.stack_quantity {
8608                    self.state.push_log(format!("Moved {}", picker.item_label));
8609                } else {
8610                    self.state.push_log(format!(
8611                        "Moved {} ×{} of {}",
8612                        picker.item_label, moved, picker.stack_quantity
8613                    ));
8614                }
8615            }
8616        }
8617        Ok(())
8618    }
8619
8620    /// `d`: drop the selected item on the ground immediately (no picker).
8621    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8622        let Some(row) = self.state.inventory_selected_row() else {
8623            anyhow::bail!("inventory empty");
8624        };
8625        if row.is_equip_shell {
8626            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8627        }
8628        if row.is_chest_shell {
8629            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8630        }
8631        let Some(inst) = row.stack.item_instance_id else {
8632            anyhow::bail!("item has no instance id");
8633        };
8634        if self.state.hand_equipped_instance_ids().contains(&inst) {
8635            anyhow::bail!("unequip that item first");
8636        }
8637        if self.state.deed_bound(&row.stack) {
8638            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8639        }
8640        if self.state.key_drop_blocked(&row.stack) {
8641            anyhow::bail!("cannot drop the key while its chest is locked");
8642        }
8643        let label = row
8644            .stack
8645            .display_name
8646            .clone()
8647            .unwrap_or_else(|| row.stack.template_id.clone());
8648        self.drop_item(inst, row.from).await?;
8649        self.state.push_log(format!("Dropped {label}"));
8650        Ok(())
8651    }
8652
8653    pub async fn drop_item(
8654        &mut self,
8655        item_instance_id: uuid::Uuid,
8656        from: flatland_protocol::InventoryLocation,
8657    ) -> anyhow::Result<()> {
8658        self.seq += 1;
8659        self.session
8660            .submit_intent(Intent::DropItem {
8661                entity_id: self.state.entity_id,
8662                item_instance_id,
8663                from,
8664                seq: self.seq,
8665            })
8666            .await?;
8667        self.state.intents_sent += 1;
8668        Ok(())
8669    }
8670
8671    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
8672    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8673        let Some(row) = self.state.inventory_selected_row() else {
8674            anyhow::bail!("inventory empty");
8675        };
8676        if row.is_equip_shell {
8677            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8678        }
8679        if row.is_chest_shell {
8680            anyhow::bail!("can't destroy a placed chest from the inventory list");
8681        }
8682        let Some(instance_id) = row.stack.item_instance_id else {
8683            anyhow::bail!("item has no instance id");
8684        };
8685        if self
8686            .state
8687            .hand_equipped_instance_ids()
8688            .contains(&instance_id)
8689        {
8690            anyhow::bail!("unequip that item first");
8691        }
8692        if self.state.deed_bound(&row.stack) {
8693            anyhow::bail!(
8694                "cannot destroy a property deed — store it or trade it to another player"
8695            );
8696        }
8697        if self.state.key_drop_blocked(&row.stack) {
8698            anyhow::bail!("cannot destroy the key while its chest is locked");
8699        }
8700        let item_label = row
8701            .stack
8702            .display_name
8703            .clone()
8704            .unwrap_or_else(|| row.stack.template_id.clone());
8705        self.state.destroy_picker = Some(DestroyPicker {
8706            item_instance_id: instance_id,
8707            from: row.from,
8708            item_label,
8709            stack_quantity: row.stack.quantity,
8710            quantity: row.stack.quantity,
8711        });
8712        self.state.destroy_confirm_pending = false;
8713        self.state.show_destroy_picker = true;
8714        self.state.show_move_picker = false;
8715        self.state.move_picker = None;
8716        Ok(())
8717    }
8718
8719    pub fn close_destroy_picker(&mut self) {
8720        self.state.show_destroy_picker = false;
8721        self.state.destroy_confirm_pending = false;
8722        self.state.destroy_picker = None;
8723    }
8724
8725    pub fn cancel_destroy_confirm(&mut self) {
8726        self.state.destroy_confirm_pending = false;
8727    }
8728
8729    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8730        if self.state.destroy_picker.is_none() {
8731            self.close_destroy_picker();
8732            return Ok(());
8733        }
8734        self.state.destroy_confirm_pending = true;
8735        Ok(())
8736    }
8737
8738    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8739        let Some(picker) = self.state.destroy_picker.clone() else {
8740            self.close_destroy_picker();
8741            return Ok(());
8742        };
8743        let qty = if picker.quantity >= picker.stack_quantity {
8744            None
8745        } else {
8746            Some(picker.quantity)
8747        };
8748        self.destroy_item(picker.item_instance_id, picker.from, qty)
8749            .await?;
8750        let destroyed = qty.unwrap_or(picker.stack_quantity);
8751        if destroyed >= picker.stack_quantity {
8752            self.state
8753                .push_log(format!("Destroyed {}", picker.item_label));
8754        } else {
8755            self.state.push_log(format!(
8756                "Destroyed {} ×{} of {}",
8757                picker.item_label, destroyed, picker.stack_quantity
8758            ));
8759        }
8760        self.close_destroy_picker();
8761        Ok(())
8762    }
8763
8764    pub async fn destroy_item(
8765        &mut self,
8766        item_instance_id: uuid::Uuid,
8767        from: flatland_protocol::InventoryLocation,
8768        quantity: Option<u32>,
8769    ) -> anyhow::Result<()> {
8770        self.seq += 1;
8771        self.session
8772            .submit_intent(Intent::DestroyItem {
8773                entity_id: self.state.entity_id,
8774                item_instance_id,
8775                from,
8776                quantity,
8777                seq: self.seq,
8778            })
8779            .await?;
8780        self.state.intents_sent += 1;
8781        Ok(())
8782    }
8783
8784    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
8785    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8786        if let Some(row) = self.state.inventory_selected_row() {
8787            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8788                return self.toggle_placed_chest_lock(container_id).await;
8789            }
8790        }
8791        self.toggle_nearby_chest_lock().await
8792    }
8793
8794    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8795        let chest = self
8796            .state
8797            .placed_containers
8798            .iter()
8799            .find(|c| c.id == container_id)
8800            .cloned()
8801            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8802        let (px, py) = self.state.player_position();
8803        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8804            anyhow::bail!("too far from {}", chest.display_name);
8805        }
8806        if !chest.accessible && chest.locked {
8807            anyhow::bail!(
8808                "need the matching key for {} (each crafted chest has its own key)",
8809                chest.display_name
8810            );
8811        }
8812        let lock = !chest.locked;
8813        self.set_container_locked(
8814            flatland_protocol::InventoryLocation::Placed {
8815                container_id: chest.id.clone(),
8816            },
8817            lock,
8818        )
8819        .await?;
8820        self.state.push_log(if lock {
8821            format!("Locked {}", chest.display_name)
8822        } else {
8823            format!("Unlocked {}", chest.display_name)
8824        });
8825        Ok(())
8826    }
8827
8828    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
8829    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8830        let chest = self
8831            .state
8832            .nearest_placed_container(CONTAINER_RANGE_M)
8833            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8834        self.toggle_placed_chest_lock(&chest.id).await
8835    }
8836
8837    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8838        self.equip_mainhand(None).await
8839    }
8840
8841    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8842        if !self.state.is_alive() {
8843            anyhow::bail!("you are dead");
8844        }
8845        self.seq += 1;
8846        self.session
8847            .submit_intent(Intent::EquipOffhand {
8848                entity_id: self.state.entity_id,
8849                template_id,
8850                instance_id: None,
8851                seq: self.seq,
8852            })
8853            .await?;
8854        self.state.intents_sent += 1;
8855        Ok(())
8856    }
8857
8858    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8859        self.equip_offhand(None).await
8860    }
8861
8862    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8863        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8864        for slot in slots {
8865            self.equip_worn(slot, None).await?;
8866        }
8867        Ok(())
8868    }
8869
8870    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8871        let (px, py) = self.state.player_position();
8872        let nearest = self
8873            .state
8874            .placed_containers
8875            .iter()
8876            .min_by(|a, b| {
8877                let da = (a.x - px).hypot(a.y - py);
8878                let db = (b.x - px).hypot(b.y - py);
8879                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8880            })
8881            .cloned();
8882        let Some(chest) = nearest else {
8883            anyhow::bail!("no chest nearby");
8884        };
8885        if (chest.x - px).hypot(chest.y - py) > 2.0 {
8886            anyhow::bail!("too far from chest");
8887        }
8888        self.pickup_container(chest.id).await
8889    }
8890
8891    pub async fn equip_worn(
8892        &mut self,
8893        slot: BodySlot,
8894        instance_id: Option<uuid::Uuid>,
8895    ) -> anyhow::Result<()> {
8896        self.seq += 1;
8897        self.session
8898            .submit_intent(Intent::EquipWorn {
8899                entity_id: self.state.entity_id,
8900                slot,
8901                instance_id,
8902                seq: self.seq,
8903            })
8904            .await?;
8905        self.state.intents_sent += 1;
8906        Ok(())
8907    }
8908
8909    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8910        self.seq += 1;
8911        self.session
8912            .submit_intent(Intent::PlaceContainer {
8913                entity_id: self.state.entity_id,
8914                item_instance_id,
8915                seq: self.seq,
8916            })
8917            .await?;
8918        self.state.intents_sent += 1;
8919        Ok(())
8920    }
8921
8922    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8923        self.seq += 1;
8924        self.session
8925            .submit_intent(Intent::PickupContainer {
8926                entity_id: self.state.entity_id,
8927                container_id,
8928                seq: self.seq,
8929            })
8930            .await?;
8931        self.state.intents_sent += 1;
8932        Ok(())
8933    }
8934
8935    pub async fn move_item(
8936        &mut self,
8937        item_instance_id: uuid::Uuid,
8938        from: flatland_protocol::InventoryLocation,
8939        to: flatland_protocol::InventoryLocation,
8940        to_parent_instance_id: Option<uuid::Uuid>,
8941        quantity: Option<u32>,
8942    ) -> anyhow::Result<()> {
8943        self.seq += 1;
8944        self.session
8945            .submit_intent(Intent::MoveItem {
8946                entity_id: self.state.entity_id,
8947                item_instance_id,
8948                from,
8949                to,
8950                to_parent_instance_id,
8951                quantity,
8952                seq: self.seq,
8953            })
8954            .await?;
8955        self.state.intents_sent += 1;
8956        Ok(())
8957    }
8958
8959    pub async fn set_container_locked(
8960        &mut self,
8961        location: flatland_protocol::InventoryLocation,
8962        locked: bool,
8963    ) -> anyhow::Result<()> {
8964        self.seq += 1;
8965        self.session
8966            .submit_intent(Intent::SetContainerLocked {
8967                entity_id: self.state.entity_id,
8968                location,
8969                locked,
8970                seq: self.seq,
8971            })
8972            .await?;
8973        self.state.intents_sent += 1;
8974        Ok(())
8975    }
8976
8977    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8978        if !self.state.is_alive() {
8979            anyhow::bail!("you are dead");
8980        }
8981        self.seq += 1;
8982        self.session
8983            .submit_intent(Intent::Use {
8984                entity_id: self.state.entity_id,
8985                template_id: template_id.to_string(),
8986                seq: self.seq,
8987            })
8988            .await?;
8989        self.state.intents_sent += 1;
8990        Ok(())
8991    }
8992
8993    /// Bind a grant consumable onto a specific item instance (unique gear status).
8994    pub async fn use_grant(
8995        &mut self,
8996        grant_instance_id: uuid::Uuid,
8997        target_instance_id: uuid::Uuid,
8998    ) -> anyhow::Result<()> {
8999        if !self.state.is_alive() {
9000            anyhow::bail!("you are dead");
9001        }
9002        self.seq += 1;
9003        self.session
9004            .submit_intent(Intent::UseGrant {
9005                entity_id: self.state.entity_id,
9006                grant_instance_id,
9007                target_instance_id,
9008                seq: self.seq,
9009            })
9010            .await?;
9011        self.state.intents_sent += 1;
9012        Ok(())
9013    }
9014
9015    pub fn open_craft_menu(&mut self) {
9016        self.state.show_craft_menu = true;
9017        self.state.show_shop_menu = false;
9018        self.state.shop_catalog = None;
9019        self.state.show_stats = false;
9020        self.state.show_inventory_menu = false;
9021        if self.state.blueprints.is_empty() {
9022            self.state.craft_menu_index = 0;
9023            self.state.craft_batch_quantity = 1;
9024            return;
9025        }
9026        self.state.craft_menu_index = self
9027            .state
9028            .craft_menu_index
9029            .min(self.state.blueprints.len() - 1);
9030        if let Some(idx) = self
9031            .state
9032            .blueprints
9033            .iter()
9034            .position(|bp| self.state.can_craft_blueprint(bp))
9035        {
9036            self.state.craft_menu_index = idx;
9037        }
9038        self.state.clamp_craft_batch_quantity();
9039    }
9040
9041    pub fn close_craft_menu(&mut self) {
9042        self.state.show_craft_menu = false;
9043    }
9044
9045    pub fn toggle_keychain_menu(&mut self) {
9046        if self.state.show_keychain_menu {
9047            self.close_keychain_menu();
9048        } else {
9049            self.state.show_keychain_menu = true;
9050            self.state.show_craft_menu = false;
9051            self.state.show_shop_menu = false;
9052            self.state.show_inventory_menu = false;
9053            let n = self.state.keychain_entries().len();
9054            if n == 0 {
9055                self.state.keychain_menu_index = 0;
9056            } else {
9057                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
9058            }
9059        }
9060    }
9061
9062    pub fn close_keychain_menu(&mut self) {
9063        self.state.show_keychain_menu = false;
9064    }
9065
9066    pub fn keychain_menu_move(&mut self, delta: i32) {
9067        let n = self.state.keychain_entries().len();
9068        if n == 0 {
9069            self.state.keychain_menu_index = 0;
9070            return;
9071        }
9072        let idx = self.state.keychain_menu_index as i32 + delta;
9073        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
9074    }
9075
9076    pub fn keychain_menu_page(&mut self, pages: i32) {
9077        let n = self.state.keychain_entries().len();
9078        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
9079    }
9080
9081    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
9082        if !self.state.is_alive() {
9083            anyhow::bail!("you are dead");
9084        }
9085        let entries = self.state.keychain_entries();
9086        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
9087            anyhow::bail!("nothing selected");
9088        };
9089        let Some(instance_id) = entry.stack.item_instance_id else {
9090            anyhow::bail!("key has no instance id");
9091        };
9092        if entry.stowed {
9093            self.move_item(
9094                instance_id,
9095                flatland_protocol::InventoryLocation::Keychain,
9096                flatland_protocol::InventoryLocation::Root,
9097                None,
9098                Some(1),
9099            )
9100            .await
9101        } else {
9102            self.move_item(
9103                instance_id,
9104                flatland_protocol::InventoryLocation::Root,
9105                flatland_protocol::InventoryLocation::Keychain,
9106                None,
9107                Some(1),
9108            )
9109            .await
9110        }
9111    }
9112
9113    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
9114        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
9115        self.state.show_shop_menu = false;
9116        self.state.shop_catalog = None;
9117        self.state.clear_shop_trade_log();
9118        if let Some(npc_id) = npc_id {
9119            self.seq += 1;
9120            self.session
9121                .submit_intent(Intent::ShopClose {
9122                    entity_id: self.state.entity_id,
9123                    npc_id,
9124                    seq: self.seq,
9125                })
9126                .await?;
9127            self.state.intents_sent += 1;
9128        }
9129        Ok(())
9130    }
9131
9132    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9133        let Some(panel) = self.state.bank_panel.clone() else {
9134            return Ok(());
9135        };
9136        self.seq += 1;
9137        self.session
9138            .submit_intent(Intent::BankDeposit {
9139                entity_id: self.state.entity_id,
9140                npc_id: panel.npc_id,
9141                amount_copper,
9142                seq: self.seq,
9143            })
9144            .await?;
9145        self.state.intents_sent += 1;
9146        Ok(())
9147    }
9148
9149    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9150        let Some(panel) = self.state.bank_panel.clone() else {
9151            return Ok(());
9152        };
9153        self.seq += 1;
9154        self.session
9155            .submit_intent(Intent::BankWithdraw {
9156                entity_id: self.state.entity_id,
9157                npc_id: panel.npc_id,
9158                amount_copper,
9159                seq: self.seq,
9160            })
9161            .await?;
9162        self.state.intents_sent += 1;
9163        Ok(())
9164    }
9165
9166    pub async fn bank_transfer(
9167        &mut self,
9168        to_character_id: Option<uuid::Uuid>,
9169        to_name: String,
9170        amount_copper: u64,
9171    ) -> anyhow::Result<()> {
9172        let Some(panel) = self.state.bank_panel.clone() else {
9173            return Ok(());
9174        };
9175        self.seq += 1;
9176        self.session
9177            .submit_intent(Intent::BankTransfer {
9178                entity_id: self.state.entity_id,
9179                npc_id: panel.npc_id,
9180                to_character_id,
9181                to_name,
9182                amount_copper,
9183                seq: self.seq,
9184            })
9185            .await?;
9186        self.state.intents_sent += 1;
9187        Ok(())
9188    }
9189
9190    pub fn bank_menu_move(&mut self, delta: i32) {
9191        let n = self.state.bank_menu_options().len();
9192        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9193            return;
9194        }
9195        let idx = self.state.bank_menu_index as i32;
9196        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9197    }
9198
9199    pub fn storage_menu_move(&mut self, delta: i32) {
9200        let n = self.state.storage_menu_options().len();
9201        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9202            return;
9203        }
9204        let idx = self.state.storage_menu_index as i32;
9205        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9206    }
9207
9208    pub fn storage_pick_move(&mut self, delta: i32) {
9209        let n = match &self.state.storage_ui_mode {
9210            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
9211            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
9212                self.state.storage_vault_options().len()
9213            }
9214            StorageUiMode::Menu
9215            | StorageUiMode::StoreAmount { .. }
9216            | StorageUiMode::TakeAmount { .. }
9217            | StorageUiMode::ShipAmount { .. } => 0,
9218        };
9219        if n == 0 {
9220            return;
9221        }
9222        match &mut self.state.storage_ui_mode {
9223            StorageUiMode::StorePick { index }
9224            | StorageUiMode::TakePick { index }
9225            | StorageUiMode::ShipPick { index, .. } => {
9226                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9227            }
9228            StorageUiMode::Menu
9229            | StorageUiMode::StoreAmount { .. }
9230            | StorageUiMode::TakeAmount { .. }
9231            | StorageUiMode::ShipAmount { .. } => {}
9232        }
9233    }
9234
9235    pub fn storage_ui_back(&mut self) {
9236        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
9237            StorageUiMode::StoreAmount { pick_index, .. } => {
9238                StorageUiMode::StorePick { index: *pick_index }
9239            }
9240            StorageUiMode::TakeAmount { pick_index, .. } => {
9241                StorageUiMode::TakePick { index: *pick_index }
9242            }
9243            StorageUiMode::ShipAmount {
9244                dest_building_id,
9245                dest_label,
9246                pick_index,
9247                ..
9248            } => StorageUiMode::ShipPick {
9249                dest_building_id: dest_building_id.clone(),
9250                dest_label: dest_label.clone(),
9251                index: *pick_index,
9252            },
9253            StorageUiMode::StorePick { .. }
9254            | StorageUiMode::TakePick { .. }
9255            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
9256            StorageUiMode::Menu => StorageUiMode::Menu,
9257        };
9258    }
9259
9260    pub fn storage_amount_append_char(&mut self, c: char) {
9261        match &mut self.state.storage_ui_mode {
9262            StorageUiMode::StoreAmount { input, .. }
9263            | StorageUiMode::TakeAmount { input, .. }
9264            | StorageUiMode::ShipAmount { input, .. } => {
9265                if c.is_ascii_digit() && input.len() < 8 {
9266                    input.push(c);
9267                }
9268            }
9269            _ => {}
9270        }
9271    }
9272
9273    pub fn storage_amount_backspace(&mut self) {
9274        match &mut self.state.storage_ui_mode {
9275            StorageUiMode::StoreAmount { input, .. }
9276            | StorageUiMode::TakeAmount { input, .. }
9277            | StorageUiMode::ShipAmount { input, .. } => {
9278                input.pop();
9279            }
9280            _ => {}
9281        }
9282    }
9283
9284    pub fn storage_ui_typing(&self) -> bool {
9285        matches!(
9286            self.state.storage_ui_mode,
9287            StorageUiMode::StoreAmount { .. }
9288                | StorageUiMode::TakeAmount { .. }
9289                | StorageUiMode::ShipAmount { .. }
9290        )
9291    }
9292
9293    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
9294        match self.state.storage_ui_mode.clone() {
9295            StorageUiMode::Menu => {
9296                let index = self.state.storage_menu_index;
9297                match index {
9298                    0 => {
9299                        let opts = self.state.storage_store_options();
9300                        if opts.is_empty() {
9301                            self.state.push_log("Nothing loose to store.");
9302                            return Ok(());
9303                        }
9304                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
9305                    }
9306                    1 => {
9307                        let opts = self.state.storage_vault_options();
9308                        if opts.is_empty() {
9309                            self.state.push_log("Vault is empty.");
9310                            return Ok(());
9311                        }
9312                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
9313                    }
9314                    n => {
9315                        let dest = self
9316                            .state
9317                            .storage_panel
9318                            .as_ref()
9319                            .and_then(|p| p.ship_destinations.get(n - 2))
9320                            .cloned();
9321                        let Some(dest) = dest else {
9322                            return Ok(());
9323                        };
9324                        let opts = self.state.storage_vault_options();
9325                        if opts.is_empty() {
9326                            self.state.push_log("Vault is empty — nothing to ship.");
9327                            return Ok(());
9328                        }
9329                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
9330                            dest_building_id: dest.building_id,
9331                            dest_label: dest.label,
9332                            index: 0,
9333                        };
9334                    }
9335                }
9336            }
9337            StorageUiMode::StorePick { index } => {
9338                let opts = self.state.storage_store_options();
9339                let Some(opt) = opts.get(index) else {
9340                    self.state.push_log("Nothing loose to store.");
9341                    self.state.storage_ui_mode = StorageUiMode::Menu;
9342                    return Ok(());
9343                };
9344                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
9345                    pick_index: index,
9346                    item_instance_id: opt.item_instance_id,
9347                    label: opt.label.clone(),
9348                    max_qty: opt.quantity.max(1),
9349                    input: String::new(),
9350                };
9351            }
9352            StorageUiMode::TakePick { index } => {
9353                let opts = self.state.storage_vault_options();
9354                let Some(opt) = opts.get(index) else {
9355                    self.state.push_log("Vault is empty.");
9356                    self.state.storage_ui_mode = StorageUiMode::Menu;
9357                    return Ok(());
9358                };
9359                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
9360                    pick_index: index,
9361                    item_instance_id: opt.item_instance_id,
9362                    label: opt.label.clone(),
9363                    max_qty: opt.quantity.max(1),
9364                    input: String::new(),
9365                };
9366            }
9367            StorageUiMode::ShipPick {
9368                dest_building_id,
9369                dest_label,
9370                index,
9371            } => {
9372                let opts = self.state.storage_vault_options();
9373                let Some(opt) = opts.get(index) else {
9374                    self.state.push_log("Vault is empty — nothing to ship.");
9375                    self.state.storage_ui_mode = StorageUiMode::Menu;
9376                    return Ok(());
9377                };
9378                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
9379                    dest_building_id,
9380                    dest_label,
9381                    pick_index: index,
9382                    item_instance_id: opt.item_instance_id,
9383                    label: opt.label.clone(),
9384                    max_qty: opt.quantity.max(1),
9385                    input: String::new(),
9386                };
9387            }
9388            StorageUiMode::StoreAmount {
9389                item_instance_id,
9390                max_qty,
9391                input,
9392                ..
9393            } => {
9394                let Some(qty) = parse_storage_quantity(&input) else {
9395                    self.state.push_log("Enter a quantity (blank or 0 = all).");
9396                    return Ok(());
9397                };
9398                let qty = qty.map(|n| n.min(max_qty).max(1));
9399                self.storage_store(item_instance_id, qty).await?;
9400                self.state.storage_ui_mode = StorageUiMode::Menu;
9401            }
9402            StorageUiMode::TakeAmount {
9403                item_instance_id,
9404                max_qty,
9405                input,
9406                ..
9407            } => {
9408                let Some(qty) = parse_storage_quantity(&input) else {
9409                    self.state.push_log("Enter a quantity (blank or 0 = all).");
9410                    return Ok(());
9411                };
9412                let qty = qty.map(|n| n.min(max_qty).max(1));
9413                self.storage_take(item_instance_id, qty).await?;
9414                self.state.storage_ui_mode = StorageUiMode::Menu;
9415            }
9416            StorageUiMode::ShipAmount {
9417                dest_building_id,
9418                item_instance_id,
9419                max_qty,
9420                input,
9421                ..
9422            } => {
9423                let Some(qty) = parse_storage_quantity(&input) else {
9424                    self.state.push_log("Enter a quantity (blank or 0 = all).");
9425                    return Ok(());
9426                };
9427                let qty = qty.map(|n| n.min(max_qty).max(1));
9428                self.storage_ship(dest_building_id, item_instance_id, qty)
9429                    .await?;
9430                self.state.storage_ui_mode = StorageUiMode::Menu;
9431            }
9432        }
9433        Ok(())
9434    }
9435
9436    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
9437        match self.state.bank_ui_mode.clone() {
9438            BankUiMode::Menu => {
9439                let choice = self
9440                    .state
9441                    .bank_menu_options()
9442                    .get(self.state.bank_menu_index)
9443                    .copied()
9444                    .unwrap_or("Deposit…");
9445                match choice {
9446                    "Withdraw…" => {
9447                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9448                            input: String::new(),
9449                        };
9450                    }
9451                    "Deposit all" => self.bank_deposit(0).await?,
9452                    "Withdraw all" => self.bank_withdraw(0).await?,
9453                    "Transfer…" => {
9454                        self.state.bank_ui_mode = BankUiMode::TransferName {
9455                            input: String::new(),
9456                        };
9457                    }
9458                    _ => {
9459                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
9460                            input: String::new(),
9461                        };
9462                    }
9463                }
9464            }
9465            BankUiMode::DepositAmount { input } => {
9466                let Some(amount) = parse_bank_copper_amount(&input) else {
9467                    self.state
9468                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
9469                    return Ok(());
9470                };
9471                self.bank_deposit(amount).await?;
9472                self.state.bank_ui_mode = BankUiMode::Menu;
9473            }
9474            BankUiMode::WithdrawAmount { input } => {
9475                let Some(amount) = parse_bank_copper_amount(&input) else {
9476                    self.state
9477                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
9478                    return Ok(());
9479                };
9480                self.bank_withdraw(amount).await?;
9481                self.state.bank_ui_mode = BankUiMode::Menu;
9482            }
9483            BankUiMode::TransferName { input } => {
9484                let name = input.trim().to_string();
9485                if name.is_empty() {
9486                    self.state.push_log("Enter the recipient character name.");
9487                    return Ok(());
9488                }
9489                self.state.bank_ui_mode = BankUiMode::TransferAmount {
9490                    to_name: name,
9491                    input: String::new(),
9492                };
9493            }
9494            BankUiMode::TransferAmount { to_name, input } => {
9495                let amount: u64 = match input.trim().parse() {
9496                    Ok(v) if v > 0 => v,
9497                    _ => {
9498                        self.state
9499                            .push_log("Enter a positive copper amount to transfer.");
9500                        return Ok(());
9501                    }
9502                };
9503                self.bank_transfer(None, to_name, amount).await?;
9504                self.state.bank_ui_mode = BankUiMode::Menu;
9505            }
9506        }
9507        Ok(())
9508    }
9509
9510    pub fn bank_transfer_back(&mut self) {
9511        match &self.state.bank_ui_mode {
9512            BankUiMode::TransferAmount { to_name, .. } => {
9513                self.state.bank_ui_mode = BankUiMode::TransferName {
9514                    input: to_name.clone(),
9515                };
9516            }
9517            BankUiMode::TransferName { .. }
9518            | BankUiMode::DepositAmount { .. }
9519            | BankUiMode::WithdrawAmount { .. } => {
9520                self.state.bank_ui_mode = BankUiMode::Menu;
9521            }
9522            BankUiMode::Menu => {}
9523        }
9524    }
9525
9526    pub fn bank_transfer_append_char(&mut self, c: char) {
9527        match &mut self.state.bank_ui_mode {
9528            BankUiMode::TransferName { input } => {
9529                if input.len() < 32 && !c.is_control() {
9530                    input.push(c);
9531                }
9532            }
9533            BankUiMode::DepositAmount { input }
9534            | BankUiMode::WithdrawAmount { input }
9535            | BankUiMode::TransferAmount { input, .. } => {
9536                if c.is_ascii_digit() && input.len() < 12 {
9537                    input.push(c);
9538                }
9539            }
9540            BankUiMode::Menu => {}
9541        }
9542    }
9543
9544    pub fn bank_transfer_backspace(&mut self) {
9545        match &mut self.state.bank_ui_mode {
9546            BankUiMode::TransferName { input }
9547            | BankUiMode::DepositAmount { input }
9548            | BankUiMode::WithdrawAmount { input }
9549            | BankUiMode::TransferAmount { input, .. } => {
9550                input.pop();
9551            }
9552            BankUiMode::Menu => {}
9553        }
9554    }
9555
9556    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9557        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
9558        self.state.clear_bank_panel();
9559        if let Some(npc_id) = npc_id {
9560            self.seq += 1;
9561            self.session
9562                .submit_intent(Intent::BankClose {
9563                    entity_id: self.state.entity_id,
9564                    npc_id,
9565                    seq: self.seq,
9566                })
9567                .await?;
9568            self.state.intents_sent += 1;
9569        }
9570        Ok(())
9571    }
9572
9573    pub async fn storage_store(
9574        &mut self,
9575        item_instance_id: uuid::Uuid,
9576        quantity: Option<u32>,
9577    ) -> anyhow::Result<()> {
9578        let Some(panel) = self.state.storage_panel.clone() else {
9579            return Ok(());
9580        };
9581        self.seq += 1;
9582        self.session
9583            .submit_intent(Intent::StorageStore {
9584                entity_id: self.state.entity_id,
9585                npc_id: panel.npc_id,
9586                item_instance_id,
9587                quantity,
9588                seq: self.seq,
9589            })
9590            .await?;
9591        self.state.intents_sent += 1;
9592        Ok(())
9593    }
9594
9595    pub async fn storage_take(
9596        &mut self,
9597        item_instance_id: uuid::Uuid,
9598        quantity: Option<u32>,
9599    ) -> anyhow::Result<()> {
9600        let Some(panel) = self.state.storage_panel.clone() else {
9601            return Ok(());
9602        };
9603        self.seq += 1;
9604        self.session
9605            .submit_intent(Intent::StorageTake {
9606                entity_id: self.state.entity_id,
9607                npc_id: panel.npc_id,
9608                item_instance_id,
9609                quantity,
9610                seq: self.seq,
9611            })
9612            .await?;
9613        self.state.intents_sent += 1;
9614        Ok(())
9615    }
9616
9617    pub async fn storage_ship(
9618        &mut self,
9619        dest_building_id: String,
9620        item_instance_id: uuid::Uuid,
9621        quantity: Option<u32>,
9622    ) -> anyhow::Result<()> {
9623        let Some(panel) = self.state.storage_panel.clone() else {
9624            return Ok(());
9625        };
9626        self.seq += 1;
9627        self.session
9628            .submit_intent(Intent::StorageShip {
9629                entity_id: self.state.entity_id,
9630                npc_id: panel.npc_id,
9631                dest_building_id,
9632                item_instance_id,
9633                quantity,
9634                seq: self.seq,
9635            })
9636            .await?;
9637        self.state.intents_sent += 1;
9638        Ok(())
9639    }
9640
9641    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9642        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
9643        self.state.clear_storage_panel();
9644        if let Some(npc_id) = npc_id {
9645            self.seq += 1;
9646            self.session
9647                .submit_intent(Intent::StorageClose {
9648                    entity_id: self.state.entity_id,
9649                    npc_id,
9650                    seq: self.seq,
9651                })
9652                .await?;
9653            self.state.intents_sent += 1;
9654        }
9655        Ok(())
9656    }
9657
9658    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9659        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
9660        self.state.clear_market_panel();
9661        if let Some(npc_id) = npc_id {
9662            self.seq += 1;
9663            self.session
9664                .submit_intent(Intent::MarketClose {
9665                    entity_id: self.state.entity_id,
9666                    npc_id,
9667                    seq: self.seq,
9668                })
9669                .await?;
9670            self.state.intents_sent += 1;
9671        }
9672        Ok(())
9673    }
9674
9675    pub fn market_move_selection(&mut self, delta: i32) {
9676        let indices = self.state.market_filtered_listing_indices();
9677        let n = indices.len();
9678        if n == 0 {
9679            self.state.market_menu_index = 0;
9680            return;
9681        }
9682        let cur = self.state.market_menu_index as i32;
9683        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9684    }
9685
9686    pub fn market_page_selection(&mut self, pages: i32) {
9687        let indices = self.state.market_filtered_listing_indices();
9688        let n = indices.len();
9689        if n == 0 {
9690            self.state.market_menu_index = 0;
9691            return;
9692        }
9693        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9694    }
9695
9696    pub fn market_list_page(&mut self, pages: i32) {
9697        match &self.state.market_ui_mode {
9698            MarketUiMode::ListSource { index } => {
9699                let n = self.state.market_list_source_options().len();
9700                if n == 0 {
9701                    return;
9702                }
9703                let next = page_list_index(*index, pages, n);
9704                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9705            }
9706            MarketUiMode::ListPricingMode { index, .. } => {
9707                let next = page_list_index(*index, pages, 2);
9708                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9709                {
9710                    *index = next;
9711                }
9712            }
9713            MarketUiMode::ListPick { source, index } => {
9714                let opts = self.state.market_list_item_options(source);
9715                let n = opts.len();
9716                if n == 0 {
9717                    return;
9718                }
9719                let next = page_list_index(*index, pages, n);
9720                self.state.market_ui_mode = MarketUiMode::ListPick {
9721                    source: source.clone(),
9722                    index: next,
9723                };
9724            }
9725            _ => {}
9726        }
9727    }
9728
9729    pub fn market_cycle_category(&mut self, delta: i32) {
9730        let groups = self.state.market_available_category_groups();
9731        // All + groups
9732        let mut labels: Vec<Option<&'static str>> = vec![None];
9733        labels.extend(groups.into_iter().map(Some));
9734        let n = labels.len() as i32;
9735        let cur = labels
9736            .iter()
9737            .position(|g| *g == self.state.market_category_filter)
9738            .unwrap_or(0) as i32;
9739        let next = (cur + delta).rem_euclid(n) as usize;
9740        self.state.market_category_filter = labels[next];
9741        self.state.market_menu_index = 0;
9742        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9743            let source = source.clone();
9744            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9745        }
9746    }
9747
9748    pub fn focus_market_filter(&mut self) {
9749        self.state.market_filter_focused = true;
9750    }
9751
9752    pub fn append_market_filter_char(&mut self, ch: char) {
9753        if !self.state.market_filter_focused {
9754            return;
9755        }
9756        if ch.is_control() {
9757            return;
9758        }
9759        if self.state.market_filter.len() < 48 {
9760            self.state.market_filter.push(ch);
9761            self.state.market_menu_index = 0;
9762            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9763                let source = source.clone();
9764                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9765            }
9766        }
9767    }
9768
9769    pub fn market_filter_backspace(&mut self) {
9770        if !self.state.market_filter_focused {
9771            return;
9772        }
9773        self.state.market_filter.pop();
9774        self.state.market_menu_index = 0;
9775        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9776            let source = source.clone();
9777            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9778        }
9779    }
9780
9781    /// Clears filter text, blurs search, or returns false if already idle.
9782    pub fn clear_or_blur_market_filter(&mut self) -> bool {
9783        if self.state.market_filter_focused {
9784            if !self.state.market_filter.is_empty() {
9785                self.state.market_filter.clear();
9786                self.state.market_menu_index = 0;
9787                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9788                    let source = source.clone();
9789                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9790                }
9791                return true;
9792            }
9793            self.state.market_filter_focused = false;
9794            return true;
9795        }
9796        if !self.state.market_filter.is_empty() {
9797            self.state.market_filter.clear();
9798            self.state.market_menu_index = 0;
9799            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9800                let source = source.clone();
9801                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9802            }
9803            return true;
9804        }
9805        false
9806    }
9807
9808    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9809        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9810            return self.market_confirm_buy(listing_id, qty).await;
9811        }
9812        let Some(panel) = self.state.market_panel.clone() else {
9813            return Ok(());
9814        };
9815        let indices = self.state.market_filtered_listing_indices();
9816        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9817            return Ok(());
9818        };
9819        let Some(listing) = panel.listings.get(raw_idx) else {
9820            return Ok(());
9821        };
9822        if listing.mine {
9823            self.seq += 1;
9824            self.session
9825                .submit_intent(Intent::MarketDelist {
9826                    entity_id: self.state.entity_id,
9827                    npc_id: panel.npc_id.clone(),
9828                    listing_id: listing.listing_id,
9829                    dest: flatland_protocol::GoodsLocation::Person,
9830                    seq: self.seq,
9831                })
9832                .await?;
9833            self.state.intents_sent += 1;
9834            return Ok(());
9835        }
9836        if listing.npc_price {
9837            self.state
9838                .push_log("NPC-price listings are bought by merchants only.");
9839            return Ok(());
9840        }
9841        let qty = 1u32.min(listing.quantity).max(1);
9842        let line = listing.unit_price_copper.saturating_mul(qty as u64);
9843        self.state.market_buy_confirm = Some((
9844            listing.listing_id,
9845            qty,
9846            listing.unit_price_copper,
9847            line,
9848            listing.display_name.clone(),
9849        ));
9850        Ok(())
9851    }
9852
9853    pub async fn market_confirm_buy(
9854        &mut self,
9855        listing_id: uuid::Uuid,
9856        quantity: u32,
9857    ) -> anyhow::Result<()> {
9858        let Some(panel) = self.state.market_panel.clone() else {
9859            self.state.market_buy_confirm = None;
9860            return Ok(());
9861        };
9862        self.state.market_buy_confirm = None;
9863        self.seq += 1;
9864        self.session
9865            .submit_intent(Intent::MarketBuy {
9866                entity_id: self.state.entity_id,
9867                npc_id: panel.npc_id,
9868                listing_id,
9869                quantity,
9870                dest: flatland_protocol::GoodsLocation::Person,
9871                seq: self.seq,
9872            })
9873            .await?;
9874        self.state.intents_sent += 1;
9875        Ok(())
9876    }
9877
9878    /// Begin the list wizard (`l` in market browse).
9879    pub fn market_begin_list(&mut self) {
9880        if self.state.market_panel.is_none() {
9881            return;
9882        }
9883        let sources = self.state.market_list_source_options();
9884        if sources.is_empty() {
9885            self.state.push_log("Nothing to list from.");
9886            return;
9887        }
9888        // One source (person only) → skip straight to item pick when it has stacks.
9889        if sources.len() == 1 {
9890            let (source, _) = sources[0].clone();
9891            let opts = self.state.market_list_item_options(&source);
9892            if opts.is_empty() {
9893                self.state.push_log("Nothing loose to list.");
9894                return;
9895            }
9896            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9897            self.state.market_buy_confirm = None;
9898            return;
9899        }
9900        self.state.market_buy_confirm = None;
9901        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9902    }
9903
9904    pub fn market_ui_back(&mut self) {
9905        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9906            MarketUiMode::Browse => MarketUiMode::Browse,
9907            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9908            MarketUiMode::ListPick { .. } => {
9909                if self.state.market_list_source_options().len() <= 1 {
9910                    MarketUiMode::Browse
9911                } else {
9912                    MarketUiMode::ListSource { index: 0 }
9913                }
9914            }
9915            MarketUiMode::ListAmount {
9916                source, pick_index, ..
9917            } => MarketUiMode::ListPick {
9918                source,
9919                index: pick_index,
9920            },
9921            MarketUiMode::ListPricingMode {
9922                source,
9923                item_instance_id,
9924                template_id,
9925                label,
9926                max_qty,
9927                quantity,
9928                pick_index,
9929                ..
9930            } => {
9931                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9932                MarketUiMode::ListAmount {
9933                    source,
9934                    pick_index,
9935                    item_instance_id,
9936                    template_id,
9937                    label,
9938                    max_qty,
9939                    input,
9940                }
9941            }
9942            MarketUiMode::ListPrice {
9943                source,
9944                pick_index,
9945                item_instance_id,
9946                template_id,
9947                label,
9948                max_qty,
9949                quantity,
9950                ..
9951            } => MarketUiMode::ListPricingMode {
9952                source,
9953                pick_index,
9954                item_instance_id,
9955                template_id,
9956                label,
9957                quantity,
9958                max_qty,
9959                index: 1,
9960            },
9961        };
9962    }
9963
9964    pub fn market_list_move(&mut self, delta: i32) {
9965        match &self.state.market_ui_mode {
9966            MarketUiMode::ListSource { index } => {
9967                let n = self.state.market_list_source_options().len();
9968                if n == 0 {
9969                    return;
9970                }
9971                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9972                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9973            }
9974            MarketUiMode::ListPricingMode { index, .. } => {
9975                let next = (*index as i32 + delta).rem_euclid(2) as usize;
9976                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9977                {
9978                    *index = next;
9979                }
9980            }
9981            MarketUiMode::ListPick { source, index } => {
9982                let opts = self.state.market_list_item_options(source);
9983                let n = opts.len();
9984                if n == 0 {
9985                    return;
9986                }
9987                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9988                self.state.market_ui_mode = MarketUiMode::ListPick {
9989                    source: source.clone(),
9990                    index: next,
9991                };
9992            }
9993            _ => {}
9994        }
9995    }
9996
9997    pub fn market_list_amount_append_char(&mut self, c: char) {
9998        if !c.is_ascii_digit() {
9999            return;
10000        }
10001        match &mut self.state.market_ui_mode {
10002            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10003                if input.len() < 12 {
10004                    input.push(c);
10005                }
10006            }
10007            _ => {}
10008        }
10009    }
10010
10011    pub fn market_list_amount_backspace(&mut self) {
10012        match &mut self.state.market_ui_mode {
10013            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10014                input.pop();
10015            }
10016            _ => {}
10017        }
10018    }
10019
10020    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
10021        match self.state.market_ui_mode.clone() {
10022            MarketUiMode::Browse => Ok(()),
10023            MarketUiMode::ListSource { index } => {
10024                let sources = self.state.market_list_source_options();
10025                let Some((source, _)) = sources.get(index).cloned() else {
10026                    return Ok(());
10027                };
10028                let opts = self.state.market_list_item_options(&source);
10029                if opts.is_empty() {
10030                    self.state.push_log("Nothing to list from that source.");
10031                    return Ok(());
10032                }
10033                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10034                Ok(())
10035            }
10036            MarketUiMode::ListPick { source, index } => {
10037                let opts = self.state.market_list_item_options(&source);
10038                let Some(opt) = opts.get(index) else {
10039                    self.state.push_log("Nothing to list.");
10040                    self.state.market_ui_mode = MarketUiMode::Browse;
10041                    return Ok(());
10042                };
10043                self.state.market_ui_mode = MarketUiMode::ListAmount {
10044                    source,
10045                    pick_index: index,
10046                    item_instance_id: opt.item_instance_id,
10047                    template_id: opt.template_id.clone(),
10048                    label: opt.label.clone(),
10049                    max_qty: opt.quantity.max(1),
10050                    input: String::new(),
10051                };
10052                Ok(())
10053            }
10054            MarketUiMode::ListAmount {
10055                source,
10056                pick_index,
10057                item_instance_id,
10058                template_id,
10059                label,
10060                max_qty,
10061                input,
10062                ..
10063            } => {
10064                let Some(qty_opt) = parse_storage_quantity(&input) else {
10065                    self.state.push_log("Enter a quantity (blank = all).");
10066                    return Ok(());
10067                };
10068                if let Some(q) = qty_opt {
10069                    if q > max_qty {
10070                        self.state.push_log(format!("Only {max_qty} available."));
10071                        return Ok(());
10072                    }
10073                }
10074                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
10075                    source,
10076                    pick_index,
10077                    item_instance_id,
10078                    template_id,
10079                    label,
10080                    quantity: qty_opt,
10081                    max_qty,
10082                    index: 0,
10083                };
10084                Ok(())
10085            }
10086            MarketUiMode::ListPricingMode {
10087                source,
10088                pick_index,
10089                item_instance_id,
10090                template_id,
10091                label,
10092                quantity,
10093                max_qty,
10094                index,
10095            } => {
10096                if index == 0 {
10097                    if self
10098                        .state
10099                        .npc_market_dump_unit_estimate(&template_id)
10100                        .is_none()
10101                    {
10102                        self.state
10103                            .push_log("That item has no NPC value — use a fixed price instead.");
10104                        return Ok(());
10105                    }
10106                    return self
10107                        .submit_market_list_intent(
10108                            source,
10109                            item_instance_id,
10110                            quantity,
10111                            0,
10112                            true,
10113                            &label,
10114                        )
10115                        .await;
10116                }
10117                self.state.market_ui_mode = MarketUiMode::ListPrice {
10118                    source,
10119                    pick_index,
10120                    item_instance_id,
10121                    template_id,
10122                    label,
10123                    quantity,
10124                    max_qty,
10125                    input: String::new(),
10126                };
10127                Ok(())
10128            }
10129            MarketUiMode::ListPrice {
10130                source,
10131                item_instance_id,
10132                label,
10133                quantity,
10134                input,
10135                ..
10136            } => {
10137                let price = input.trim().parse::<u64>().unwrap_or(0);
10138                if price == 0 {
10139                    self.state
10140                        .push_log("Enter a unit price of at least 1 copper.");
10141                    return Ok(());
10142                }
10143                self.submit_market_list_intent(
10144                    source,
10145                    item_instance_id,
10146                    quantity,
10147                    price,
10148                    false,
10149                    &label,
10150                )
10151                .await
10152            }
10153        }
10154    }
10155
10156    async fn submit_market_list_intent(
10157        &mut self,
10158        source: MarketListSourceKind,
10159        item_instance_id: uuid::Uuid,
10160        quantity: Option<u32>,
10161        unit_price_copper: u64,
10162        npc_price: bool,
10163        label: &str,
10164    ) -> anyhow::Result<()> {
10165        let Some(panel) = self.state.market_panel.clone() else {
10166            self.state.market_ui_mode = MarketUiMode::Browse;
10167            return Ok(());
10168        };
10169        let goods = match source {
10170            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
10171            MarketListSourceKind::TownStorage { building_id } => {
10172                flatland_protocol::GoodsLocation::TownStorage { building_id }
10173            }
10174        };
10175        self.seq += 1;
10176        self.session
10177            .submit_intent(Intent::MarketList {
10178                entity_id: self.state.entity_id,
10179                npc_id: panel.npc_id,
10180                source: goods,
10181                item_instance_id,
10182                quantity,
10183                unit_price_copper,
10184                npc_price,
10185                seq: self.seq,
10186            })
10187            .await?;
10188        self.state.intents_sent += 1;
10189        if npc_price {
10190            self.state
10191                .push_log(format!("Listing {label} at NPC price…"));
10192        } else {
10193            self.state
10194                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
10195        }
10196        self.state.market_ui_mode = MarketUiMode::Browse;
10197        Ok(())
10198    }
10199
10200    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
10201    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
10202        let return_to_verbs = self.state.npc_verb_target.is_some();
10203        self.close_shop_menu().await?;
10204        if return_to_verbs {
10205            self.state.show_npc_verb_menu = true;
10206        }
10207        Ok(())
10208    }
10209
10210    pub fn shop_tab_toggle(&mut self) {
10211        self.state.shop_tab = match self.state.shop_tab {
10212            ShopTab::Buy => ShopTab::Sell,
10213            ShopTab::Sell => ShopTab::Buy,
10214        };
10215        self.state.shop_menu_index = 0;
10216        if self.state.shop_tab == ShopTab::Sell {
10217            self.state.shop_quantity_set_max();
10218        }
10219        self.state.clamp_shop_selection();
10220    }
10221
10222    pub fn shop_menu_move(&mut self, delta: i32) {
10223        self.state.shop_menu_move(delta);
10224    }
10225
10226    pub fn shop_quantity_adjust(&mut self, delta: i32) {
10227        self.state.shop_quantity_adjust(delta);
10228    }
10229
10230    pub fn shop_quantity_set_max(&mut self) {
10231        self.state.shop_quantity_set_max();
10232    }
10233
10234    pub fn shop_quantity_set_min(&mut self) {
10235        self.state.shop_quantity_set_min();
10236    }
10237
10238    pub fn toggle_quest_menu(&mut self) {
10239        self.state.show_quest_menu = !self.state.show_quest_menu;
10240        if self.state.show_quest_menu {
10241            self.state.quest_menu_index = 0;
10242            self.state.quest_withdraw_confirm = false;
10243            self.state.show_workers_menu = false;
10244        }
10245    }
10246
10247    pub fn toggle_workers_menu(&mut self) {
10248        if self.state.show_workers_menu {
10249            self.close_workers_menu_ui();
10250        } else {
10251            self.state.show_workers_menu = true;
10252            self.state.workers_menu_index = 0;
10253            self.state.show_quest_menu = false;
10254            self.close_worker_give_picker();
10255            self.close_worker_give_target_picker();
10256            self.close_worker_take_picker();
10257            self.close_worker_teach_picker();
10258            self.cancel_worker_rename();
10259        }
10260    }
10261
10262    /// Close workers UI layers without network (caller should release attend).
10263    pub fn close_workers_menu_ui(&mut self) {
10264        self.state.show_workers_menu = false;
10265        self.cancel_worker_dismissal();
10266        self.close_worker_give_picker();
10267        self.close_worker_give_target_picker();
10268        self.close_worker_take_picker();
10269        self.close_worker_teach_picker();
10270        self.cancel_worker_rename();
10271    }
10272
10273    /// Open the workers menu focused on `instance_id` and pause that worker's job.
10274    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
10275        let Some(idx) = self
10276            .state
10277            .hired_workers
10278            .iter()
10279            .position(|w| w.instance_id == instance_id)
10280        else {
10281            anyhow::bail!("worker not found");
10282        };
10283        let label = self.state.hired_workers[idx].label.clone();
10284        self.state.show_workers_menu = true;
10285        self.state.workers_menu_index = idx;
10286        self.state.show_quest_menu = false;
10287        self.close_worker_give_picker();
10288        self.close_worker_give_target_picker();
10289        self.close_worker_take_picker();
10290        self.close_worker_teach_picker();
10291        self.cancel_worker_rename();
10292        self.set_worker_attending(instance_id, true).await?;
10293        self.state
10294            .push_log(format!("Managing {label} — job paused while menu is open"));
10295        Ok(())
10296    }
10297
10298    /// Close workers menu and release any attend pause.
10299    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
10300        self.close_workers_menu_ui();
10301        self.release_worker_attend().await
10302    }
10303
10304    async fn set_worker_attending(
10305        &mut self,
10306        instance_id: &str,
10307        attending: bool,
10308    ) -> anyhow::Result<()> {
10309        if attending {
10310            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10311                return Ok(());
10312            }
10313            // Switch target: release previous first.
10314            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
10315                if prev != instance_id {
10316                    self.send_attend_hired_worker(&prev, false).await?;
10317                }
10318            }
10319            self.send_attend_hired_worker(instance_id, true).await?;
10320            self.state.attending_worker_instance_id = Some(instance_id.to_string());
10321        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10322            self.send_attend_hired_worker(instance_id, false).await?;
10323            self.state.attending_worker_instance_id = None;
10324        }
10325        Ok(())
10326    }
10327
10328    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
10329        let Some(id) = self.state.attending_worker_instance_id.take() else {
10330            return Ok(());
10331        };
10332        self.send_attend_hired_worker(&id, false).await
10333    }
10334
10335    async fn send_attend_hired_worker(
10336        &mut self,
10337        worker_instance_id: &str,
10338        attending: bool,
10339    ) -> anyhow::Result<()> {
10340        self.seq += 1;
10341        self.session
10342            .submit_intent(Intent::AttendHiredWorker {
10343                entity_id: self.state.entity_id,
10344                worker_instance_id: worker_instance_id.to_string(),
10345                attending,
10346                seq: self.seq,
10347            })
10348            .await?;
10349        self.state.intents_sent += 1;
10350        Ok(())
10351    }
10352
10353    pub fn workers_menu_move(&mut self, delta: i32) {
10354        let n = self.state.hired_workers.len();
10355        if n == 0 {
10356            return;
10357        }
10358        let idx = self.state.workers_menu_index as i32;
10359        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10360    }
10361
10362    pub fn toggle_workers_menu_compact(&mut self) {
10363        self.state.workers_menu_compact = !self.state.workers_menu_compact;
10364        let mut cfg = crate::client_config::ClientConfig::load();
10365        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
10366    }
10367
10368    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
10369        let Some(worker) = self
10370            .state
10371            .hired_workers
10372            .get(self.state.workers_menu_index)
10373            .cloned()
10374        else {
10375            anyhow::bail!("no worker selected");
10376        };
10377        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
10378            .await
10379    }
10380
10381    /// Open a confirmation dialog before permanently dismissing the selected worker.
10382    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
10383        let Some(worker) = self
10384            .state
10385            .hired_workers
10386            .get(self.state.workers_menu_index)
10387            .cloned()
10388        else {
10389            anyhow::bail!("no worker selected");
10390        };
10391        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
10392            worker_instance_id: worker.instance_id,
10393            worker_label: worker.label,
10394        });
10395        Ok(())
10396    }
10397
10398    pub fn cancel_worker_dismissal(&mut self) {
10399        self.state.worker_dismiss_confirmation = None;
10400    }
10401
10402    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
10403        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
10404            return Ok(());
10405        };
10406        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
10407            .await?;
10408        self.cancel_worker_dismissal();
10409        Ok(())
10410    }
10411
10412    async fn dismiss_worker_by_id(
10413        &mut self,
10414        worker_instance_id: &str,
10415        worker_label: &str,
10416    ) -> anyhow::Result<()> {
10417        self.seq += 1;
10418        self.session
10419            .submit_intent(Intent::DismissWorker {
10420                entity_id: self.state.entity_id,
10421                worker_instance_id: worker_instance_id.to_string(),
10422                seq: self.seq,
10423            })
10424            .await?;
10425        self.state.intents_sent += 1;
10426        self.state
10427            .hired_workers
10428            .retain(|w| w.instance_id != worker_instance_id);
10429        if self.state.workers_menu_index >= self.state.hired_workers.len() {
10430            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
10431        }
10432        self.state.push_log(format!("Dismissed {worker_label}"));
10433        Ok(())
10434    }
10435
10436    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
10437        let Some(worker) = self
10438            .state
10439            .hired_workers
10440            .get(self.state.workers_menu_index)
10441            .cloned()
10442        else {
10443            anyhow::bail!("no worker selected");
10444        };
10445        let mode = match worker.mode {
10446            flatland_protocol::WorkerModeView::Companion => "defender",
10447            flatland_protocol::WorkerModeView::Defender => "job_loop",
10448            flatland_protocol::WorkerModeView::JobLoop => "idle",
10449            flatland_protocol::WorkerModeView::Idle => "companion",
10450        };
10451        self.seq += 1;
10452        self.session
10453            .submit_intent(Intent::SetWorkerMode {
10454                entity_id: self.state.entity_id,
10455                worker_instance_id: worker.instance_id,
10456                mode: mode.into(),
10457                seq: self.seq,
10458            })
10459            .await?;
10460        self.state.intents_sent += 1;
10461        Ok(())
10462    }
10463
10464    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
10465        let Some(worker) = self
10466            .state
10467            .hired_workers
10468            .get(self.state.workers_menu_index)
10469            .cloned()
10470        else {
10471            anyhow::bail!("no worker selected");
10472        };
10473        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
10474            anyhow::bail!("switch the worker to companion mode first");
10475        }
10476        if worker.step_label.starts_with("delivering to ")
10477            || worker.step_label == "returning to you"
10478        {
10479            anyhow::bail!("worker is already delivering to storage");
10480        }
10481        self.seq += 1;
10482        self.session
10483            .submit_intent(Intent::DeliverWorkerToNearestStorage {
10484                entity_id: self.state.entity_id,
10485                worker_instance_id: worker.instance_id.clone(),
10486                seq: self.seq,
10487            })
10488            .await?;
10489        self.state.intents_sent += 1;
10490        self.state.push_log(format!(
10491            "{} is delivering carried items to storage",
10492            worker.label
10493        ));
10494        Ok(())
10495    }
10496
10497    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
10498        let Some(worker) = self
10499            .state
10500            .hired_workers
10501            .get(self.state.workers_menu_index)
10502            .cloned()
10503        else {
10504            anyhow::bail!("no worker selected");
10505        };
10506        if !(worker.step_label.starts_with("delivering to ")
10507            || worker.step_label == "returning to you")
10508        {
10509            anyhow::bail!("worker has no active delivery");
10510        }
10511        self.seq += 1;
10512        self.session
10513            .submit_intent(Intent::CancelWorkerDelivery {
10514                entity_id: self.state.entity_id,
10515                worker_instance_id: worker.instance_id,
10516                seq: self.seq,
10517            })
10518            .await?;
10519        self.state.intents_sent += 1;
10520        self.state
10521            .push_log(format!("Canceled delivery for {}", worker.label));
10522        Ok(())
10523    }
10524
10525    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
10526        if self.state.hired_workers.is_empty() {
10527            return self.hire_worker_laborer().await;
10528        }
10529        self.workers_toggle_mode_selected().await
10530    }
10531
10532    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
10533    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
10534    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10535        let row = self
10536            .state
10537            .inventory_selected_row()
10538            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
10539            .clone();
10540        if row.from != flatland_protocol::InventoryLocation::Root {
10541            anyhow::bail!("select a carried item to give");
10542        }
10543        let Some(instance_id) = row.stack.item_instance_id else {
10544            anyhow::bail!("that stack can't be given");
10545        };
10546        let options = self.nearby_worker_give_targets();
10547        if options.is_empty() {
10548            anyhow::bail!(
10549                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10550            );
10551        }
10552        let item_label = row
10553            .stack
10554            .display_name
10555            .as_deref()
10556            .unwrap_or(&row.stack.template_id)
10557            .to_string();
10558        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10559            item_instance_id: instance_id,
10560            item_label,
10561            quantity: None,
10562            options,
10563        });
10564        self.state.worker_give_target_picker_index = 0;
10565        self.state.show_worker_give_target_picker = true;
10566        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
10567        self.state.show_inventory_menu = false;
10568        Ok(())
10569    }
10570
10571    /// Hired workers within give/take range, nearest first.
10572    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10573        let (px, py, _) = self.state.player_position_with_z();
10574        let mut options: Vec<WorkerGiveTargetOption> = self
10575            .state
10576            .hired_workers
10577            .iter()
10578            .filter_map(|w| {
10579                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10580                if dist > WORKER_GIVE_RANGE_M {
10581                    return None;
10582                }
10583                Some(WorkerGiveTargetOption {
10584                    instance_id: w.instance_id.clone(),
10585                    label: w.label.clone(),
10586                    distance_m: dist,
10587                })
10588            })
10589            .collect();
10590        options.sort_by(|a, b| {
10591            a.distance_m
10592                .partial_cmp(&b.distance_m)
10593                .unwrap_or(std::cmp::Ordering::Equal)
10594        });
10595        options
10596    }
10597
10598    pub fn close_worker_give_target_picker(&mut self) {
10599        self.state.show_worker_give_target_picker = false;
10600        self.state.worker_give_target_picker = None;
10601        self.state.worker_give_target_picker_index = 0;
10602    }
10603
10604    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10605        let Some(picker) = &self.state.worker_give_target_picker else {
10606            return;
10607        };
10608        let n = picker.options.len();
10609        if n == 0 {
10610            return;
10611        }
10612        let idx = self.state.worker_give_target_picker_index as i32;
10613        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10614    }
10615
10616    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10617        let Some(picker) = self.state.worker_give_target_picker.clone() else {
10618            anyhow::bail!("give target picker not open");
10619        };
10620        let Some(opt) = picker
10621            .options
10622            .get(self.state.worker_give_target_picker_index)
10623            .cloned()
10624        else {
10625            anyhow::bail!("no worker selected");
10626        };
10627        let Some(worker) = self
10628            .state
10629            .hired_workers
10630            .iter()
10631            .find(|w| w.instance_id == opt.instance_id)
10632            .cloned()
10633        else {
10634            self.close_worker_give_target_picker();
10635            anyhow::bail!("worker no longer hired");
10636        };
10637        self.give_item_to_worker(
10638            &worker.instance_id,
10639            &worker.label,
10640            worker.x,
10641            worker.y,
10642            picker.item_instance_id,
10643            &picker.item_label,
10644            picker.quantity,
10645        )
10646        .await?;
10647        self.close_worker_give_target_picker();
10648        Ok(())
10649    }
10650
10651    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
10652    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10653        self.open_worker_give_target_picker()
10654    }
10655
10656    /// Open the workers-menu give picker for the selected worker (must be in range).
10657    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10658        let Some(worker) = self
10659            .state
10660            .hired_workers
10661            .get(self.state.workers_menu_index)
10662            .cloned()
10663        else {
10664            anyhow::bail!("select a hired worker first");
10665        };
10666        let (px, py, _) = self.state.player_position_with_z();
10667        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10668        if dist > WORKER_GIVE_RANGE_M {
10669            anyhow::bail!(
10670                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10671                worker.label
10672            );
10673        }
10674        let options = self.state.giveable_inventory_options();
10675        if options.is_empty() {
10676            anyhow::bail!("nothing in inventory to give");
10677        }
10678        self.state.worker_give_picker = Some(WorkerGivePicker {
10679            worker_instance_id: worker.instance_id,
10680            worker_label: worker.label,
10681            options,
10682        });
10683        self.state.worker_give_picker_index = 0;
10684        self.state.show_worker_give_picker = true;
10685        Ok(())
10686    }
10687
10688    pub fn close_worker_give_picker(&mut self) {
10689        self.state.show_worker_give_picker = false;
10690        self.state.worker_give_picker = None;
10691        self.state.worker_give_picker_index = 0;
10692    }
10693
10694    pub fn worker_give_picker_move(&mut self, delta: i32) {
10695        let Some(picker) = &self.state.worker_give_picker else {
10696            return;
10697        };
10698        let n = picker.options.len();
10699        if n == 0 {
10700            return;
10701        }
10702        let idx = self.state.worker_give_picker_index as i32;
10703        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10704    }
10705
10706    /// Confirm the selected row in the workers-menu give picker.
10707    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10708        let Some(picker) = self.state.worker_give_picker.clone() else {
10709            anyhow::bail!("give picker not open");
10710        };
10711        let Some(opt) = picker
10712            .options
10713            .get(self.state.worker_give_picker_index)
10714            .cloned()
10715        else {
10716            anyhow::bail!("no item selected");
10717        };
10718        let Some(worker) = self
10719            .state
10720            .hired_workers
10721            .iter()
10722            .find(|w| w.instance_id == picker.worker_instance_id)
10723            .cloned()
10724        else {
10725            self.close_worker_give_picker();
10726            anyhow::bail!("worker no longer hired");
10727        };
10728        self.give_item_to_worker(
10729            &worker.instance_id,
10730            &worker.label,
10731            worker.x,
10732            worker.y,
10733            opt.item_instance_id,
10734            &opt.label,
10735            None,
10736        )
10737        .await?;
10738        // Refresh options (stack may be gone / reduced) or close when empty.
10739        let options = self.state.giveable_inventory_options();
10740        if options.is_empty() {
10741            self.close_worker_give_picker();
10742        } else {
10743            self.state.worker_give_picker = Some(WorkerGivePicker {
10744                worker_instance_id: picker.worker_instance_id,
10745                worker_label: picker.worker_label,
10746                options,
10747            });
10748            if self.state.worker_give_picker_index
10749                >= self
10750                    .state
10751                    .worker_give_picker
10752                    .as_ref()
10753                    .map(|p| p.options.len())
10754                    .unwrap_or(0)
10755            {
10756                self.state.worker_give_picker_index = self
10757                    .state
10758                    .worker_give_picker
10759                    .as_ref()
10760                    .map(|p| p.options.len().saturating_sub(1))
10761                    .unwrap_or(0);
10762            }
10763        }
10764        Ok(())
10765    }
10766
10767    /// Open the workers-menu teach picker for the selected worker (must be in range).
10768    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10769        let Some(worker) = self
10770            .state
10771            .hired_workers
10772            .get(self.state.workers_menu_index)
10773            .cloned()
10774        else {
10775            anyhow::bail!("select a hired worker first");
10776        };
10777        let (px, py, _) = self.state.player_position_with_z();
10778        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10779        if dist > WORKER_GIVE_RANGE_M {
10780            anyhow::bail!(
10781                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10782                worker.label
10783            );
10784        }
10785        let options = self.state.teachable_blueprint_options(&worker);
10786        if options.is_empty() {
10787            anyhow::bail!("no recipes you know that {} still needs", worker.label);
10788        }
10789        self.state.worker_teach_picker = Some(WorkerTeachPicker {
10790            worker_instance_id: worker.instance_id,
10791            worker_label: worker.label,
10792            worker_level: worker.level,
10793            options,
10794        });
10795        self.state.worker_teach_picker_index = 0;
10796        self.state.show_worker_teach_picker = true;
10797        Ok(())
10798    }
10799
10800    pub fn close_worker_teach_picker(&mut self) {
10801        self.state.show_worker_teach_picker = false;
10802        self.state.worker_teach_picker = None;
10803        self.state.worker_teach_picker_index = 0;
10804    }
10805
10806    pub fn worker_teach_picker_move(&mut self, delta: i32) {
10807        let Some(picker) = &self.state.worker_teach_picker else {
10808            return;
10809        };
10810        let n = picker.options.len();
10811        if n == 0 {
10812            return;
10813        }
10814        let idx = self.state.worker_teach_picker_index as i32;
10815        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10816    }
10817
10818    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10819        let Some(picker) = self.state.worker_teach_picker.clone() else {
10820            anyhow::bail!("teach picker not open");
10821        };
10822        let Some(opt) = picker
10823            .options
10824            .get(self.state.worker_teach_picker_index)
10825            .cloned()
10826        else {
10827            anyhow::bail!("nothing selected");
10828        };
10829        if !opt.level_ok {
10830            anyhow::bail!(
10831                "{} needs level {} (is level {})",
10832                picker.worker_label,
10833                opt.min_level,
10834                opt.worker_level
10835            );
10836        }
10837        if !opt.can_afford {
10838            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10839        }
10840        let Some(worker) = self
10841            .state
10842            .hired_workers
10843            .iter()
10844            .find(|w| w.instance_id == picker.worker_instance_id)
10845            .cloned()
10846        else {
10847            anyhow::bail!("worker gone");
10848        };
10849        let (px, py, _) = self.state.player_position_with_z();
10850        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10851        if dist > WORKER_GIVE_RANGE_M {
10852            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10853        }
10854        self.seq += 1;
10855        self.session
10856            .submit_intent(Intent::TeachWorkerBlueprint {
10857                entity_id: self.state.entity_id,
10858                worker_instance_id: picker.worker_instance_id.clone(),
10859                blueprint_id: opt.blueprint_id.clone(),
10860                seq: self.seq,
10861            })
10862            .await?;
10863        self.state.intents_sent += 1;
10864        self.state.push_log(format!(
10865            "Teaching {} to {} ({} cp)",
10866            opt.label, picker.worker_label, opt.cost_copper
10867        ));
10868        self.close_worker_teach_picker();
10869        Ok(())
10870    }
10871
10872    async fn give_item_to_worker(
10873        &mut self,
10874        worker_instance_id: &str,
10875        worker_label: &str,
10876        worker_x: f32,
10877        worker_y: f32,
10878        item_instance_id: uuid::Uuid,
10879        item_label: &str,
10880        quantity: Option<u32>,
10881    ) -> anyhow::Result<()> {
10882        let (px, py, _) = self.state.player_position_with_z();
10883        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10884        if dist > WORKER_GIVE_RANGE_M {
10885            anyhow::bail!("worker {worker_label} too far — stand next to them");
10886        }
10887        self.seq += 1;
10888        self.session
10889            .submit_intent(Intent::GiveWorkerItem {
10890                entity_id: self.state.entity_id,
10891                worker_instance_id: worker_instance_id.to_string(),
10892                item_instance_id,
10893                quantity,
10894                seq: self.seq,
10895            })
10896            .await?;
10897        self.state.intents_sent += 1;
10898        self.state
10899            .remove_carried_instance(item_instance_id, quantity);
10900        self.state
10901            .push_log(format!("Gave {item_label} to {worker_label}"));
10902        Ok(())
10903    }
10904
10905    /// Equip one item from the player's inventory on the selected worker.
10906    /// The server validates the requested hand/body slot and replaces the
10907    /// previous item into the worker's pack.
10908    pub async fn equip_item_on_worker(
10909        &mut self,
10910        worker_instance_id: &str,
10911        item_instance_id: uuid::Uuid,
10912        slot: &str,
10913    ) -> anyhow::Result<()> {
10914        let Some(worker) = self
10915            .state
10916            .hired_workers
10917            .iter()
10918            .find(|worker| worker.instance_id == worker_instance_id)
10919            .cloned()
10920        else {
10921            anyhow::bail!("worker not found");
10922        };
10923        let (px, py, _) = self.state.player_position_with_z();
10924        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
10925            anyhow::bail!("worker {} too far — stand next to them", worker.label);
10926        }
10927        self.seq += 1;
10928        self.session
10929            .submit_intent(Intent::EquipWorkerItem {
10930                entity_id: self.state.entity_id,
10931                worker_instance_id: worker.instance_id.clone(),
10932                item_instance_id,
10933                slot: slot.to_string(),
10934                seq: self.seq,
10935            })
10936            .await?;
10937        self.state.intents_sent += 1;
10938        self.state
10939            .push_log(format!("Equipped {slot} on {}", worker.label));
10940        Ok(())
10941    }
10942
10943    /// Open the workers-menu take picker for the selected worker (must be in range).
10944    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10945        let Some(worker) = self
10946            .state
10947            .hired_workers
10948            .get(self.state.workers_menu_index)
10949            .cloned()
10950        else {
10951            anyhow::bail!("select a hired worker first");
10952        };
10953        let (px, py, _) = self.state.player_position_with_z();
10954        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10955        if dist > WORKER_GIVE_RANGE_M {
10956            anyhow::bail!(
10957                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10958                worker.label
10959            );
10960        }
10961        let options = Self::worker_inventory_options(&worker);
10962        if options.is_empty() {
10963            anyhow::bail!("{} isn't carrying anything", worker.label);
10964        }
10965        let initial_qty = options
10966            .first()
10967            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10968            .unwrap_or(1);
10969        self.state.worker_take_picker = Some(WorkerTakePicker {
10970            worker_instance_id: worker.instance_id,
10971            worker_label: worker.label,
10972            options,
10973            quantity: initial_qty,
10974        });
10975        self.state.worker_take_picker_index = 0;
10976        self.state.show_worker_take_picker = true;
10977        Ok(())
10978    }
10979
10980    fn worker_inventory_options(
10981        worker: &flatland_protocol::HiredWorkerView,
10982    ) -> Vec<WorkerGiveOption> {
10983        worker
10984            .inventory
10985            .iter()
10986            .filter_map(|stack| {
10987                let item_instance_id = stack.item_instance_id?;
10988                let label = stack
10989                    .display_name
10990                    .clone()
10991                    .unwrap_or_else(|| stack.template_id.clone());
10992                let label = if stack.quantity > 1 {
10993                    format!("{label} ×{}", stack.quantity)
10994                } else {
10995                    label
10996                };
10997                Some(WorkerGiveOption {
10998                    item_instance_id,
10999                    label,
11000                    quantity: stack.quantity,
11001                    template_id: stack.template_id.clone(),
11002                })
11003            })
11004            .collect()
11005    }
11006
11007    pub fn close_worker_take_picker(&mut self) {
11008        self.state.show_worker_take_picker = false;
11009        self.state.worker_take_picker = None;
11010        self.state.worker_take_picker_index = 0;
11011    }
11012
11013    pub fn worker_take_picker_move(&mut self, delta: i32) {
11014        let Some(picker) = &self.state.worker_take_picker else {
11015            return;
11016        };
11017        let n = picker.options.len();
11018        if n == 0 {
11019            return;
11020        }
11021        let idx = self.state.worker_take_picker_index as i32;
11022        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11023        self.clamp_worker_take_quantity();
11024    }
11025
11026    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
11027        let Some(picker) = &mut self.state.worker_take_picker else {
11028            return;
11029        };
11030        let max = picker
11031            .options
11032            .get(self.state.worker_take_picker_index)
11033            .map(|o| o.quantity.max(1))
11034            .unwrap_or(1);
11035        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
11036        picker.quantity = next as u32;
11037    }
11038
11039    pub fn worker_take_picker_set_quantity_max(&mut self) {
11040        let Some(picker) = &mut self.state.worker_take_picker else {
11041            return;
11042        };
11043        let max = picker
11044            .options
11045            .get(self.state.worker_take_picker_index)
11046            .map(|o| o.quantity.max(1))
11047            .unwrap_or(1);
11048        picker.quantity = max;
11049    }
11050
11051    pub fn worker_take_picker_set_quantity_min(&mut self) {
11052        let Some(picker) = &mut self.state.worker_take_picker else {
11053            return;
11054        };
11055        picker.quantity = 1;
11056        self.clamp_worker_take_quantity();
11057    }
11058
11059    fn clamp_worker_take_quantity(&mut self) {
11060        let Some(picker) = &mut self.state.worker_take_picker else {
11061            return;
11062        };
11063        let max = picker
11064            .options
11065            .get(self.state.worker_take_picker_index)
11066            .map(|o| o.quantity.max(1))
11067            .unwrap_or(1);
11068        if picker.quantity == 0 || picker.quantity > max {
11069            picker.quantity = if max > 1 { 1 } else { max };
11070        }
11071    }
11072
11073    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
11074        let Some(picker) = self.state.worker_take_picker.clone() else {
11075            anyhow::bail!("take picker not open");
11076        };
11077        let Some(opt) = picker
11078            .options
11079            .get(self.state.worker_take_picker_index)
11080            .cloned()
11081        else {
11082            anyhow::bail!("no item selected");
11083        };
11084        let Some(worker) = self
11085            .state
11086            .hired_workers
11087            .iter()
11088            .find(|w| w.instance_id == picker.worker_instance_id)
11089            .cloned()
11090        else {
11091            self.close_worker_take_picker();
11092            anyhow::bail!("worker no longer hired");
11093        };
11094        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
11095        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
11096        self.take_item_from_worker(
11097            &worker.instance_id,
11098            &worker.label,
11099            worker.x,
11100            worker.y,
11101            opt.item_instance_id,
11102            &opt.label,
11103            intent_qty,
11104        )
11105        .await?;
11106        // Leave the picker open — server Interaction / hired-workers sync refresh
11107        // options. Do not optimistic-strip stacks (rejects used to look like success).
11108        Ok(())
11109    }
11110
11111    async fn take_item_from_worker(
11112        &mut self,
11113        worker_instance_id: &str,
11114        worker_label: &str,
11115        worker_x: f32,
11116        worker_y: f32,
11117        item_instance_id: uuid::Uuid,
11118        item_label: &str,
11119        quantity: Option<u32>,
11120    ) -> anyhow::Result<()> {
11121        let (px, py, _) = self.state.player_position_with_z();
11122        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11123        if dist > WORKER_GIVE_RANGE_M {
11124            anyhow::bail!("worker {worker_label} too far — stand next to them");
11125        }
11126        self.seq += 1;
11127        self.session
11128            .submit_intent(Intent::TakeWorkerItem {
11129                entity_id: self.state.entity_id,
11130                worker_instance_id: worker_instance_id.to_string(),
11131                item_instance_id,
11132                quantity,
11133                seq: self.seq,
11134            })
11135            .await?;
11136        self.state.intents_sent += 1;
11137        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
11138        self.state.push_log(format!(
11139            "Taking {item_label}{qty_note} from {worker_label}…"
11140        ));
11141        Ok(())
11142    }
11143
11144    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
11145        if let Some(since) = self.state.pending_worker_hire_since {
11146            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
11147                anyhow::bail!("hire request still pending — wait for the worker roster update");
11148            }
11149            self.state.pending_worker_hire_since = None;
11150        }
11151        if !self.state.has_worker_lodging() {
11152            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
11153        }
11154        self.seq += 1;
11155        self.session
11156            .submit_intent(Intent::HireWorker {
11157                entity_id: self.state.entity_id,
11158                def_id: "worker_laborer".into(),
11159                wage_copper_per_interval: 8,
11160                lodging_container_id: None,
11161                job_yaml: None,
11162                seq: self.seq,
11163            })
11164            .await?;
11165        self.state.intents_sent += 1;
11166        self.state.pending_worker_hire_since = Some(Instant::now());
11167        Ok(())
11168    }
11169
11170    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
11171        let Some(worker) = self
11172            .state
11173            .hired_workers
11174            .get(self.state.workers_menu_index)
11175            .cloned()
11176        else {
11177            anyhow::bail!("select a hired worker first");
11178        };
11179        let lodging = worker.lodging_container_id.clone().or_else(|| {
11180            crate::worker_route_editor::owned_lodging_container_ids(
11181                &self.state.placed_containers,
11182                self.state.character_id,
11183            )
11184            .into_iter()
11185            .next()
11186            .map(|(id, _)| id)
11187        });
11188        let label = worker.label.clone();
11189        let editor = if let Some(route) = &worker.route {
11190            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
11191                worker.instance_id,
11192                worker.label,
11193                route,
11194                lodging,
11195            )
11196        } else {
11197            crate::worker_route_editor::WorkerRouteEditorState::new(
11198                worker.instance_id,
11199                worker.label,
11200                lodging,
11201            )
11202        };
11203        self.state.worker_route_editor = Some(editor);
11204        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11205            if let Some(collapsed) =
11206                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
11207            {
11208                ed.panel_collapsed = collapsed;
11209            }
11210        }
11211        self.state.show_workers_menu = false;
11212        self.state.push_log(format!(
11213            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
11214        ));
11215        Ok(())
11216    }
11217
11218    pub fn close_worker_route_editor(&mut self) {
11219        self.state.worker_route_editor = None;
11220    }
11221
11222    pub fn worker_route_editor_toggle_panel(&mut self) {
11223        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11224            ed.toggle_panel_collapsed();
11225            let collapsed = ed.panel_collapsed;
11226            let mut cfg = crate::client_config::ClientConfig::load();
11227            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
11228        }
11229    }
11230
11231    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
11232        let n = {
11233            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11234                return;
11235            };
11236            ed.append_waypoint(x, y, z);
11237            ed.stop_count()
11238        };
11239        self.state
11240            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
11241    }
11242
11243    // ---- picker candidate snapshots ----------------------------------
11244
11245    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
11246        let (px, py, _) = self.state.player_position_with_z();
11247        let inside = self.state.effective_inside_building();
11248        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
11249            &self.state.placed_containers,
11250            &self.state.buildings,
11251            self.state.character_id,
11252            px,
11253            py,
11254            &self.state.hired_workers,
11255            inside.as_deref(),
11256        )
11257    }
11258
11259    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
11260        self.state.route_editor_node_candidates()
11261    }
11262
11263    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
11264        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
11265        let nodes = self.state.route_editor_node_candidates();
11266        let index = if nodes.is_empty() {
11267            ROUTE_PICKER_DONE_ROW
11268        } else {
11269            index.max(1).min(nodes.len())
11270        };
11271        self.re_open_sheet(S::HarvestPicker {
11272            index,
11273            picked,
11274            nodes,
11275        });
11276    }
11277
11278    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
11279        let (px, py, _) = self.state.player_position_with_z();
11280        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
11281    }
11282
11283    fn re_template_candidates(&self) -> Vec<String> {
11284        let mut extra = Vec::new();
11285        if let Some(ed) = self.state.worker_route_editor.as_ref() {
11286            for stop in &ed.stops {
11287                match stop {
11288                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
11289                        filter: Some(filter),
11290                        ..
11291                    } => extra.extend(filter.iter().cloned()),
11292                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
11293                        extra.push(template.clone());
11294                    }
11295                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
11296                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
11297                        {
11298                            extra.push(bp.output.clone());
11299                            for input in &bp.inputs {
11300                                extra.push(input.template_id.clone());
11301                            }
11302                        }
11303                    }
11304                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
11305                        for it in items {
11306                            extra.push(it.template.clone());
11307                        }
11308                    }
11309                    _ => {}
11310                }
11311            }
11312            // Worker-known recipe outputs even if the player hasn't learned them yet.
11313            if let Some(worker) = self
11314                .state
11315                .hired_workers
11316                .iter()
11317                .find(|w| w.instance_id == ed.worker_instance_id)
11318            {
11319                for recipe in &worker.known_blueprint_ids {
11320                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
11321                        extra.push(bp.output.clone());
11322                    }
11323                }
11324            }
11325        }
11326        crate::worker_route_editor::route_item_template_candidates(
11327            &self.state.placed_containers,
11328            self.state.character_id,
11329            &self.state.inventory,
11330            &self.state.blueprints,
11331            &self.state.resource_nodes,
11332            &extra,
11333        )
11334    }
11335
11336    fn re_blueprint_ids(&self) -> Vec<String> {
11337        let worker_known: Option<&[String]> = self
11338            .state
11339            .worker_route_editor
11340            .as_ref()
11341            .and_then(|ed| {
11342                self.state
11343                    .hired_workers
11344                    .iter()
11345                    .find(|w| w.instance_id == ed.worker_instance_id)
11346            })
11347            .map(|w| w.known_blueprint_ids.as_slice());
11348        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
11349    }
11350
11351    fn re_bed_candidates(&self) -> Vec<(String, String)> {
11352        crate::worker_route_editor::owned_lodging_container_ids(
11353            &self.state.placed_containers,
11354            self.state.character_id,
11355        )
11356    }
11357
11358    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
11359        self.state
11360            .placed_containers
11361            .iter()
11362            .find(|c| c.id == container_id)
11363            .map(|c| c.contents.clone())
11364            .unwrap_or_default()
11365    }
11366
11367    // ---- sheet navigation (`plans/33`) --------------------------------
11368
11369    fn re_sheet_supports_filter(&self) -> bool {
11370        use crate::worker_route_editor::RouteEditorSheet as S;
11371        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11372            matches!(
11373                ed.sheet,
11374                S::HarvestPicker { .. }
11375                    | S::SellItem { .. }
11376                    | S::DepositFilter { .. }
11377                    | S::WithdrawItems { .. }
11378                    | S::WithdrawContainers { .. }
11379                    | S::DepositContainers { .. }
11380                    | S::SellNpcs { .. }
11381                    | S::CraftBlueprint { .. }
11382                    | S::BedPicker { .. }
11383            )
11384        })
11385    }
11386
11387    /// Whether a sheet row is shown under the current `/` filter.
11388    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
11389        use crate::worker_route_editor::{
11390            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
11391            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11392        };
11393        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11394            return false;
11395        };
11396        let filter = &ed.sheet_filter;
11397        match &ed.sheet {
11398            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
11399            S::SellItem { templates, .. } => {
11400                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
11401                    return true;
11402                }
11403                let slot = row.saturating_sub(2);
11404                templates.get(slot).is_some_and(|t| {
11405                    let label = self.state.template_display_name(t);
11406                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11407                })
11408            }
11409            S::DepositFilter { rows, .. } => {
11410                if row >= rows.len() {
11411                    return true;
11412                }
11413                rows.get(row).is_some_and(|(t, _)| {
11414                    let label = self.state.template_display_name(t);
11415                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11416                })
11417            }
11418            S::WithdrawItems { lines, .. } => {
11419                if row >= lines.len() {
11420                    return true;
11421                }
11422                lines.get(row).is_some_and(|l| {
11423                    let label = self.state.template_display_name(&l.template);
11424                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
11425                })
11426            }
11427            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11428                self.re_container_candidates().get(row).is_some_and(|c| {
11429                    list_filter_row_matches(
11430                        filter,
11431                        Some(c.dist),
11432                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
11433                    )
11434                })
11435            }
11436            S::SellNpcs { .. } => {
11437                if row == 0 {
11438                    return true;
11439                }
11440                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
11441                    list_filter_row_matches(
11442                        filter,
11443                        Some(n.dist),
11444                        &[n.label.as_str(), n.id.as_str()],
11445                    )
11446                })
11447            }
11448            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
11449                let label = self
11450                    .state
11451                    .blueprints
11452                    .iter()
11453                    .find(|b| &b.id == id)
11454                    .map(|b| {
11455                        if b.label.is_empty() {
11456                            id.as_str()
11457                        } else {
11458                            b.label.as_str()
11459                        }
11460                    })
11461                    .unwrap_or(id.as_str());
11462                list_filter_row_matches(filter, None, &[id.as_str(), label])
11463            }),
11464            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
11465                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
11466            }),
11467            _ => true,
11468        }
11469    }
11470
11471    fn re_sheet_clamp_index(&mut self) {
11472        let count = self.re_sheet_row_count();
11473        if count == 0 {
11474            return;
11475        }
11476        let cur = self.re_sheet_index();
11477        if self.re_sheet_row_visible(cur) {
11478            return;
11479        }
11480        for offset in 1..count {
11481            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
11482                self.re_sheet_set_index(cur + offset);
11483                return;
11484            }
11485            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
11486                self.re_sheet_set_index(cur - offset);
11487                return;
11488            }
11489        }
11490    }
11491
11492    fn re_sheet_set_index(&mut self, index: usize) {
11493        use crate::worker_route_editor::RouteEditorSheet as S;
11494        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11495            return;
11496        };
11497        match &mut ed.sheet {
11498            S::AddMenu { index: slot }
11499            | S::WaypointMenu { index: slot }
11500            | S::HarvestPicker { index: slot, .. }
11501            | S::WithdrawContainers { index: slot }
11502            | S::DepositContainers { index: slot }
11503            | S::SellNpcs { index: slot }
11504            | S::CraftBlueprint { index: slot }
11505            | S::BedPicker { index: slot }
11506            | S::FarmPlotPicker { index: slot, .. }
11507            | S::FarmPlantSeed { index: slot, .. }
11508            | S::WithdrawItems { index: slot, .. }
11509            | S::DepositFilter { index: slot, .. }
11510            | S::SellItem { index: slot, .. } => *slot = index,
11511            _ => {}
11512        }
11513    }
11514
11515    pub fn re_focus_sheet_filter(&mut self) {
11516        if !self.re_sheet_supports_filter() {
11517            return;
11518        }
11519        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11520            ed.sheet_filter_focused = true;
11521        }
11522    }
11523
11524    pub fn re_blur_sheet_filter_keep_text(&mut self) {
11525        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11526            return;
11527        };
11528        if !ed.sheet_filter_focused {
11529            return;
11530        }
11531        ed.sheet_filter_focused = false;
11532        self.re_sheet_clamp_index();
11533    }
11534
11535    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
11536        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11537            return false;
11538        };
11539        if ed.sheet_filter_focused {
11540            ed.sheet_filter_focused = false;
11541            self.re_sheet_clamp_index();
11542            return true;
11543        }
11544        if !ed.sheet_filter.is_empty() {
11545            ed.sheet_filter.clear();
11546            self.re_sheet_clamp_index();
11547            return true;
11548        }
11549        false
11550    }
11551
11552    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
11553        if ch.is_control() {
11554            return;
11555        }
11556        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11557            return;
11558        };
11559        if !ed.sheet_filter_focused {
11560            return;
11561        }
11562        ed.sheet_filter.push(ch);
11563        self.re_sheet_set_index(0);
11564        self.re_sheet_clamp_index();
11565    }
11566
11567    pub fn re_sheet_filter_backspace(&mut self) {
11568        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11569            return;
11570        };
11571        if !ed.sheet_filter_focused {
11572            return;
11573        }
11574        ed.sheet_filter.pop();
11575        self.re_sheet_set_index(0);
11576        self.re_sheet_clamp_index();
11577    }
11578
11579    /// Row count of the current sheet (for cursor wrapping).
11580    pub fn re_sheet_row_count(&self) -> usize {
11581        use crate::worker_route_editor::{
11582            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
11583        };
11584        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11585            return 0;
11586        };
11587        match &ed.sheet {
11588            S::Stops => ed.stops.len(),
11589            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
11590            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
11591            S::WaypointMapPick => 0,
11592            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
11593            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11594                self.re_container_candidates().len()
11595            }
11596            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
11597            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
11598            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
11599            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
11600            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11601            S::WaitEntry { .. } => 1,
11602            S::BedPicker { .. } => self.re_bed_candidates().len(),
11603            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11604            S::FarmPlantSeed { seeds, .. } => seeds.len(),
11605        }
11606    }
11607
11608    /// Current sheet cursor index (0 for sheets without one).
11609    pub fn re_sheet_index(&self) -> usize {
11610        use crate::worker_route_editor::RouteEditorSheet as S;
11611        let Some(ed) = self.state.worker_route_editor.as_ref() else {
11612            return 0;
11613        };
11614        match &ed.sheet {
11615            S::AddMenu { index }
11616            | S::WaypointMenu { index }
11617            | S::HarvestPicker { index, .. }
11618            | S::WithdrawContainers { index }
11619            | S::DepositContainers { index }
11620            | S::SellNpcs { index }
11621            | S::CraftBlueprint { index }
11622            | S::BedPicker { index }
11623            | S::FarmPlotPicker { index, .. }
11624            | S::FarmPlantSeed { index, .. }
11625            | S::WithdrawItems { index, .. }
11626            | S::DepositFilter { index, .. }
11627            | S::SellItem { index, .. } => *index,
11628            _ => 0,
11629        }
11630    }
11631
11632    /// Move the current sheet's cursor, wrapping within its rows.
11633    pub fn re_sheet_move(&mut self, delta: i32) {
11634        let count = self.re_sheet_row_count();
11635        if count == 0 {
11636            return;
11637        }
11638        let cur = self.re_sheet_index();
11639        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11640        self.re_sheet_set_index(next);
11641    }
11642
11643    pub fn re_sheet_page(&mut self, pages: i32) {
11644        let count = self.re_sheet_row_count();
11645        if count == 0 {
11646            return;
11647        }
11648        let cur = self.re_sheet_index();
11649        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11650        self.re_sheet_set_index(next);
11651    }
11652
11653    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
11654    pub fn re_sheet_adjust(&mut self, delta: i32) {
11655        use crate::worker_route_editor::RouteEditorSheet as S;
11656        let index = self.re_sheet_index();
11657        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11658            return;
11659        };
11660        match &mut ed.sheet {
11661            S::WithdrawItems { lines, .. } => {
11662                if let Some(line) = lines.get_mut(index) {
11663                    line.adjust_qty(delta);
11664                }
11665            }
11666            S::WaitEntry { ticks } => {
11667                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11668            }
11669            _ => {}
11670        }
11671    }
11672
11673    pub fn re_sheet_back(&mut self) {
11674        let Some(ed) = self.state.worker_route_editor.as_mut() else {
11675            return;
11676        };
11677        use crate::worker_route_editor::RouteEditorSheet as S;
11678        let was_editing = ed.editing_index.is_some();
11679        let from_top_picker = matches!(
11680            ed.sheet,
11681            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11682        );
11683        ed.sheet_back();
11684        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11685            // Chest retarget commits on pick; Esc here just ends the edit sheet.
11686            self.state
11687                .push_log("Route: left edit sheet — press s to save current stops".to_string());
11688        }
11689    }
11690
11691    /// True when the editor is at its root sheet (Esc should close it).
11692    pub fn re_at_root_sheet(&self) -> bool {
11693        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11694            matches!(
11695                ed.sheet,
11696                crate::worker_route_editor::RouteEditorSheet::Stops
11697            )
11698        })
11699    }
11700
11701    pub fn re_open_add_menu(&mut self) {
11702        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11703            ed.open_add_menu();
11704        }
11705    }
11706
11707    pub fn re_open_bed_picker(&mut self) {
11708        let beds = self.re_bed_candidates();
11709        if beds.is_empty() {
11710            self.state
11711                .push_log("Route: place a camp bed first".to_string());
11712            return;
11713        }
11714        let current = self
11715            .state
11716            .worker_route_editor
11717            .as_ref()
11718            .and_then(|ed| ed.lodging_container_id.clone());
11719        let index = current
11720            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11721            .unwrap_or(0);
11722        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11723    }
11724
11725    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11726        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11727            ed.open_sheet(sheet);
11728        }
11729    }
11730
11731    /// Confirm a sheet's stop and log the outcome.
11732    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
11733        let appended = self
11734            .state
11735            .worker_route_editor
11736            .as_mut()
11737            .is_some_and(|ed| ed.confirm_stop(stop));
11738        if appended {
11739            self.state.push_log(format!("Route: + {what}"));
11740        } else {
11741            self.state
11742                .push_log(format!("Route: {what} already in route — selected it"));
11743        }
11744    }
11745
11746    fn re_open_withdraw_items(&mut self, container_id: String) {
11747        use crate::worker_route_editor::{
11748            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11749        };
11750        let contents = self.re_container_contents(&container_id);
11751        // When editing, keep the stop's item picks even if the player retargets
11752        // to a different (possibly empty) chest — otherwise Done has nothing to
11753        // confirm and the edit appears to "revert".
11754        let existing = self
11755            .state
11756            .worker_route_editor
11757            .as_ref()
11758            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11759            .and_then(|stop| match stop {
11760                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11761                _ => None,
11762            })
11763            .unwrap_or_default();
11764        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11765        // Commit the new chest immediately while editing so Esc-before-Done still
11766        // keeps the retarget (items update only when Done is pressed).
11767        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11768            let _ = ed.retarget_withdraw_container(container_id.clone());
11769        }
11770        self.re_open_sheet(S::WithdrawItems {
11771            container_id,
11772            lines,
11773            index: 0,
11774        });
11775    }
11776
11777    fn re_withdraw_items_activate(&mut self, index: usize) {
11778        use crate::worker_route_editor::{
11779            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11780        };
11781        enum Outcome {
11782            Cycled,
11783            Confirmed(String),
11784            Empty,
11785        }
11786        let outcome = {
11787            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11788                return;
11789            };
11790            let S::WithdrawItems {
11791                container_id,
11792                lines,
11793                index: sheet_index,
11794            } = &mut ed.sheet
11795            else {
11796                return;
11797            };
11798            *sheet_index = index;
11799            if index < lines.len() {
11800                lines[index].cycle();
11801                Outcome::Cycled
11802            } else {
11803                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11804                if items.is_empty() {
11805                    Outcome::Empty
11806                } else {
11807                    let stop = WorkerRouteStop::WithdrawFrom {
11808                        container_id: container_id.clone(),
11809                        items,
11810                    };
11811                    let summary = stop.summary();
11812                    ed.confirm_stop(stop);
11813                    Outcome::Confirmed(summary)
11814                }
11815            }
11816        };
11817        match outcome {
11818            Outcome::Cycled => {}
11819            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11820            Outcome::Empty => self.state.push_log(
11821                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
11822            ),
11823        }
11824    }
11825
11826    fn re_open_deposit_filter(&mut self, container_id: String) {
11827        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11828        // Preserve filter when retargeting the deposit chest while editing.
11829        let existing_filter = self
11830            .state
11831            .worker_route_editor
11832            .as_ref()
11833            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11834            .and_then(|stop| match stop {
11835                WorkerRouteStop::DepositAt { filter, .. } => {
11836                    Some(filter.clone().unwrap_or_default())
11837                }
11838                _ => None,
11839            });
11840        let mut candidates = self.re_template_candidates();
11841        if let Some(ref chosen) = existing_filter {
11842            for t in chosen {
11843                if !candidates.iter().any(|c| c == t) {
11844                    candidates.push(t.clone());
11845                }
11846            }
11847            candidates.sort();
11848            candidates.dedup();
11849        }
11850        let rows: Vec<(String, bool)> = match existing_filter {
11851            Some(chosen) => candidates
11852                .iter()
11853                .map(|t| (t.clone(), chosen.contains(t)))
11854                .collect(),
11855            None => candidates.into_iter().map(|t| (t, false)).collect(),
11856        };
11857        if let Some(ed) = self.state.worker_route_editor.as_mut() {
11858            let _ = ed.retarget_deposit_container(container_id.clone());
11859        }
11860        self.re_open_sheet(S::DepositFilter {
11861            container_id,
11862            rows,
11863            index: 0,
11864        });
11865    }
11866
11867    fn re_deposit_filter_activate(&mut self, index: usize) {
11868        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11869        let mut confirmed: Option<String> = None;
11870        {
11871            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11872                return;
11873            };
11874            let S::DepositFilter {
11875                container_id,
11876                rows,
11877                index: sheet_index,
11878            } = &mut ed.sheet
11879            else {
11880                return;
11881            };
11882            *sheet_index = index;
11883            if index < rows.len() {
11884                rows[index].1 = !rows[index].1;
11885            } else {
11886                // "Done" row.
11887                let chosen: Vec<String> = rows
11888                    .iter()
11889                    .filter(|(_, on)| *on)
11890                    .map(|(t, _)| t.clone())
11891                    .collect();
11892                let filter = if chosen.is_empty() {
11893                    None
11894                } else {
11895                    Some(chosen)
11896                };
11897                let stop = WorkerRouteStop::DepositAt {
11898                    container_id: container_id.clone(),
11899                    filter,
11900                };
11901                confirmed = Some(stop.summary());
11902                ed.confirm_stop(stop);
11903            }
11904        }
11905        if let Some(what) = confirmed {
11906            self.state.push_log(format!("Route: + {what}"));
11907        }
11908    }
11909
11910    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11911        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11912        // Prefill when editing an existing sell stop.
11913        let (pre_npc, pre_template, pre_all) = self
11914            .state
11915            .worker_route_editor
11916            .as_ref()
11917            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11918            .and_then(|stop| match stop {
11919                WorkerRouteStop::TradeWith {
11920                    npc_id,
11921                    template,
11922                    sell_all,
11923                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11924                _ => None,
11925            })
11926            .unwrap_or((None, None, true));
11927        let npc_id = npc_id.or(pre_npc);
11928        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
11929            &self.re_template_candidates(),
11930            &self.state.npcs,
11931            npc_id.as_deref(),
11932        );
11933        // Keep an existing invalid selection visible long enough to remove it
11934        // when editing a route after NPC buy lists change.
11935        if let Some(template) = pre_template.as_ref() {
11936            if !templates.iter().any(|candidate| candidate == template) {
11937                templates.push(template.clone());
11938                templates.sort();
11939            }
11940        }
11941        if templates.is_empty() {
11942            self.state
11943                .push_log("Route: no sellable item templates for that merchant".to_string());
11944            return;
11945        }
11946        let mut picked = std::collections::BTreeSet::new();
11947        if let Some(t) = pre_template {
11948            picked.insert(t);
11949        }
11950        self.re_open_sheet(S::SellItem {
11951            npc_id,
11952            templates,
11953            index: if picked.is_empty() {
11954                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11955            } else {
11956                2
11957            },
11958            sell_all: pre_all,
11959            picked,
11960        });
11961    }
11962
11963    fn re_sell_item_activate(&mut self, index: usize) {
11964        use crate::worker_route_editor::{
11965            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11966        };
11967        let mut batch_log: Option<String> = None;
11968        {
11969            let Some(ed) = self.state.worker_route_editor.as_mut() else {
11970                return;
11971            };
11972            let S::SellItem {
11973                npc_id,
11974                templates,
11975                index: sheet_index,
11976                sell_all,
11977                picked,
11978            } = &mut ed.sheet
11979            else {
11980                return;
11981            };
11982            *sheet_index = index;
11983            if index == ROUTE_PICKER_DONE_ROW {
11984                if picked.is_empty() {
11985                    batch_log =
11986                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
11987                } else {
11988                    let picks: Vec<String> = picked.iter().cloned().collect();
11989                    let npc = npc_id.clone();
11990                    let all = *sell_all;
11991                    let added = ed.confirm_trade_picks(npc, &picks, all);
11992                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
11993                }
11994            } else if index == SELL_ITEM_TOGGLE_ROW {
11995                *sell_all = !*sell_all;
11996            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11997                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
11998                    std::slice::from_ref(template),
11999                    &self.state.npcs,
12000                    npc_id.as_deref(),
12001                )
12002                .iter()
12003                .any(|candidate| candidate == template);
12004                if !sellable && !picked.contains(template) {
12005                    return;
12006                }
12007                if picked.contains(template) {
12008                    picked.remove(template);
12009                } else {
12010                    picked.insert(template.clone());
12011                }
12012            }
12013        }
12014        if let Some(msg) = batch_log {
12015            self.state.push_log(msg);
12016        }
12017    }
12018
12019    /// Enter on the stop list: open the selected stop's sheet, prefilled.
12020    pub fn re_edit_selected_stop(&mut self) {
12021        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12022        let Some(stop) = self
12023            .state
12024            .worker_route_editor
12025            .as_ref()
12026            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
12027        else {
12028            self.state
12029                .push_log("Route: no stop selected — press a to add one".to_string());
12030            return;
12031        };
12032        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12033            ed.begin_edit_selected();
12034        }
12035        match stop {
12036            WorkerRouteStop::Waypoint { .. } => {
12037                self.re_open_sheet(S::WaypointMenu { index: 0 });
12038            }
12039            WorkerRouteStop::HarvestNode { node_id } => {
12040                let nodes = self.state.route_editor_node_candidates();
12041                if nodes.is_empty() {
12042                    self.re_cancel_edit();
12043                    self.state
12044                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
12045                } else {
12046                    let mut picked = std::collections::BTreeSet::new();
12047                    picked.insert(node_id.clone());
12048                    let index = nodes
12049                        .iter()
12050                        .position(|n| n.id == node_id)
12051                        .map(|i| i + 1)
12052                        .unwrap_or(1);
12053                    self.re_open_harvest_picker(index, picked);
12054                }
12055            }
12056            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
12057                // Open the container picker so the player can retarget storage
12058                // (previously jumped straight to items on the same chest).
12059                let containers = self.re_container_candidates();
12060                if containers.is_empty() {
12061                    self.re_cancel_edit();
12062                    self.state
12063                        .push_log("Route: place a storage chest first".to_string());
12064                } else {
12065                    let index = containers
12066                        .iter()
12067                        .position(|c| c.id == container_id)
12068                        .unwrap_or(0);
12069                    self.re_open_sheet(S::WithdrawContainers { index });
12070                }
12071            }
12072            WorkerRouteStop::DepositAt { container_id, .. } => {
12073                let containers = self.re_container_candidates();
12074                if containers.is_empty() {
12075                    self.re_cancel_edit();
12076                    self.state
12077                        .push_log("Route: place a storage chest first".to_string());
12078                } else {
12079                    let index = containers
12080                        .iter()
12081                        .position(|c| c.id == container_id)
12082                        .unwrap_or(0);
12083                    self.re_open_sheet(S::DepositContainers { index });
12084                }
12085            }
12086            WorkerRouteStop::TradeWith { npc_id, .. } => {
12087                let npcs = self.re_npc_candidates();
12088                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
12089                let index = npc_id
12090                    .as_ref()
12091                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
12092                    .unwrap_or(0);
12093                self.re_open_sheet(S::SellNpcs { index });
12094            }
12095            WorkerRouteStop::CraftAt { blueprint, .. } => {
12096                let bps = self.re_blueprint_ids();
12097                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
12098                if bps.is_empty() {
12099                    self.re_cancel_edit();
12100                    self.state
12101                        .push_log("Route: no known blueprints to retarget".to_string());
12102                } else {
12103                    self.re_open_sheet(S::CraftBlueprint { index });
12104                }
12105            }
12106            WorkerRouteStop::CultivatePlot { .. } => {
12107                self.re_open_farm_plot_picker(
12108                    crate::worker_route_editor::FarmPlotAction::Cultivate,
12109                );
12110            }
12111            WorkerRouteStop::PlantPlot { .. } => {
12112                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
12113            }
12114            WorkerRouteStop::HarvestPlot { .. } => {
12115                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
12116            }
12117            WorkerRouteStop::RestIfNeeded => {
12118                self.re_cancel_edit();
12119                self.state
12120                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
12121            }
12122            WorkerRouteStop::Wait { wait_ticks } => {
12123                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
12124            }
12125        }
12126    }
12127
12128    fn re_cancel_edit(&mut self) {
12129        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12130            ed.editing_index = None;
12131        }
12132    }
12133
12134    // ---- mouse clicks inside the overlay ------------------------------
12135
12136    pub fn worker_route_editor_ui_click(
12137        &mut self,
12138        click: crate::worker_route_editor::RouteEditorClick,
12139    ) {
12140        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
12141        match click {
12142            RouteEditorClick::SelectStop(i) => {
12143                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12144                    ed.sheet = S::Stops;
12145                    ed.select_stop(i);
12146                }
12147            }
12148            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
12149            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
12150            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
12151        }
12152    }
12153
12154    /// Activate (Enter / click) a row of the current sheet.
12155    pub fn re_sheet_row_activate(&mut self, row: usize) {
12156        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12157        let Some(sheet) = self
12158            .state
12159            .worker_route_editor
12160            .as_ref()
12161            .map(|ed| ed.sheet.clone())
12162        else {
12163            return;
12164        };
12165        match sheet {
12166            S::Stops => {
12167                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12168                    ed.select_stop(row);
12169                }
12170            }
12171            S::AddMenu { .. } => match row {
12172                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
12173                1 => {
12174                    if self.re_node_candidates().is_empty() {
12175                        self.state.push_log(
12176                            "Route: no harvestable nodes visible in this region".to_string(),
12177                        );
12178                    } else {
12179                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
12180                    }
12181                }
12182                2 | 3 => {
12183                    if self.re_container_candidates().is_empty() {
12184                        self.state
12185                            .push_log("Route: place a storage chest first".to_string());
12186                    } else if row == 2 {
12187                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
12188                    } else {
12189                        self.re_open_sheet(S::DepositContainers { index: 0 });
12190                    }
12191                }
12192                4 => {
12193                    if self.re_template_candidates().is_empty() {
12194                        self.state.push_log(
12195                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
12196                                .to_string(),
12197                        );
12198                    } else {
12199                        self.re_open_sheet(S::SellNpcs { index: 0 });
12200                    }
12201                }
12202                5 => {
12203                    if self.re_blueprint_ids().is_empty() {
12204                        self.state.push_log(
12205                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
12206                                .to_string(),
12207                        );
12208                    } else {
12209                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
12210                    }
12211                }
12212                6 => self.re_confirm_stop(
12213                    WorkerRouteStop::RestIfNeeded,
12214                    "rest at lodging (if needed)".into(),
12215                ),
12216                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
12217                8 => self.re_open_farm_plot_picker(
12218                    crate::worker_route_editor::FarmPlotAction::Cultivate,
12219                ),
12220                9 => {
12221                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
12222                }
12223                10 => self
12224                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
12225                _ => {}
12226            },
12227            S::WaypointMenu { .. } => match row {
12228                0 => {
12229                    let (x, y, z) = self.state.player_position_with_z();
12230                    let stop = WorkerRouteStop::Waypoint { x, y, z };
12231                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12232                }
12233                1 => {
12234                    self.re_open_sheet(S::WaypointMapPick);
12235                    self.state.push_log(
12236                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
12237                    );
12238                }
12239                _ => {}
12240            },
12241            S::HarvestPicker { .. } => {
12242                let mut log: Option<String> = None;
12243                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12244                    let S::HarvestPicker {
12245                        index: sheet_index,
12246                        picked,
12247                        nodes,
12248                    } = &mut ed.sheet
12249                    else {
12250                        return;
12251                    };
12252                    *sheet_index = row;
12253                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
12254                        if picked.is_empty() {
12255                            log = Some(
12256                                "Route: pick at least one node (Space toggles, Done confirms)"
12257                                    .into(),
12258                            );
12259                        } else {
12260                            let ids: Vec<String> = picked.iter().cloned().collect();
12261                            let added = ed.confirm_harvest_picks(&ids);
12262                            log = Some(format!("Route: + {added} harvest stop(s)"));
12263                        }
12264                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
12265                        if picked.contains(&n.id) {
12266                            picked.remove(&n.id);
12267                        } else {
12268                            picked.insert(n.id.clone());
12269                        }
12270                    }
12271                }
12272                if let Some(msg) = log {
12273                    self.state.push_log(msg);
12274                }
12275            }
12276            S::WithdrawContainers { .. } => {
12277                let containers = self.re_container_candidates();
12278                if let Some(c) = containers.get(row) {
12279                    let id = c.id.clone();
12280                    self.re_open_withdraw_items(id);
12281                }
12282            }
12283            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
12284            S::DepositContainers { .. } => {
12285                let containers = self.re_container_candidates();
12286                if let Some(c) = containers.get(row) {
12287                    let id = c.id.clone();
12288                    self.re_open_deposit_filter(id);
12289                }
12290            }
12291            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
12292            S::SellNpcs { .. } => {
12293                let npcs = self.re_npc_candidates();
12294                let npc_id = if row == 0 {
12295                    None
12296                } else {
12297                    npcs.get(row - 1).map(|n| n.id.clone())
12298                };
12299                if row == 0 || npc_id.is_some() {
12300                    self.re_open_sell_item(npc_id);
12301                }
12302            }
12303            S::SellItem { .. } => self.re_sell_item_activate(row),
12304            S::CraftBlueprint { .. } => {
12305                let bps = self.re_blueprint_ids();
12306                if let Some(bp) = bps.get(row) {
12307                    let stop = WorkerRouteStop::CraftAt {
12308                        device: "hand".into(),
12309                        blueprint: bp.clone(),
12310                        qty: None,
12311                    };
12312                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
12313                }
12314            }
12315            S::WaitEntry { ticks } => {
12316                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
12317                self.re_confirm_stop(stop, format!("wait {ticks}t"));
12318            }
12319            S::BedPicker { .. } => {
12320                let beds = self.re_bed_candidates();
12321                if let Some((id, name)) = beds.get(row) {
12322                    let (id, name) = (id.clone(), name.clone());
12323                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12324                        ed.lodging_container_id = Some(id.clone());
12325                        ed.sheet = S::Stops;
12326                    }
12327                    self.state
12328                        .push_log(format!("Route: rest bed set to {name}"));
12329                }
12330            }
12331            S::FarmPlotPicker { action, .. } => {
12332                let plots = self.re_farm_plot_candidates();
12333                let Some(plot) = plots.get(row).cloned() else {
12334                    return;
12335                };
12336                match action {
12337                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
12338                        let label = plot_route_label(&plot);
12339                        self.re_confirm_stop(
12340                            WorkerRouteStop::CultivatePlot {
12341                                plot_id: plot.plot_id,
12342                            },
12343                            format!("cultivate {label}"),
12344                        );
12345                    }
12346                    crate::worker_route_editor::FarmPlotAction::Harvest => {
12347                        let label = plot_route_label(&plot);
12348                        self.re_confirm_stop(
12349                            WorkerRouteStop::HarvestPlot {
12350                                plot_id: plot.plot_id,
12351                            },
12352                            format!("harvest {label}"),
12353                        );
12354                    }
12355                    crate::worker_route_editor::FarmPlotAction::Plant => {
12356                        let seeds = self.re_farm_seed_candidates();
12357                        if seeds.is_empty() {
12358                            self.state.push_log(
12359                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
12360                            );
12361                            return;
12362                        }
12363                        self.re_open_sheet(S::FarmPlantSeed {
12364                            plot_id: plot.plot_id,
12365                            seeds,
12366                            index: 0,
12367                        });
12368                    }
12369                }
12370            }
12371            S::FarmPlantSeed { plot_id, seeds, .. } => {
12372                if let Some(seed) = seeds.get(row).cloned() {
12373                    self.re_confirm_stop(
12374                        WorkerRouteStop::PlantPlot {
12375                            plot_id,
12376                            seed_template: seed.clone(),
12377                        },
12378                        format!("plant {seed}"),
12379                    );
12380                }
12381            }
12382            S::WaypointMapPick => {}
12383        }
12384    }
12385
12386    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
12387        use crate::worker_route_editor::RouteEditorSheet as S;
12388        if self.re_farm_plot_candidates().is_empty() {
12389            self.state
12390                .push_log("Route: no farmable plots visible — claim land or get farm access first");
12391            return;
12392        }
12393        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
12394    }
12395
12396    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
12397        self.state
12398            .property_plots
12399            .iter()
12400            .filter(|p| p.is_mine || p.may_farm)
12401            .cloned()
12402            .collect()
12403    }
12404
12405    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
12406    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
12407    /// and known crop seeds so routes can withdraw-then-plant.
12408    fn re_farm_seed_candidates(&self) -> Vec<String> {
12409        let mut set = std::collections::BTreeSet::new();
12410        let looks_like_seed =
12411            |id: &str| id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed";
12412        for (id, _, _) in self.state.farm_seed_entries() {
12413            set.insert(id);
12414        }
12415        for c in &self.state.placed_containers {
12416            let mine = match (self.state.character_id, c.owner_character_id) {
12417                (Some(a), Some(b)) => a == b,
12418                _ => false,
12419            };
12420            if !mine {
12421                continue;
12422            }
12423            for s in &c.contents {
12424                if s.quantity > 0
12425                    && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
12426                {
12427                    set.insert(s.template_id.clone());
12428                }
12429            }
12430        }
12431        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12432            for stop in &ed.stops {
12433                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
12434                    stop
12435                {
12436                    for it in items {
12437                        if looks_like_seed(&it.template) {
12438                            set.insert(it.template.clone());
12439                        }
12440                    }
12441                }
12442                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
12443                    seed_template,
12444                    ..
12445                } = stop
12446                {
12447                    if !seed_template.is_empty() {
12448                        set.insert(seed_template.clone());
12449                    }
12450                }
12451            }
12452        }
12453        for id in self.state.inventory_hints.keys() {
12454            if looks_like_seed(id) {
12455                set.insert(id.clone());
12456            }
12457        }
12458        // Authored crop seeds (always offer so withdraw-then-plant routes work).
12459        for id in ["potato_seed", "carrot_seed"] {
12460            set.insert(id.to_string());
12461        }
12462        set.into_iter().collect()
12463    }
12464
12465    // ---- map clicks (sheet-scoped accelerators) ------------------------
12466
12467    /// Map click while the route editor is open. When a picker sheet is open
12468    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
12469    /// container, NPC clicks the merchant, node clicks the harvest target);
12470    /// otherwise the click quick-adds the nearest target (context-aware).
12471    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
12472        use crate::worker_route_editor as wre;
12473        use wre::RouteEditorSheet as S;
12474        if self.state.worker_route_editor.is_none() {
12475            return;
12476        }
12477        let sheet = self
12478            .state
12479            .worker_route_editor
12480            .as_ref()
12481            .map(|ed| ed.sheet.clone())
12482            .unwrap_or(S::Stops);
12483        match sheet {
12484            S::WaypointMapPick => {
12485                let (_, _, z) = self.state.player_position_with_z();
12486                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
12487                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12488                // Stay in map-pick mode when adding (not editing) for fast pathing.
12489                let editing = self
12490                    .state
12491                    .worker_route_editor
12492                    .as_ref()
12493                    .is_some_and(|ed| ed.editing_index.is_some());
12494                if !editing {
12495                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12496                        ed.sheet = S::WaypointMapPick;
12497                    }
12498                }
12499            }
12500            S::HarvestPicker { .. } => {
12501                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12502                    let mut log: Option<String> = None;
12503                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12504                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
12505                            return;
12506                        };
12507                        let selected = if picked.contains(&node.id) {
12508                            picked.remove(&node.id);
12509                            false
12510                        } else {
12511                            picked.insert(node.id.clone());
12512                            true
12513                        };
12514                        log = Some(format!(
12515                            "Route: {} {}",
12516                            if selected { "selected" } else { "deselected" },
12517                            resource_node_route_label(node)
12518                        ));
12519                    }
12520                    if let Some(msg) = log {
12521                        self.state.push_log(msg);
12522                    }
12523                }
12524            }
12525            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
12526                // Chest click picks (or switches) the withdraw source.
12527                let inside = self.state.effective_inside_building();
12528                if let Some(cid) = wre::pick_storage_container_at(
12529                    &self.state.placed_containers,
12530                    self.state.character_id,
12531                    x,
12532                    y,
12533                    inside.as_deref(),
12534                ) {
12535                    self.re_open_withdraw_items(cid);
12536                }
12537            }
12538            S::DepositContainers { .. } | S::DepositFilter { .. } => {
12539                let inside = self.state.effective_inside_building();
12540                if let Some(cid) = wre::pick_storage_container_at(
12541                    &self.state.placed_containers,
12542                    self.state.character_id,
12543                    x,
12544                    y,
12545                    inside.as_deref(),
12546                ) {
12547                    self.re_open_deposit_filter(cid);
12548                }
12549            }
12550            S::SellNpcs { .. } => {
12551                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12552                    self.re_open_sell_item(Some(npc_id));
12553                }
12554            }
12555            S::SellItem { .. } => {
12556                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12557                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
12558                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
12559                            *slot = Some(npc_id.clone());
12560                        }
12561                    }
12562                    self.state
12563                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12564                }
12565            }
12566            // Stop list / other sheets: context-aware quick add.
12567            _ => self.worker_route_editor_quick_add_click(x, y),
12568        }
12569    }
12570
12571    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
12572    /// node wins; duplicates select the existing stop; a click on a merchant
12573    /// while a sell stop is selected pins it.
12574    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
12575        use crate::worker_route_editor as wre;
12576        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
12577            let dx = ax - bx;
12578            let dy = ay - by;
12579            (dx * dx + dy * dy).sqrt()
12580        };
12581
12582        // 1. Retarget the selected stop when the click names its target:
12583        //    sell stop → pin the merchant; withdraw stop → set the source chest.
12584        let selected_stop_kind = self
12585            .state
12586            .worker_route_editor
12587            .as_ref()
12588            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
12589            .map(|s| match s {
12590                wre::WorkerRouteStop::TradeWith { .. } => 1,
12591                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
12592                _ => 0,
12593            })
12594            .unwrap_or(0);
12595        if selected_stop_kind == 1 {
12596            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12597                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12598                    ed.set_selected_trade_npc(npc_id.clone());
12599                }
12600                self.state
12601                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12602                return;
12603            }
12604        }
12605        if selected_stop_kind == 2 {
12606            let inside = self.state.effective_inside_building();
12607            if let Some(cid) = wre::pick_storage_container_at(
12608                &self.state.placed_containers,
12609                self.state.character_id,
12610                x,
12611                y,
12612                inside.as_deref(),
12613            ) {
12614                let name = self
12615                    .state
12616                    .placed_containers
12617                    .iter()
12618                    .find(|c| c.id == cid)
12619                    .map(|c| c.display_name.clone())
12620                    .unwrap_or_else(|| "container".into());
12621                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12622                    ed.set_selected_withdraw_container(cid.clone());
12623                }
12624                self.state
12625                    .push_log(format!("Route: withdraw source → {name}"));
12626                return;
12627            }
12628        }
12629
12630        // 2. Nearest candidate of any kind wins. Category order is the
12631        //    tie-break for exact overlaps (bed over chest over merchant over node).
12632        enum Target {
12633            Bed(String),
12634            Container(String),
12635            Npc(String, String),
12636            Node(String, String),
12637        }
12638        let mut best: Option<(f32, u8, Target)> = None;
12639        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12640            let better = match best {
12641                None => true,
12642                Some((bd, brank, _)) => {
12643                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
12644                }
12645            };
12646            if better {
12647                *best = Some((d, rank, t));
12648            }
12649        };
12650        let inside = self.state.effective_inside_building();
12651        if let Some(bed_id) = wre::pick_lodging_container_at(
12652            &self.state.placed_containers,
12653            self.state.character_id,
12654            x,
12655            y,
12656            inside.as_deref(),
12657        ) {
12658            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12659                // A bed that's already the rest bed doubles as a storage chest —
12660                // a second click on it means "deposit here", not "set bed again".
12661                let already_bed =
12662                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12663                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
12664                    });
12665                if already_bed {
12666                    consider(
12667                        dist(x, y, c.x, c.y),
12668                        1,
12669                        Target::Container(bed_id),
12670                        &mut best,
12671                    );
12672                } else {
12673                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12674                }
12675            }
12676        }
12677        if let Some(cid) = wre::pick_storage_container_at(
12678            &self.state.placed_containers,
12679            self.state.character_id,
12680            x,
12681            y,
12682            inside.as_deref(),
12683        ) {
12684            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12685                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12686            }
12687        }
12688        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12689            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12690                consider(
12691                    dist(x, y, n.x, n.y),
12692                    2,
12693                    Target::Npc(npc_id, label),
12694                    &mut best,
12695                );
12696            }
12697        }
12698        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12699            let d = dist(x, y, node.x, node.y);
12700            let label = resource_node_route_label(node);
12701            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
12702        }
12703
12704        match best.map(|(_, _, t)| t) {
12705            Some(Target::Bed(bed_id)) => {
12706                let name = self
12707                    .state
12708                    .placed_containers
12709                    .iter()
12710                    .find(|c| c.id == bed_id)
12711                    .map(|c| c.display_name.clone())
12712                    .unwrap_or_else(|| "camp bed".into());
12713                if let Some(ed) = self.state.worker_route_editor.as_mut() {
12714                    ed.lodging_container_id = Some(bed_id.clone());
12715                }
12716                self.state
12717                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12718            }
12719            Some(Target::Container(cid)) => {
12720                let name = self
12721                    .state
12722                    .placed_containers
12723                    .iter()
12724                    .find(|c| c.id == cid)
12725                    .map(|c| c.display_name.clone())
12726                    .unwrap_or_else(|| "container".into());
12727                let added = self
12728                    .state
12729                    .worker_route_editor
12730                    .as_mut()
12731                    .is_some_and(|ed| ed.append_deposit_at(&cid));
12732                if added {
12733                    self.state
12734                        .push_log(format!("Route: + deposit at {name} ({cid})"));
12735                } else {
12736                    self.state.push_log(format!(
12737                        "Route: {name} already in route — selected it (d to remove)"
12738                    ));
12739                }
12740            }
12741            Some(Target::Npc(npc_id, label)) => {
12742                // Quick-add sell: first storage template (the full sell flow
12743                // lives in the a → Sell sheet).
12744                let template = self.re_template_candidates().into_iter().next();
12745                let Some(template) = template else {
12746                    self.state.push_log(
12747                        "Route: no items in your storage to sell — stock a chest first".to_string(),
12748                    );
12749                    return;
12750                };
12751                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12752                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
12753                });
12754                if added {
12755                    self.state
12756                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12757                } else {
12758                    self.state.push_log(format!(
12759                        "Route: {label} already sells {template} — selected it (d to remove)"
12760                    ));
12761                }
12762            }
12763            Some(Target::Node(id, label)) => {
12764                let added = self
12765                    .state
12766                    .worker_route_editor
12767                    .as_mut()
12768                    .is_some_and(|ed| ed.append_harvest_node(&id));
12769                if added {
12770                    self.state
12771                        .push_log(format!("Route: + harvest node {label}"));
12772                } else {
12773                    self.state.push_log(format!(
12774                        "Route: {label} already in route — selected it (d to remove)"
12775                    ));
12776                }
12777            }
12778            None => {}
12779        }
12780    }
12781
12782    pub fn worker_route_editor_select(&mut self, delta: i32) {
12783        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12784            return;
12785        };
12786        if ed.stops.is_empty() {
12787            return;
12788        }
12789        let n = ed.stops.len() as i32;
12790        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12791        ed.selected_stop_index = next;
12792    }
12793
12794    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12795        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12796            return;
12797        };
12798        if delta < 0 {
12799            ed.move_selected_up();
12800        } else if delta > 0 {
12801            ed.move_selected_down();
12802        }
12803    }
12804
12805    pub fn worker_route_editor_delete_selected(&mut self) {
12806        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12807            let before = ed.stop_count();
12808            ed.remove_selected_stop();
12809            ed.stop_count() < before
12810        });
12811        if removed {
12812            self.state.push_log("Route: removed selected stop");
12813        }
12814    }
12815
12816    /// Clear all stops. Saving afterwards parks the worker in idle mode
12817    /// instead of leaving it in a broken job loop.
12818    pub fn worker_route_editor_clear_stops(&mut self) {
12819        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12820            return;
12821        };
12822        if ed.stops.is_empty() {
12823            self.state
12824                .push_log("Route: already empty — s saves an idle worker".to_string());
12825            return;
12826        }
12827        ed.stops.clear();
12828        ed.selected_stop_index = 0;
12829        self.state.push_log(
12830            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
12831        );
12832    }
12833
12834    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12835        if self.state.pending_worker_job_ack.is_some() {
12836            anyhow::bail!("route save still pending — wait for server ack");
12837        }
12838        let Some(ed) = self.state.worker_route_editor.clone() else {
12839            anyhow::bail!("route editor not open");
12840        };
12841        // Empty route = stand down: park the worker in idle mode rather than
12842        // erroring out or leaving it marching a ghost loop.
12843        let (job_yaml, idle) = if ed.stops.is_empty() {
12844            (ed.build_idle_job_yaml(), true)
12845        } else {
12846            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12847        };
12848        let worker_id = ed.worker_instance_id.clone();
12849        let route_view = if idle { None } else { Some(ed.to_route_view()) };
12850        let mode = if idle {
12851            flatland_protocol::WorkerModeView::Idle
12852        } else {
12853            flatland_protocol::WorkerModeView::JobLoop
12854        };
12855        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12856            .state
12857            .hired_workers
12858            .iter()
12859            .find(|w| w.instance_id == worker_id)
12860            .map(|w| {
12861                (
12862                    w.route.clone(),
12863                    w.mode,
12864                    w.step_label.clone(),
12865                    w.last_error.clone(),
12866                )
12867            })
12868            .unwrap_or((
12869                None,
12870                flatland_protocol::WorkerModeView::Idle,
12871                String::new(),
12872                None,
12873            ));
12874        self.seq += 1;
12875        let seq = self.seq;
12876        self.session
12877            .submit_intent(Intent::SetWorkerJob {
12878                entity_id: self.state.entity_id,
12879                worker_instance_id: worker_id.clone(),
12880                job_yaml,
12881                seq,
12882            })
12883            .await?;
12884        self.state.intents_sent += 1;
12885        if let Some(w) = self
12886            .state
12887            .hired_workers
12888            .iter_mut()
12889            .find(|w| w.instance_id == worker_id)
12890        {
12891            w.route = route_view;
12892            w.mode = mode;
12893            w.last_error = None;
12894            if idle {
12895                w.step_label.clear();
12896                w.route_stop_index = None;
12897            }
12898        }
12899        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12900            seq,
12901            worker_instance_id: worker_id,
12902            worker_label: ed.worker_label.clone(),
12903            idle,
12904            stop_count: ed.stops.len(),
12905            prev_route,
12906            prev_mode,
12907            prev_step_label,
12908            prev_last_error,
12909        });
12910        self.state.push_log(format!(
12911            "Route: saving for {}… (waiting for server)",
12912            ed.worker_label
12913        ));
12914        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
12915        Ok(())
12916    }
12917    pub fn quest_menu_move(&mut self, delta: i32) {
12918        let n = self.state.active_quest_entries().len();
12919        if n == 0 {
12920            return;
12921        }
12922        let idx = self.state.quest_menu_index as i32;
12923        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12924    }
12925
12926    pub fn quest_menu_page(&mut self, pages: i32) {
12927        let n = self.state.active_quest_entries().len();
12928        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12929    }
12930
12931    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12932        let Some(offer) = self.state.pending_quest_offer.clone() else {
12933            anyhow::bail!("no quest offer");
12934        };
12935        self.seq += 1;
12936        let seq = self.seq;
12937        self.session
12938            .submit_intent(Intent::AcceptQuest {
12939                entity_id: self.state.entity_id,
12940                quest_id: offer.quest_id,
12941                seq,
12942            })
12943            .await?;
12944        self.state.intents_sent += 1;
12945        Ok(())
12946    }
12947
12948    pub fn quest_offer_decline(&mut self) {
12949        self.state.show_quest_offer = false;
12950        self.state.pending_quest_offer = None;
12951        if !self.state.show_npc_chat
12952            && !self.state.show_shop_menu
12953            && self.state.npc_verb_target.is_some()
12954        {
12955            self.state.show_npc_verb_menu = true;
12956        }
12957    }
12958
12959    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12960        if !self.state.show_quest_menu {
12961            return Ok(());
12962        }
12963        let active: Vec<_> = self
12964            .state
12965            .active_quest_entries()
12966            .into_iter()
12967            .cloned()
12968            .collect();
12969        let Some(entry) = active.get(self.state.quest_menu_index) else {
12970            return Ok(());
12971        };
12972        if self.state.quest_withdraw_confirm {
12973            if !entry.can_withdraw {
12974                anyhow::bail!("quest cannot be withdrawn");
12975            }
12976            self.seq += 1;
12977            let seq = self.seq;
12978            self.session
12979                .submit_intent(Intent::WithdrawQuest {
12980                    entity_id: self.state.entity_id,
12981                    quest_id: entry.quest_id.clone(),
12982                    seq,
12983                })
12984                .await?;
12985            self.state.intents_sent += 1;
12986            self.state.quest_withdraw_confirm = false;
12987            return Ok(());
12988        }
12989        self.seq += 1;
12990        let seq = self.seq;
12991        self.session
12992            .submit_intent(Intent::TrackQuest {
12993                entity_id: self.state.entity_id,
12994                quest_id: entry.quest_id.clone(),
12995                seq,
12996            })
12997            .await?;
12998        self.state.intents_sent += 1;
12999        Ok(())
13000    }
13001
13002    pub fn quest_request_withdraw(&mut self) {
13003        if self.state.show_quest_menu {
13004            self.state.quest_withdraw_confirm = true;
13005        }
13006    }
13007
13008    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
13009        if !self.state.is_alive() {
13010            anyhow::bail!("you are dead");
13011        }
13012        let Some(catalog) = self.state.shop_catalog.clone() else {
13013            anyhow::bail!("no shop open");
13014        };
13015        self.seq += 1;
13016        let seq = self.seq;
13017        match self.state.shop_tab {
13018            ShopTab::Buy => {
13019                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
13020                    anyhow::bail!("nothing selected");
13021                };
13022                if offer.already_owned {
13023                    anyhow::bail!("already owned");
13024                }
13025                self.session
13026                    .submit_intent(Intent::ShopBuy {
13027                        entity_id: self.state.entity_id,
13028                        npc_id: catalog.npc_id.clone(),
13029                        offer_id: offer.offer_id.clone(),
13030                        quantity: self.state.shop_quantity,
13031                        seq,
13032                    })
13033                    .await?;
13034            }
13035            ShopTab::Sell => {
13036                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
13037                    anyhow::bail!("nothing to sell");
13038                };
13039                if line.quantity == 0 {
13040                    anyhow::bail!("you have no {}", line.label);
13041                }
13042                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
13043                self.session
13044                    .submit_intent(Intent::ShopSell {
13045                        entity_id: self.state.entity_id,
13046                        npc_id: catalog.npc_id.clone(),
13047                        template_id: line.template_id.clone(),
13048                        quantity,
13049                        seq,
13050                    })
13051                    .await?;
13052            }
13053        }
13054        self.state.intents_sent += 1;
13055        Ok(())
13056    }
13057
13058    pub fn craft_menu_move(&mut self, delta: i32) {
13059        let n = self.state.blueprints.len();
13060        if n == 0 {
13061            return;
13062        }
13063        let idx = self.state.craft_menu_index as i32;
13064        let next = (idx + delta).rem_euclid(n as i32);
13065        self.state.craft_menu_index = next as usize;
13066        self.state.clamp_craft_batch_quantity();
13067    }
13068
13069    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
13070        self.state.craft_batch_adjust_quantity(delta);
13071    }
13072
13073    pub fn craft_batch_set_max(&mut self) {
13074        self.state.craft_batch_set_max();
13075    }
13076
13077    pub fn craft_batch_set_min(&mut self) {
13078        self.state.craft_batch_set_min();
13079    }
13080
13081    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
13082        let Some(blueprint) = self
13083            .state
13084            .blueprints
13085            .get(self.state.craft_menu_index)
13086            .cloned()
13087        else {
13088            anyhow::bail!("no blueprints known");
13089        };
13090        if !self.state.can_craft_blueprint(&blueprint) {
13091            let hint = self
13092                .state
13093                .craft_missing_hint(&blueprint)
13094                .unwrap_or_else(|| "missing materials".into());
13095            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
13096        }
13097        let count = self.state.craft_batch_quantity;
13098        let max = self.state.max_craft_batches(&blueprint);
13099        if max == 0 {
13100            anyhow::bail!("cannot craft {}", blueprint.label);
13101        }
13102        let batches = count.min(max);
13103        self.craft(&blueprint.id, Some(batches)).await?;
13104        self.state.show_craft_menu = false;
13105        Ok(())
13106    }
13107
13108    pub async fn move_by(
13109        &mut self,
13110        forward: f32,
13111        strafe: f32,
13112        vertical: f32,
13113        sprint: bool,
13114        sneak: bool,
13115    ) -> anyhow::Result<()> {
13116        if !self.state.is_alive() {
13117            anyhow::bail!("you are dead");
13118        }
13119        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
13120            self.last_move_forward = forward;
13121            self.last_move_strafe = strafe;
13122        }
13123        self.seq += 1;
13124        self.session
13125            .submit_intent(Intent::Move {
13126                entity_id: self.state.entity_id,
13127                forward,
13128                strafe,
13129                vertical,
13130                sprint: sprint && !sneak,
13131                sneak,
13132                seq: self.seq,
13133            })
13134            .await?;
13135        self.state.intents_sent += 1;
13136        Ok(())
13137    }
13138
13139    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
13140        if !self.state.connected {
13141            crate::harvest_trace!("harvest_nearest rejected: not connected");
13142            anyhow::bail!("not connected");
13143        }
13144        if !self.state.is_alive() {
13145            crate::harvest_trace!("harvest_nearest rejected: player dead");
13146            anyhow::bail!("you are dead");
13147        }
13148        if self.state.harvest_in_progress {
13149            if self.state.harvest_state_stale() {
13150                self.state.clear_harvest_state();
13151            } else {
13152                anyhow::bail!("already harvesting");
13153            }
13154        }
13155        let (px, py) = self
13156            .state
13157            .player
13158            .as_ref()
13159            .map(|p| (p.transform.position.x, p.transform.position.y))
13160            .unwrap_or((0.0, 0.0));
13161
13162        let available = self
13163            .state
13164            .resource_nodes
13165            .iter()
13166            .filter(|n| !n.harvest_off)
13167            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13168            .count();
13169        let node_id = self
13170            .state
13171            .resource_nodes
13172            .iter()
13173            .filter(|n| !n.harvest_off)
13174            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13175            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
13176            .min_by(|a, b| {
13177                let da = distance(px, py, a.x, a.y);
13178                let db = distance(px, py, b.x, b.y);
13179                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
13180            })
13181            .map(|n| n.id.clone());
13182
13183        let Some(node_id) = node_id else {
13184            let has_loot = self
13185                .state
13186                .ground_drops
13187                .iter()
13188                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13189            if has_loot {
13190                return self.pickup_nearest().await;
13191            }
13192            anyhow::bail!(
13193                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
13194            );
13195        };
13196
13197        self.seq += 1;
13198        let seq = self.seq;
13199        crate::harvest_trace!(
13200            entity_id = self.state.entity_id,
13201            node_id = %node_id,
13202            seq,
13203            px,
13204            py,
13205            available_nodes = available,
13206            "submitting harvest intent"
13207        );
13208        self.session
13209            .submit_intent(Intent::Harvest {
13210                entity_id: self.state.entity_id,
13211                node_id,
13212                seq,
13213            })
13214            .await?;
13215        self.state.intents_sent += 1;
13216        self.state.harvest_in_progress = true;
13217        self.state.harvest_started_at = Some(Instant::now());
13218        self.state.push_log("Harvesting…");
13219        crate::harvest_trace!(
13220            entity_id = self.state.entity_id,
13221            seq,
13222            "harvest intent queued to session"
13223        );
13224        Ok(())
13225    }
13226
13227    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
13228        if !self.state.is_alive() {
13229            anyhow::bail!("you are dead");
13230        }
13231        let blueprint_id = self
13232            .state
13233            .blueprints
13234            .iter()
13235            .find(|bp| self.state.can_craft_blueprint(bp))
13236            .map(|bp| bp.id.clone())
13237            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
13238        self.craft(&blueprint_id, None).await
13239    }
13240
13241    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
13242        if !self.state.is_alive() {
13243            anyhow::bail!("you are dead");
13244        }
13245        self.seq += 1;
13246        self.session
13247            .submit_intent(Intent::Craft {
13248                entity_id: self.state.entity_id,
13249                blueprint_id: blueprint_id.to_string(),
13250                count,
13251                seq: self.seq,
13252            })
13253            .await?;
13254        self.state.intents_sent += 1;
13255        let (label, batches) = self
13256            .state
13257            .blueprints
13258            .iter()
13259            .find(|b| b.id == blueprint_id)
13260            .map(|b| {
13261                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
13262                (b.label.as_str(), n)
13263            })
13264            .unwrap_or((blueprint_id, count.unwrap_or(1)));
13265        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
13266        Ok(())
13267    }
13268
13269    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
13270        if !self.state.is_alive() {
13271            anyhow::bail!("you are dead");
13272        }
13273        let target_id = match self.state.nearest_interact_target() {
13274            Some(id) => id,
13275            None => {
13276                anyhow::bail!("nothing to interact with nearby");
13277            }
13278        };
13279        if self.state.npcs.iter().any(|n| n.id == target_id) {
13280            self.state.show_npc_verb_menu = true;
13281            self.state.npc_verb_target = Some(target_id);
13282            self.state.npc_verb_index = 0;
13283            return Ok(());
13284        }
13285        if self
13286            .state
13287            .hired_workers
13288            .iter()
13289            .any(|w| w.instance_id == target_id)
13290        {
13291            return self.open_workers_menu_for(&target_id).await;
13292        }
13293        if let Ok(peer_id) = target_id.parse::<EntityId>() {
13294            if self
13295                .state
13296                .hired_workers
13297                .iter()
13298                .any(|w| w.entity_id == peer_id)
13299            {
13300                if let Some(w) = self
13301                    .state
13302                    .hired_workers
13303                    .iter()
13304                    .find(|w| w.entity_id == peer_id)
13305                {
13306                    let id = w.instance_id.clone();
13307                    return self.open_workers_menu_for(&id).await;
13308                }
13309            }
13310            if let Some(entity) = self
13311                .state
13312                .entities
13313                .iter()
13314                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
13315            {
13316                self.state.player_verbs.open_for(peer_id, &entity.label);
13317                return Ok(());
13318            }
13319        }
13320        self.seq += 1;
13321        self.session
13322            .submit_intent(Intent::Interact {
13323                entity_id: self.state.entity_id,
13324                target_id: target_id.clone(),
13325                seq: self.seq,
13326            })
13327            .await?;
13328        self.state.intents_sent += 1;
13329        Ok(())
13330    }
13331
13332    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
13333    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
13334        if !self.state.is_alive() {
13335            anyhow::bail!("you are dead");
13336        }
13337        let (px, py) = self.state.player_position();
13338        let has_loot = self
13339            .state
13340            .ground_drops
13341            .iter()
13342            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13343        if has_loot {
13344            return self.pickup_nearest().await;
13345        }
13346        if self
13347            .state
13348            .placed_containers
13349            .iter()
13350            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
13351        {
13352            return self.pickup_nearest_container().await;
13353        }
13354
13355        if let Some(plot) = self.state.my_plot_under_player().cloned() {
13356            // Double-tap f within 1.2s sells; otherwise farm the plot.
13357            const SELL_WINDOW: Duration = Duration::from_millis(1200);
13358            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
13359                && self
13360                    .state
13361                    .sell_plot_armed_at
13362                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
13363            if sell_armed {
13364                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
13365            }
13366            self.state.sell_plot_confirm = None;
13367            self.state.sell_plot_armed_at = None;
13368
13369            // Prefer farm actions on your own land unless an NPC/player/door/board
13370            // is in interact range (those still win).
13371            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
13372                self.state.npcs.iter().any(|n| n.id == id)
13373                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
13374                    || self.state.doors.iter().any(|d| d.id == id)
13375                    || self.state.interactables.iter().any(|i| {
13376                        i.id == id
13377                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
13378                    })
13379                    || id.parse::<EntityId>().is_ok_and(|eid| {
13380                        self.state
13381                            .entities
13382                            .iter()
13383                            .any(|e| e.id == eid && e.id != self.state.entity_id)
13384                    })
13385            });
13386            if !blocking_interact {
13387                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
13388                match self.harvest_nearest().await {
13389                    Ok(()) => return Ok(()),
13390                    Err(err) => {
13391                        let msg = err.to_string();
13392                        if !(msg.contains("no harvestable")
13393                            || msg.contains("press p")
13394                            || msg.contains("press f")
13395                            || msg.contains("nothing"))
13396                        {
13397                            return Err(err);
13398                        }
13399                    }
13400                }
13401                return Ok(());
13402            }
13403        }
13404        if self.state.nearest_interact_target().is_some() {
13405            return self.interact_nearest().await;
13406        }
13407        // Prefer a clear "move closer" when a board is visible but out of reach,
13408        // instead of silently falling through to harvest.
13409        if let Some((label, dist)) = self.state.nearest_quest_board() {
13410            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
13411                anyhow::bail!(
13412                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
13413                );
13414            }
13415        }
13416
13417        match self.harvest_nearest().await {
13418            Ok(()) => Ok(()),
13419            Err(err) => {
13420                let msg = err.to_string();
13421                if msg.contains("no harvestable")
13422                    || msg.contains("press p")
13423                    || msg.contains("press f")
13424                {
13425                    anyhow::bail!(
13426                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
13427                    );
13428                }
13429                Err(err)
13430            }
13431        }
13432    }
13433
13434    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
13435    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
13436        if !self.state.is_alive() {
13437            anyhow::bail!("you are dead");
13438        }
13439        if self.state.claim_mode.is_some() {
13440            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
13441        }
13442        let zone = self
13443            .state
13444            .free_property_zone_under_player()
13445            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
13446        let zone_id = zone.id.clone();
13447        let label = zone
13448            .label
13449            .as_deref()
13450            .filter(|s| !s.trim().is_empty())
13451            .unwrap_or(zone.id.as_str())
13452            .to_string();
13453        self.enter_claim_mode(&zone_id);
13454        self.state.push_log(format!(
13455            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
13456        ));
13457        Ok(())
13458    }
13459
13460    /// Enter claim mode over a property zone (default 4×4 or min size).
13461    pub fn enter_claim_mode(&mut self, zone_id: &str) {
13462        let Some(zone) = self
13463            .state
13464            .property_zones
13465            .iter()
13466            .find(|z| z.id == zone_id)
13467            .cloned()
13468        else {
13469            self.state.push_log("unknown property zone");
13470            return;
13471        };
13472        self.state.sell_plot_confirm = None;
13473        self.state.sell_plot_armed_at = None;
13474        let min_area = self
13475            .state
13476            .property_plot_settings
13477            .as_ref()
13478            .map(|s| s.min_plot_area_m2)
13479            .unwrap_or(4.0)
13480            .max(1.0);
13481        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
13482        let side = 4u32.max(min_side);
13483        let (px, py) = self.state.player_position();
13484        let anchor_x = px.floor();
13485        let anchor_y = py.floor();
13486        self.state.claim_mode = Some(ClaimModeState {
13487            zone_id: zone.id.clone(),
13488            width_m: side,
13489            height_m: side,
13490            anchor_x,
13491            anchor_y,
13492        });
13493        let label = zone
13494            .label
13495            .as_deref()
13496            .filter(|s| !s.trim().is_empty())
13497            .unwrap_or(zone.id.as_str());
13498        self.state.push_log(format!(
13499            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
13500        ));
13501    }
13502
13503    pub fn cancel_claim_mode(&mut self) {
13504        if self.state.claim_mode.take().is_some() {
13505            self.state.push_log("Claim cancelled");
13506        }
13507    }
13508
13509    /// Enter relocate mode for a placed container (1×1 ghost).
13510    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
13511        if !self.state.is_alive() {
13512            anyhow::bail!("you are dead");
13513        }
13514        if self.state.relocate_mode.is_some() {
13515            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
13516        }
13517        if self.state.claim_mode.is_some() {
13518            anyhow::bail!("finish or cancel claim mode first");
13519        }
13520        let chest = self
13521            .state
13522            .placed_containers
13523            .iter()
13524            .find(|c| c.id == container_id)
13525            .cloned()
13526            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
13527        let (px, py) = self.state.player_position();
13528        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
13529            anyhow::bail!("too far from {}", chest.display_name);
13530        }
13531        if chest.locked && !chest.accessible {
13532            anyhow::bail!(
13533                "need the matching key for {} before moving it",
13534                chest.display_name
13535            );
13536        }
13537        let label = if chest.display_name.trim().is_empty() {
13538            chest.template_id.clone()
13539        } else {
13540            chest.display_name.clone()
13541        };
13542        self.state.relocate_mode = Some(RelocateModeState {
13543            container_id: chest.id.clone(),
13544            label: label.clone(),
13545            cursor_x: chest.x.floor() + 0.5,
13546            cursor_y: chest.y.floor() + 0.5,
13547        });
13548        self.state.push_log(format!(
13549            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
13550        ));
13551        Ok(())
13552    }
13553
13554    /// World `Shift+m`: relocate nearest owned/accessible placed container.
13555    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
13556        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
13557            anyhow::bail!("no chest nearby to relocate");
13558        };
13559        if chest.locked && !chest.accessible {
13560            anyhow::bail!(
13561                "need the matching key for {} before moving it",
13562                chest.display_name
13563            );
13564        }
13565        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
13566        // Server enforces ownership; client starts on nearest accessible chest in range.
13567        self.begin_relocate_container(&chest.id)
13568    }
13569
13570    pub fn cancel_relocate_mode(&mut self) {
13571        if self.state.relocate_mode.take().is_some() {
13572            self.state.push_log("Relocate cancelled");
13573        }
13574    }
13575
13576    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
13577        let Some(mode) = self.state.relocate_mode.as_mut() else {
13578            return;
13579        };
13580        let max_x = self.state.world_width_m.max(1.0);
13581        let max_y = self.state.world_height_m.max(1.0);
13582        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
13583        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
13584        mode.cursor_x = nx.floor() + 0.5;
13585        mode.cursor_y = ny.floor() + 0.5;
13586    }
13587
13588    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
13589        let Some(mode) = self.state.relocate_mode.as_mut() else {
13590            return;
13591        };
13592        let max_x = self.state.world_width_m.max(1.0);
13593        let max_y = self.state.world_height_m.max(1.0);
13594        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
13595        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
13596    }
13597
13598    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
13599        if !self.state.is_alive() {
13600            anyhow::bail!("you are dead");
13601        }
13602        let Some(mode) = self.state.relocate_mode.clone() else {
13603            anyhow::bail!("not relocating");
13604        };
13605        let (px, py) = self.state.player_position();
13606        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
13607        if dist > 8.0 {
13608            anyhow::bail!("destination too far (max 8 m)");
13609        }
13610        self.seq += 1;
13611        self.session
13612            .submit_intent(Intent::MovePlacedContainer {
13613                entity_id: self.state.entity_id,
13614                container_id: mode.container_id.clone(),
13615                x: mode.cursor_x,
13616                y: mode.cursor_y,
13617                seq: self.seq,
13618            })
13619            .await?;
13620        self.state.intents_sent += 1;
13621        self.state.relocate_mode = None;
13622        self.state.push_log(format!("Moving {}…", mode.label));
13623        Ok(())
13624    }
13625
13626    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13627        let Some(mode) = self.state.claim_mode.as_mut() else {
13628            return;
13629        };
13630        mode.width_m = w.max(1);
13631        mode.height_m = h.max(1);
13632    }
13633
13634    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13635        let Some(mode) = self.state.claim_mode.as_mut() else {
13636            return;
13637        };
13638        let w = (mode.width_m as i32 + dw).max(1) as u32;
13639        let h = (mode.height_m as i32 + dh).max(1) as u32;
13640        mode.width_m = w;
13641        mode.height_m = h;
13642    }
13643
13644    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
13645    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13646        let Some(mode) = self.state.claim_mode.as_mut() else {
13647            return;
13648        };
13649        let max_x = self.state.world_width_m.max(1.0);
13650        let max_y = self.state.world_height_m.max(1.0);
13651        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13652        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13653        mode.anchor_x = nx.floor();
13654        mode.anchor_y = ny.floor();
13655    }
13656
13657    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13658        if !self.state.is_alive() {
13659            anyhow::bail!("you are dead");
13660        }
13661        let Some(mode) = self.state.claim_mode.clone() else {
13662            anyhow::bail!("not in claim mode");
13663        };
13664        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13665            self.state.claim_quote()
13666        else {
13667            anyhow::bail!("cannot quote claim");
13668        };
13669        if !valid {
13670            anyhow::bail!(reason);
13671        }
13672        if !can_afford {
13673            anyhow::bail!(
13674                "not enough copper (need {})",
13675                crate::currency::format_copper(purchase)
13676            );
13677        }
13678        let (x0, y0, x1, y1) = self
13679            .state
13680            .claim_footprint_rect()
13681            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13682        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13683        self.seq += 1;
13684        self.session
13685            .submit_intent(Intent::BuyPlot {
13686                entity_id: self.state.entity_id,
13687                zone_id: mode.zone_id,
13688                x0,
13689                y0,
13690                x1,
13691                y1,
13692                seq: self.seq,
13693            })
13694            .await?;
13695        self.state.intents_sent += 1;
13696        self.state.claim_mode = None;
13697        self.state.push_log(format!(
13698            "Buying plot for {}",
13699            crate::currency::format_copper(purchase)
13700        ));
13701        Ok(())
13702    }
13703
13704    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13705        if !self.state.is_alive() {
13706            anyhow::bail!("you are dead");
13707        }
13708        let zone_id = self
13709            .state
13710            .claim_mode
13711            .as_ref()
13712            .map(|m| m.zone_id.clone())
13713            .or_else(|| {
13714                self.state
13715                    .free_property_zone_under_player()
13716                    .map(|z| z.id.clone())
13717            })
13718            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13719        self.seq += 1;
13720        self.session
13721            .submit_intent(Intent::BuyPlotAllFree {
13722                entity_id: self.state.entity_id,
13723                zone_id,
13724                seq: self.seq,
13725            })
13726            .await?;
13727        self.state.intents_sent += 1;
13728        self.state.claim_mode = None;
13729        self.state.push_log("Claiming largest free plot…");
13730        Ok(())
13731    }
13732
13733    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
13734        if !self.state.is_alive() {
13735            anyhow::bail!("you are dead");
13736        }
13737        self.seq += 1;
13738        self.session
13739            .submit_intent(Intent::SellPlotToCrown {
13740                entity_id: self.state.entity_id,
13741                plot_id,
13742                seq: self.seq,
13743            })
13744            .await?;
13745        self.state.intents_sent += 1;
13746        self.state.sell_plot_confirm = None;
13747        self.state.sell_plot_armed_at = None;
13748        self.state.push_log("Selling plot to the crown…");
13749        Ok(())
13750    }
13751
13752    pub async fn set_plot_farm_public(
13753        &mut self,
13754        plot_id: uuid::Uuid,
13755        public: bool,
13756        public_tax_discount_bps: u32,
13757    ) -> anyhow::Result<()> {
13758        self.seq += 1;
13759        self.session
13760            .submit_intent(Intent::SetPlotFarmPublic {
13761                entity_id: self.state.entity_id,
13762                plot_id,
13763                public,
13764                public_tax_discount_bps,
13765                seq: self.seq,
13766            })
13767            .await?;
13768        self.state.intents_sent += 1;
13769        Ok(())
13770    }
13771
13772    pub async fn plot_farm_allow_upsert(
13773        &mut self,
13774        plot_id: uuid::Uuid,
13775        character_id: Option<uuid::Uuid>,
13776        character_name: String,
13777        tax_discount_bps: u32,
13778    ) -> anyhow::Result<()> {
13779        self.seq += 1;
13780        self.session
13781            .submit_intent(Intent::PlotFarmAllowUpsert {
13782                entity_id: self.state.entity_id,
13783                plot_id,
13784                character_id,
13785                character_name,
13786                tax_discount_bps,
13787                seq: self.seq,
13788            })
13789            .await?;
13790        self.state.intents_sent += 1;
13791        Ok(())
13792    }
13793
13794    pub async fn plot_farm_allow_remove(
13795        &mut self,
13796        plot_id: uuid::Uuid,
13797        character_id: uuid::Uuid,
13798    ) -> anyhow::Result<()> {
13799        self.seq += 1;
13800        self.session
13801            .submit_intent(Intent::PlotFarmAllowRemove {
13802                entity_id: self.state.entity_id,
13803                plot_id,
13804                character_id,
13805                seq: self.seq,
13806            })
13807            .await?;
13808        self.state.intents_sent += 1;
13809        Ok(())
13810    }
13811
13812    pub fn open_farm_access_panel(&mut self) {
13813        let Some(plot) = self.state.my_plot_under_player() else {
13814            self.state
13815                .push_log("Stand on your deed plot to manage farm access");
13816            return;
13817        };
13818        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13819        self.state.farm_access_index = 0;
13820        self.state.show_farm_access = true;
13821    }
13822
13823    pub fn close_farm_access_panel(&mut self) {
13824        self.state.show_farm_access = false;
13825        self.state.farm_access_name_draft.clear();
13826        self.state.farm_access_index = 0;
13827    }
13828
13829    pub fn farm_access_move(&mut self, delta: i32) {
13830        let n = self.farm_access_row_count().max(1);
13831        let idx = self.state.farm_access_index as i32 + delta;
13832        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13833    }
13834
13835    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13836        let Some(plot) = self.state.my_plot_under_player() else {
13837            return vec![FarmAccessRow::PublicToggle];
13838        };
13839        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13840        for g in &plot.farm_allow {
13841            rows.push(FarmAccessRow::AllowRemove {
13842                character_id: g.character_id,
13843                label: if g.character_label.trim().is_empty() {
13844                    g.character_id.to_string()[..8].to_string()
13845                } else {
13846                    g.character_label.clone()
13847                },
13848                tax_discount_bps: g.tax_discount_bps,
13849            });
13850        }
13851        for e in &self.state.entities {
13852            if e.id == self.state.entity_id || e.label.trim().is_empty() {
13853                continue;
13854            }
13855            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13856                continue;
13857            }
13858            if self
13859                .state
13860                .npcs
13861                .iter()
13862                .any(|n| n.id == e.label || n.label == e.label)
13863            {
13864                continue;
13865            }
13866            if plot
13867                .farm_allow
13868                .iter()
13869                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13870            {
13871                continue;
13872            }
13873            rows.push(FarmAccessRow::NearbyAdd {
13874                name: e.label.clone(),
13875            });
13876        }
13877        rows
13878    }
13879
13880    pub fn farm_access_row_count(&self) -> usize {
13881        self.farm_access_rows().len().max(1)
13882    }
13883
13884    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13885        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13886            self.close_farm_access_panel();
13887            return Ok(());
13888        };
13889        let rows = self.farm_access_rows();
13890        let Some(row) = rows.get(self.state.farm_access_index) else {
13891            return Ok(());
13892        };
13893        match row {
13894            FarmAccessRow::PublicToggle => {
13895                self.set_plot_farm_public(
13896                    plot.plot_id,
13897                    !plot.farm_public,
13898                    plot.public_tax_discount_bps,
13899                )
13900                .await
13901            }
13902            FarmAccessRow::PublicDiscount => Ok(()),
13903            FarmAccessRow::AllowRemove { character_id, .. } => {
13904                self.plot_farm_allow_remove(plot.plot_id, *character_id)
13905                    .await
13906            }
13907            FarmAccessRow::NearbyAdd { name } => {
13908                let disc = self
13909                    .state
13910                    .farm_access_discount_bps
13911                    .max(plot.public_tax_discount_bps);
13912                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13913                    .await
13914            }
13915        }
13916    }
13917
13918    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13919        let Some(plot) = self.state.my_plot_under_player().cloned() else {
13920            return Ok(());
13921        };
13922        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13923        self.state.farm_access_discount_bps = next;
13924        self.state.farm_access_index = 1;
13925        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13926            .await
13927    }
13928
13929    /// Till the cell under you on a farmable plot (`c`).
13930    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13931        if self.state.farmable_plot_under_player().is_none() {
13932            anyhow::bail!("stand on a farmable plot to cultivate");
13933        }
13934        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13935            let (px, py) = self.state.player_position();
13936            if self
13937                .state
13938                .terrain_at(px, py)
13939                .is_some_and(|k| k == TerrainKindView::Tilled)
13940            {
13941                anyhow::bail!("already tilled — stand on bare soil and press c");
13942            }
13943            anyhow::bail!("cannot till this cell — move onto soil on your plot");
13944        };
13945        self.cultivate_at(tx, ty).await
13946    }
13947
13948    /// Plant seeds on empty tilled soil under you (`p`).
13949    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13950        if self.state.farmable_plot_under_player().is_none() {
13951            anyhow::bail!("stand on a farmable plot to plant");
13952        }
13953        if !self.state.underfoot_free_tilled_plant_slot() {
13954            anyhow::bail!("stand on empty tilled soil and press p");
13955        }
13956        let seeds = self.state.farm_seed_entries();
13957        if seeds.is_empty() {
13958            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13959        }
13960        if seeds.len() == 1 {
13961            return self.plant_seeds(seeds[0].0.clone(), 1).await;
13962        }
13963        self.open_plant_menu();
13964        Ok(())
13965    }
13966
13967    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
13968    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13969        let Some(plot) = self.state.my_plot_under_player() else {
13970            anyhow::bail!("stand on your plot to build");
13971        };
13972        if plot.building_id.is_some() {
13973            anyhow::bail!("this plot already has a building");
13974        }
13975        let building_now = self
13976            .state
13977            .timed_channel
13978            .as_ref()
13979            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13980        if !building_now && self.state.building_materials.is_empty() {
13981            anyhow::bail!("no building materials loaded — wait a moment and try again");
13982        }
13983        self.state.show_plot_build_menu = true;
13984        self.state.show_craft_menu = false;
13985        self.state.show_shop_menu = false;
13986        self.state.shop_catalog = None;
13987        self.state.show_stats = false;
13988        self.state.show_inventory_menu = false;
13989        self.state.plot_build_focus_wall = true;
13990        let walls = self.state.plot_build_wall_options().len();
13991        let roofs = self.state.plot_build_roof_options().len();
13992        if walls > 0 {
13993            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13994        } else {
13995            self.state.plot_build_wall_index = 0;
13996        }
13997        if roofs > 0 {
13998            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13999        } else {
14000            self.state.plot_build_roof_index = 0;
14001        }
14002        Ok(())
14003    }
14004
14005    pub fn close_plot_build_menu(&mut self) {
14006        self.state.show_plot_build_menu = false;
14007    }
14008
14009    pub fn plot_build_menu_move(&mut self, delta: i32) {
14010        let walls = self.state.plot_build_wall_options();
14011        let roofs = self.state.plot_build_roof_options();
14012        if self.state.plot_build_focus_wall {
14013            if walls.is_empty() {
14014                return;
14015            }
14016            let n = walls.len() as i32;
14017            let cur = self.state.plot_build_wall_index as i32;
14018            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
14019        } else {
14020            if roofs.is_empty() {
14021                return;
14022            }
14023            let n = roofs.len() as i32;
14024            let cur = self.state.plot_build_roof_index as i32;
14025            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
14026        }
14027    }
14028
14029    pub fn plot_build_menu_toggle_focus(&mut self) {
14030        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
14031    }
14032
14033    /// Confirm selection from the B menu.
14034    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
14035        let wall = self
14036            .state
14037            .plot_build_selected_wall()
14038            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
14039            .id
14040            .clone();
14041        let roof = self
14042            .state
14043            .plot_build_selected_roof()
14044            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
14045            .id
14046            .clone();
14047        // Keep the B menu open so it switches to the in-progress panel.
14048        self.start_plot_build(&wall, &roof).await
14049    }
14050
14051    /// Cancel an in-progress plot build (materials returned).
14052    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
14053        self.seq += 1;
14054        self.session
14055            .submit_intent(Intent::CancelPlotBuild {
14056                entity_id: self.state.entity_id,
14057                seq: self.seq,
14058            })
14059            .await?;
14060        self.state.intents_sent += 1;
14061        Ok(())
14062    }
14063
14064    /// Start timed shell craft on the plot's solid tilled rectangle.
14065    pub async fn start_plot_build(
14066        &mut self,
14067        wall_material_id: &str,
14068        roof_material_id: &str,
14069    ) -> anyhow::Result<()> {
14070        let Some(plot) = self.state.my_plot_under_player() else {
14071            anyhow::bail!("stand on your plot to build");
14072        };
14073        if plot.building_id.is_some() {
14074            anyhow::bail!("this plot already has a building");
14075        }
14076        let plot_id = plot.plot_id;
14077        self.seq += 1;
14078        self.session
14079            .submit_intent(Intent::StartPlotBuild {
14080                entity_id: self.state.entity_id,
14081                plot_id,
14082                wall_material_id: wall_material_id.to_string(),
14083                roof_material_id: roof_material_id.to_string(),
14084                seq: self.seq,
14085            })
14086            .await?;
14087        self.state.intents_sent += 1;
14088        Ok(())
14089    }
14090
14091    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
14092    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
14093        let (px, py) = self.state.player_position();
14094        let mut best: Option<(f32, String, bool)> = None;
14095        for d in &self.state.doors {
14096            if d.lock_id.is_none() {
14097                continue;
14098            }
14099            let dist = (d.x - px).hypot(d.y - py);
14100            if dist > DOOR_INTERACTION_RADIUS_M {
14101                continue;
14102            }
14103            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
14104                best = Some((dist, d.id.clone(), d.locked));
14105            }
14106        }
14107        let Some((_, door_id, locked_now)) = best else {
14108            anyhow::bail!("no lockable door nearby");
14109        };
14110        let locked = !locked_now;
14111        self.seq += 1;
14112        self.session
14113            .submit_intent(Intent::SetDoorLocked {
14114                entity_id: self.state.entity_id,
14115                door_id,
14116                locked,
14117                seq: self.seq,
14118            })
14119            .await?;
14120        self.state.intents_sent += 1;
14121        Ok(())
14122    }
14123
14124    /// Enter through a nearby open player-building door (`Enter`).
14125    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
14126        if !self.state.is_alive() {
14127            anyhow::bail!("you are dead");
14128        }
14129        if self.state.effective_inside_building().is_some() {
14130            anyhow::bail!("already inside");
14131        }
14132        let (px, py) = self.state.player_position();
14133        let mut best: Option<(f32, String)> = None;
14134        for d in &self.state.doors {
14135            if !d.open || d.locked {
14136                continue;
14137            }
14138            let player_house = self
14139                .state
14140                .buildings
14141                .iter()
14142                .find(|b| b.id == d.building_id)
14143                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14144            if !player_house {
14145                continue;
14146            }
14147            let dist = (d.x - px).hypot(d.y - py);
14148            if dist > DOOR_INTERACTION_RADIUS_M {
14149                continue;
14150            }
14151            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14152                best = Some((dist, d.id.clone()));
14153            }
14154        }
14155        let Some((_, door_id)) = best else {
14156            anyhow::bail!("no open house door nearby — open with f first");
14157        };
14158        self.seq += 1;
14159        self.session
14160            .submit_intent(Intent::EnterBuildingDoor {
14161                entity_id: self.state.entity_id,
14162                door_id,
14163                seq: self.seq,
14164            })
14165            .await?;
14166        self.state.intents_sent += 1;
14167        Ok(())
14168    }
14169
14170    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
14171    /// Works even when the door is closed or locked.
14172    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
14173        if !self.state.is_alive() {
14174            anyhow::bail!("you are dead");
14175        }
14176        let Some(bid) = self.state.effective_inside_building() else {
14177            anyhow::bail!("not inside a building");
14178        };
14179        let (px, py) = self.state.player_position();
14180        let mut best: Option<(f32, String)> = None;
14181        for d in &self.state.doors {
14182            if d.building_id != bid || d.portal.is_none() {
14183                continue;
14184            }
14185            let player_house = self
14186                .state
14187                .buildings
14188                .iter()
14189                .find(|b| b.id == d.building_id)
14190                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14191            if !player_house {
14192                continue;
14193            }
14194            let dist = (d.x - px).hypot(d.y - py);
14195            if dist > 1.5 {
14196                continue;
14197            }
14198            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14199                best = Some((dist, d.id.clone()));
14200            }
14201        }
14202        let Some((_, door_id)) = best else {
14203            anyhow::bail!("stand by the door to exit");
14204        };
14205        self.seq += 1;
14206        self.session
14207            .submit_intent(Intent::ExitBuildingDoor {
14208                entity_id: self.state.entity_id,
14209                door_id,
14210                seq: self.seq,
14211            })
14212            .await?;
14213        self.state.intents_sent += 1;
14214        Ok(())
14215    }
14216
14217    /// Confirm interior layout for your player building (copper charged on server).
14218    pub async fn confirm_interior_edit(
14219        &mut self,
14220        building_id: String,
14221        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
14222        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
14223    ) -> anyhow::Result<()> {
14224        self.seq += 1;
14225        self.session
14226            .submit_intent(Intent::ConfirmInteriorEdit {
14227                entity_id: self.state.entity_id,
14228                building_id,
14229                rooms,
14230                room_doors,
14231                seq: self.seq,
14232            })
14233            .await?;
14234        self.state.intents_sent += 1;
14235        Ok(())
14236    }
14237
14238    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
14239        if !self.state.is_alive() {
14240            anyhow::bail!("you are dead");
14241        }
14242        self.seq += 1;
14243        self.session
14244            .submit_intent(Intent::Cultivate {
14245                entity_id: self.state.entity_id,
14246                x,
14247                y,
14248                seq: self.seq,
14249            })
14250            .await?;
14251        self.state.intents_sent += 1;
14252        Ok(())
14253    }
14254
14255    pub async fn plant_seeds(
14256        &mut self,
14257        seed_template_id: String,
14258        quantity: u32,
14259    ) -> anyhow::Result<()> {
14260        if !self.state.is_alive() {
14261            anyhow::bail!("you are dead");
14262        }
14263        self.seq += 1;
14264        self.session
14265            .submit_intent(Intent::PlantSeeds {
14266                entity_id: self.state.entity_id,
14267                seed_template_id: seed_template_id.clone(),
14268                quantity,
14269                seq: self.seq,
14270            })
14271            .await?;
14272        self.state.intents_sent += 1;
14273        self.state
14274            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
14275        Ok(())
14276    }
14277
14278    pub fn open_plant_menu(&mut self) {
14279        if self.state.farm_seed_entries().is_empty() {
14280            self.state.push_log("No seeds in inventory to plant");
14281            return;
14282        }
14283        self.state.show_plant_menu = true;
14284        self.state.plant_menu_index = 0;
14285        self.state.plant_quantity = 1;
14286        self.state.clamp_plant_menu();
14287    }
14288
14289    pub fn close_plant_menu(&mut self) {
14290        self.state.show_plant_menu = false;
14291    }
14292
14293    pub fn plant_menu_move(&mut self, delta: i32) {
14294        let n = self.state.farm_seed_entries().len();
14295        if n == 0 {
14296            return;
14297        }
14298        let idx = self.state.plant_menu_index as i32 + delta;
14299        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
14300        self.state.clamp_plant_menu();
14301    }
14302
14303    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
14304        let next = self.state.plant_quantity as i32 + delta;
14305        self.state.plant_quantity = next.max(1) as u32;
14306        self.state.clamp_plant_menu();
14307    }
14308
14309    pub fn plant_menu_set_quantity_max(&mut self) {
14310        if let Some((_, max, _)) = self.state.plant_menu_selection() {
14311            self.state.plant_quantity = max;
14312        }
14313        self.state.clamp_plant_menu();
14314    }
14315
14316    pub fn plant_menu_set_quantity_min(&mut self) {
14317        self.state.plant_quantity = 1;
14318        self.state.clamp_plant_menu();
14319    }
14320
14321    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
14322        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
14323            self.close_plant_menu();
14324            anyhow::bail!("no seeds to plant");
14325        };
14326        self.close_plant_menu();
14327        self.plant_seeds(seed, qty).await?;
14328        self.state.push_log(format!("Planted {qty}× {label}"));
14329        Ok(())
14330    }
14331
14332    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
14333    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
14334    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
14335        if !self.state.is_alive() {
14336            anyhow::bail!("you are dead");
14337        }
14338        let binding = self
14339            .state
14340            .hotbar_ability(slot)
14341            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
14342            .to_string();
14343        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
14344            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
14345            if qty == 0 {
14346                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
14347            }
14348            return self.use_item(template_id).await;
14349        }
14350        let ability_id = binding;
14351        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
14352            return self
14353                .cast_ability(&ability_id, Some(self.state.entity_id))
14354                .await;
14355        }
14356        let is_heal = ability_id == "heal_touch"
14357            || self
14358                .state
14359                .ability_meta
14360                .get(&ability_id)
14361                .map(|meta| meta.is_heal)
14362                .unwrap_or(false);
14363        let target = if is_heal {
14364            Some(
14365                self.state
14366                    .target_for_slot(2)
14367                    .unwrap_or(self.state.entity_id),
14368            )
14369        } else {
14370            self.state
14371                .target_for_slot(1)
14372                .or_else(|| self.state.target_for_slot(2))
14373        };
14374        let Some(target_id) = target else {
14375            anyhow::bail!("no target — Tab to select, then press the hotbar key");
14376        };
14377        self.cast_ability(&ability_id, Some(target_id)).await
14378    }
14379
14380    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
14381    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
14382    pub async fn set_hotbar_slot(
14383        &mut self,
14384        slot: u8,
14385        ability_id: Option<&str>,
14386    ) -> anyhow::Result<()> {
14387        if !self.state.is_alive() {
14388            anyhow::bail!("you are dead");
14389        }
14390        if !(1..=9).contains(&slot) {
14391            anyhow::bail!("hotbar slot must be 1–9");
14392        }
14393        let ability_id = ability_id
14394            .map(str::trim)
14395            .filter(|id| !id.is_empty())
14396            .map(str::to_string);
14397        self.seq += 1;
14398        self.session
14399            .submit_intent(Intent::SetHotbarSlot {
14400                entity_id: self.state.entity_id,
14401                slot,
14402                ability_id: ability_id.clone(),
14403                seq: self.seq,
14404            })
14405            .await?;
14406        self.state.intents_sent += 1;
14407        let idx = (slot - 1) as usize;
14408        if self.state.hotbar.len() < 9 {
14409            self.state.hotbar.resize(9, None);
14410        }
14411        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
14412            *slot_mut = ability_id.clone();
14413        }
14414        match ability_id {
14415            Some(id) => {
14416                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
14417                    format!("use {tid}")
14418                } else {
14419                    id
14420                };
14421                self.state.push_log(format!("Hotbar {slot} → {label}"))
14422            }
14423            None => self.state.push_log(format!("Hotbar {slot} cleared")),
14424        }
14425        Ok(())
14426    }
14427
14428    pub fn npc_verb_options(&self) -> Vec<&'static str> {
14429        self.state.npc_verb_options()
14430    }
14431
14432    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
14433        let Some(npc_id) = self.state.npc_verb_target.clone() else {
14434            return Ok(());
14435        };
14436        let options = self.npc_verb_options();
14437        let choice = options
14438            .get(self.state.npc_verb_index)
14439            .copied()
14440            .unwrap_or("Talk");
14441        match choice {
14442            "Turn in" => {
14443                self.submit_npc_quest_turn_in(&npc_id).await?;
14444                self.state.show_npc_verb_menu = false;
14445            }
14446            "Trade" | "Bank" | "Storage" | "Market" => {
14447                self.seq += 1;
14448                self.session
14449                    .submit_intent(Intent::Interact {
14450                        entity_id: self.state.entity_id,
14451                        target_id: npc_id,
14452                        seq: self.seq,
14453                    })
14454                    .await?;
14455                self.state.intents_sent += 1;
14456            }
14457            _ => {
14458                self.seq += 1;
14459                self.session
14460                    .submit_intent(Intent::NpcTalkOpen {
14461                        entity_id: self.state.entity_id,
14462                        npc_id,
14463                        seq: self.seq,
14464                    })
14465                    .await?;
14466                self.state.intents_sent += 1;
14467            }
14468        }
14469        Ok(())
14470    }
14471
14472    async fn submit_npc_quest_turn_in(&mut self, npc_id: &str) -> anyhow::Result<()> {
14473        let pending: Vec<(String, u32, String)> = self
14474            .state
14475            .quest_log
14476            .iter()
14477            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
14478            .flat_map(|q| q.objectives.iter())
14479            .filter(|o| !o.done && o.kind == "give_item" && o.npc_ref.as_deref() == Some(npc_id))
14480            .filter_map(|o| {
14481                let template = o.item_template.clone()?;
14482                let remaining = o.required.saturating_sub(o.current);
14483                if remaining == 0 {
14484                    return None;
14485                }
14486                Some((template, remaining, o.label.clone()))
14487            })
14488            .collect();
14489        if pending.is_empty() {
14490            self.state.push_log("Nothing to turn in here.");
14491            return Ok(());
14492        }
14493        let mut sent = 0u32;
14494        for (template, remaining, label) in pending {
14495            let held = self.state.count_inventory_template(&template);
14496            let qty = remaining.min(held);
14497            if qty == 0 {
14498                self.state.push_log(format!("Need {label}"));
14499                continue;
14500            }
14501            self.seq += 1;
14502            self.session
14503                .submit_intent(Intent::QuestGiveItem {
14504                    entity_id: self.state.entity_id,
14505                    npc_id: npc_id.to_string(),
14506                    template_id: template,
14507                    quantity: qty,
14508                    seq: self.seq,
14509                })
14510                .await?;
14511            self.state.intents_sent += 1;
14512            sent += 1;
14513        }
14514        if sent > 0 {
14515            self.state.push_log("Turning in quest items.");
14516        }
14517        Ok(())
14518    }
14519
14520    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
14521        let Some(chat) = self.state.npc_chat.clone() else {
14522            return Ok(());
14523        };
14524        let message = chat.input.trim().to_string();
14525        if message.is_empty() || chat.pending {
14526            return Ok(());
14527        }
14528        if let Some(c) = self.state.npc_chat.as_mut() {
14529            c.lines.push(format!("You: {message}"));
14530            c.input.clear();
14531            c.pending = true;
14532        }
14533        self.seq += 1;
14534        self.session
14535            .submit_intent(Intent::NpcTalkSay {
14536                entity_id: self.state.entity_id,
14537                npc_id: chat.npc_id,
14538                message,
14539                seq: self.seq,
14540            })
14541            .await?;
14542        self.state.intents_sent += 1;
14543        Ok(())
14544    }
14545
14546    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
14547        let topic = self
14548            .state
14549            .npc_chat
14550            .as_ref()
14551            .and_then(|c| c.suggested_topics.get(index))
14552            .cloned();
14553        let Some(topic) = topic else {
14554            return Ok(());
14555        };
14556        if let Some(c) = self.state.npc_chat.as_mut() {
14557            if c.pending {
14558                return Ok(());
14559            }
14560            c.input = topic;
14561        }
14562        self.npc_talk_send().await
14563    }
14564
14565    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
14566        let return_to_verbs = self.state.npc_verb_target.is_some();
14567        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
14568            self.state.show_npc_chat = false;
14569            if return_to_verbs {
14570                self.state.show_npc_verb_menu = true;
14571            }
14572            return Ok(());
14573        };
14574        self.seq += 1;
14575        self.session
14576            .submit_intent(Intent::NpcTalkClose {
14577                entity_id: self.state.entity_id,
14578                npc_id,
14579                seq: self.seq,
14580            })
14581            .await?;
14582        self.state.intents_sent += 1;
14583        self.state.show_npc_chat = false;
14584        self.state.npc_chat = None;
14585        if return_to_verbs {
14586            self.state.show_npc_verb_menu = true;
14587        }
14588        Ok(())
14589    }
14590
14591    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
14592    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
14593        if self.state.show_quest_offer
14594            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
14595        {
14596            self.quest_offer_decline();
14597            return Ok(());
14598        }
14599        if self.state.show_npc_chat {
14600            return self.npc_talk_close().await;
14601        }
14602        if self.state.show_shop_menu {
14603            return self.back_from_shop_menu().await;
14604        }
14605        if self.state.bank_panel.is_some() {
14606            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
14607                self.bank_transfer_back();
14608                return Ok(());
14609            }
14610            return self.close_bank_panel().await;
14611        }
14612        if self.state.storage_panel.is_some() {
14613            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
14614                self.storage_ui_back();
14615                return Ok(());
14616            }
14617            return self.close_storage_panel().await;
14618        }
14619        if self.state.market_panel.is_some() {
14620            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
14621                self.market_ui_back();
14622                return Ok(());
14623            }
14624            if self.state.market_buy_confirm.is_some() {
14625                self.state.market_buy_confirm = None;
14626                return Ok(());
14627            }
14628            return self.close_market_panel().await;
14629        }
14630        if self.state.show_npc_verb_menu {
14631            self.state.show_npc_verb_menu = false;
14632            self.state.npc_verb_target = None;
14633        }
14634        Ok(())
14635    }
14636
14637    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
14638        self.seq += 1;
14639        self.session
14640            .submit_intent(Intent::TestDamage {
14641                entity_id: self.state.entity_id,
14642                amount,
14643                seq: self.seq,
14644            })
14645            .await?;
14646        self.state.intents_sent += 1;
14647        Ok(())
14648    }
14649
14650    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
14651        self.cycle_combat_target_slot(1, reverse).await
14652    }
14653
14654    pub async fn cycle_combat_target_slot(
14655        &mut self,
14656        slot_index: u8,
14657        reverse: bool,
14658    ) -> anyhow::Result<()> {
14659        if !self.state.is_alive() {
14660            anyhow::bail!("you are dead");
14661        }
14662        let candidates = self.state.candidates_for_slot(slot_index);
14663        if candidates.is_empty() {
14664            anyhow::bail!("no targets nearby");
14665        }
14666        let current = self.state.target_for_slot(slot_index);
14667        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
14668        let next_idx = match idx {
14669            None => 0,
14670            Some(i) if reverse => {
14671                if i == 0 {
14672                    candidates.len() - 1
14673                } else {
14674                    i - 1
14675                }
14676            }
14677            Some(i) => (i + 1) % candidates.len(),
14678        };
14679        if idx == Some(next_idx) && candidates.len() == 1 {
14680            self.clear_combat_target_slot(slot_index).await?;
14681            return Ok(());
14682        }
14683        let (target_id, label) = candidates[next_idx].clone();
14684        self.set_combat_target_slot(slot_index, target_id, &label)
14685            .await
14686    }
14687
14688    pub async fn set_combat_target_slot(
14689        &mut self,
14690        slot_index: u8,
14691        target_id: EntityId,
14692        label: &str,
14693    ) -> anyhow::Result<()> {
14694        if !self.state.is_alive() {
14695            anyhow::bail!("you are dead");
14696        }
14697        self.seq += 1;
14698        self.session
14699            .submit_intent(Intent::SetTargetSlot {
14700                entity_id: self.state.entity_id,
14701                slot_index,
14702                target_id,
14703                seq: self.seq,
14704            })
14705            .await?;
14706        self.state.intents_sent += 1;
14707        if slot_index == 1 {
14708            self.state.combat_target = Some(target_id);
14709            self.state.combat_target_label = Some(label.to_string());
14710        }
14711        self.state
14712            .push_log(format!("Slot {slot_index} target: {label}"));
14713        Ok(())
14714    }
14715
14716    pub async fn set_combat_target(
14717        &mut self,
14718        target_id: EntityId,
14719        label: &str,
14720    ) -> anyhow::Result<()> {
14721        self.set_combat_target_slot(1, target_id, label).await
14722    }
14723
14724    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14725        if slot_index == 1 && self.state.combat_target.is_none() {
14726            return Ok(());
14727        }
14728        self.seq += 1;
14729        self.session
14730            .submit_intent(Intent::ClearTargetSlot {
14731                entity_id: self.state.entity_id,
14732                slot_index,
14733                seq: self.seq,
14734            })
14735            .await?;
14736        if slot_index == 1 {
14737            self.state.combat_target = None;
14738            self.state.combat_target_label = None;
14739        }
14740        self.state.intents_sent += 1;
14741        self.state
14742            .push_log(format!("Slot {slot_index} target cleared"));
14743        Ok(())
14744    }
14745
14746    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14747        self.clear_combat_target_slot(1).await
14748    }
14749
14750    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14751        if !self.state.is_alive() {
14752            anyhow::bail!("you are dead");
14753        }
14754        self.seq += 1;
14755        self.session
14756            .submit_intent(Intent::AdvanceRotation {
14757                entity_id: self.state.entity_id,
14758                slot_index,
14759                seq: self.seq,
14760            })
14761            .await?;
14762        self.state.intents_sent += 1;
14763        Ok(())
14764    }
14765
14766    pub async fn assign_slot_preset(
14767        &mut self,
14768        slot_index: u8,
14769        preset_id: &str,
14770    ) -> anyhow::Result<()> {
14771        if !self.state.is_alive() {
14772            anyhow::bail!("you are dead");
14773        }
14774        self.seq += 1;
14775        self.session
14776            .submit_intent(Intent::AssignSlotPreset {
14777                entity_id: self.state.entity_id,
14778                slot_index,
14779                preset_id: preset_id.to_string(),
14780                seq: self.seq,
14781            })
14782            .await?;
14783        self.state.intents_sent += 1;
14784        if let Some(slot) = self
14785            .state
14786            .combat_slots
14787            .iter_mut()
14788            .find(|s| s.slot_index == slot_index)
14789        {
14790            slot.preset_id = Some(preset_id.to_string());
14791            if let Some(preset) = self
14792                .state
14793                .rotation_presets
14794                .iter()
14795                .find(|p| p.id == preset_id)
14796            {
14797                slot.preset_label = Some(preset.label.clone());
14798                slot.rotation = preset.abilities.clone();
14799                slot.rotation_index = 0;
14800            }
14801        }
14802        self.state
14803            .push_log(format!("T{slot_index} loadout → {preset_id}"));
14804        Ok(())
14805    }
14806
14807    pub async fn cast_ability(
14808        &mut self,
14809        ability_id: &str,
14810        target_id: Option<EntityId>,
14811    ) -> anyhow::Result<()> {
14812        if !self.state.is_alive() {
14813            anyhow::bail!("you are dead");
14814        }
14815        let allows_ground = self.state.ability_allows_ground(ability_id);
14816        let requires_ground = self.state.ability_requires_ground(ability_id);
14817        if requires_ground && self.state.ground_target.is_none() {
14818            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14819        }
14820        let (resolved_target_id, target_point) = if allows_ground {
14821            if let Some((x, y, z)) = self.state.ground_target {
14822                (
14823                    target_id.unwrap_or(self.state.entity_id),
14824                    Some(flatland_protocol::AimPoint { x, y, z }),
14825                )
14826            } else {
14827                (
14828                    target_id
14829                        .or_else(|| self.state.target_for_slot(2))
14830                        .or_else(|| self.state.target_for_slot(1))
14831                        .unwrap_or(self.state.entity_id),
14832                    None,
14833                )
14834            }
14835        } else {
14836            (
14837                target_id
14838                    .or_else(|| self.state.target_for_slot(2))
14839                    .or_else(|| self.state.target_for_slot(1))
14840                    .unwrap_or(self.state.entity_id),
14841                None,
14842            )
14843        };
14844        self.seq += 1;
14845        self.session
14846            .submit_intent(Intent::Cast {
14847                entity_id: self.state.entity_id,
14848                ability_id: ability_id.to_string(),
14849                target_id: resolved_target_id,
14850                target_point,
14851                seq: self.seq,
14852            })
14853            .await?;
14854        self.state.intents_sent += 1;
14855        match target_point {
14856            Some(point) => self.state.push_log(format!(
14857                "Cast {ability_id} → ({:.1}, {:.1})",
14858                point.x, point.y
14859            )),
14860            None => self
14861                .state
14862                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14863        }
14864        Ok(())
14865    }
14866
14867    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14868        self.seq += 1;
14869        self.session
14870            .submit_intent(Intent::UpsertRotationPreset {
14871                entity_id: self.state.entity_id,
14872                preset: preset.clone(),
14873                seq: self.seq,
14874            })
14875            .await?;
14876        self.state.intents_sent += 1;
14877        if let Some(existing) = self
14878            .state
14879            .rotation_presets
14880            .iter_mut()
14881            .find(|p| p.id == preset.id)
14882        {
14883            *existing = preset.clone();
14884        } else {
14885            self.state.rotation_presets.push(preset.clone());
14886        }
14887        for slot in &mut self.state.combat_slots {
14888            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14889                slot.preset_label = Some(preset.label.clone());
14890                slot.rotation = preset.abilities.clone();
14891            }
14892        }
14893        self.state
14894            .push_log(format!("Saved rotation: {}", preset.label));
14895        Ok(())
14896    }
14897
14898    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14899        self.seq += 1;
14900        self.session
14901            .submit_intent(Intent::DeleteRotationPreset {
14902                entity_id: self.state.entity_id,
14903                preset_id: preset_id.to_string(),
14904                seq: self.seq,
14905            })
14906            .await?;
14907        self.state.intents_sent += 1;
14908        self.state.rotation_presets.retain(|p| p.id != preset_id);
14909        for slot in &mut self.state.combat_slots {
14910            if slot.preset_id.as_deref() == Some(preset_id) {
14911                slot.preset_id = None;
14912                slot.preset_label = None;
14913                slot.rotation.clear();
14914                slot.rotation_index = 0;
14915            }
14916        }
14917        self.state
14918            .push_log(format!("Deleted rotation: {preset_id}"));
14919        Ok(())
14920    }
14921
14922    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14923        if !self.state.is_alive() {
14924            anyhow::bail!("you are dead");
14925        }
14926        let enabled = !self
14927            .state
14928            .combat_slots
14929            .iter()
14930            .find(|s| s.slot_index == slot_index)
14931            .map(|s| s.auto_enabled)
14932            .unwrap_or(false);
14933        self.seq += 1;
14934        self.session
14935            .submit_intent(Intent::SetAutoAttack {
14936                entity_id: self.state.entity_id,
14937                slot_index,
14938                enabled,
14939                seq: self.seq,
14940            })
14941            .await?;
14942        if slot_index == 1 {
14943            self.state.auto_attack = enabled;
14944        }
14945        self.state.intents_sent += 1;
14946        self.state.push_log(format!(
14947            "T{slot_index} auto {}",
14948            if enabled { "ON" } else { "OFF" }
14949        ));
14950        Ok(())
14951    }
14952
14953    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14954        if !self.state.connected {
14955            anyhow::bail!("not connected");
14956        }
14957        if !self.state.is_alive() {
14958            anyhow::bail!("you are dead");
14959        }
14960        let (px, py) = self.state.player_position();
14961        if self
14962            .state
14963            .ground_drops
14964            .iter()
14965            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14966        {
14967            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14968        }
14969        self.seq += 1;
14970        self.session
14971            .submit_intent(Intent::Pickup {
14972                entity_id: self.state.entity_id,
14973                drop_id: None,
14974                seq: self.seq,
14975            })
14976            .await?;
14977        self.state.intents_sent += 1;
14978        self.state.push_audio(crate::social::AudioCue::LootPickup);
14979        Ok(())
14980    }
14981
14982    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14983        if !self.state.is_alive() {
14984            anyhow::bail!("you are dead");
14985        }
14986        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
14987        self.seq += 1;
14988        self.session
14989            .submit_intent(Intent::Dodge {
14990                entity_id: self.state.entity_id,
14991                forward,
14992                strafe,
14993                seq: self.seq,
14994            })
14995            .await?;
14996        self.state.intents_sent += 1;
14997        self.state.push_log("Dodge!");
14998        self.state.push_audio(crate::social::AudioCue::CombatDodge);
14999        Ok(())
15000    }
15001
15002    pub async fn lunge(&mut self) -> anyhow::Result<()> {
15003        if !self.state.is_alive() {
15004            anyhow::bail!("you are dead");
15005        }
15006        let (forward, strafe) = self.last_move_axes();
15007        self.seq += 1;
15008        self.session
15009            .submit_intent(Intent::Lunge {
15010                entity_id: self.state.entity_id,
15011                forward,
15012                strafe,
15013                seq: self.seq,
15014            })
15015            .await?;
15016        self.state.intents_sent += 1;
15017        self.state.push_log("Lunge!");
15018        Ok(())
15019    }
15020
15021    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
15022        if !self.state.is_alive() {
15023            anyhow::bail!("you are dead");
15024        }
15025        self.seq += 1;
15026        self.session
15027            .submit_intent(Intent::DirectionalJump {
15028                entity_id: self.state.entity_id,
15029                forward,
15030                strafe,
15031                seq: self.seq,
15032            })
15033            .await?;
15034        self.state.intents_sent += 1;
15035        self.state.push_log("Jump!");
15036        Ok(())
15037    }
15038
15039    /// Remembered WASD axes for lunge when not currently moving.
15040    pub fn last_move_axes(&self) -> (f32, f32) {
15041        (self.last_move_forward, self.last_move_strafe)
15042    }
15043
15044    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
15045        if !self.state.is_alive() {
15046            anyhow::bail!("you are dead");
15047        }
15048        self.seq += 1;
15049        self.session
15050            .submit_intent(Intent::Block {
15051                entity_id: self.state.entity_id,
15052                enabled,
15053                seq: self.seq,
15054            })
15055            .await?;
15056        self.state.intents_sent += 1;
15057        if enabled {
15058            self.state.push_log("Blocking");
15059            self.state.push_audio(crate::social::AudioCue::CombatBlock);
15060        }
15061        Ok(())
15062    }
15063
15064    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
15065        if !self.state.is_alive() {
15066            anyhow::bail!("you are dead");
15067        }
15068        self.seq += 1;
15069        self.session
15070            .submit_intent(Intent::EquipMainhand {
15071                entity_id: self.state.entity_id,
15072                template_id,
15073                instance_id: None,
15074                seq: self.seq,
15075            })
15076            .await?;
15077        self.state.intents_sent += 1;
15078        Ok(())
15079    }
15080
15081    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
15082    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
15083        let idx = self.state.equip_menu_index;
15084        let slots = equip_paperdoll_rows(&self.state);
15085        let Some(row) = slots.get(idx) else {
15086            return Ok(());
15087        };
15088        match row {
15089            EquipPaperdollRow::Body { slot, filled } => {
15090                if *filled {
15091                    self.equip_worn(*slot, None).await
15092                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
15093                    self.equip_worn(*slot, Some(inst)).await
15094                } else {
15095                    self.state
15096                        .push_log(format!("No item for {}", body_slot_label(*slot)));
15097                    Ok(())
15098                }
15099            }
15100            EquipPaperdollRow::Mainhand { filled } => {
15101                if *filled {
15102                    self.unequip_mainhand().await
15103                } else if let Some(tid) = first_inventory_weapon(&self.state) {
15104                    self.equip_mainhand(Some(tid)).await
15105                } else {
15106                    self.state.push_log("No weapon in inventory".to_string());
15107                    Ok(())
15108                }
15109            }
15110            EquipPaperdollRow::Offhand { filled, locked } => {
15111                if *locked {
15112                    self.state
15113                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
15114                    Ok(())
15115                } else if *filled {
15116                    self.unequip_offhand().await
15117                } else if let Some(tid) = first_inventory_offhand(&self.state) {
15118                    self.equip_offhand(Some(tid)).await
15119                } else {
15120                    self.state
15121                        .push_log("No offhand item in inventory".to_string());
15122                    Ok(())
15123                }
15124            }
15125        }
15126    }
15127
15128    pub async fn say(
15129        &mut self,
15130        channel: flatland_protocol::ChatChannel,
15131        text: &str,
15132    ) -> anyhow::Result<()> {
15133        self.say_to(channel, text, None).await
15134    }
15135
15136    pub async fn say_to(
15137        &mut self,
15138        channel: flatland_protocol::ChatChannel,
15139        text: &str,
15140        to_entity: Option<EntityId>,
15141    ) -> anyhow::Result<()> {
15142        self.seq += 1;
15143        self.session
15144            .submit_intent(Intent::Say {
15145                entity_id: self.state.entity_id,
15146                channel,
15147                text: text.to_string(),
15148                to_entity,
15149                seq: self.seq,
15150            })
15151            .await?;
15152        self.state.intents_sent += 1;
15153        Ok(())
15154    }
15155
15156    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
15157        let Some(peer) = self.state.player_verbs.target_entity else {
15158            return Ok(());
15159        };
15160        let label = self.state.player_verbs.target_label.clone();
15161        let choice = crate::social::PlayerVerbState::options()
15162            .get(self.state.player_verbs.index)
15163            .copied()
15164            .unwrap_or("Whisper");
15165        self.state.player_verbs.close();
15166        match choice {
15167            "Trade" => {
15168                // Only request — peer must accept (Y in chat). Do not auto-respond;
15169                // TradeRespond requires a pending inbound request from the peer.
15170                self.seq += 1;
15171                self.session
15172                    .submit_intent(Intent::TradeRequest {
15173                        entity_id: self.state.entity_id,
15174                        peer_entity_id: peer,
15175                        seq: self.seq,
15176                    })
15177                    .await?;
15178                self.state.intents_sent += 1;
15179                self.state.social_chat.push_system(format!(
15180                    "Trade request sent to {label} — waiting for accept"
15181                ));
15182            }
15183            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
15184            _ => self.state.social_chat.focus_nearby(),
15185        }
15186        Ok(())
15187    }
15188
15189    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
15190        let Some(pending) = self.state.social_chat.pending_trade.take() else {
15191            return Ok(());
15192        };
15193        self.seq += 1;
15194        self.session
15195            .submit_intent(Intent::TradeRespond {
15196                entity_id: self.state.entity_id,
15197                peer_entity_id: pending.from_entity,
15198                accept,
15199                seq: self.seq,
15200            })
15201            .await?;
15202        self.state.intents_sent += 1;
15203        if accept {
15204            self.state
15205                .social_chat
15206                .push_system(format!("Accepted trade with {}", pending.from_name));
15207        } else {
15208            self.state
15209                .social_chat
15210                .push_system(format!("Declined trade with {}", pending.from_name));
15211        }
15212        Ok(())
15213    }
15214
15215    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
15216        let text = self.state.social_chat.buffer.trim().to_string();
15217        if text.is_empty() {
15218            return Ok(());
15219        }
15220        self.state.social_chat.buffer.clear();
15221        if crate::social::is_chat_slash_line(&text) {
15222            match crate::social::parse_chat_slash(&text) {
15223                Some(cmd) => return self.apply_chat_slash(cmd).await,
15224                None => {
15225                    self.state.social_chat.push_system(format!(
15226                        "Unknown command — {}",
15227                        crate::social::chat_slash_help_text()
15228                    ));
15229                    return Ok(());
15230                }
15231            }
15232        }
15233        let thread = self.state.social_chat.thread;
15234        let channel = thread.channel();
15235        let to = thread.to_entity();
15236        if let Some(peer) = to {
15237            let label = self.state.social_chat.peer_label.clone();
15238            self.state
15239                .social_chat
15240                .remember_whisper_peer(peer, &label, channel);
15241        }
15242        self.say_to(channel, &text, to).await
15243    }
15244
15245    async fn apply_chat_slash(
15246        &mut self,
15247        cmd: crate::social::ChatSlashCommand,
15248    ) -> anyhow::Result<()> {
15249        use crate::social::{chat_slash_help_text, ChatSlashCommand};
15250        match cmd {
15251            ChatSlashCommand::Help => {
15252                self.state
15253                    .social_chat
15254                    .push_system(chat_slash_help_text().to_string());
15255                Ok(())
15256            }
15257            ChatSlashCommand::Nearby { message } => {
15258                self.state.social_chat.focus_nearby();
15259                self.state
15260                    .social_chat
15261                    .push_system("Nearby speech — everyone close can hear");
15262                if let Some(msg) = message {
15263                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
15264                        .await
15265                } else {
15266                    Ok(())
15267                }
15268            }
15269            ChatSlashCommand::Reply { message } => {
15270                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15271                    self.state
15272                        .social_chat
15273                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
15274                    return Ok(());
15275                };
15276                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
15277                self.state
15278                    .social_chat
15279                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
15280                self.state.social_chat.push_system(format!(
15281                    "Replying to {} — type and Enter · /nearby",
15282                    peer.label
15283                ));
15284                if let Some(msg) = message {
15285                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
15286                } else {
15287                    Ok(())
15288                }
15289            }
15290            ChatSlashCommand::Whisper { name, message } => {
15291                let (peer_id, label, stone) = if let Some(name) = name {
15292                    match self.resolve_whisper_target(&name) {
15293                        Ok(t) => t,
15294                        Err(err) => {
15295                            self.state.social_chat.push_system(err);
15296                            return Ok(());
15297                        }
15298                    }
15299                } else {
15300                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15301                        self.state.social_chat.push_system(
15302                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
15303                        );
15304                        return Ok(());
15305                    };
15306                    (
15307                        peer.entity_id,
15308                        peer.label,
15309                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
15310                    )
15311                };
15312                self.state
15313                    .social_chat
15314                    .set_whisper_thread(peer_id, &label, stone);
15315                let channel = if stone {
15316                    flatland_protocol::ChatChannel::WhisperStone
15317                } else {
15318                    flatland_protocol::ChatChannel::Whisper
15319                };
15320                if let Some(msg) = message {
15321                    self.state
15322                        .social_chat
15323                        .push_system(format!("Whisper → {label}"));
15324                    self.say_to(channel, &msg, Some(peer_id)).await
15325                } else {
15326                    self.state.social_chat.push_system(format!(
15327                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
15328                    ));
15329                    Ok(())
15330                }
15331            }
15332        }
15333    }
15334
15335    /// Resolve `/whisper Name` against AOI players (label match).
15336    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
15337        let needle = name.trim().to_ascii_lowercase();
15338        if needle.is_empty() {
15339            return Err("Usage: /whisper Name [message]".into());
15340        }
15341        let mut candidates: Vec<(EntityId, String)> = self
15342            .state
15343            .entities
15344            .iter()
15345            .filter(|e| e.id != self.state.entity_id)
15346            .filter(|e| !e.label.trim().is_empty())
15347            .filter(|e| e.vitals.is_some())
15348            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
15349            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
15350            .map(|e| (e.id, e.label.clone()))
15351            .collect();
15352
15353        // Also allow matching the last whisper peer by name even if they left AOI briefly.
15354        if let Some(last) = &self.state.social_chat.last_whisper_peer {
15355            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
15356                candidates.push((last.entity_id, last.label.clone()));
15357            }
15358        }
15359
15360        let exact: Vec<_> = candidates
15361            .iter()
15362            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
15363            .cloned()
15364            .collect();
15365        let pool = if exact.len() == 1 {
15366            exact
15367        } else if exact.len() > 1 {
15368            return Err(format!(
15369                "Several players named '{name}' nearby — move closer and try again"
15370            ));
15371        } else {
15372            let starts: Vec<_> = candidates
15373                .iter()
15374                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
15375                .cloned()
15376                .collect();
15377            if starts.len() == 1 {
15378                starts
15379            } else if starts.len() > 1 {
15380                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
15381                return Err(format!(
15382                    "Ambiguous name '{name}' — matches: {}",
15383                    names.join(", ")
15384                ));
15385            } else {
15386                let contains: Vec<_> = candidates
15387                    .iter()
15388                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
15389                    .cloned()
15390                    .collect();
15391                if contains.len() == 1 {
15392                    contains
15393                } else if contains.is_empty() {
15394                    return Err(format!(
15395                        "No player matching '{name}' in range — get closer or check the spelling"
15396                    ));
15397                } else {
15398                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
15399                    return Err(format!(
15400                        "Ambiguous name '{name}' — matches: {}",
15401                        names.join(", ")
15402                    ));
15403                }
15404            }
15405        };
15406
15407        let (id, label) = pool.into_iter().next().unwrap();
15408        let stone = self
15409            .state
15410            .social_chat
15411            .last_whisper_peer
15412            .as_ref()
15413            .is_some_and(|p| {
15414                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
15415            });
15416        Ok((id, label, stone))
15417    }
15418
15419    pub async fn trade_present_selected(
15420        &mut self,
15421        item_instance_id: uuid::Uuid,
15422    ) -> anyhow::Result<()> {
15423        self.trade_present_quantity(item_instance_id, None).await
15424    }
15425
15426    pub async fn trade_present_quantity(
15427        &mut self,
15428        item_instance_id: uuid::Uuid,
15429        quantity: Option<u32>,
15430    ) -> anyhow::Result<()> {
15431        self.seq += 1;
15432        self.session
15433            .submit_intent(Intent::TradePresent {
15434                entity_id: self.state.entity_id,
15435                item_instance_id,
15436                quantity,
15437                seq: self.seq,
15438            })
15439            .await?;
15440        self.state.intents_sent += 1;
15441        self.state.trade_ui.qty_entry = None;
15442        self.state.trade_ui.picking_inventory = false;
15443        Ok(())
15444    }
15445
15446    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
15447    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
15448        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
15449            let qty = self.state.trade_ui.present_quantity();
15450            return self
15451                .trade_present_quantity(entry.item_instance_id, qty)
15452                .await;
15453        }
15454        if !self.state.trade_ui.picking_inventory {
15455            return Ok(());
15456        }
15457        let stacks = self.state.trade_presentable_stacks();
15458        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
15459            return Ok(());
15460        };
15461        let Some(id) = stack.item_instance_id else {
15462            return Ok(());
15463        };
15464        let label = stack
15465            .display_name
15466            .clone()
15467            .unwrap_or_else(|| stack.template_id.clone());
15468        if stack.quantity <= 1 {
15469            self.trade_present_quantity(id, Some(1)).await
15470        } else {
15471            self.state
15472                .trade_ui
15473                .begin_qty_entry(id, label, stack.quantity);
15474            Ok(())
15475        }
15476    }
15477
15478    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
15479        self.seq += 1;
15480        self.session
15481            .submit_intent(Intent::TradeSetReady {
15482                entity_id: self.state.entity_id,
15483                ready,
15484                seq: self.seq,
15485            })
15486            .await?;
15487        self.state.intents_sent += 1;
15488        Ok(())
15489    }
15490
15491    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
15492        self.seq += 1;
15493        self.session
15494            .submit_intent(Intent::TradeCancel {
15495                entity_id: self.state.entity_id,
15496                seq: self.seq,
15497            })
15498            .await?;
15499        self.state.intents_sent += 1;
15500        self.state.trade_ui.close();
15501        Ok(())
15502    }
15503
15504    pub async fn destroy_whisper_stone(
15505        &mut self,
15506        item_instance_id: uuid::Uuid,
15507    ) -> anyhow::Result<()> {
15508        self.seq += 1;
15509        self.session
15510            .submit_intent(Intent::DestroyWhisperStone {
15511                entity_id: self.state.entity_id,
15512                item_instance_id,
15513                seq: self.seq,
15514            })
15515            .await?;
15516        self.state.intents_sent += 1;
15517        Ok(())
15518    }
15519
15520    pub async fn stop(&mut self) -> anyhow::Result<()> {
15521        self.seq += 1;
15522        self.session
15523            .submit_intent(Intent::Stop {
15524                entity_id: self.state.entity_id,
15525                seq: self.seq,
15526            })
15527            .await?;
15528        self.state.intents_sent += 1;
15529        Ok(())
15530    }
15531
15532    pub fn disconnect(&self) {
15533        self.session.disconnect();
15534    }
15535}
15536
15537fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
15538    let dx = ax - bx;
15539    let dy = ay - by;
15540    (dx * dx + dy * dy).sqrt()
15541}
15542
15543#[cfg(test)]
15544mod tests {
15545    use std::collections::BTreeMap;
15546
15547    use super::*;
15548    use flatland_protocol::{
15549        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
15550    };
15551
15552    fn sample_state() -> GameState {
15553        let mut state = GameState {
15554            session_id: 1,
15555            entity_id: 1,
15556            character_id: None,
15557            tick: 0,
15558            chunk_rev: 0,
15559            content_rev: 0,
15560            publish_rev: 0,
15561            entities: vec![EntityState {
15562                id: 1,
15563                label: "You".into(),
15564                transform: Transform {
15565                    position: WorldCoord::surface(128.0, 128.0),
15566                    yaw: 0.0,
15567                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15568                },
15569                vitals: None,
15570                attributes: None,
15571                skills: None,
15572                inside_building: None,
15573                tile_id: None,
15574                paperdoll_ref: None,
15575                draw_scale: 1.0,
15576                presentation_state: None,
15577                sprite_mode: None,
15578                progression_xp: None,
15579                combat_cues: vec![],
15580                statuses: vec![],
15581            }],
15582            player: None,
15583            resource_nodes: vec![ResourceNodeView {
15584                id: "oak-1".into(),
15585                label: "Oak".into(),
15586                x: 130.0,
15587                y: 128.0,
15588                z: 0.0,
15589                item_template: "oak_log".into(),
15590                state: ResourceNodeState::Available,
15591                blocking: true,
15592                blocking_radius_m: 0.8,
15593                harvest_off: false,
15594                tile_id: None,
15595                yaw: 0.0,
15596                pitch: 0.0,
15597                roll: 0.0,
15598                draw_scale: 1.0,
15599                sprite_mode: None,
15600                growth_progress: None,
15601                presentation_state: None,
15602                channel_start_tick: None,
15603                channel_end_tick: None,
15604                harvest_drop_templates: vec![],
15605            }],
15606            ground_drops: vec![],
15607            placed_containers: vec![],
15608            buildings: vec![BuildingView {
15609                id: "broker-hut".into(),
15610                label: "Broker".into(),
15611                x: 148.0,
15612                y: 118.0,
15613                width_m: 8.0,
15614                depth_m: 6.0,
15615                interior_blueprint: Some("broker_hut".into()),
15616                tags: vec![],
15617                market_boundary_zone_ids: vec![],
15618                market_max_volume: None,
15619                wall_set: None,
15620                roof_set: None,
15621            }],
15622            doors: vec![flatland_protocol::DoorView {
15623                id: "door-1".into(),
15624                building_id: "broker-hut".into(),
15625                x: 148.0,
15626                y: 118.0,
15627                open: false,
15628                portal: Some("front".into()),
15629                locked: false,
15630                accessible: true,
15631                lock_id: None,
15632            }],
15633            interior_map: None,
15634            npcs: vec![],
15635            blueprints: vec![],
15636            building_materials: vec![],
15637            world_x0: 0.0,
15638            world_y0: 0.0,
15639            world_width_m: 256.0,
15640            world_height_m: 256.0,
15641            terrain_zones: Vec::new(),
15642            z_platforms: Vec::new(),
15643            z_transitions: Vec::new(),
15644            z_bands_outdoor_backup: None,
15645            world_clock: flatland_protocol::WorldClock::default(),
15646            inventory: std::collections::HashMap::new(),
15647            inventory_hints: std::collections::HashMap::new(),
15648            logs: VecDeque::new(),
15649            intents_sent: 0,
15650            ticks_received: 0,
15651            connected: true,
15652            disconnect_reason: None,
15653            show_stats: false,
15654            hud_log_hidden: false,
15655            show_equip_menu: false,
15656            equip_menu_index: 0,
15657            show_craft_menu: false,
15658            show_plot_build_menu: false,
15659            plot_build_focus_wall: true,
15660            plot_build_wall_index: 0,
15661            plot_build_roof_index: 0,
15662            craft_menu_index: 0,
15663            craft_batch_quantity: 1,
15664            show_shop_menu: false,
15665            shop_catalog: None,
15666            bank_panel: None,
15667            bank_menu_index: 0,
15668            bank_ui_mode: BankUiMode::Menu,
15669            storage_panel: None,
15670            market_panel: None,
15671            market_menu_index: 0,
15672            market_filter: String::new(),
15673            market_filter_focused: false,
15674            market_category_filter: None,
15675            market_buy_confirm: None,
15676            market_ui_mode: MarketUiMode::Browse,
15677            storage_menu_index: 0,
15678            storage_ui_mode: StorageUiMode::Menu,
15679            shop_tab: ShopTab::default(),
15680            shop_menu_index: 0,
15681            shop_quantity: 1,
15682            shop_trade_log: VecDeque::new(),
15683            show_npc_verb_menu: false,
15684            npc_verb_target: None,
15685            npc_verb_index: 0,
15686            player_verbs: crate::social::PlayerVerbState::default(),
15687            social_chat: crate::social::SocialChatState::default(),
15688            trade_ui: crate::social::TradeUiState::default(),
15689            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15690            show_npc_chat: false,
15691            npc_chat: None,
15692            show_inventory_menu: false,
15693            inventory_menu_index: 0,
15694            inventory_tab: InventoryTab::OnPerson,
15695            inventory_filter: String::new(),
15696            inventory_filter_focused: false,
15697            show_move_picker: false,
15698            show_rename_prompt: false,
15699            rename_plot_id: None,
15700            highlighted_plot_id: None,
15701            show_worker_rename: false,
15702            rename_buffer: String::new(),
15703            move_picker_index: 0,
15704            move_picker: None,
15705            show_grant_picker: false,
15706            grant_picker_index: 0,
15707            grant_picker: None,
15708            show_destroy_picker: false,
15709            destroy_confirm_pending: false,
15710            destroy_picker: None,
15711            combat_target: None,
15712            combat_target_label: None,
15713            ground_target: None,
15714            combat_fx: Vec::new(),
15715            ground_hazards: Vec::new(),
15716            property_zones: Vec::new(),
15717            tax_zones: Vec::new(),
15718            growth_zones: Vec::new(),
15719            biome_zones: Vec::new(),
15720            terrain_kind_nav: Vec::new(),
15721            property_plots: Vec::new(),
15722            property_plot_settings: None,
15723            claim_mode: None,
15724            relocate_mode: None,
15725            sell_plot_confirm: None,
15726            sell_plot_armed_at: None,
15727            show_plant_menu: false,
15728            plant_menu_index: 0,
15729            show_farm_access: false,
15730            farm_access_name_draft: String::new(),
15731            farm_access_discount_bps: 0,
15732            farm_access_index: 0,
15733            plant_quantity: 1,
15734            in_combat: false,
15735            auto_attack: true,
15736            combat_has_los: false,
15737            attack_cd_ticks: 0,
15738            gcd_ticks: 0,
15739            weapon_ability_id: "unarmed".into(),
15740            mainhand_template_id: None,
15741            mainhand_label: None,
15742            mainhand_instance_id: None,
15743            offhand_template_id: None,
15744            offhand_label: None,
15745            offhand_instance_id: None,
15746            mainhand_hand_slots: 1,
15747            defense: None,
15748            worn: BTreeMap::new(),
15749            carry_mass: 0.0,
15750            carry_mass_max: 0.0,
15751            encumbrance: flatland_protocol::EncumbranceState::Light,
15752            inventory_stacks: Vec::new(),
15753            keychain_stacks: Vec::new(),
15754            whisper_pouch_stacks: Vec::new(),
15755            combat_target_detail: None,
15756            statuses: Vec::new(),
15757            cast_progress: None,
15758            timed_channel: None,
15759            plot_build_offer: None,
15760            ability_cooldowns: Vec::new(),
15761            blocking_active: false,
15762            max_target_slots: 1,
15763            combat_slots: Vec::new(),
15764            rotation_presets: Vec::new(),
15765            known_abilities: Vec::new(),
15766            ability_meta: std::collections::HashMap::new(),
15767            ability_mastery: std::collections::HashMap::new(),
15768            hotbar: vec![None; 9],
15769            max_abilities_per_rotation: 0,
15770            show_loadout_menu: false,
15771            show_keychain_menu: false,
15772            keychain_menu_index: 0,
15773            show_rotation_editor: false,
15774            loadout_menu_index: 0,
15775            loadout_hotbar_slot: 1,
15776            loadout_ability_index: 0,
15777            loadout_focus_presets: false,
15778            rotation_editor: RotationEditorState::default(),
15779            harvest_in_progress: false,
15780            harvest_started_at: None,
15781            pending_craft_ack: None,
15782            pending_worker_job_ack: None,
15783            attending_worker_instance_id: None,
15784            quest_log: Vec::new(),
15785            interactables: Vec::new(),
15786            ledger: None,
15787            career: None,
15788            character_sheet_tab: CharacterSheetTab::Character,
15789            ledger_period: LedgerPeriod::Day,
15790            show_quest_offer: false,
15791            pending_quest_offer: None,
15792            show_quest_menu: false,
15793            quest_menu_index: 0,
15794            quest_withdraw_confirm: false,
15795            hired_workers: Vec::new(),
15796            show_workers_menu: false,
15797            workers_menu_index: 0,
15798            worker_dismiss_confirmation: None,
15799            workers_menu_compact: false,
15800            worker_step_display: BTreeMap::new(),
15801            worker_error_display: BTreeMap::new(),
15802            worker_health_ring_until: BTreeMap::new(),
15803            pending_worker_hire_since: None,
15804            show_worker_give_picker: false,
15805            worker_give_picker_index: 0,
15806            worker_give_picker: None,
15807            show_worker_give_target_picker: false,
15808            worker_give_target_picker_index: 0,
15809            worker_give_target_picker: None,
15810            show_worker_take_picker: false,
15811            worker_take_picker_index: 0,
15812            worker_take_picker: None,
15813            show_worker_teach_picker: false,
15814            worker_teach_picker_index: 0,
15815            worker_teach_picker: None,
15816            worker_route_editor: None,
15817            progression_curve: None,
15818        };
15819        state.player = state.entities.first().cloned();
15820        state
15821    }
15822
15823    #[test]
15824    fn whisper_cancels_when_peer_walks_out_of_range() {
15825        let mut state = sample_state();
15826        state.player = state.entities.first().cloned();
15827        let mut peer = state.entities[0].clone();
15828        peer.id = 2;
15829        peer.label = "Ada".into();
15830        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
15831        state.entities.push(peer.clone());
15832        state.social_chat.focus_whisper(2, "Ada");
15833        state.refresh_whisper_range();
15834        assert!(matches!(
15835            state.social_chat.thread,
15836            crate::social::ChatThreadKind::Whisper { peer: 2 }
15837        ));
15838
15839        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
15840        state.entities[1] = peer;
15841        state.refresh_whisper_range();
15842        assert_eq!(
15843            state.social_chat.thread,
15844            crate::social::ChatThreadKind::Nearby
15845        );
15846        assert!(!state.social_chat.input_focused);
15847    }
15848
15849    #[test]
15850    fn probe_use_world_hired_worker_manage() {
15851        let mut state = sample_state();
15852        state
15853            .hired_workers
15854            .push(flatland_protocol::HiredWorkerView {
15855                instance_id: "worker-1".into(),
15856                entity_id: 42,
15857                def_id: "worker_laborer".into(),
15858                label: "Sam".into(),
15859                x: 129.0,
15860                y: 128.0,
15861                z: 0.0,
15862                mode: flatland_protocol::WorkerModeView::JobLoop,
15863                state: flatland_protocol::WorkerStateView::Working,
15864                step_label: "cultivate".into(),
15865                vitals: flatland_protocol::WorkerVitalsSummary {
15866                    health_pct: 100.0,
15867                    stamina_pct: 100.0,
15868                    mana_pct: 100.0,
15869                    hunger_pct: 100.0,
15870                    thirst_pct: 100.0,
15871                },
15872                carry_pct: 0.0,
15873                last_error: None,
15874                wage_copper_per_interval: 1,
15875                effective_wage_copper: 1,
15876                wage_meters_walked: 0.0,
15877                lodging_container_id: None,
15878                route: None,
15879                route_stop_index: None,
15880                known_blueprint_ids: Vec::new(),
15881                level: 1,
15882                worker_xp: 0.0,
15883                inventory: Vec::new(),
15884                equipment: flatland_protocol::WorkerEquipmentView::default(),
15885                issue_hint: None,
15886            });
15887        let probe = state.probe_use_world();
15888        let primary = probe.primary.expect("primary");
15889        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15890        assert_eq!(primary.id, "worker-1");
15891        assert!(primary.hint_line().contains("Manage"));
15892        assert!(primary.hint_line().contains("Sam"));
15893        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
15894    }
15895
15896    #[test]
15897    fn market_clerk_verb_options_include_market() {
15898        let mut state = sample_state();
15899        state.npcs.push(flatland_protocol::NpcView {
15900            id: "mira_market".into(),
15901            label: "Mira".into(),
15902            role: "market_clerk".into(),
15903            x: 129.0,
15904            y: 128.0,
15905            building_id: Some("town_market".into()),
15906            entity_id: None,
15907            life_state: None,
15908            hp_pct: None,
15909            can_trade: false,
15910            buy_templates: vec![],
15911            tile_id: None,
15912            behavior_state: None,
15913            presentation_state: None,
15914            sprite_mode: None,
15915            paperdoll_ref: None,
15916            draw_scale: 1.0,
15917            yaw: None,
15918            perception_fov_deg: None,
15919            perception_sight_m: None,
15920            perception_hear_m: None,
15921        });
15922        state.npc_verb_target = Some("mira_market".into());
15923        assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15924    }
15925
15926    #[test]
15927    fn butcher_verb_options_include_turn_in_for_give_item() {
15928        let mut state = sample_state();
15929        state.npcs.push(flatland_protocol::NpcView {
15930            id: "town_butcher_1".into(),
15931            label: "Brutus".into(),
15932            role: "butcher".into(),
15933            x: 129.0,
15934            y: 128.0,
15935            building_id: None,
15936            entity_id: None,
15937            life_state: None,
15938            hp_pct: None,
15939            can_trade: true,
15940            buy_templates: vec!["raw_venison".into()],
15941            tile_id: None,
15942            behavior_state: None,
15943            presentation_state: None,
15944            sprite_mode: None,
15945            paperdoll_ref: None,
15946            draw_scale: 1.0,
15947            yaw: None,
15948            perception_fov_deg: None,
15949            perception_sight_m: None,
15950            perception_hear_m: None,
15951        });
15952        state.quest_log.push(flatland_protocol::QuestLogEntry {
15953            quest_id: "deer_threat".into(),
15954            title: "Deer threat".into(),
15955            description: String::new(),
15956            status: flatland_protocol::QuestStatusView::Active,
15957            current_step_id: Some("deliver".into()),
15958            current_step_title: "Deliver venison".into(),
15959            objectives: vec![flatland_protocol::QuestObjectiveProgress {
15960                label: "Give 3 Raw venison to Brutus".into(),
15961                current: 0,
15962                required: 3,
15963                done: false,
15964                kind: "give_item".into(),
15965                npc_ref: Some("town_butcher_1".into()),
15966                item_template: Some("raw_venison".into()),
15967                blueprint_id: None,
15968                building_id: None,
15969            }],
15970            is_tracked: true,
15971            can_withdraw: true,
15972        });
15973        state.npc_verb_target = Some("town_butcher_1".into());
15974        assert_eq!(state.npc_verb_options(), vec!["Turn in", "Talk", "Trade"]);
15975    }
15976
15977    #[test]
15978    fn market_list_excludes_currency_stacks() {
15979        let mut state = sample_state();
15980        state.inventory_stacks = vec![
15981            flatland_protocol::ItemStack {
15982                template_id: "copper_coin".into(),
15983                quantity: 50,
15984                item_instance_id: Some(uuid::Uuid::from_u128(10)),
15985                display_name: Some("Copper Coin".into()),
15986                ..Default::default()
15987            },
15988            flatland_protocol::ItemStack {
15989                template_id: "oak_log".into(),
15990                quantity: 2,
15991                item_instance_id: Some(uuid::Uuid::from_u128(11)),
15992                display_name: Some("Oak Log".into()),
15993                ..Default::default()
15994            },
15995            flatland_protocol::ItemStack {
15996                template_id: "whisper_stone".into(),
15997                quantity: 1,
15998                item_instance_id: Some(uuid::Uuid::from_u128(12)),
15999                display_name: Some("Whisper Stone".into()),
16000                category: Some("quest".into()),
16001                listable: Some(false),
16002                ..Default::default()
16003            },
16004        ];
16005        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
16006        assert_eq!(opts.len(), 1);
16007        assert!(opts[0].label.contains("Oak"));
16008    }
16009
16010    #[test]
16011    fn market_browse_filters_by_category_and_search() {
16012        let mut state = sample_state();
16013        state.market_panel = Some(flatland_protocol::MarketPanel {
16014            npc_id: "mira_market".into(),
16015            npc_label: "Mira".into(),
16016            building_id: "town_market".into(),
16017            building_label: "Town Market".into(),
16018            used_volume: 0.0,
16019            max_volume: 100.0,
16020            listings: vec![
16021                flatland_protocol::MarketListingView {
16022                    listing_id: uuid::Uuid::from_u128(1),
16023                    seller_character_id: uuid::Uuid::from_u128(2),
16024                    seller_label: "Ada".into(),
16025                    hall_building_id: "town_market".into(),
16026                    hall_label: "Town Market".into(),
16027                    template_id: "oak_log".into(),
16028                    display_name: "Oak Log".into(),
16029                    category: "resource".into(),
16030                    quantity: 3,
16031                    unit_price_copper: 10,
16032                    line_total_copper: 30,
16033                    npc_price: false,
16034                    npc_dump_unit_copper: None,
16035                    mine: false,
16036                },
16037                flatland_protocol::MarketListingView {
16038                    listing_id: uuid::Uuid::from_u128(3),
16039                    seller_character_id: uuid::Uuid::from_u128(2),
16040                    seller_label: "Ada".into(),
16041                    hall_building_id: "town_market".into(),
16042                    hall_label: "Town Market".into(),
16043                    template_id: "short_sword".into(),
16044                    display_name: "Short Sword".into(),
16045                    category: "weapon".into(),
16046                    quantity: 1,
16047                    unit_price_copper: 100,
16048                    line_total_copper: 100,
16049                    npc_price: false,
16050                    npc_dump_unit_copper: None,
16051                    mine: false,
16052                },
16053            ],
16054            tax_bps: 0,
16055            tax_flat_copper: 0,
16056            list_vaults: vec![],
16057        });
16058        assert_eq!(state.market_filtered_listing_indices().len(), 2);
16059        state.market_category_filter = Some("Weapons");
16060        let weapons = state.market_filtered_listing_indices();
16061        assert_eq!(weapons.len(), 1);
16062        assert_eq!(
16063            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
16064            "Short Sword"
16065        );
16066        state.market_category_filter = None;
16067        state.market_filter = "oak".into();
16068        let oak = state.market_filtered_listing_indices();
16069        assert_eq!(oak.len(), 1);
16070        assert_eq!(
16071            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
16072            "Oak Log"
16073        );
16074    }
16075
16076    #[test]
16077    fn market_list_source_includes_person_and_vaults() {
16078        let mut state = sample_state();
16079        let item_id = uuid::Uuid::from_u128(1);
16080        state.inventory_stacks = vec![flatland_protocol::ItemStack {
16081            template_id: "oak_log".into(),
16082            quantity: 2,
16083            item_instance_id: Some(item_id),
16084            display_name: Some("Oak Log".into()),
16085            ..Default::default()
16086        }];
16087        state.market_panel = Some(flatland_protocol::MarketPanel {
16088            npc_id: "mira_market".into(),
16089            npc_label: "Mira".into(),
16090            building_id: "town_market".into(),
16091            building_label: "Town Market".into(),
16092            used_volume: 0.0,
16093            max_volume: 100.0,
16094            listings: vec![],
16095            tax_bps: 0,
16096            tax_flat_copper: 0,
16097            list_vaults: vec![flatland_protocol::MarketListVault {
16098                building_id: "town_storage".into(),
16099                building_label: "Town Storage".into(),
16100                contents: vec![flatland_protocol::ItemStack {
16101                    template_id: "lumber".into(),
16102                    quantity: 1,
16103                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
16104                    display_name: Some("Lumber".into()),
16105                    ..Default::default()
16106                }],
16107            }],
16108        });
16109        let sources = state.market_list_source_options();
16110        assert_eq!(sources.len(), 2);
16111        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
16112        assert!(matches!(
16113            sources[1].0,
16114            MarketListSourceKind::TownStorage { .. }
16115        ));
16116        assert!(sources[1].1.contains("Town Storage"));
16117    }
16118
16119    #[test]
16120    fn npc_market_dump_estimate_from_town_storage_vault() {
16121        let mut state = sample_state();
16122        state.market_panel = Some(flatland_protocol::MarketPanel {
16123            npc_id: "mira_market".into(),
16124            npc_label: "Mira".into(),
16125            building_id: "town_market".into(),
16126            building_label: "Town Market".into(),
16127            used_volume: 0.0,
16128            max_volume: 100.0,
16129            listings: vec![],
16130            tax_bps: 0,
16131            tax_flat_copper: 0,
16132            list_vaults: vec![flatland_protocol::MarketListVault {
16133                building_id: "town_storage".into(),
16134                building_label: "Town Storage".into(),
16135                contents: vec![flatland_protocol::ItemStack {
16136                    template_id: "lumber".into(),
16137                    quantity: 3,
16138                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
16139                    display_name: Some("Lumber".into()),
16140                    base_value_copper: Some(20),
16141                    ..Default::default()
16142                }],
16143            }],
16144        });
16145        assert_eq!(
16146            state.npc_market_dump_unit_estimate("lumber"),
16147            Some(9),
16148            "vault stack base_value should enable NPC price estimate"
16149        );
16150    }
16151
16152    #[test]
16153    fn probe_use_world_npc_beats_nearby_loot() {
16154        let mut state = sample_state();
16155        state.npcs.push(flatland_protocol::NpcView {
16156            id: "ada".into(),
16157            label: "Ada".into(),
16158            role: "broker".into(),
16159            x: 129.0,
16160            y: 128.0,
16161            building_id: None,
16162            entity_id: None,
16163            life_state: None,
16164            hp_pct: None,
16165            can_trade: true,
16166            buy_templates: vec!["lumber".into()],
16167            tile_id: None,
16168            behavior_state: None,
16169            presentation_state: None,
16170            sprite_mode: None,
16171            paperdoll_ref: None,
16172            draw_scale: 1.0,
16173            yaw: None,
16174            perception_fov_deg: None,
16175            perception_sight_m: None,
16176            perception_hear_m: None,
16177        });
16178        state.ground_drops.push(flatland_protocol::GroundDropView {
16179            id: "d1".into(),
16180            template_id: "lumber".into(),
16181            quantity: 1,
16182            x: 128.5,
16183            y: 128.0,
16184            z: 0.0,
16185            tile_id: None,
16186            display_name: None,
16187            yaw: 0.0,
16188            pitch: 0.0,
16189            roll: 0.0,
16190            draw_scale: 1.0,
16191        });
16192        let probe = state.probe_use_world();
16193        let primary = probe.primary.expect("primary");
16194        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
16195        assert_eq!(primary.id, "ada");
16196    }
16197
16198    #[test]
16199    fn probe_use_world_harvest_when_in_range() {
16200        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
16201        let probe = state.probe_use_world();
16202        assert!(
16203            probe.primary.is_none(),
16204            "oak is 2m away, out of harvest range"
16205        );
16206        assert!(probe
16207            .candidates
16208            .iter()
16209            .any(|c| c.kind == crate::UseWorldKind::Harvest));
16210
16211        let mut state = sample_state();
16212        state.resource_nodes[0].x = 129.0;
16213        let probe = state.probe_use_world();
16214        let primary = probe.primary.expect("primary");
16215        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
16216    }
16217
16218    #[test]
16219    fn probe_use_world_door_uses_building_label() {
16220        let mut state = sample_state();
16221        state.doors[0].x = 129.0;
16222        state.doors[0].y = 128.0;
16223        let probe = state.probe_use_world();
16224        let primary = probe.primary.expect("primary");
16225        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
16226        assert_eq!(primary.label, "Broker");
16227        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
16228    }
16229
16230    #[test]
16231    fn empty_entity_tick_preserves_welcome_snapshot() {
16232        let mut state = sample_state();
16233        state.inventory.insert("carrot".into(), 3);
16234        let delta = TickDelta {
16235            tick: 1,
16236            entities: vec![],
16237            resource_nodes: vec![],
16238            ground_drops: vec![],
16239            placed_containers: vec![],
16240            buildings: vec![],
16241            doors: vec![],
16242            interior_map: None,
16243            npcs: vec![],
16244            inventory: vec![],
16245            blueprints: vec![],
16246            building_materials: vec![],
16247            world_clock: flatland_protocol::WorldClock::default(),
16248            combat: None,
16249            quest_log: vec![],
16250            hired_workers: Vec::new(),
16251            interactables: vec![],
16252            ledger: None,
16253            career: None,
16254            combat_fx: Vec::new(),
16255            ground_hazards: Vec::new(),
16256            property_plots: Vec::new(),
16257            terrain_overlays: Vec::new(),
16258        };
16259
16260        state.apply_tick_fields(&delta, 1);
16261
16262        assert_eq!(state.entities.len(), 1);
16263        assert!(state.player.is_some());
16264        assert_eq!(state.inventory.get("carrot"), Some(&3));
16265        assert_eq!(state.resource_nodes.len(), 1);
16266    }
16267
16268    #[test]
16269    fn tick_preserves_world_layers_when_delta_omits_them() {
16270        let mut state = sample_state();
16271        let delta = TickDelta {
16272            tick: 1,
16273            entities: state.entities.clone(),
16274            resource_nodes: vec![],
16275            ground_drops: vec![],
16276            placed_containers: vec![],
16277            buildings: vec![],
16278            doors: vec![],
16279            interior_map: None,
16280            npcs: vec![],
16281            inventory: vec![],
16282            blueprints: vec![],
16283            building_materials: vec![],
16284            world_clock: flatland_protocol::WorldClock::default(),
16285            combat: None,
16286            quest_log: vec![],
16287            hired_workers: Vec::new(),
16288            interactables: vec![],
16289            ledger: None,
16290            career: None,
16291            combat_fx: Vec::new(),
16292            ground_hazards: Vec::new(),
16293            property_plots: Vec::new(),
16294            terrain_overlays: Vec::new(),
16295        };
16296
16297        state.apply_tick_fields(&delta, 1);
16298
16299        assert_eq!(state.resource_nodes.len(), 1);
16300        assert_eq!(state.buildings.len(), 1);
16301        assert_eq!(state.doors.len(), 1);
16302    }
16303
16304    #[test]
16305    fn tick_updates_resource_nodes_when_server_sends_them() {
16306        let mut state = sample_state();
16307        let delta = TickDelta {
16308            tick: 1,
16309            entities: state.entities.clone(),
16310            resource_nodes: vec![ResourceNodeView {
16311                id: "oak-1".into(),
16312                label: "Oak".into(),
16313                x: 130.0,
16314                y: 128.0,
16315                z: 0.0,
16316                item_template: "oak_log".into(),
16317                state: ResourceNodeState::Cooldown,
16318                blocking: true,
16319                blocking_radius_m: 0.8,
16320                harvest_off: false,
16321                tile_id: None,
16322                yaw: 0.0,
16323                pitch: 0.0,
16324                roll: 0.0,
16325                draw_scale: 1.0,
16326                sprite_mode: None,
16327                growth_progress: None,
16328                presentation_state: None,
16329                channel_start_tick: None,
16330                channel_end_tick: None,
16331                harvest_drop_templates: vec![],
16332            }],
16333            buildings: vec![],
16334            doors: vec![],
16335            interior_map: None,
16336            npcs: vec![],
16337            inventory: vec![],
16338            blueprints: vec![],
16339            building_materials: vec![],
16340            world_clock: flatland_protocol::WorldClock::default(),
16341            ground_drops: vec![],
16342            placed_containers: vec![],
16343            combat: None,
16344            quest_log: vec![],
16345            hired_workers: Vec::new(),
16346            interactables: vec![],
16347            ledger: None,
16348            career: None,
16349            combat_fx: Vec::new(),
16350            ground_hazards: Vec::new(),
16351            property_plots: Vec::new(),
16352            terrain_overlays: Vec::new(),
16353        };
16354
16355        state.apply_tick_fields(&delta, 1);
16356
16357        assert!(matches!(
16358            state.resource_nodes[0].state,
16359            ResourceNodeState::Cooldown
16360        ));
16361    }
16362
16363    #[test]
16364    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
16365        let mut state = GameState {
16366            session_id: 1,
16367            entity_id: 1,
16368            character_id: None,
16369            tick: 0,
16370            chunk_rev: 0,
16371            content_rev: 0,
16372            publish_rev: 0,
16373            entities: vec![EntityState {
16374                id: 1,
16375                label: "You".into(),
16376                transform: Transform {
16377                    position: WorldCoord::surface(4.5, 2.0),
16378                    yaw: 0.0,
16379                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16380                },
16381                vitals: None,
16382                attributes: None,
16383                skills: None,
16384                inside_building: Some("broker_hut".into()),
16385                tile_id: None,
16386                paperdoll_ref: None,
16387                draw_scale: 1.0,
16388                presentation_state: None,
16389                sprite_mode: None,
16390                progression_xp: None,
16391                combat_cues: vec![],
16392                statuses: vec![],
16393            }],
16394            player: None,
16395            resource_nodes: vec![],
16396            ground_drops: vec![],
16397            placed_containers: vec![],
16398            buildings: vec![BuildingView {
16399                id: "broker_hut".into(),
16400                label: "Broker".into(),
16401                x: 158.0,
16402                y: 124.0,
16403                width_m: 8.0,
16404                depth_m: 6.0,
16405                interior_blueprint: Some("broker_hut".into()),
16406                tags: vec![],
16407                market_boundary_zone_ids: vec![],
16408                market_max_volume: None,
16409                wall_set: None,
16410                roof_set: None,
16411            }],
16412            doors: vec![flatland_protocol::DoorView {
16413                id: "broker_hut_exit".into(),
16414                building_id: "broker_hut".into(),
16415                x: 4.3,
16416                y: 0.9,
16417                open: true,
16418                portal: Some("front".into()),
16419                locked: false,
16420                accessible: true,
16421                lock_id: None,
16422            }],
16423            interior_map: None,
16424            npcs: vec![flatland_protocol::NpcView {
16425                id: "ada_broker".into(),
16426                label: "Ada".into(),
16427                x: 4.5,
16428                y: 2.0,
16429                building_id: Some("broker_hut".into()),
16430                role: "broker".into(),
16431                entity_id: None,
16432                life_state: None,
16433                hp_pct: None,
16434                can_trade: true,
16435                buy_templates: vec!["lumber".into()],
16436                tile_id: None,
16437                behavior_state: None,
16438                presentation_state: None,
16439                sprite_mode: None,
16440                paperdoll_ref: None,
16441                draw_scale: 1.0,
16442                yaw: None,
16443                perception_fov_deg: None,
16444                perception_sight_m: None,
16445                perception_hear_m: None,
16446            }],
16447            blueprints: vec![],
16448            building_materials: vec![],
16449            world_x0: 0.0,
16450            world_y0: 0.0,
16451            world_width_m: 256.0,
16452            world_height_m: 256.0,
16453            terrain_zones: Vec::new(),
16454            z_platforms: Vec::new(),
16455            z_transitions: Vec::new(),
16456            z_bands_outdoor_backup: None,
16457            world_clock: flatland_protocol::WorldClock::default(),
16458            inventory: std::collections::HashMap::new(),
16459            inventory_hints: std::collections::HashMap::new(),
16460            logs: VecDeque::new(),
16461            intents_sent: 0,
16462            ticks_received: 0,
16463            connected: true,
16464            disconnect_reason: None,
16465            show_stats: false,
16466            hud_log_hidden: false,
16467            show_equip_menu: false,
16468            equip_menu_index: 0,
16469            show_craft_menu: false,
16470            show_plot_build_menu: false,
16471            plot_build_focus_wall: true,
16472            plot_build_wall_index: 0,
16473            plot_build_roof_index: 0,
16474            craft_menu_index: 0,
16475            craft_batch_quantity: 1,
16476            show_shop_menu: false,
16477            shop_catalog: None,
16478            bank_panel: None,
16479            bank_menu_index: 0,
16480            bank_ui_mode: BankUiMode::Menu,
16481            storage_panel: None,
16482            market_panel: None,
16483            market_menu_index: 0,
16484            market_filter: String::new(),
16485            market_filter_focused: false,
16486            market_category_filter: None,
16487            market_buy_confirm: None,
16488            market_ui_mode: MarketUiMode::Browse,
16489            storage_menu_index: 0,
16490            storage_ui_mode: StorageUiMode::Menu,
16491            shop_tab: ShopTab::default(),
16492            shop_menu_index: 0,
16493            shop_quantity: 1,
16494            shop_trade_log: VecDeque::new(),
16495            show_npc_verb_menu: false,
16496            npc_verb_target: None,
16497            npc_verb_index: 0,
16498            player_verbs: crate::social::PlayerVerbState::default(),
16499            social_chat: crate::social::SocialChatState::default(),
16500            trade_ui: crate::social::TradeUiState::default(),
16501            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16502            show_npc_chat: false,
16503            npc_chat: None,
16504            show_inventory_menu: false,
16505            inventory_menu_index: 0,
16506            inventory_tab: InventoryTab::OnPerson,
16507            inventory_filter: String::new(),
16508            inventory_filter_focused: false,
16509            show_move_picker: false,
16510            show_rename_prompt: false,
16511            rename_plot_id: None,
16512            highlighted_plot_id: None,
16513            show_worker_rename: false,
16514            rename_buffer: String::new(),
16515            move_picker_index: 0,
16516            move_picker: None,
16517            show_grant_picker: false,
16518            grant_picker_index: 0,
16519            grant_picker: None,
16520            show_destroy_picker: false,
16521            destroy_confirm_pending: false,
16522            destroy_picker: None,
16523            combat_target: None,
16524            combat_target_label: None,
16525            ground_target: None,
16526            combat_fx: Vec::new(),
16527            ground_hazards: Vec::new(),
16528            property_zones: Vec::new(),
16529            tax_zones: Vec::new(),
16530            growth_zones: Vec::new(),
16531            biome_zones: Vec::new(),
16532            terrain_kind_nav: Vec::new(),
16533            property_plots: Vec::new(),
16534            property_plot_settings: None,
16535            claim_mode: None,
16536            relocate_mode: None,
16537            sell_plot_confirm: None,
16538            sell_plot_armed_at: None,
16539            show_plant_menu: false,
16540            plant_menu_index: 0,
16541            show_farm_access: false,
16542            farm_access_name_draft: String::new(),
16543            farm_access_discount_bps: 0,
16544            farm_access_index: 0,
16545            plant_quantity: 1,
16546            in_combat: false,
16547            auto_attack: true,
16548            combat_has_los: false,
16549            attack_cd_ticks: 0,
16550            gcd_ticks: 0,
16551            weapon_ability_id: "unarmed".into(),
16552            mainhand_template_id: None,
16553            mainhand_label: None,
16554            mainhand_instance_id: None,
16555            offhand_template_id: None,
16556            offhand_label: None,
16557            offhand_instance_id: None,
16558            mainhand_hand_slots: 1,
16559            defense: None,
16560            worn: BTreeMap::new(),
16561            carry_mass: 0.0,
16562            carry_mass_max: 0.0,
16563            encumbrance: flatland_protocol::EncumbranceState::Light,
16564            inventory_stacks: Vec::new(),
16565            keychain_stacks: Vec::new(),
16566            whisper_pouch_stacks: Vec::new(),
16567            combat_target_detail: None,
16568            statuses: Vec::new(),
16569            cast_progress: None,
16570            timed_channel: None,
16571            plot_build_offer: None,
16572            ability_cooldowns: Vec::new(),
16573            blocking_active: false,
16574            max_target_slots: 1,
16575            combat_slots: Vec::new(),
16576            rotation_presets: Vec::new(),
16577            known_abilities: Vec::new(),
16578            ability_meta: std::collections::HashMap::new(),
16579            ability_mastery: std::collections::HashMap::new(),
16580            hotbar: vec![None; 9],
16581            max_abilities_per_rotation: 0,
16582            show_loadout_menu: false,
16583            show_keychain_menu: false,
16584            keychain_menu_index: 0,
16585            show_rotation_editor: false,
16586            loadout_menu_index: 0,
16587            loadout_hotbar_slot: 1,
16588            loadout_ability_index: 0,
16589            loadout_focus_presets: false,
16590            rotation_editor: RotationEditorState::default(),
16591            harvest_in_progress: false,
16592            harvest_started_at: None,
16593            pending_craft_ack: None,
16594            pending_worker_job_ack: None,
16595            attending_worker_instance_id: None,
16596            quest_log: Vec::new(),
16597            interactables: Vec::new(),
16598            ledger: None,
16599            career: None,
16600            character_sheet_tab: CharacterSheetTab::Character,
16601            ledger_period: LedgerPeriod::Day,
16602            show_quest_offer: false,
16603            pending_quest_offer: None,
16604            show_quest_menu: false,
16605            quest_menu_index: 0,
16606            quest_withdraw_confirm: false,
16607            hired_workers: Vec::new(),
16608            show_workers_menu: false,
16609            workers_menu_index: 0,
16610            worker_dismiss_confirmation: None,
16611            workers_menu_compact: false,
16612            worker_step_display: BTreeMap::new(),
16613            worker_error_display: BTreeMap::new(),
16614            worker_health_ring_until: BTreeMap::new(),
16615            pending_worker_hire_since: None,
16616            show_worker_give_picker: false,
16617            worker_give_picker_index: 0,
16618            worker_give_picker: None,
16619            show_worker_give_target_picker: false,
16620            worker_give_target_picker_index: 0,
16621            worker_give_target_picker: None,
16622            show_worker_take_picker: false,
16623            worker_take_picker_index: 0,
16624            worker_take_picker: None,
16625            show_worker_teach_picker: false,
16626            worker_teach_picker_index: 0,
16627            worker_teach_picker: None,
16628            worker_route_editor: None,
16629            progression_curve: None,
16630        };
16631        state.player = state.entities.first().cloned();
16632        assert_eq!(
16633            state.nearest_interact_target().as_deref(),
16634            Some("ada_broker")
16635        );
16636    }
16637
16638    #[test]
16639    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
16640        let mut state = sample_state();
16641        // Player is at (128, 128) per sample_state(). One chest just inside
16642        // CONTAINER_RANGE_M, one clearly beyond it.
16643        state.placed_containers = vec![
16644            flatland_protocol::PlacedContainerView {
16645                id: "near".into(),
16646                template_id: "wooden_chest_small".into(),
16647                display_name: "Wooden Chest".into(),
16648                x: 130.0,
16649                y: 128.0,
16650                z: 0.0,
16651                locked: true,
16652                accessible: true,
16653                owner_character_id: None,
16654                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
16655                lock_id: None,
16656                capacity_volume: None,
16657                item_instance_id: Some(uuid::Uuid::from_u128(1)),
16658                tile_id: None,
16659                worker_lodging_capacity: None,
16660                blocking: false,
16661                blocking_radius_m: 0.0,
16662                building_id: None,
16663            },
16664            flatland_protocol::PlacedContainerView {
16665                id: "far".into(),
16666                template_id: "wooden_chest_small".into(),
16667                display_name: "Distant Chest".into(),
16668                x: 128.0 + CONTAINER_RANGE_M + 5.0,
16669                y: 128.0,
16670                z: 0.0,
16671                locked: false,
16672                accessible: true,
16673                owner_character_id: None,
16674                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
16675                lock_id: None,
16676                capacity_volume: None,
16677                item_instance_id: Some(uuid::Uuid::from_u128(2)),
16678                tile_id: None,
16679                worker_lodging_capacity: None,
16680                blocking: false,
16681                blocking_radius_m: 0.0,
16682                building_id: None,
16683            },
16684        ];
16685
16686        let nearby = state.nearby_containers();
16687        assert_eq!(
16688            nearby.len(),
16689            1,
16690            "far chest must not appear once out of range"
16691        );
16692        assert_eq!(nearby[0].view.id, "near");
16693        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
16694        assert!(nearby[0].rows[0].is_chest_shell);
16695
16696        // The same chest, but locked and inaccessible (no key held), must hide
16697        // contents but still show the selectable chest shell row.
16698        state.placed_containers[0].accessible = false;
16699        let nearby = state.nearby_containers();
16700        assert_eq!(nearby.len(), 1);
16701        assert_eq!(nearby[0].rows.len(), 1);
16702        assert!(nearby[0].rows[0].is_chest_shell);
16703    }
16704
16705    #[test]
16706    fn chest_pickup_destinations_offer_person_and_worn_bag() {
16707        let mut state = sample_state();
16708        let back_id = uuid::Uuid::from_u128(42);
16709        state.worn.insert(
16710            BodySlot::Back,
16711            flatland_protocol::ItemStack {
16712                template_id: "travel_backpack".into(),
16713                quantity: 1,
16714                item_instance_id: Some(back_id),
16715                props: Default::default(),
16716                status_bindings: Vec::new(),
16717                contents: Vec::new(),
16718                display_name: Some("Travel Backpack".into()),
16719                category: Some("container".into()),
16720                base_mass: Some(2.5),
16721                base_volume: Some(12.0),
16722                capacity_volume: Some(80.0),
16723                stackable: Some(false),
16724                world_placeable: Some(false),
16725                worker_lodging_capacity: None,
16726                equip_slot: None,
16727                armor_physical: None,
16728                resists: vec![],
16729                hand_slots: None,
16730                listable: None,
16731                ..Default::default()
16732            },
16733        );
16734        let opts = state.chest_pickup_destinations("chest-1");
16735        assert!(matches!(
16736            opts.first().map(|o| &o.kind),
16737            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
16738        ));
16739        assert!(opts.iter().any(|o| matches!(
16740            &o.kind,
16741            MoveOptionKind::PickupPlaced {
16742                nest_parent_instance_id: None,
16743                ..
16744            }
16745        )));
16746        assert!(opts.iter().any(|o| matches!(
16747            &o.kind,
16748            MoveOptionKind::PickupPlaced {
16749                nest_parent_instance_id: Some(id),
16750                ..
16751            } if *id == back_id
16752        )));
16753        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16754    }
16755
16756    #[test]
16757    fn placed_container_public_label_hides_owner_custom_name() {
16758        let owner = uuid::Uuid::from_u128(99);
16759        let mut state = sample_state();
16760        state.character_id = Some(uuid::Uuid::from_u128(1));
16761        state.inventory_hints.insert(
16762            "wooden_chest_medium".into(),
16763            InventoryHint {
16764                display_name: "Medium Wooden Chest".into(),
16765                category: "container".into(),
16766                base_mass: None,
16767                base_volume: None,
16768                capacity_volume: None,
16769                stackable: false,
16770                listable: true,
16771                base_value_copper: None,
16772            },
16773        );
16774        let chest = flatland_protocol::PlacedContainerView {
16775            id: "c1".into(),
16776            template_id: "wooden_chest_medium".into(),
16777            display_name: "Barry's Loot #a3f2".into(),
16778            x: 128.0,
16779            y: 128.0,
16780            z: 0.0,
16781            locked: false,
16782            accessible: true,
16783            owner_character_id: Some(owner),
16784            contents: vec![],
16785            lock_id: None,
16786            capacity_volume: None,
16787            item_instance_id: None,
16788            tile_id: None,
16789            worker_lodging_capacity: None,
16790            blocking: false,
16791            blocking_radius_m: 0.0,
16792            building_id: None,
16793        };
16794        assert_eq!(
16795            state.placed_container_public_label(&chest),
16796            "Medium Wooden Chest"
16797        );
16798        state.character_id = Some(owner);
16799        assert_eq!(
16800            state.placed_container_public_label(&chest),
16801            "Barry's Loot #a3f2"
16802        );
16803    }
16804
16805    #[test]
16806    fn location_context_shows_crop_growth_percent_not_depleted() {
16807        let mut state = sample_state();
16808        state.player = state.entities.first().cloned();
16809        state.resource_nodes[0].label = "Carrot (growing)".into();
16810        state.resource_nodes[0].x = 128.2;
16811        state.resource_nodes[0].y = 128.0;
16812        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16813        state.resource_nodes[0].growth_progress = Some(0.47);
16814        let lines = state.location_context_lines();
16815        let line = lines
16816            .iter()
16817            .find(|l| l.text.contains("Carrot"))
16818            .map(|l| l.text.as_str())
16819            .unwrap_or("");
16820        assert!(
16821            line.contains("(growing, 47%)"),
16822            "expected growth percent, got: {line}"
16823        );
16824        assert!(
16825            !line.contains("depleted"),
16826            "growing crop should not show depleted: {line}"
16827        );
16828    }
16829
16830    #[test]
16831    fn resource_node_near_action_suffix_prefers_growth() {
16832        let node = ResourceNodeView {
16833            id: "crop".into(),
16834            label: "Wheat".into(),
16835            x: 0.0,
16836            y: 0.0,
16837            z: 0.0,
16838            item_template: "wheat".into(),
16839            state: ResourceNodeState::Cooldown,
16840            blocking: false,
16841            blocking_radius_m: 0.0,
16842            harvest_off: false,
16843            tile_id: None,
16844            yaw: 0.0,
16845            pitch: 0.0,
16846            roll: 0.0,
16847            draw_scale: 1.0,
16848            sprite_mode: None,
16849            growth_progress: Some(0.12),
16850            presentation_state: None,
16851            channel_start_tick: None,
16852            channel_end_tick: None,
16853            harvest_drop_templates: vec![],
16854        };
16855        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
16856    }
16857
16858    #[test]
16859    fn location_context_lists_nearby_resource_node() {
16860        let mut state = sample_state();
16861        state.player = state.entities.first().cloned();
16862        state.resource_nodes[0].x = 128.2;
16863        state.resource_nodes[0].y = 128.0;
16864        let lines = state.location_context_lines();
16865        assert!(
16866            lines
16867                .iter()
16868                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16869            "expected resource node in context: {:?}",
16870            lines
16871        );
16872    }
16873
16874    #[test]
16875    fn quest_board_usable_within_board_radius() {
16876        let mut state = sample_state();
16877        state.player = state.entities.first().cloned();
16878        state.interactables = vec![flatland_protocol::InteractableView {
16879            id: "board-1".into(),
16880            kind: "quest_board".into(),
16881            label: "Town Quest Board".into(),
16882            x: 130.5,
16883            y: 128.0,
16884            z: 0.0,
16885            board_id: Some("starter_town_board".into()),
16886        }];
16887        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
16888        assert_eq!(
16889            state.nearest_interact_target().as_deref(),
16890            Some("board-1"),
16891            "quest board should be selectable at ~2.5m"
16892        );
16893        let lines = state.location_context_lines();
16894        assert!(
16895            lines
16896                .iter()
16897                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16898            "HUD should advertise f when board is in range: {:?}",
16899            lines
16900        );
16901    }
16902
16903    #[test]
16904    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
16905        let mut state = sample_state();
16906        state.worn.insert(
16907            BodySlot::Back,
16908            flatland_protocol::ItemStack {
16909                template_id: "travel_backpack".into(),
16910                quantity: 1,
16911                item_instance_id: Some(uuid::Uuid::from_u128(3)),
16912                props: Default::default(),
16913                status_bindings: Vec::new(),
16914                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16915                display_name: None,
16916                category: None,
16917                base_mass: None,
16918                base_volume: None,
16919                capacity_volume: None,
16920                stackable: None,
16921                world_placeable: None,
16922                worker_lodging_capacity: None,
16923                equip_slot: None,
16924                armor_physical: None,
16925                resists: vec![],
16926                hand_slots: None,
16927                listable: None,
16928                ..Default::default()
16929            },
16930        );
16931        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16932        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16933            id: "chest-1".into(),
16934            template_id: "wooden_chest_small".into(),
16935            display_name: "Wooden Chest".into(),
16936            x: 129.0,
16937            y: 128.0,
16938            z: 0.0,
16939            locked: false,
16940            accessible: true,
16941            owner_character_id: None,
16942            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16943            lock_id: None,
16944            capacity_volume: None,
16945            item_instance_id: Some(uuid::Uuid::from_u128(4)),
16946            tile_id: None,
16947            worker_lodging_capacity: None,
16948            blocking: false,
16949            blocking_radius_m: 0.0,
16950            building_id: None,
16951        }];
16952
16953        state.inventory_tab = InventoryTab::OnPerson;
16954        let rows = state.inventory_selectable_rows();
16955        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16956        assert_eq!(
16957            sections,
16958            vec![
16959                InventorySection::Person, // iron_ore carried in backpack
16960                InventorySection::Person, // lumber
16961            ]
16962        );
16963        assert_eq!(rows[0].stack.template_id, "iron_ore");
16964        assert_eq!(rows[0].depth, 0);
16965        assert!(!rows[0].is_equip_shell);
16966        assert_eq!(rows[1].stack.template_id, "lumber");
16967
16968        let lines = state.inventory_browser_lines();
16969        assert!(lines.iter().any(|l| matches!(
16970            l,
16971            InventoryBrowserLine::Section(s) if s.contains("carried bags")
16972        )));
16973        assert!(lines.iter().any(|l| matches!(
16974            l,
16975            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
16976        )));
16977        assert!(!lines.iter().any(|l| matches!(
16978            l,
16979            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16980        )));
16981        assert!(!lines.iter().any(|l| matches!(
16982            l,
16983            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16984        )));
16985
16986        state.inventory_tab = InventoryTab::Nearby;
16987        let nearby_rows = state.inventory_selectable_rows();
16988        assert_eq!(nearby_rows.len(), 2);
16989        assert!(nearby_rows[0].is_chest_shell);
16990        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16991        let nearby_lines = state.inventory_browser_lines();
16992        assert!(nearby_lines.iter().any(|l| matches!(
16993            l,
16994            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16995        )));
16996    }
16997
16998    #[test]
16999    fn give_worker_notice_does_not_put_item_back_in_bag() {
17000        let mut state = sample_state();
17001        let id = uuid::Uuid::from_u128(42);
17002        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
17003        saw.item_instance_id = Some(id);
17004        saw.display_name = Some("Handsaw".into());
17005        state.sync_inventory_from_stacks(&[saw]);
17006        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
17007
17008        state.remove_carried_instance(id, None);
17009        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
17010        assert!(state.inventory_stacks.is_empty());
17011
17012        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
17013            target_id: "worker-1".into(),
17014            message: "Gave 1x Handsaw to Laborer".into(),
17015            coins_delta: 0,
17016            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
17017        });
17018        assert_eq!(
17019            state.inventory.get("handsaw").copied().unwrap_or(0),
17020            0,
17021            "Gave notice must not restore the handed stack"
17022        );
17023    }
17024
17025    #[test]
17026    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
17027        let mut state = sample_state();
17028        let back_id = uuid::Uuid::from_u128(5);
17029        state.worn.insert(
17030            BodySlot::Back,
17031            flatland_protocol::ItemStack {
17032                template_id: "travel_backpack".into(),
17033                quantity: 1,
17034                item_instance_id: Some(back_id),
17035                props: Default::default(),
17036                status_bindings: Vec::new(),
17037                contents: Vec::new(),
17038                display_name: None,
17039                category: Some("container".into()),
17040                base_mass: None,
17041                base_volume: None,
17042                capacity_volume: Some(80.0),
17043                stackable: None,
17044                world_placeable: None,
17045                worker_lodging_capacity: None,
17046                equip_slot: None,
17047                armor_physical: None,
17048                resists: vec![],
17049                hand_slots: None,
17050                listable: None,
17051                ..Default::default()
17052            },
17053        );
17054        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17055            id: "chest-1".into(),
17056            template_id: "wooden_chest_small".into(),
17057            display_name: "Wooden Chest".into(),
17058            x: 129.0,
17059            y: 128.0,
17060            z: 0.0,
17061            locked: false,
17062            accessible: true,
17063            owner_character_id: None,
17064            contents: Vec::new(),
17065            lock_id: None,
17066            capacity_volume: None,
17067            item_instance_id: Some(uuid::Uuid::from_u128(6)),
17068            tile_id: None,
17069            worker_lodging_capacity: None,
17070            blocking: false,
17071            blocking_radius_m: 0.0,
17072            building_id: None,
17073        }];
17074
17075        // Item currently sitting loose on the person (Root): backpack + nearby
17076        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
17077        let opts = state.move_destinations_for(
17078            &flatland_protocol::InventoryLocation::Root,
17079            None,
17080            None,
17081            "lumber",
17082        );
17083        assert!(!opts.iter().any(|o| matches!(
17084            &o.kind,
17085            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
17086        )));
17087        assert!(opts.iter().any(|o| matches!(
17088            &o.kind,
17089            MoveOptionKind::Move { location, parent_instance_id, .. }
17090                if *location == flatland_protocol::InventoryLocation::Worn {
17091                    slot: BodySlot::Back,
17092                } && *parent_instance_id == Some(back_id)
17093        )));
17094        assert!(opts.iter().any(|o| matches!(
17095            &o.kind,
17096            MoveOptionKind::Move { location, .. }
17097                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
17098        )));
17099        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
17100        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
17101
17102        // Item currently inside the worn backpack: the backpack itself must be
17103        // excluded from its own destination list (can't move an item into the
17104        // container it's already in).
17105        let from_backpack = flatland_protocol::InventoryLocation::Worn {
17106            slot: BodySlot::Back,
17107        };
17108        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
17109        assert!(!opts.iter().any(|o| matches!(
17110            &o.kind,
17111            MoveOptionKind::Move { location, parent_instance_id, .. }
17112                if *location == from_backpack && *parent_instance_id == Some(back_id)
17113        )));
17114        assert!(opts.iter().any(|o| matches!(
17115            &o.kind,
17116            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
17117        )));
17118    }
17119
17120    #[test]
17121    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
17122        let mut state = sample_state();
17123        // Insert out of display order — BTreeMap iteration must still yield the
17124        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
17125        state.worn.insert(
17126            BodySlot::Waist,
17127            flatland_protocol::ItemStack {
17128                template_id: "simple_belt".into(),
17129                quantity: 1,
17130                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17131                props: Default::default(),
17132                status_bindings: Vec::new(),
17133                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
17134                display_name: None,
17135                category: Some("container".into()),
17136                base_mass: None,
17137                base_volume: None,
17138                capacity_volume: None,
17139                stackable: None,
17140                world_placeable: None,
17141                worker_lodging_capacity: None,
17142                equip_slot: None,
17143                armor_physical: None,
17144                resists: vec![],
17145                hand_slots: None,
17146                listable: None,
17147                ..Default::default()
17148            },
17149        );
17150        state.worn.insert(
17151            BodySlot::Head,
17152            flatland_protocol::ItemStack {
17153                template_id: "cloth_cap".into(),
17154                quantity: 1,
17155                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17156                props: Default::default(),
17157                status_bindings: Vec::new(),
17158                contents: Vec::new(),
17159                display_name: None,
17160                category: Some("armor".into()),
17161                base_mass: None,
17162                base_volume: None,
17163                capacity_volume: None,
17164                stackable: None,
17165                world_placeable: None,
17166                worker_lodging_capacity: None,
17167                equip_slot: None,
17168                armor_physical: None,
17169                resists: vec![],
17170                hand_slots: None,
17171                listable: None,
17172                ..Default::default()
17173            },
17174        );
17175        state.worn.insert(
17176            BodySlot::Back,
17177            flatland_protocol::ItemStack {
17178                template_id: "travel_backpack".into(),
17179                quantity: 1,
17180                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17181                props: Default::default(),
17182                status_bindings: Vec::new(),
17183                contents: Vec::new(),
17184                display_name: None,
17185                category: Some("container".into()),
17186                base_mass: None,
17187                base_volume: None,
17188                capacity_volume: None,
17189                stackable: None,
17190                world_placeable: None,
17191                worker_lodging_capacity: None,
17192                equip_slot: None,
17193                armor_physical: None,
17194                resists: vec![],
17195                hand_slots: None,
17196                listable: None,
17197                ..Default::default()
17198            },
17199        );
17200
17201        let rows = state.worn_rows();
17202        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
17203        assert_eq!(rows.len(), 4);
17204        assert_eq!(rows[0].stack.template_id, "cloth_cap");
17205        assert!(rows[0].is_equip_shell);
17206        assert_eq!(rows[1].stack.template_id, "travel_backpack");
17207        assert!(rows[1].is_equip_shell);
17208        assert_eq!(rows[2].stack.template_id, "simple_belt");
17209        assert!(rows[2].is_equip_shell);
17210        assert_eq!(rows[3].stack.template_id, "leather_pouch");
17211        assert_eq!(rows[3].depth, 1);
17212        assert!(!rows[3].is_equip_shell);
17213    }
17214
17215    #[test]
17216    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
17217        let mut state = sample_state();
17218        state.worn.insert(
17219            BodySlot::Waist,
17220            flatland_protocol::ItemStack {
17221                template_id: "simple_belt".into(),
17222                quantity: 1,
17223                item_instance_id: Some(uuid::Uuid::from_u128(20)),
17224                props: Default::default(),
17225                status_bindings: Vec::new(),
17226                contents: Vec::new(),
17227                display_name: Some("Simple Belt".into()),
17228                category: Some("container".into()),
17229                base_mass: None,
17230                base_volume: None,
17231                capacity_volume: None,
17232                stackable: None,
17233                world_placeable: None,
17234                worker_lodging_capacity: None,
17235                equip_slot: None,
17236                armor_physical: None,
17237                resists: vec![],
17238                hand_slots: None,
17239                listable: None,
17240                ..Default::default()
17241            },
17242        );
17243        state.worn.insert(
17244            BodySlot::Head,
17245            flatland_protocol::ItemStack {
17246                template_id: "cloth_cap".into(),
17247                quantity: 1,
17248                item_instance_id: Some(uuid::Uuid::from_u128(21)),
17249                props: Default::default(),
17250                status_bindings: Vec::new(),
17251                contents: Vec::new(),
17252                display_name: Some("Cloth Cap".into()),
17253                category: Some("armor".into()),
17254                base_mass: None,
17255                base_volume: None,
17256                capacity_volume: None,
17257                stackable: None,
17258                world_placeable: None,
17259                worker_lodging_capacity: None,
17260                equip_slot: None,
17261                armor_physical: None,
17262                resists: vec![],
17263                hand_slots: None,
17264                listable: None,
17265                ..Default::default()
17266            },
17267        );
17268
17269        let opts = state.move_destinations_for(
17270            &flatland_protocol::InventoryLocation::Root,
17271            None,
17272            None,
17273            "leather_pouch",
17274        );
17275        assert!(
17276            opts.iter().any(|o| matches!(
17277                &o.kind,
17278                MoveOptionKind::Move { location, .. }
17279                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17280            )),
17281            "belt loop must be offered when moving a pouch"
17282        );
17283        assert!(
17284            !opts.iter().any(|o| matches!(
17285                &o.kind,
17286                MoveOptionKind::Move { location, .. }
17287                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
17288            )),
17289            "armor slots can't hold other items and must not appear as move destinations"
17290        );
17291        let belt_opt = opts
17292            .iter()
17293            .find(|o| matches!(
17294                &o.kind,
17295                MoveOptionKind::Move { location, .. }
17296                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17297            ))
17298            .unwrap();
17299        assert!(belt_opt.label.contains("belt loop"));
17300
17301        let opts = state.move_destinations_for(
17302            &flatland_protocol::InventoryLocation::Root,
17303            None,
17304            None,
17305            "lumber",
17306        );
17307        assert!(
17308            !opts.iter().any(|o| o.label.contains("belt loop")),
17309            "loose materials must not target the belt shell — only nested pouches"
17310        );
17311    }
17312
17313    #[test]
17314    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
17315        let mut state = sample_state();
17316        let belt_id = uuid::Uuid::from_u128(30);
17317        let pouch_id = uuid::Uuid::from_u128(31);
17318        state.worn.insert(
17319            BodySlot::Waist,
17320            flatland_protocol::ItemStack {
17321                template_id: "simple_belt".into(),
17322                quantity: 1,
17323                item_instance_id: Some(belt_id),
17324                props: Default::default(),
17325                status_bindings: Vec::new(),
17326                world_placeable: None,
17327                worker_lodging_capacity: None,
17328                equip_slot: None,
17329                armor_physical: None,
17330                resists: vec![],
17331                hand_slots: None,
17332                contents: vec![flatland_protocol::ItemStack {
17333                    template_id: "dimensional_pouch".into(),
17334                    quantity: 1,
17335                    item_instance_id: Some(pouch_id),
17336                    props: Default::default(),
17337                    status_bindings: Vec::new(),
17338                    contents: Vec::new(),
17339                    display_name: Some("Dimensional Pouch".into()),
17340                    category: Some("container".into()),
17341                    base_mass: None,
17342                    base_volume: None,
17343                    capacity_volume: Some(200.0),
17344                    stackable: None,
17345                    world_placeable: None,
17346                    worker_lodging_capacity: None,
17347                    equip_slot: None,
17348                    armor_physical: None,
17349                    resists: vec![],
17350                    hand_slots: None,
17351                    listable: None,
17352                    ..Default::default()
17353                }],
17354                display_name: Some("Simple Belt".into()),
17355                category: Some("container".into()),
17356                base_mass: None,
17357                base_volume: None,
17358                capacity_volume: None,
17359                stackable: None,
17360                listable: None,
17361                ..Default::default()
17362            },
17363        );
17364
17365        let opts = state.move_destinations_for(
17366            &flatland_protocol::InventoryLocation::Root,
17367            None,
17368            None,
17369            "iron_ore",
17370        );
17371        assert!(
17372            opts.iter().any(|o| matches!(
17373                &o.kind,
17374                MoveOptionKind::Move {
17375                    location,
17376                    parent_instance_id,
17377                    ..
17378                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17379                    && *parent_instance_id == Some(pouch_id)
17380            )),
17381            "dimensional pouch clipped on belt must accept loose items"
17382        );
17383        assert!(
17384            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
17385            "destination label should name the pouch"
17386        );
17387    }
17388
17389    #[test]
17390    fn container_volume_label_on_placed_chest_shell() {
17391        let mut state = sample_state();
17392        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17393            id: "chest-1".into(),
17394            template_id: "wooden_chest_small".into(),
17395            display_name: "Camp Chest".into(),
17396            x: 129.0,
17397            y: 128.0,
17398            z: 0.0,
17399            locked: false,
17400            accessible: true,
17401            owner_character_id: None,
17402            contents: vec![flatland_protocol::ItemStack {
17403                template_id: "iron_ore".into(),
17404                quantity: 2,
17405                item_instance_id: None,
17406                props: Default::default(),
17407                status_bindings: Vec::new(),
17408                contents: Vec::new(),
17409                display_name: None,
17410                category: None,
17411                base_mass: None,
17412                base_volume: Some(2.0),
17413                capacity_volume: None,
17414                stackable: None,
17415                world_placeable: None,
17416                worker_lodging_capacity: None,
17417                equip_slot: None,
17418                armor_physical: None,
17419                resists: vec![],
17420                hand_slots: None,
17421                listable: None,
17422                ..Default::default()
17423            }],
17424            lock_id: None,
17425            capacity_volume: Some(60.0),
17426            item_instance_id: Some(uuid::Uuid::from_u128(4)),
17427            tile_id: None,
17428            worker_lodging_capacity: None,
17429            blocking: false,
17430            blocking_radius_m: 0.0,
17431            building_id: None,
17432        }];
17433        let nearby = state.nearby_containers();
17434        let label = state.container_volume_label(&nearby[0].rows[0]);
17435        assert!(
17436            label.contains("vol 4/60"),
17437            "expected used/cap in label, got {label}"
17438        );
17439        assert!(
17440            label.contains("56 free"),
17441            "expected free space, got {label}"
17442        );
17443    }
17444
17445    #[test]
17446    fn key_pair_chest_label_from_placed_lock_id() {
17447        let mut state = sample_state();
17448        let owner = uuid::Uuid::from_u128(77);
17449        state.character_id = Some(owner);
17450        let lock = uuid::Uuid::from_u128(99).to_string();
17451        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17452            id: "chest-1".into(),
17453            template_id: "wooden_chest_small".into(),
17454            display_name: "Barry's Loot #a3f2".into(),
17455            x: 129.0,
17456            y: 128.0,
17457            z: 0.0,
17458            locked: true,
17459            accessible: true,
17460            owner_character_id: Some(owner),
17461            contents: Vec::new(),
17462            lock_id: Some(lock.clone()),
17463            capacity_volume: None,
17464            item_instance_id: Some(uuid::Uuid::from_u128(4)),
17465            tile_id: None,
17466            worker_lodging_capacity: None,
17467            blocking: false,
17468            blocking_radius_m: 0.0,
17469            building_id: None,
17470        }];
17471        let key_id = uuid::Uuid::from_u128(5);
17472        let key = flatland_protocol::ItemStack {
17473            template_id: KEY_TEMPLATE.into(),
17474            quantity: 1,
17475            item_instance_id: Some(key_id),
17476            props: BTreeMap::from([
17477                (PROP_OPENS_LOCK_ID.into(), lock),
17478                (
17479                    PROP_OPENS_CONTAINER_NAME.into(),
17480                    "Barry's Loot #a3f2".into(),
17481                ),
17482            ]),
17483            status_bindings: Vec::new(),
17484            contents: Vec::new(),
17485            display_name: Some("Container Key".into()),
17486            category: Some("key".into()),
17487            base_mass: None,
17488            base_volume: None,
17489            capacity_volume: None,
17490            stackable: None,
17491            world_placeable: None,
17492            worker_lodging_capacity: None,
17493            equip_slot: None,
17494            armor_physical: None,
17495            resists: vec![],
17496            hand_slots: None,
17497            listable: None,
17498            ..Default::default()
17499        };
17500        state.inventory_stacks = vec![key.clone()];
17501        assert_eq!(
17502            state.key_pair_chest_label(&key).as_deref(),
17503            Some("Barry's Loot #a3f2")
17504        );
17505        assert!(state.key_drop_blocked(&key));
17506    }
17507
17508    #[test]
17509    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
17510        let mut state = sample_state();
17511        let lock = uuid::Uuid::from_u128(101).to_string();
17512        let key = flatland_protocol::ItemStack {
17513            template_id: KEY_TEMPLATE.into(),
17514            quantity: 1,
17515            item_instance_id: Some(uuid::Uuid::from_u128(7)),
17516            props: BTreeMap::from([
17517                (PROP_OPENS_LOCK_ID.into(), lock),
17518                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
17519            ]),
17520            status_bindings: Vec::new(),
17521            contents: Vec::new(),
17522            display_name: None,
17523            category: Some("key".into()),
17524            base_mass: None,
17525            base_volume: None,
17526            capacity_volume: None,
17527            stackable: None,
17528            world_placeable: None,
17529            worker_lodging_capacity: None,
17530            equip_slot: None,
17531            armor_physical: None,
17532            resists: vec![],
17533            hand_slots: None,
17534            listable: None,
17535            ..Default::default()
17536        };
17537        state.placed_containers.clear();
17538        assert_eq!(
17539            state.key_pair_chest_label(&key).as_deref(),
17540            Some("Camp Stash")
17541        );
17542    }
17543
17544    #[test]
17545    fn key_drop_allowed_when_paired_chest_unlocked() {
17546        let mut state = sample_state();
17547        let lock = uuid::Uuid::from_u128(100).to_string();
17548        let key_id = uuid::Uuid::from_u128(6);
17549        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17550            id: "chest-1".into(),
17551            template_id: "wooden_chest_small".into(),
17552            display_name: "Camp Chest".into(),
17553            x: 129.0,
17554            y: 128.0,
17555            z: 0.0,
17556            locked: false,
17557            accessible: true,
17558            owner_character_id: None,
17559            contents: Vec::new(),
17560            lock_id: Some(lock.clone()),
17561            capacity_volume: None,
17562            item_instance_id: None,
17563            tile_id: None,
17564            worker_lodging_capacity: None,
17565            blocking: false,
17566            blocking_radius_m: 0.0,
17567            building_id: None,
17568        }];
17569        let key = flatland_protocol::ItemStack {
17570            template_id: KEY_TEMPLATE.into(),
17571            quantity: 1,
17572            item_instance_id: Some(key_id),
17573            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
17574            status_bindings: Vec::new(),
17575            contents: Vec::new(),
17576            display_name: None,
17577            category: Some("key".into()),
17578            base_mass: None,
17579            base_volume: None,
17580            capacity_volume: None,
17581            stackable: None,
17582            world_placeable: None,
17583            worker_lodging_capacity: None,
17584            equip_slot: None,
17585            armor_physical: None,
17586            resists: vec![],
17587            hand_slots: None,
17588            listable: None,
17589            ..Default::default()
17590        };
17591        state.inventory_stacks = vec![key.clone()];
17592        assert!(!state.key_drop_blocked(&key));
17593        let opts = state.move_destinations_for(
17594            &flatland_protocol::InventoryLocation::Root,
17595            None,
17596            Some(key_id),
17597            KEY_TEMPLATE,
17598        );
17599        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
17600    }
17601
17602    #[test]
17603    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
17604        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
17605
17606        let mut state = sample_state();
17607        let curve = ProgressionCurve::default();
17608        let bootstrap =
17609            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
17610        let mut fresh = bootstrap.clone();
17611        fresh.strength += 0.08;
17612        if let Some(player) = state.player.as_mut() {
17613            player.progression_xp = Some(bootstrap);
17614        }
17615
17616        let combat = CombatHud {
17617            progression_xp: Some(fresh.clone()),
17618            progression_baseline: curve.baseline_display,
17619            progression_xp_base: curve.xp_base,
17620            progression_xp_growth: curve.xp_growth,
17621            attributes: state.player.as_ref().and_then(|p| p.attributes),
17622            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
17623            ..CombatHud::default()
17624        };
17625        state.apply_combat_hud(&combat);
17626
17627        let xp = state
17628            .player
17629            .as_ref()
17630            .and_then(|p| p.progression_xp.as_ref())
17631            .expect("xp");
17632        assert!((xp.strength - fresh.strength).abs() < 0.001);
17633        assert!(state.progression_curve.is_some());
17634    }
17635
17636    #[test]
17637    fn combat_hud_syncs_known_abilities_and_hotbar() {
17638        use flatland_protocol::CombatHud;
17639
17640        let mut state = sample_state();
17641        let combat = CombatHud {
17642            known_abilities: vec!["unarmed".into(), "fireball".into()],
17643            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
17644            max_abilities_per_rotation: 4,
17645            ability_id: "short_sword_slash".into(),
17646            ..CombatHud::default()
17647        };
17648        state.apply_combat_hud(&combat);
17649
17650        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
17651        assert_eq!(state.hotbar_ability(1), Some("fireball"));
17652        assert_eq!(state.hotbar_ability(2), None);
17653        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
17654        assert_eq!(state.max_abilities_per_rotation, 4);
17655        let choices = state.loadout_ability_choices();
17656        assert!(choices.iter().any(|a| a == "short_sword_slash"));
17657        assert!(choices.iter().any(|a| a == "fireball"));
17658    }
17659
17660    #[test]
17661    fn loadout_hotbar_choices_include_inventory_consumables() {
17662        let mut state = sample_state();
17663        state.known_abilities = vec!["unarmed".into()];
17664        state.weapon_ability_id = "unarmed".into();
17665        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17666            template_id: "bottle_of_water".into(),
17667            quantity: 3,
17668            item_instance_id: Some(uuid::Uuid::from_u128(9)),
17669            display_name: Some("Bottle of Water".into()),
17670            category: Some("consumable".into()),
17671            ..Default::default()
17672        }];
17673        state.inventory.insert("bottle_of_water".into(), 3);
17674        state.inventory_hints.insert(
17675            "bottle_of_water".into(),
17676            InventoryHint {
17677                display_name: "Bottle of Water".into(),
17678                category: "consumable".into(),
17679                ..Default::default()
17680            },
17681        );
17682
17683        let choices = state.loadout_hotbar_choices();
17684        assert!(choices.iter().any(|c| c.binding == "unarmed"));
17685        let water = choices
17686            .iter()
17687            .find(|c| c.binding == "item:bottle_of_water")
17688            .expect("water binding");
17689        assert_eq!(water.meta.as_deref(), Some("use"));
17690        assert!(water.label.contains("Water"));
17691        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
17692        state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
17693        assert_eq!(
17694            state.hotbar_slot_label(5).as_deref(),
17695            Some("Bottle of Water×3")
17696        );
17697    }
17698
17699    #[test]
17700    fn storage_store_options_excludes_hand_equipped() {
17701        let mut state = sample_state();
17702        let sword_id = uuid::Uuid::from_u128(11);
17703        let ore_id = uuid::Uuid::from_u128(22);
17704        state.inventory_stacks = vec![
17705            flatland_protocol::ItemStack {
17706                template_id: "short_sword".into(),
17707                quantity: 1,
17708                item_instance_id: Some(sword_id),
17709                display_name: Some("Short Sword".into()),
17710                category: Some("weapon".into()),
17711                ..Default::default()
17712            },
17713            flatland_protocol::ItemStack {
17714                template_id: "iron_ore".into(),
17715                quantity: 5,
17716                item_instance_id: Some(ore_id),
17717                display_name: Some("Iron Ore".into()),
17718                category: Some("resource".into()),
17719                ..Default::default()
17720            },
17721        ];
17722        state.mainhand_template_id = Some("short_sword".into());
17723        state.mainhand_instance_id = Some(sword_id);
17724
17725        let opts = state.storage_store_options();
17726        assert_eq!(opts.len(), 1);
17727        assert_eq!(opts[0].item_instance_id, ore_id);
17728        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
17729    }
17730
17731    #[test]
17732    fn loose_consumable_move_picker_offers_use_and_storage() {
17733        let mut state = sample_state();
17734        let inst = uuid::Uuid::from_u128(77);
17735        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17736            template_id: "carrot".into(),
17737            quantity: 2,
17738            item_instance_id: Some(inst),
17739            props: Default::default(),
17740            status_bindings: Vec::new(),
17741            contents: Vec::new(),
17742            display_name: Some("Wild Carrot".into()),
17743            category: Some("consumable".into()),
17744            base_mass: None,
17745            base_volume: None,
17746            capacity_volume: None,
17747            stackable: Some(true),
17748            world_placeable: None,
17749            worker_lodging_capacity: None,
17750            equip_slot: None,
17751            armor_physical: None,
17752            resists: vec![],
17753            hand_slots: None,
17754            listable: None,
17755            ..Default::default()
17756        }];
17757        state.inventory_hints.insert(
17758            "carrot".into(),
17759            InventoryHint {
17760                display_name: "Wild Carrot".into(),
17761                category: "consumable".into(),
17762                base_mass: Some(0.15),
17763                base_volume: Some(0.3),
17764                capacity_volume: None,
17765                stackable: true,
17766                listable: true,
17767                base_value_copper: None,
17768            },
17769        );
17770        state.show_inventory_menu = true;
17771        state.inventory_menu_index = 0;
17772
17773        let row = state.inventory_selected_row().expect("carrot row");
17774        let mut options = state.move_destinations_for(
17775            &row.from,
17776            row.from_parent_instance_id,
17777            row.stack.item_instance_id,
17778            &row.stack.template_id,
17779        );
17780        if row.from == flatland_protocol::InventoryLocation::Root
17781            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
17782        {
17783            options.insert(
17784                0,
17785                MoveOption {
17786                    label: "Use (eat / drink)".into(),
17787                    kind: MoveOptionKind::Use,
17788                },
17789            );
17790        }
17791
17792        assert_eq!(
17793            options.first().map(|o| &o.label),
17794            Some(&"Use (eat / drink)".into())
17795        );
17796        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
17797        assert!(options
17798            .iter()
17799            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
17800    }
17801
17802    #[test]
17803    fn inventory_category_group_order_is_stable() {
17804        assert_eq!(inventory_category_group("weapon").0, "Weapons");
17805        assert_eq!(inventory_category_group("armor").0, "Armor");
17806        assert_eq!(inventory_category_group("consumable").0, "Consumables");
17807        assert_eq!(inventory_category_group("resource").0, "Resources");
17808        assert_eq!(inventory_category_group("container").0, "Containers");
17809        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
17810        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
17811    }
17812
17813    #[test]
17814    fn page_list_index_clamps_without_wrap() {
17815        assert_eq!(page_list_index(0, -1, 25), 0);
17816        assert_eq!(page_list_index(0, 1, 25), 10);
17817        assert_eq!(page_list_index(12, 1, 25), 22);
17818        assert_eq!(page_list_index(22, 1, 25), 24);
17819        assert_eq!(page_list_index(5, 1, 0), 0);
17820        assert_eq!(page_list_index(3, -1, 8), 0);
17821    }
17822
17823    #[test]
17824    fn inventory_filter_hides_non_matching_person_items() {
17825        let mut state = sample_state();
17826        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17827        sword.display_name = Some("Iron Sword".into());
17828        sword.category = Some("weapon".into());
17829        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
17830        herb.display_name = Some("Wild Herb".into());
17831        herb.category = Some("consumable".into());
17832        state.inventory_stacks = vec![sword, herb];
17833        state.inventory_tab = InventoryTab::OnPerson;
17834        state.inventory_filter = "sword".into();
17835
17836        let rows = state.inventory_selectable_rows();
17837        assert_eq!(rows.len(), 1);
17838        assert_eq!(rows[0].stack.template_id, "iron_sword");
17839
17840        let lines = state.inventory_browser_lines();
17841        assert!(lines.iter().any(|l| matches!(
17842            l,
17843            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17844        )));
17845        assert!(!lines.iter().any(|l| matches!(
17846            l,
17847            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17848        )));
17849    }
17850
17851    #[test]
17852    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17853        let mut state = sample_state();
17854        let id_a = uuid::Uuid::from_u128(0xa1);
17855        let id_b = uuid::Uuid::from_u128(0xb2);
17856        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17857        sword_a.display_name = Some("Iron Sword".into());
17858        sword_a.category = Some("weapon".into());
17859        sword_a.item_instance_id = Some(id_a);
17860        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17861        sword_b.display_name = Some("Iron Sword".into());
17862        sword_b.category = Some("weapon".into());
17863        sword_b.item_instance_id = Some(id_b);
17864        state.inventory_stacks = vec![sword_a, sword_b];
17865        state.inventory_tab = InventoryTab::OnPerson;
17866
17867        let lines = state.inventory_browser_lines();
17868        let items: Vec<_> = lines
17869            .iter()
17870            .filter_map(|l| match l {
17871                InventoryBrowserLine::Item {
17872                    title,
17873                    instance_tooltip,
17874                    ..
17875                } => Some((title.clone(), instance_tooltip.clone())),
17876                _ => None,
17877            })
17878            .collect();
17879        assert_eq!(items.len(), 2);
17880        for (title, tip) in &items {
17881            assert!(
17882                !title.contains('#'),
17883                "title should not show instance suffix: {title}"
17884            );
17885            assert!(
17886                tip.is_some(),
17887                "two identical rows should expose instance on hover"
17888            );
17889        }
17890
17891        state.inventory_stacks.pop();
17892        let lines = state.inventory_browser_lines();
17893        let one = lines.iter().find_map(|l| match l {
17894            InventoryBrowserLine::Item {
17895                title,
17896                instance_tooltip,
17897                ..
17898            } => Some((title.clone(), instance_tooltip.clone())),
17899            _ => None,
17900        });
17901        let (title, tip) = one.expect("one sword row");
17902        assert!(!title.contains('#'));
17903        assert!(tip.is_none(), "single row should not need instance tooltip");
17904    }
17905
17906    #[test]
17907    fn inventory_person_rows_group_by_category() {
17908        let mut state = sample_state();
17909        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17910        sword.category = Some("weapon".into());
17911        sword.display_name = Some("Iron Sword".into());
17912        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17913        ore.category = Some("resource".into());
17914        ore.display_name = Some("Iron Ore".into());
17915        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17916        potion.category = Some("consumable".into());
17917        potion.display_name = Some("Health Potion".into());
17918        state.inventory_stacks = vec![ore, potion, sword];
17919        state.inventory_tab = InventoryTab::OnPerson;
17920
17921        let lines = state.inventory_browser_lines();
17922        let labels: Vec<&str> = lines
17923            .iter()
17924            .filter_map(|l| match l {
17925                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17926                _ => None,
17927            })
17928            .collect();
17929        assert!(
17930            labels.iter().any(|s| s.contains("Weapons")),
17931            "expected Weapons group: {labels:?}"
17932        );
17933        assert!(labels.iter().any(|s| s.contains("Consumables")));
17934        assert!(labels.iter().any(|s| s.contains("Resources")));
17935
17936        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17937        let consumable_pos = labels
17938            .iter()
17939            .position(|s| s.contains("Consumables"))
17940            .unwrap();
17941        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17942        assert!(weapon_pos < consumable_pos);
17943        assert!(consumable_pos < resource_pos);
17944    }
17945
17946    #[test]
17947    fn inventory_tab_cycle_resets_selection() {
17948        let mut state = sample_state();
17949        state.inventory_tab = InventoryTab::OnPerson;
17950        state.inventory_menu_index = 3;
17951        state.inventory_tab = state.inventory_tab.cycle(true);
17952        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17953        // Client method resets index; enum cycle alone does not — verify cycle labels.
17954        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17955        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17956        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17957        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17958    }
17959
17960    #[test]
17961    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17962        assert_eq!(parse_bank_copper_amount(""), Some(0));
17963        assert_eq!(parse_bank_copper_amount("  "), Some(0));
17964        assert_eq!(parse_bank_copper_amount("0"), Some(0));
17965        assert_eq!(parse_bank_copper_amount("250"), Some(250));
17966        assert_eq!(parse_bank_copper_amount("nope"), None);
17967    }
17968
17969    #[test]
17970    fn parse_storage_quantity_blank_and_zero_mean_all() {
17971        assert_eq!(parse_storage_quantity(""), Some(None));
17972        assert_eq!(parse_storage_quantity("  "), Some(None));
17973        assert_eq!(parse_storage_quantity("0"), Some(None));
17974        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17975        assert_eq!(parse_storage_quantity("nope"), None);
17976    }
17977
17978    #[test]
17979    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17980        assert!(worker_error_is_hud_noise("path stuck — repathing"));
17981        assert!(worker_error_is_hud_noise(
17982            "path stuck — nudged clear, repathing"
17983        ));
17984        assert!(worker_error_is_hud_noise(
17985            "returned to lodging after path failures"
17986        ));
17987        // Real problem: player may need to place lodging / fix assignment.
17988        assert!(!worker_error_is_hud_noise(
17989            "path stuck — no lodging to reset to"
17990        ));
17991    }
17992
17993    #[test]
17994    fn leaving_building_restores_outdoor_z_bands() {
17995        use flatland_protocol::{InteriorMapView, ZPlatformView};
17996
17997        let mut state = sample_state();
17998        state.z_platforms.clear();
17999        state.z_transitions.clear();
18000        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
18001        state.interior_map = Some(InteriorMapView {
18002            building_id: "broker_hut".into(),
18003            blueprint_id: "broker_hut".into(),
18004            background_color: "#000".into(),
18005            default_floor_color: None,
18006            floor_height_m: 3.0,
18007            z_platforms: vec![ZPlatformView {
18008                id: "floor_0".into(),
18009                z: 0.0,
18010                x0: 0.0,
18011                y0: 0.0,
18012                x1: 8.0,
18013                y1: 8.0,
18014            }],
18015            z_transitions: vec![],
18016            rooms: vec![],
18017            room_doors: vec![],
18018        });
18019        state.sync_interior_map_context();
18020        assert_eq!(
18021            state.z_platforms.len(),
18022            1,
18023            "indoors installs interior platforms"
18024        );
18025        assert!(state.z_bands_outdoor_backup.is_some());
18026
18027        state.player.as_mut().unwrap().inside_building = None;
18028        state.sync_interior_map_context();
18029        assert!(
18030            state.z_platforms.is_empty(),
18031            "leaving must restore outdoor bands (empty), not leave interior platforms"
18032        );
18033        assert!(state.z_bands_outdoor_backup.is_none());
18034        assert!(state.interior_map.is_none());
18035    }
18036
18037    #[test]
18038    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
18039        let node = ResourceNodeView {
18040            id: "crop-carrot-1_copy10".into(),
18041            label: "crop-carrot-1_copy10".into(),
18042            x: 0.0,
18043            y: 0.0,
18044            z: 0.0,
18045            item_template: "carrot".into(),
18046            state: ResourceNodeState::Available,
18047            blocking: false,
18048            blocking_radius_m: 0.5,
18049            harvest_off: false,
18050            tile_id: None,
18051            yaw: 0.0,
18052            pitch: 0.0,
18053            roll: 0.0,
18054            draw_scale: 1.0,
18055            sprite_mode: None,
18056            growth_progress: None,
18057            presentation_state: None,
18058            channel_start_tick: None,
18059            channel_end_tick: None,
18060            harvest_drop_templates: vec![],
18061        };
18062        let label = super::resource_node_route_label(&node);
18063        assert!(label.starts_with("Carrot ("), "got {label}");
18064        assert!(label.ends_with(')'), "got {label}");
18065
18066        let mut named = node;
18067        named.label = "Sweet Pad".into();
18068        named.id = "crop-carrot-a3f2b1c0".into();
18069        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
18070    }
18071
18072    #[test]
18073    fn plot_public_label_uses_owner_zone_and_label() {
18074        let plot = flatland_protocol::PropertyPlotView {
18075            plot_id: uuid::Uuid::nil(),
18076            property_zone_id: "zone_a".into(),
18077            zone_label: Some("Starter Town East 1".into()),
18078            deed_instance_id: uuid::Uuid::nil(),
18079            x0: 0.0,
18080            y0: 0.0,
18081            x1: 4.0,
18082            y1: 4.0,
18083            upkeep_copper_per_day: 1,
18084            arrears_days: 0,
18085            is_mine: true,
18086            may_farm: true,
18087            purchase_basis_copper: 0,
18088            farm_public: false,
18089            public_tax_discount_bps: 0,
18090            farm_allow: vec![],
18091            owner_character_id: None,
18092            owner_label: Some("Madsin".into()),
18093            building_id: None,
18094            plot_code: "xyz1234a".into(),
18095            label: "Food Pad".into(),
18096        };
18097        assert_eq!(
18098            super::plot_public_label(&plot),
18099            "Madsin — Starter Town East 1 — Food Pad"
18100        );
18101    }
18102}