Skip to main content

flatland_client_lib/
game.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5    AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6    CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7    LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8    ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15    #[default]
16    Character,
17    Ledger,
18    Career,
19}
20
21impl CharacterSheetTab {
22    pub fn cycle(self) -> Self {
23        match self {
24            Self::Character => Self::Ledger,
25            Self::Ledger => Self::Career,
26            Self::Career => Self::Character,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33    #[default]
34    Day,
35    Week,
36    Month,
37    Lifetime,
38}
39
40impl LedgerPeriod {
41    pub fn label(self) -> &'static str {
42        match self {
43            Self::Day => "Day",
44            Self::Week => "Week",
45            Self::Month => "Month",
46            Self::Lifetime => "All",
47        }
48    }
49
50    pub fn cycle(self) -> Self {
51        match self {
52            Self::Day => Self::Week,
53            Self::Week => Self::Month,
54            Self::Month => Self::Lifetime,
55            Self::Lifetime => Self::Day,
56        }
57    }
58
59    pub fn from_digit(c: char) -> Option<Self> {
60        match c {
61            '1' => Some(Self::Day),
62            '2' => Some(Self::Week),
63            '3' => Some(Self::Month),
64            '4' => Some(Self::Lifetime),
65            _ => None,
66        }
67    }
68}
69
70/// Matches `flatland_sim::containers` prop keys (client does not depend on sim).
71const KEY_TEMPLATE: &str = "container_key";
72const PROP_LOCK_ID: &str = "lock_id";
73const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
74const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
75const PROP_CUSTOM_NAME: &str = "custom_name";
76const PROP_LOCKED: &str = "locked";
77
78fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
79    stack
80        .props
81        .get(PROP_LOCKED)
82        .is_some_and(|v| v == "true" || v == "1")
83}
84
85const MAX_LOG_LINES: usize = 200;
86const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
87const INTERACTION_RADIUS_M: f32 = 1.5;
88const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
89const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
90const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
91/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
92const CRAFT_STAMINA_COST: f32 = 3.0;
93/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
94const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
95/// Keep the last worker route error visible in the HUD after the server clears it.
96const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
97
98/// Catalog hints synced from server `ItemStack` wire rows.
99#[derive(Debug, Clone, Default)]
100pub struct InventoryHint {
101    pub display_name: String,
102    pub category: String,
103    pub base_mass: Option<f32>,
104    pub base_volume: Option<f32>,
105    pub capacity_volume: Option<f32>,
106    pub stackable: bool,
107}
108
109/// Rotation editor overlay mode (`plans/26` §C2.5).
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum RotationEditorMode {
112    #[default]
113    List,
114    EditSequence,
115    PickAbility,
116    EditLabel,
117}
118
119/// Local rotation editor UI state (not persisted).
120#[derive(Debug, Clone, Default)]
121pub struct RotationEditorState {
122    pub mode: RotationEditorMode,
123    pub list_index: usize,
124    pub ability_index: usize,
125    pub picker_index: usize,
126    pub draft: Option<RotationPreset>,
127    pub label_buffer: String,
128}
129
130impl RotationEditorState {
131    pub fn reset(&mut self) {
132        *self = Self::default();
133    }
134}
135
136/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
137/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
138/// client only ever shows chests the server will actually let you use — this is
139/// what makes a chest disappear from the menu as soon as you walk away.
140pub const CONTAINER_RANGE_M: f32 = 3.0;
141
142/// Broad section of the inventory browser a row belongs to (drives the grouped
143/// "Worn" / "On you" / "Nearby chest" headers in the UI).
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum InventorySection {
146    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
147    Worn,
148    /// Loose on your person — not worn, not inside a placed chest.
149    Person,
150    /// Inside a placed chest within reach.
151    Nearby,
152}
153
154/// Top-level inventory browser tab (`b` menu).
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub enum InventoryTab {
157    #[default]
158    OnPerson,
159    Nearby,
160}
161
162impl InventoryTab {
163    pub fn label(self) -> &'static str {
164        match self {
165            Self::OnPerson => "On person",
166            Self::Nearby => "Nearby storage",
167        }
168    }
169
170    pub fn cycle(self, forward: bool) -> Self {
171        match (self, forward) {
172            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
173            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
174        }
175    }
176}
177
178/// Rows jumped by PageUp / PageDown in list UIs.
179pub const LIST_PAGE_SIZE: usize = 10;
180
181/// Case-insensitive substring match for list filters (name / template / label).
182pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
183    if filter.is_empty() {
184        return true;
185    }
186    haystack
187        .to_ascii_lowercase()
188        .contains(&filter.to_ascii_lowercase())
189}
190
191/// Clamp a list selection after a page jump (`pages` is typically ±1).
192pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
193    if len == 0 {
194        return 0;
195    }
196    let page = LIST_PAGE_SIZE as i32;
197    let next = index as i32 + pages * page;
198    next.clamp(0, (len as i32) - 1) as usize
199}
200
201/// Advance `index` by `delta` among indices where `pred` is true (wraps).
202pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
203    if len == 0 {
204        return 0;
205    }
206    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
207    if matching.is_empty() {
208        return index.min(len - 1);
209    }
210    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
211    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
212    matching[next]
213}
214
215/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
216pub fn page_filtered_index(
217    index: usize,
218    pages: i32,
219    len: usize,
220    pred: impl Fn(usize) -> bool,
221) -> usize {
222    if len == 0 {
223        return 0;
224    }
225    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
226    if matching.is_empty() {
227        return index.min(len - 1);
228    }
229    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
230    let next = page_list_index(pos, pages, matching.len());
231    matching[next]
232}
233
234/// Category group for On-person loose items (stable order).
235pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
236    match category {
237        "weapon" | "ammo" => ("Weapons", 0),
238        "armor" | "shield" | "offhand" => ("Armor", 1),
239        "consumable" => ("Consumables", 2),
240        "resource" | "harvest_node" => ("Resources", 3),
241        "container" | "lodging" => ("Containers", 4),
242        "currency" | "key" => ("Currency & keys", 5),
243        "tool" | "misc" | "furniture" | "quest" => ("Gear & misc", 6),
244        _ => ("Other", 7),
245    }
246}
247
248/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
249fn parse_bank_copper_amount(input: &str) -> Option<u64> {
250    let s = input.trim();
251    if s.is_empty() {
252        return Some(0);
253    }
254    s.parse::<u64>().ok()
255}
256
257/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
258fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
259    let s = input.trim();
260    if s.is_empty() || s == "0" {
261        return Some(None);
262    }
263    let n = s.parse::<u32>().ok()?;
264    if n == 0 {
265        return Some(None);
266    }
267    Some(Some(n))
268}
269
270fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
271    let name = stack
272        .display_name
273        .as_deref()
274        .unwrap_or(stack.template_id.as_str());
275    if stack.quantity > 1 {
276        format!("{name} ×{}", stack.quantity)
277    } else {
278        name.to_string()
279    }
280}
281
282/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
283/// browser section headers and the move-destination picker.
284pub fn body_slot_label(slot: BodySlot) -> &'static str {
285    match slot {
286        BodySlot::Head => "Head",
287        BodySlot::Chest => "Chest",
288        BodySlot::Forearms => "Forearms",
289        BodySlot::Legs => "Legs",
290        BodySlot::Feet => "Feet",
291        BodySlot::Cloak => "Cloak",
292        BodySlot::Back => "Back",
293        BodySlot::Waist => "Waist",
294        BodySlot::Earrings => "Earrings",
295        BodySlot::Necklace => "Necklace",
296        BodySlot::Eyeglasses => "Eyeglasses",
297        BodySlot::RingLeft1 => "Ring L1",
298        BodySlot::RingLeft2 => "Ring L2",
299        BodySlot::RingRight1 => "Ring R1",
300        BodySlot::RingRight2 => "Ring R2",
301    }
302}
303
304fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
305    let cat = stack.category.as_deref().unwrap_or("");
306    match mode {
307        "while_equipped" => {
308            stack.equip_slot.is_some()
309                || cat == "weapon"
310                || cat == "shield"
311                || cat == "offhand"
312                || cat == "armor"
313        }
314        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
315    }
316}
317
318fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
319    if grant_tags.is_empty() {
320        return true;
321    }
322    let target_tags: Vec<&str> = stack
323        .props
324        .get("allowed_enchant_tags")
325        .map(|s| {
326            s.split(',')
327                .map(str::trim)
328                .filter(|t| !t.is_empty())
329                .collect()
330        })
331        .unwrap_or_default();
332    if target_tags.is_empty() {
333        return true;
334    }
335    grant_tags.iter().any(|t| target_tags.contains(t))
336}
337
338/// Default sim tick rate when the client has no settings (matches server-settings).
339pub const DEFAULT_TICK_HZ: u32 = 30;
340
341/// Human-readable remaining time for an item status binding.
342pub fn format_binding_ttl(
343    binding: &flatland_protocol::ItemStatusBinding,
344    tick: u64,
345    tick_hz: u32,
346) -> String {
347    let Some(expires) = binding.expires_at_tick else {
348        return "permanent".into();
349    };
350    let hz = tick_hz.max(1) as f32;
351    let remaining = expires.saturating_sub(tick) as f32 / hz;
352    if remaining <= 0.0 {
353        return "expired".into();
354    }
355    if remaining >= 120.0 {
356        format!("{:.0}m left", remaining / 60.0)
357    } else if remaining >= 10.0 {
358        format!("{remaining:.0}s left")
359    } else {
360        format!("{remaining:.1}s left")
361    }
362}
363
364pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
365    match mode {
366        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
367        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
368    }
369}
370
371/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
372pub fn format_status_bindings_suffix(
373    bindings: &[flatland_protocol::ItemStatusBinding],
374    tick: u64,
375    tick_hz: u32,
376) -> String {
377    if bindings.is_empty() {
378        return String::new();
379    }
380    let parts: Vec<String> = bindings
381        .iter()
382        .map(|b| {
383            format!(
384                "{} ({}, {})",
385                b.effect_id,
386                format_binding_mode(b.mode),
387                format_binding_ttl(b, tick, tick_hz)
388            )
389        })
390        .collect();
391    format!(" · {}", parts.join("; "))
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum EquipPaperdollRow {
396    Body { slot: BodySlot, filled: bool },
397    Mainhand { filled: bool },
398    Offhand { filled: bool, locked: bool },
399}
400
401pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
402    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
403        .iter()
404        .map(|slot| EquipPaperdollRow::Body {
405            slot: *slot,
406            filled: state.worn.contains_key(slot),
407        })
408        .collect();
409    let two_hand = state.mainhand_hand_slots >= 2;
410    rows.push(EquipPaperdollRow::Mainhand {
411        filled: state.mainhand_template_id.is_some(),
412    });
413    rows.push(EquipPaperdollRow::Offhand {
414        filled: state.offhand_template_id.is_some(),
415        locked: two_hand,
416    });
417    rows
418}
419
420fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
421    for stack in &state.inventory_stacks {
422        let matches = stack
423            .equip_slot
424            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
425            .unwrap_or(false)
426            || guess_body_slot(&stack.template_id) == Some(slot);
427        if matches {
428            return stack.item_instance_id;
429        }
430    }
431    None
432}
433
434fn is_client_ring(slot: BodySlot) -> bool {
435    matches!(
436        slot,
437        BodySlot::RingLeft1
438            | BodySlot::RingLeft2
439            | BodySlot::RingRight1
440            | BodySlot::RingRight2
441    )
442}
443
444fn first_inventory_weapon(state: &GameState) -> Option<String> {
445    for stack in &state.inventory_stacks {
446        if stack.category.as_deref() == Some("weapon") {
447            return Some(stack.template_id.clone());
448        }
449    }
450    None
451}
452
453fn first_inventory_offhand(state: &GameState) -> Option<String> {
454    for stack in &state.inventory_stacks {
455        let cat = stack.category.as_deref().unwrap_or("");
456        if matches!(cat, "shield" | "offhand") {
457            return Some(stack.template_id.clone());
458        }
459    }
460    None
461}
462
463/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
464/// on the stack when present; otherwise guesses from `template_id`.
465fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
466    if template_id.contains("backpack") {
467        Some(BodySlot::Back)
468    } else if template_id.contains("belt") {
469        Some(BodySlot::Waist)
470    } else if template_id.contains("cloak") || template_id.contains("cape") {
471        Some(BodySlot::Cloak)
472    } else if template_id.contains("cap")
473        || template_id.contains("hat")
474        || template_id.contains("helm")
475    {
476        Some(BodySlot::Head)
477    } else if template_id.contains("shirt")
478        || template_id.contains("robe")
479        || template_id.contains("vest")
480        || template_id.contains("chest")
481        || template_id.contains("jerkin")
482    {
483        Some(BodySlot::Chest)
484    } else if template_id.contains("sleeves")
485        || template_id.contains("gloves")
486        || template_id.contains("gauntlets")
487    {
488        Some(BodySlot::Forearms)
489    } else if template_id.contains("pants") || template_id.contains("leggings") {
490        Some(BodySlot::Legs)
491    } else if template_id.contains("boots") || template_id.contains("shoes") {
492        Some(BodySlot::Feet)
493    } else if template_id.contains("earring") {
494        Some(BodySlot::Earrings)
495    } else if template_id.contains("necklace") || template_id.contains("amulet") {
496        Some(BodySlot::Necklace)
497    } else if template_id.contains("glass")
498        || template_id.contains("spectacles")
499        || template_id.contains("goggles")
500    {
501        Some(BodySlot::Eyeglasses)
502    } else if template_id.contains("ring") {
503        Some(BodySlot::RingLeft1)
504    } else {
505        None
506    }
507}
508
509/// One row in the inventory browser tree.
510#[derive(Debug, Clone)]
511pub struct InventoryRow {
512    pub depth: usize,
513    pub stack: flatland_protocol::ItemStack,
514    /// `MoveItem` source location for this stack.
515    pub from: flatland_protocol::InventoryLocation,
516    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
517    pub from_parent_instance_id: Option<uuid::Uuid>,
518    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
519    pub is_equip_shell: bool,
520    /// Placed world chest shell — lock/unlock via Enter or `l`.
521    pub is_chest_shell: bool,
522    pub section: InventorySection,
523}
524
525/// Formatted inventory row text shared by TUI and gfx browsers.
526#[derive(Debug, Clone)]
527pub struct InventoryRowView {
528    pub depth: usize,
529    /// Dense single-line label (legacy / TUI).
530    pub text: String,
531    /// Primary label for redesigned gfx rows (name, qty, optional #id / slot).
532    pub title: String,
533    pub mass_kg: Option<f32>,
534    pub volume: Option<(f32, f32)>,
535}
536
537/// One line in the sectioned inventory browser (headers are non-selectable).
538#[derive(Debug, Clone)]
539pub enum InventoryBrowserLine {
540    Section(String),
541    SlotLabel(String),
542    Hint(String),
543    Blank,
544    Item {
545        selectable_index: usize,
546        selected: bool,
547        depth: usize,
548        text: String,
549        title: String,
550        mass_kg: Option<f32>,
551        volume: Option<(f32, f32)>,
552    },
553}
554
555/// Bank teller panel focus (action list vs amount / transfer prompts).
556#[derive(Debug, Clone, PartialEq, Eq, Default)]
557pub enum BankUiMode {
558    #[default]
559    Menu,
560    DepositAmount {
561        input: String,
562    },
563    WithdrawAmount {
564        input: String,
565    },
566    TransferName {
567        input: String,
568    },
569    TransferAmount {
570        to_name: String,
571        input: String,
572    },
573}
574
575/// Town storage manager focus (action list vs item pickers vs quantity).
576#[derive(Debug, Clone, PartialEq, Eq, Default)]
577pub enum StorageUiMode {
578    #[default]
579    Menu,
580    /// Pick a loose on-person stack to store.
581    StorePick {
582        index: usize,
583    },
584    /// Quantity for a chosen store stack (blank/0 = all).
585    StoreAmount {
586        pick_index: usize,
587        item_instance_id: uuid::Uuid,
588        label: String,
589        max_qty: u32,
590        input: String,
591    },
592    /// Pick a vault stack to take.
593    TakePick {
594        index: usize,
595    },
596    /// Quantity for a chosen take stack (blank/0 = all).
597    TakeAmount {
598        pick_index: usize,
599        item_instance_id: uuid::Uuid,
600        label: String,
601        max_qty: u32,
602        input: String,
603    },
604    /// Pick a vault stack to ship to `dest_building_id`.
605    ShipPick {
606        dest_building_id: String,
607        dest_label: String,
608        index: usize,
609    },
610    /// Quantity for a chosen ship stack (blank/0 = all).
611    ShipAmount {
612        dest_building_id: String,
613        dest_label: String,
614        pick_index: usize,
615        item_instance_id: uuid::Uuid,
616        label: String,
617        max_qty: u32,
618        input: String,
619    },
620}
621
622/// One selectable stack in a storage store/take/ship picker.
623#[derive(Debug, Clone)]
624pub struct StoragePickOption {
625    pub item_instance_id: uuid::Uuid,
626    pub label: String,
627    pub quantity: u32,
628}
629
630/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
631/// the browser (empty when locked without the matching key).
632#[derive(Debug, Clone)]
633pub struct NearbyContainer {
634    pub view: flatland_protocol::PlacedContainerView,
635    pub distance_m: f32,
636    pub rows: Vec<InventoryRow>,
637}
638
639/// One key row in the keychain overlay (carried vs stowed).
640#[derive(Debug, Clone)]
641pub struct KeychainEntry {
642    pub stack: flatland_protocol::ItemStack,
643    pub stowed: bool,
644}
645
646/// A destination the currently-picked item could be moved to.
647#[derive(Debug, Clone)]
648pub struct MoveOption {
649    pub label: String,
650    pub kind: MoveOptionKind,
651}
652
653#[derive(Debug, Clone, PartialEq)]
654pub enum MoveOptionKind {
655    Move {
656        location: flatland_protocol::InventoryLocation,
657        parent_instance_id: Option<uuid::Uuid>,
658    },
659    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
660    PickupPlaced {
661        container_id: String,
662        nest_location: flatland_protocol::InventoryLocation,
663        nest_parent_instance_id: Option<uuid::Uuid>,
664    },
665    /// Eat/drink a loose consumable (one unit per use).
666    Use,
667    /// Open grant-target picker for `grants_item_status` consumables.
668    GrantApply,
669    Drop,
670    Cancel,
671}
672
673/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
674#[derive(Debug, Clone)]
675pub struct GrantTargetPicker {
676    pub grant_instance_id: uuid::Uuid,
677    pub grant_label: String,
678    pub effect_id: String,
679    pub mode: String,
680    pub options: Vec<GrantTargetOption>,
681    pub filter: String,
682    pub filter_focused: bool,
683}
684
685#[derive(Debug, Clone)]
686pub struct GrantTargetOption {
687    pub label: String,
688    pub target_instance_id: uuid::Uuid,
689}
690
691/// Active "move to…" destination picker state for the selected inventory item.
692#[derive(Debug, Clone)]
693pub struct MovePicker {
694    pub item_instance_id: uuid::Uuid,
695    pub from: flatland_protocol::InventoryLocation,
696    pub item_label: String,
697    pub template_id: String,
698    pub stack_quantity: u32,
699    pub quantity: u32,
700    pub options: Vec<MoveOption>,
701    pub filter: String,
702    pub filter_focused: bool,
703}
704
705/// Active permanent-delete picker for the selected inventory item.
706#[derive(Debug, Clone)]
707pub struct DestroyPicker {
708    pub item_instance_id: uuid::Uuid,
709    pub from: flatland_protocol::InventoryLocation,
710    pub item_label: String,
711    pub stack_quantity: u32,
712    pub quantity: u32,
713}
714
715/// One giveable stack in the workers-menu give picker.
716#[derive(Debug, Clone)]
717pub struct WorkerGiveOption {
718    pub item_instance_id: uuid::Uuid,
719    pub label: String,
720    pub quantity: u32,
721    pub template_id: String,
722}
723
724/// Give an on-person inventory stack to the selected hired worker.
725#[derive(Debug, Clone)]
726pub struct WorkerGivePicker {
727    pub worker_instance_id: String,
728    pub worker_label: String,
729    pub options: Vec<WorkerGiveOption>,
730}
731
732/// Nearby hired worker choice when giving a selected inventory stack (`g`).
733#[derive(Debug, Clone)]
734pub struct WorkerGiveTargetOption {
735    pub instance_id: String,
736    pub label: String,
737    pub distance_m: f32,
738}
739
740/// Pick which nearby worker receives the selected inventory item.
741#[derive(Debug, Clone)]
742pub struct WorkerGiveTargetPicker {
743    pub item_instance_id: uuid::Uuid,
744    pub item_label: String,
745    pub quantity: Option<u32>,
746    pub options: Vec<WorkerGiveTargetOption>,
747}
748
749/// Take an item from a hired worker back into the employer's inventory.
750#[derive(Debug, Clone)]
751pub struct WorkerTakePicker {
752    pub worker_instance_id: String,
753    pub worker_label: String,
754    pub options: Vec<WorkerGiveOption>,
755}
756
757/// Max distance (m) to hand an item to a hired worker.
758pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
759
760/// One teachable blueprint in the workers-menu teach picker.
761#[derive(Debug, Clone)]
762pub struct WorkerTeachOption {
763    pub blueprint_id: String,
764    pub label: String,
765    pub cost_copper: u64,
766    pub min_level: u32,
767    pub worker_level: u32,
768    pub can_afford: bool,
769    pub level_ok: bool,
770}
771
772/// Teach a known blueprint to the selected hired worker.
773#[derive(Debug, Clone)]
774pub struct WorkerTeachPicker {
775    pub worker_instance_id: String,
776    pub worker_label: String,
777    pub worker_level: u32,
778    pub options: Vec<WorkerTeachOption>,
779}
780
781/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
782/// do not thrash the UI.
783#[derive(Debug, Clone, Default)]
784pub struct StickyWorkerStep {
785    shown: String,
786    pending: String,
787    pending_since: Option<Instant>,
788}
789
790impl StickyWorkerStep {
791    fn from_label(label: String) -> Self {
792        Self {
793            shown: label.clone(),
794            pending: label,
795            pending_since: Some(Instant::now()),
796        }
797    }
798
799    fn observe(&mut self, label: &str, now: Instant) {
800        let pending_since = self.pending_since.unwrap_or(now);
801        if label == self.pending {
802            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
803            {
804                self.shown = self.pending.clone();
805            }
806            return;
807        }
808        self.pending = label.to_string();
809        self.pending_since = Some(now);
810        // Empty → first value, or first observation: show immediately.
811        if self.shown.is_empty() {
812            self.shown = self.pending.clone();
813        }
814    }
815}
816
817/// Last non-transient worker error — held so route flicker stays readable.
818/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
819#[derive(Debug, Clone, Default)]
820pub struct StickyWorkerError {
821    message: String,
822    last_seen: Option<Instant>,
823}
824
825impl StickyWorkerError {
826    fn observe(&mut self, err: Option<&str>, now: Instant) {
827        if let Some(e) = err {
828            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
829                self.message = e.to_string();
830                self.last_seen = Some(now);
831            }
832            return;
833        }
834        if let Some(seen) = self.last_seen {
835            if now.duration_since(seen) > WORKER_ERROR_HOLD {
836                self.message.clear();
837                self.last_seen = None;
838            }
839        }
840    }
841
842    pub fn shown(&self, now: Instant) -> Option<&str> {
843        if self.message.is_empty() {
844            return None;
845        }
846        let seen = self.last_seen?;
847        if now.duration_since(seen) > WORKER_ERROR_HOLD {
848            return None;
849        }
850        Some(self.message.as_str())
851    }
852}
853
854/// First non-transient worker issue for the status bar (strike, route error, etc.).
855/// Routine recovery noise (path failures → lodging) stays in the game log only.
856pub fn worker_attention_line(state: &GameState) -> Option<String> {
857    use flatland_protocol::WorkerStateView;
858    let now = Instant::now();
859    for w in &state.hired_workers {
860        if matches!(w.state, WorkerStateView::Strike) {
861            return Some(format!(
862                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
863                w.label
864            ));
865        }
866        if let Some(err) = state
867            .worker_error_display
868            .get(&w.instance_id)
869            .and_then(|s| s.shown(now))
870        {
871            if !worker_error_is_hud_noise(err) {
872                return Some(format!("Worker {}: {err}", w.label));
873            }
874        }
875        if let Some(err) = &w.last_error {
876            if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
877                return Some(format!("Worker {}: {err}", w.label));
878            }
879        }
880    }
881    None
882}
883
884/// True when a worker `last_error` is transient noise (soft-skip chatter).
885pub fn worker_error_is_transient(err: &str) -> bool {
886    let e = err.to_ascii_lowercase();
887    e.contains("continuing route")
888        || e.contains("storage full")
889        || e.starts_with("nothing to withdraw")
890}
891
892/// True for routine worker recoveries that should not paint the status bar.
893/// Still logged when they change (see [`GameState::apply_hired_workers`]).
894pub fn worker_error_is_hud_noise(err: &str) -> bool {
895    let e = err.to_ascii_lowercase();
896    e.contains("returned to lodging after path")
897        || e.contains("path failure")
898        || e.contains("no path to")
899        || e.contains("pathfinding")
900}
901
902/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
903#[derive(Debug, Clone)]
904pub struct PendingWorkerJobAck {
905    pub seq: u32,
906    pub worker_instance_id: String,
907    pub worker_label: String,
908    pub idle: bool,
909    pub stop_count: usize,
910    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
911    pub prev_mode: flatland_protocol::WorkerModeView,
912    pub prev_step_label: String,
913    pub prev_last_error: Option<String>,
914}
915
916fn push_inventory_rows(
917    rows: &mut Vec<InventoryRow>,
918    depth: usize,
919    stack: &flatland_protocol::ItemStack,
920    from: &flatland_protocol::InventoryLocation,
921    from_parent_instance_id: Option<uuid::Uuid>,
922    section: InventorySection,
923) {
924    push_inventory_rows_filtered(
925        rows,
926        depth,
927        stack,
928        from,
929        from_parent_instance_id,
930        section,
931        "",
932    );
933}
934
935fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
936    if filter.is_empty() {
937        return true;
938    }
939    let f = filter.to_ascii_lowercase();
940    let name = stack
941        .display_name
942        .as_deref()
943        .unwrap_or("")
944        .to_ascii_lowercase();
945    let tid = stack.template_id.to_ascii_lowercase();
946    name.contains(&f)
947        || tid.contains(&f)
948        || stack
949            .contents
950            .iter()
951            .any(|c| stack_matches_filter(c, filter))
952}
953
954fn push_inventory_rows_filtered(
955    rows: &mut Vec<InventoryRow>,
956    depth: usize,
957    stack: &flatland_protocol::ItemStack,
958    from: &flatland_protocol::InventoryLocation,
959    from_parent_instance_id: Option<uuid::Uuid>,
960    section: InventorySection,
961    filter: &str,
962) {
963    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
964        return;
965    }
966    let self_hit = filter.is_empty() || {
967        let f = filter.to_ascii_lowercase();
968        let name = stack
969            .display_name
970            .as_deref()
971            .unwrap_or("")
972            .to_ascii_lowercase();
973        let tid = stack.template_id.to_ascii_lowercase();
974        name.contains(&f) || tid.contains(&f)
975    };
976    rows.push(InventoryRow {
977        depth,
978        stack: stack.clone(),
979        from: from.clone(),
980        from_parent_instance_id,
981        is_equip_shell: false,
982        is_chest_shell: false,
983        section,
984    });
985    for child in &stack.contents {
986        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
987            push_inventory_rows_filtered(
988                rows,
989                depth + 1,
990                child,
991                from,
992                stack.item_instance_id,
993                section,
994                if self_hit { "" } else { filter },
995            );
996        }
997    }
998}
999
1000#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1001pub enum ShopTab {
1002    #[default]
1003    Buy,
1004    Sell,
1005}
1006
1007#[derive(Debug, Clone)]
1008pub struct NpcChatState {
1009    pub npc_id: String,
1010    pub npc_label: String,
1011    pub lines: Vec<String>,
1012    pub input: String,
1013    pub pending: bool,
1014    pub talk_depth: flatland_protocol::NpcTalkDepth,
1015    pub trade_allowed: bool,
1016    pub banner: Option<String>,
1017}
1018
1019impl Default for NpcChatState {
1020    fn default() -> Self {
1021        Self {
1022            npc_id: String::new(),
1023            npc_label: String::new(),
1024            lines: Vec::new(),
1025            input: String::new(),
1026            pending: false,
1027            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1028            trade_allowed: true,
1029            banner: None,
1030        }
1031    }
1032}
1033
1034#[derive(Debug, Clone)]
1035pub struct GameState {
1036    pub session_id: SessionId,
1037    pub entity_id: EntityId,
1038    /// Logged-in character — used to show owner-only container labels.
1039    pub character_id: Option<uuid::Uuid>,
1040    pub tick: Tick,
1041    pub chunk_rev: u64,
1042    pub content_rev: u64,
1043    pub publish_rev: u64,
1044    pub entities: Vec<EntityState>,
1045    pub player: Option<EntityState>,
1046    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1047    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1048    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1049    pub buildings: Vec<BuildingView>,
1050    pub doors: Vec<DoorView>,
1051    pub interior_map: Option<InteriorMapView>,
1052    pub npcs: Vec<NpcView>,
1053    pub blueprints: Vec<BlueprintView>,
1054    pub world_width_m: f32,
1055    pub world_height_m: f32,
1056    pub terrain_zones: Vec<TerrainZoneView>,
1057    pub z_platforms: Vec<ZPlatformView>,
1058    pub z_transitions: Vec<ZTransitionView>,
1059    pub world_clock: flatland_protocol::WorldClock,
1060    pub inventory: std::collections::HashMap<String, u32>,
1061    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1062    pub logs: VecDeque<String>,
1063    pub intents_sent: u64,
1064    pub ticks_received: u64,
1065    pub connected: bool,
1066    pub disconnect_reason: Option<String>,
1067    pub show_stats: bool,
1068    pub show_equip_menu: bool,
1069    pub equip_menu_index: usize,
1070    pub show_craft_menu: bool,
1071    pub craft_menu_index: usize,
1072    /// How many timed crafts to queue when confirming the craft menu.
1073    pub craft_batch_quantity: u32,
1074    pub show_shop_menu: bool,
1075    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1076    pub bank_panel: Option<flatland_protocol::BankPanel>,
1077    pub bank_menu_index: usize,
1078    pub bank_ui_mode: BankUiMode,
1079    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1080    pub storage_menu_index: usize,
1081    pub storage_ui_mode: StorageUiMode,
1082    pub shop_tab: ShopTab,
1083    pub shop_menu_index: usize,
1084    pub shop_quantity: u32,
1085    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1086    pub shop_trade_log: VecDeque<String>,
1087    pub show_npc_verb_menu: bool,
1088    pub npc_verb_target: Option<String>,
1089    pub npc_verb_index: usize,
1090    pub show_npc_chat: bool,
1091    pub npc_chat: Option<NpcChatState>,
1092    pub show_inventory_menu: bool,
1093    pub inventory_menu_index: usize,
1094    pub inventory_tab: InventoryTab,
1095    pub inventory_filter: String,
1096    pub inventory_filter_focused: bool,
1097    pub show_move_picker: bool,
1098    pub move_picker_index: usize,
1099    pub move_picker: Option<MovePicker>,
1100    pub show_grant_picker: bool,
1101    pub grant_picker_index: usize,
1102    pub grant_picker: Option<GrantTargetPicker>,
1103    pub show_destroy_picker: bool,
1104    pub destroy_confirm_pending: bool,
1105    pub destroy_picker: Option<DestroyPicker>,
1106    /// Rename prompt for a selected container (`n` in inventory).
1107    pub show_rename_prompt: bool,
1108    /// Rename prompt for a hired worker (`n` in workers menu).
1109    pub show_worker_rename: bool,
1110    pub rename_buffer: String,
1111    /// Slot-1 combat target (mirrors server after SetTarget).
1112    pub combat_target: Option<EntityId>,
1113    pub combat_target_label: Option<String>,
1114    /// Active combat footprints from the server (`plans/39`).
1115    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1116    pub in_combat: bool,
1117    pub auto_attack: bool,
1118    pub combat_has_los: bool,
1119    pub attack_cd_ticks: u64,
1120    pub gcd_ticks: u64,
1121    pub weapon_ability_id: String,
1122    pub mainhand_template_id: Option<String>,
1123    pub mainhand_label: Option<String>,
1124    pub offhand_template_id: Option<String>,
1125    pub offhand_label: Option<String>,
1126    pub mainhand_hand_slots: u8,
1127    pub defense: Option<flatland_protocol::DefenseHud>,
1128    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1129    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1130    pub carry_mass: f32,
1131    pub carry_mass_max: f32,
1132    pub encumbrance: flatland_protocol::EncumbranceState,
1133    /// Full nested inventory stacks from the server (root only; worn are separate).
1134    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1135    /// Keys stowed on the virtual keychain (zero carry mass).
1136    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1137    /// Active status effects on the local player (buff/debuff strip).
1138    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1139    pub combat_target_detail: Option<CombatTargetHud>,
1140    pub cast_progress: Option<CastProgressHud>,
1141    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1142    pub blocking_active: bool,
1143    pub max_target_slots: u8,
1144    pub combat_slots: Vec<CombatSlotHud>,
1145    pub rotation_presets: Vec<RotationPreset>,
1146    /// Learned abilities from combat HUD (usable casts).
1147    pub known_abilities: Vec<String>,
1148    /// Server-persisted hotbar bindings (index 0 = key 1).
1149    pub hotbar: Vec<Option<String>>,
1150    /// Max abilities allowed in one rotation (mind score).
1151    pub max_abilities_per_rotation: u8,
1152    pub show_loadout_menu: bool,
1153    pub show_keychain_menu: bool,
1154    pub keychain_menu_index: usize,
1155    pub show_rotation_editor: bool,
1156    /// Selected rotation preset in the loadout menu.
1157    pub loadout_menu_index: usize,
1158    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1159    pub loadout_hotbar_slot: u8,
1160    /// Selected known-ability row in the loadout menu.
1161    pub loadout_ability_index: usize,
1162    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1163    pub loadout_focus_presets: bool,
1164    pub rotation_editor: RotationEditorState,
1165    /// True after a harvest intent is accepted until result/reject/disconnect.
1166    pub harvest_in_progress: bool,
1167    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1168    pub harvest_started_at: Option<Instant>,
1169    /// Craft log deferred until the server acks the craft intent.
1170    pub pending_craft_ack: Option<(u32, String, u32)>,
1171    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1172    pub interactables: Vec<flatland_protocol::InteractableView>,
1173    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1174    pub career: Option<flatland_protocol::PlayerCareerView>,
1175    pub character_sheet_tab: CharacterSheetTab,
1176    pub ledger_period: LedgerPeriod,
1177    pub show_quest_offer: bool,
1178    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1179    pub show_quest_menu: bool,
1180    pub quest_menu_index: usize,
1181    pub quest_withdraw_confirm: bool,
1182    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1183    pub show_workers_menu: bool,
1184    pub workers_menu_index: usize,
1185    /// Workers panel: one-line rows instead of full cards (more on screen).
1186    pub workers_menu_compact: bool,
1187    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1188    /// Public so out-of-crate tests can construct [`GameState`].
1189    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1190    /// Held worker route errors (readable when server clears `last_error` each tick).
1191    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1192    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1193    pub show_worker_give_picker: bool,
1194    pub worker_give_picker_index: usize,
1195    pub worker_give_picker: Option<WorkerGivePicker>,
1196    /// Inventory `g` — pick which nearby worker receives the selected stack.
1197    pub show_worker_give_target_picker: bool,
1198    pub worker_give_target_picker_index: usize,
1199    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1200    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1201    pub show_worker_take_picker: bool,
1202    pub worker_take_picker_index: usize,
1203    pub worker_take_picker: Option<WorkerTakePicker>,
1204    /// Teach-blueprint sheet opened from the workers menu (`t`).
1205    pub show_worker_teach_picker: bool,
1206    pub worker_teach_picker_index: usize,
1207    pub worker_teach_picker: Option<WorkerTeachPicker>,
1208    /// Active harvest-route editor (`h` → `e` on a worker).
1209    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1210    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1211    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1212    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1213    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1214}
1215
1216impl GameState {
1217    pub fn push_log(&mut self, line: impl Into<String>) {
1218        self.logs.push_back(line.into());
1219        while self.logs.len() > MAX_LOG_LINES {
1220            self.logs.pop_front();
1221        }
1222    }
1223
1224    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1225        self.shop_trade_log.push_back(line.into());
1226        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1227            self.shop_trade_log.pop_front();
1228        }
1229    }
1230
1231    pub fn clear_shop_trade_log(&mut self) {
1232        self.shop_trade_log.clear();
1233    }
1234
1235    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1236        if !self.show_shop_menu {
1237            return;
1238        }
1239        let msg = notice.message.trim();
1240        if msg.is_empty() {
1241            return;
1242        }
1243        if notice.coins_delta != 0
1244            || msg.starts_with("Bought ")
1245            || msg.starts_with("Sold ")
1246            || msg.contains("taught you how to craft")
1247            || msg.starts_with("need ")
1248        {
1249            self.push_shop_trade_log(msg);
1250        }
1251    }
1252
1253    pub fn is_alive(&self) -> bool {
1254        self.player
1255            .as_ref()
1256            .and_then(|p| p.vitals)
1257            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1258            .unwrap_or(true)
1259    }
1260
1261    /// Verb menu entries for the current `npc_verb_target`.
1262    pub fn npc_verb_options(&self) -> Vec<&'static str> {
1263        let Some(ref id) = self.npc_verb_target else {
1264            return vec![];
1265        };
1266        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1267            return vec!["Talk"];
1268        };
1269        let role = npc.role.as_str();
1270        if Self::npc_role_is_bank(role) {
1271            return vec!["Bank", "Talk"];
1272        }
1273        if Self::npc_role_is_storage(role) {
1274            return vec!["Storage", "Talk"];
1275        }
1276        if npc.can_trade || Self::npc_role_can_trade(role) {
1277            vec!["Talk", "Trade"]
1278        } else {
1279            vec!["Talk"]
1280        }
1281    }
1282
1283    fn npc_role_can_trade(role: &str) -> bool {
1284        matches!(role, "broker" | "cook" | "farmer" | "merchant")
1285    }
1286
1287    fn npc_role_is_bank(role: &str) -> bool {
1288        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1289    }
1290
1291    fn npc_role_is_storage(role: &str) -> bool {
1292        role.eq_ignore_ascii_case("storage_manager")
1293    }
1294
1295    pub fn bank_menu_options(&self) -> Vec<&'static str> {
1296        vec![
1297            "Deposit…",
1298            "Withdraw…",
1299            "Deposit all",
1300            "Withdraw all",
1301            "Transfer…",
1302        ]
1303    }
1304
1305    pub fn storage_menu_options(&self) -> Vec<String> {
1306        let mut opts = vec!["Store…".into(), "Take…".into()];
1307        if let Some(panel) = &self.storage_panel {
1308            for dest in &panel.ship_destinations {
1309                opts.push(format!(
1310                    "Ship → {} ({} cp / {} ticks)",
1311                    dest.label, dest.fee_copper, dest.travel_ticks
1312                ));
1313            }
1314        }
1315        opts
1316    }
1317
1318    /// Loose on-person stacks eligible for town-storage store.
1319    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1320        self.person_rows()
1321            .into_iter()
1322            .filter(|r| r.depth == 0)
1323            .filter_map(|r| {
1324                let id = r.stack.item_instance_id?;
1325                Some(StoragePickOption {
1326                    item_instance_id: id,
1327                    label: storage_stack_label(&r.stack),
1328                    quantity: r.stack.quantity,
1329                })
1330            })
1331            .collect()
1332    }
1333
1334    /// Vault stacks eligible for take / ship.
1335    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1336        let Some(panel) = &self.storage_panel else {
1337            return Vec::new();
1338        };
1339        panel
1340            .contents
1341            .iter()
1342            .filter_map(|s| {
1343                let id = s.item_instance_id?;
1344                Some(StoragePickOption {
1345                    item_instance_id: id,
1346                    label: storage_stack_label(s),
1347                    quantity: s.quantity,
1348                })
1349            })
1350            .collect()
1351    }
1352
1353    pub fn clear_harvest_state(&mut self) {
1354        self.harvest_in_progress = false;
1355        self.harvest_started_at = None;
1356    }
1357
1358    fn harvest_state_stale(&self) -> bool {
1359        match self.harvest_started_at {
1360            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1361            None => self.harvest_in_progress,
1362        }
1363    }
1364
1365    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1366        self.player.as_ref().and_then(|p| p.vitals)
1367    }
1368
1369    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1370        let materials_ok = blueprint.inputs.iter().all(|input| {
1371            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1372        });
1373        let tools_ok = blueprint
1374            .required_tools
1375            .iter()
1376            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1377        let station_ok = match blueprint.station.as_deref() {
1378            None | Some("hand") => true,
1379            Some(tag) => self.player_at_station_tag(tag),
1380        };
1381        materials_ok && tools_ok && station_ok
1382    }
1383
1384    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1385        if !self.can_craft_blueprint(blueprint) {
1386            return 0;
1387        }
1388        let mut limit = u32::MAX;
1389        for input in &blueprint.inputs {
1390            if input.quantity == 0 {
1391                continue;
1392            }
1393            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1394            limit = limit.min(have / input.quantity);
1395        }
1396        for tool in &blueprint.required_tools {
1397            if tool.consumed {
1398                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1399                limit = limit.min(have);
1400            }
1401        }
1402        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1403        if CRAFT_STAMINA_COST > 0.0 {
1404            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1405        }
1406        limit
1407    }
1408
1409    pub fn clamp_craft_batch_quantity(&mut self) {
1410        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1411            self.craft_batch_quantity = 1;
1412            return;
1413        };
1414        let max = self.max_craft_batches(bp).max(1);
1415        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1416    }
1417
1418    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1419        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1420            return;
1421        };
1422        let max = self.max_craft_batches(&bp).max(1);
1423        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1424        self.craft_batch_quantity = next as u32;
1425    }
1426
1427    pub fn craft_batch_set_max(&mut self) {
1428        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1429            return;
1430        };
1431        let max = self.max_craft_batches(&bp);
1432        self.craft_batch_quantity = if max == 0 { 1 } else { max };
1433    }
1434
1435    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1436        let preserve_ui = self.show_shop_menu;
1437        let tab = self.shop_tab;
1438        let index = self.shop_menu_index;
1439        let qty = self.shop_quantity;
1440
1441        self.show_shop_menu = true;
1442        self.bank_panel = None;
1443        self.show_craft_menu = false;
1444        self.show_inventory_menu = false;
1445        self.show_stats = false;
1446        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1447            self.npc_verb_target = Some(catalog.npc_id.clone());
1448        }
1449        self.shop_catalog = Some(catalog);
1450
1451        if preserve_ui {
1452            self.shop_tab = tab;
1453            self.shop_menu_index = index;
1454            self.shop_quantity = qty;
1455        } else {
1456            self.shop_tab = ShopTab::Buy;
1457            self.shop_menu_index = 0;
1458            self.shop_quantity = 1;
1459            self.clear_shop_trade_log();
1460        }
1461        self.show_npc_verb_menu = false;
1462        self.clamp_shop_selection();
1463    }
1464
1465    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1466        let same_teller = self
1467            .bank_panel
1468            .as_ref()
1469            .is_some_and(|p| p.npc_id == panel.npc_id);
1470        self.bank_panel = Some(panel);
1471        self.storage_panel = None;
1472        self.shop_catalog = None;
1473        self.show_shop_menu = false;
1474        self.show_craft_menu = false;
1475        self.show_inventory_menu = false;
1476        self.show_stats = false;
1477        self.show_npc_verb_menu = false;
1478        self.show_npc_chat = false;
1479        self.npc_chat = None;
1480        if !same_teller {
1481            self.bank_menu_index = 0;
1482            self.bank_ui_mode = BankUiMode::Menu;
1483        }
1484        if let Some(panel) = &self.bank_panel {
1485            if self.npc_verb_target.is_none() {
1486                self.npc_verb_target = Some(panel.npc_id.clone());
1487            }
1488        }
1489    }
1490
1491    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1492        let same_manager = self
1493            .storage_panel
1494            .as_ref()
1495            .is_some_and(|p| p.npc_id == panel.npc_id);
1496        self.storage_panel = Some(panel);
1497        self.bank_panel = None;
1498        self.bank_ui_mode = BankUiMode::Menu;
1499        self.shop_catalog = None;
1500        self.show_shop_menu = false;
1501        self.show_craft_menu = false;
1502        self.show_inventory_menu = false;
1503        self.show_stats = false;
1504        self.show_npc_verb_menu = false;
1505        self.show_npc_chat = false;
1506        self.npc_chat = None;
1507        if !same_manager {
1508            self.storage_menu_index = 0;
1509            self.storage_ui_mode = StorageUiMode::Menu;
1510        } else {
1511            self.clamp_storage_pick_index();
1512        }
1513        if let Some(panel) = &self.storage_panel {
1514            if self.npc_verb_target.is_none() {
1515                self.npc_verb_target = Some(panel.npc_id.clone());
1516            }
1517        }
1518    }
1519
1520    pub fn clear_bank_panel(&mut self) {
1521        self.bank_panel = None;
1522        self.bank_menu_index = 0;
1523        self.bank_ui_mode = BankUiMode::Menu;
1524    }
1525
1526    pub fn clear_storage_panel(&mut self) {
1527        self.storage_panel = None;
1528        self.storage_menu_index = 0;
1529        self.storage_ui_mode = StorageUiMode::Menu;
1530    }
1531
1532    fn clamp_storage_pick_index(&mut self) {
1533        match &self.storage_ui_mode {
1534            StorageUiMode::StorePick { index } => {
1535                let n = self.storage_store_options().len();
1536                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1537                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1538            }
1539            StorageUiMode::TakePick { index } => {
1540                let n = self.storage_vault_options().len();
1541                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1542                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1543            }
1544            StorageUiMode::ShipPick {
1545                dest_building_id,
1546                dest_label,
1547                index,
1548            } => {
1549                let n = self.storage_vault_options().len();
1550                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1551                self.storage_ui_mode = StorageUiMode::ShipPick {
1552                    dest_building_id: dest_building_id.clone(),
1553                    dest_label: dest_label.clone(),
1554                    index: next,
1555                };
1556            }
1557            StorageUiMode::Menu
1558            | StorageUiMode::StoreAmount { .. }
1559            | StorageUiMode::TakeAmount { .. }
1560            | StorageUiMode::ShipAmount { .. } => {}
1561        }
1562    }
1563
1564    pub fn shop_list_len(&self) -> usize {
1565        let Some(catalog) = &self.shop_catalog else {
1566            return 0;
1567        };
1568        match self.shop_tab {
1569            ShopTab::Buy => catalog.sells.len(),
1570            ShopTab::Sell => catalog.buys.len(),
1571        }
1572    }
1573
1574    pub fn shop_menu_move(&mut self, delta: i32) {
1575        let n = self.shop_list_len();
1576        if n == 0 {
1577            return;
1578        }
1579        let idx = self.shop_menu_index as i32;
1580        let next = (idx + delta).rem_euclid(n as i32);
1581        self.shop_menu_index = next as usize;
1582        self.clamp_shop_quantity();
1583    }
1584
1585    pub fn shop_quantity_adjust(&mut self, delta: i32) {
1586        let max = self.shop_quantity_max();
1587        if max == 0 {
1588            self.shop_quantity = 0;
1589            return;
1590        }
1591        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
1592        self.shop_quantity = next as u32;
1593    }
1594
1595    pub(crate) fn clamp_shop_selection(&mut self) {
1596        let n = self.shop_list_len();
1597        if n == 0 {
1598            self.shop_menu_index = 0;
1599        } else {
1600            self.shop_menu_index = self.shop_menu_index.min(n - 1);
1601        }
1602        self.clamp_shop_quantity();
1603    }
1604
1605    fn shop_quantity_max(&self) -> u32 {
1606        let Some(catalog) = &self.shop_catalog else {
1607            return 1;
1608        };
1609        match self.shop_tab {
1610            ShopTab::Buy => {
1611                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
1612                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
1613                        return 1;
1614                    }
1615                }
1616                99
1617            }
1618            ShopTab::Sell => catalog
1619                .buys
1620                .get(self.shop_menu_index)
1621                .map(|l| l.quantity)
1622                .unwrap_or(0),
1623        }
1624    }
1625
1626    pub fn shop_quantity_set_max(&mut self) {
1627        self.shop_quantity = self.shop_quantity_max();
1628    }
1629
1630    fn clamp_shop_quantity(&mut self) {
1631        let max = self.shop_quantity_max();
1632        if max == 0 {
1633            self.shop_quantity = 0;
1634        } else {
1635            self.shop_quantity = self.shop_quantity.max(1).min(max);
1636        }
1637    }
1638
1639    pub fn player_at_station_tag(&self, tag: &str) -> bool {
1640        let Some(id) = self.effective_inside_building() else {
1641            return false;
1642        };
1643        self.buildings
1644            .iter()
1645            .find(|b| b.id == id)
1646            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
1647    }
1648
1649    /// Short hint for UI when a recipe cannot be started.
1650    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
1651        if self.can_craft_blueprint(blueprint) {
1652            return None;
1653        }
1654        let mut missing = Vec::new();
1655        for input in &blueprint.inputs {
1656            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1657            if have < input.quantity {
1658                missing.push(format!(
1659                    "{}×{} (have {have})",
1660                    input.quantity, input.template_id
1661                ));
1662            }
1663        }
1664        for tool in &blueprint.required_tools {
1665            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1666            if have < 1 {
1667                missing.push(format!("tool: {}", tool.item));
1668            }
1669        }
1670        if let Some(station) = blueprint.station.as_deref() {
1671            if station != "hand" && !self.player_at_station_tag(station) {
1672                missing.push(format!("station: {station} (enter building)"));
1673            }
1674        }
1675        if missing.is_empty() {
1676            None
1677        } else {
1678            Some(missing.join(", "))
1679        }
1680    }
1681
1682    pub fn player_entity(&self) -> Option<&EntityState> {
1683        self.player
1684            .as_ref()
1685            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
1686    }
1687
1688    pub fn player_position(&self) -> (f32, f32) {
1689        let (x, y, _) = self.player_position_with_z();
1690        (x, y)
1691    }
1692
1693    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
1694        if let Some(p) = self.player_entity() {
1695            (
1696                p.transform.position.x,
1697                p.transform.position.y,
1698                p.transform.position.z,
1699            )
1700        } else {
1701            (0.0, 0.0, 0.0)
1702        }
1703    }
1704
1705    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
1706        let mut rows: Vec<(String, u32, String)> = self
1707            .inventory
1708            .iter()
1709            .filter(|(_, q)| **q > 0)
1710            .map(|(id, qty)| {
1711                let label = self
1712                    .inventory_hints
1713                    .get(id)
1714                    .map(|h| h.display_name.clone())
1715                    .unwrap_or_else(|| id.clone());
1716                (id.clone(), *qty, label)
1717            })
1718            .collect();
1719        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
1720        rows
1721    }
1722
1723    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
1724        self.inventory_hints
1725            .get(template_id)
1726            .map(|h| h.category.as_str())
1727            .filter(|c| !c.is_empty())
1728    }
1729
1730    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
1731        stack
1732            .props
1733            .get("grants_item_status_effect")
1734            .map(|s| !s.is_empty())
1735            .unwrap_or(false)
1736    }
1737
1738    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
1739        stack
1740            .props
1741            .get("grants_item_status_effect")
1742            .map(String::as_str)
1743            .filter(|s| !s.is_empty())
1744    }
1745
1746    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
1747        stack
1748            .props
1749            .get("grants_item_status_mode")
1750            .map(String::as_str)
1751            .unwrap_or("on_hit")
1752    }
1753
1754    /// Candidate gear for a grant consumable (inventory + worn).
1755    pub fn grant_target_options(
1756        &self,
1757        grant: &flatland_protocol::ItemStack,
1758    ) -> Vec<GrantTargetOption> {
1759        let mode = Self::grant_mode(grant);
1760        let grant_tags: Vec<&str> = grant
1761            .props
1762            .get("grants_item_status_tags")
1763            .map(|s| {
1764                s.split(',')
1765                    .map(str::trim)
1766                    .filter(|t| !t.is_empty())
1767                    .collect()
1768            })
1769            .unwrap_or_default();
1770        let grant_id = grant.item_instance_id;
1771        let mut out = Vec::new();
1772        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
1773            let Some(iid) = stack.item_instance_id else {
1774                return;
1775            };
1776            if Some(iid) == grant_id {
1777                return;
1778            }
1779            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
1780                return;
1781            }
1782            if !grant_target_matches_mode(stack, mode) {
1783                return;
1784            }
1785            if !grant_tags_match(stack, &grant_tags) {
1786                return;
1787            }
1788            let name = stack
1789                .display_name
1790                .clone()
1791                .unwrap_or_else(|| stack.template_id.clone());
1792            let bindings = if stack.status_bindings.is_empty() {
1793                String::new()
1794            } else {
1795                format!(
1796                    " · {}",
1797                    stack
1798                        .status_bindings
1799                        .iter()
1800                        .map(|b| b.effect_id.as_str())
1801                        .collect::<Vec<_>>()
1802                        .join(", ")
1803                )
1804            };
1805            out.push(GrantTargetOption {
1806                label: format!("{where_label}: {name}{bindings}"),
1807                target_instance_id: iid,
1808            });
1809        };
1810        fn walk(
1811            stacks: &[flatland_protocol::ItemStack],
1812            where_label: &str,
1813            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
1814        ) {
1815            for s in stacks {
1816                push(s, where_label);
1817                if !s.contents.is_empty() {
1818                    let nested = format!(
1819                        "{where_label}/{}",
1820                        s.display_name
1821                            .as_deref()
1822                            .unwrap_or(s.template_id.as_str())
1823                    );
1824                    walk(&s.contents, &nested, push);
1825                }
1826            }
1827        }
1828        walk(&self.inventory_stacks, "Bag", &mut push);
1829        for (slot, stack) in &self.worn {
1830            push(stack, body_slot_label(*slot));
1831            let nest = format!(
1832                "{}/{}",
1833                body_slot_label(*slot),
1834                stack
1835                    .display_name
1836                    .as_deref()
1837                    .unwrap_or(stack.template_id.as_str())
1838            );
1839            walk(&stack.contents, &nest, &mut push);
1840        }
1841        out
1842    }
1843
1844    pub fn item_base_mass(&self, template_id: &str) -> f32 {
1845        self.inventory_hints
1846            .get(template_id)
1847            .and_then(|h| h.base_mass)
1848            .unwrap_or(0.5)
1849    }
1850
1851    pub fn item_base_volume(&self, template_id: &str) -> f32 {
1852        self.inventory_hints
1853            .get(template_id)
1854            .and_then(|h| h.base_volume)
1855            .unwrap_or(1.0)
1856    }
1857
1858    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
1859        let unit = stack
1860            .base_mass
1861            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
1862        unit * stack.quantity as f32
1863    }
1864
1865    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
1866        let unit = stack.base_volume.unwrap_or(1.0);
1867        unit * stack.quantity as f32
1868            + stack
1869                .contents
1870                .iter()
1871                .map(Self::stack_tree_volume)
1872                .sum::<f32>()
1873    }
1874
1875    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
1876        contents.iter().map(Self::stack_tree_volume).sum()
1877    }
1878
1879    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
1880        self.inventory_hints
1881            .get(template_id)
1882            .and_then(|h| h.capacity_volume)
1883            .filter(|c| *c > 0.0)
1884    }
1885
1886    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
1887        stack
1888            .capacity_volume
1889            .filter(|c| *c > 0.0)
1890            .or_else(|| self.template_capacity_volume(&stack.template_id))
1891    }
1892
1893    /// Volume used / capacity / free space label for storage containers in the inventory UI.
1894    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
1895        let Some((used, cap)) = self.container_volume_stats(row) else {
1896            return String::new();
1897        };
1898        let free = (cap - used).max(0.0);
1899        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
1900    }
1901
1902    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
1903        if row.is_chest_shell {
1904            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
1905                return None;
1906            };
1907            let chest = self
1908                .placed_containers
1909                .iter()
1910                .find(|c| c.id == *container_id)?;
1911            let cap = self
1912                .stack_capacity_volume(&row.stack)
1913                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
1914            let used = if chest.accessible {
1915                Self::contents_used_volume(&chest.contents)
1916            } else {
1917                0.0
1918            };
1919            return Some((used, cap));
1920        }
1921
1922        let cap = self.stack_capacity_volume(&row.stack)?;
1923        let used = Self::contents_used_volume(&row.stack.contents);
1924        Some((used, cap))
1925    }
1926
1927    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1928        if row.is_chest_shell {
1929            return true;
1930        }
1931        if row.is_equip_shell {
1932            return self.inventory_item_category(&row.stack.template_id) == Some("container");
1933        }
1934        self.inventory_item_category(&row.stack.template_id) == Some("container")
1935            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1936    }
1937
1938    fn container_stack_for(
1939        &self,
1940        location: &flatland_protocol::InventoryLocation,
1941        parent_instance_id: Option<uuid::Uuid>,
1942    ) -> Option<flatland_protocol::ItemStack> {
1943        match location {
1944            flatland_protocol::InventoryLocation::Root => {
1945                let pid = parent_instance_id?;
1946                self.find_stack_by_instance(&self.inventory_stacks, pid)
1947            }
1948            flatland_protocol::InventoryLocation::Worn { slot } => {
1949                let worn = self.worn.get(slot)?;
1950                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1951                    Some(worn.clone())
1952                } else {
1953                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1954                }
1955            }
1956            flatland_protocol::InventoryLocation::Placed { container_id } => {
1957                let chest = self
1958                    .placed_containers
1959                    .iter()
1960                    .find(|c| c.id == *container_id)?;
1961                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1962                    Some(flatland_protocol::ItemStack {
1963                        template_id: chest.template_id.clone(),
1964                        quantity: 1,
1965                        item_instance_id: chest.item_instance_id,
1966                        props: Default::default(),
1967                        status_bindings: Vec::new(),
1968                        contents: chest.contents.clone(),
1969                        display_name: Some(chest.display_name.clone()),
1970                        category: Some("container".into()),
1971                        capacity_volume: self
1972                            .inventory_hints
1973                            .get(&chest.template_id)
1974                            .and_then(|h| h.capacity_volume),
1975                        worker_lodging_capacity: chest.worker_lodging_capacity,
1976                        ..Default::default()
1977                    })
1978                } else {
1979                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
1980                }
1981            }
1982            flatland_protocol::InventoryLocation::Keychain => None,
1983        }
1984    }
1985
1986    fn find_stack_by_instance(
1987        &self,
1988        stacks: &[flatland_protocol::ItemStack],
1989        instance_id: uuid::Uuid,
1990    ) -> Option<flatland_protocol::ItemStack> {
1991        for stack in stacks {
1992            if stack.item_instance_id == Some(instance_id) {
1993                return Some(stack.clone());
1994            }
1995            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
1996                return Some(found);
1997            }
1998        }
1999        None
2000    }
2001
2002    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
2003    pub fn max_movable_to(
2004        &self,
2005        template_id: &str,
2006        stack_qty: u32,
2007        from: &flatland_protocol::InventoryLocation,
2008        to: &flatland_protocol::InventoryLocation,
2009        parent_instance_id: Option<uuid::Uuid>,
2010    ) -> u32 {
2011        let unit_vol = self.item_base_volume(template_id);
2012        let unit_mass = self.item_base_mass(template_id);
2013        let mut limit = stack_qty;
2014
2015        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2016            let cap = parent
2017                .capacity_volume
2018                .or_else(|| {
2019                    self.inventory_hints
2020                        .get(&parent.template_id)
2021                        .and_then(|h| h.capacity_volume)
2022                })
2023                .unwrap_or(0.0);
2024            if cap > 0.0 && unit_vol > 0.0 {
2025                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2026                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2027            }
2028        }
2029
2030        let to_person = matches!(
2031            to,
2032            flatland_protocol::InventoryLocation::Root
2033                | flatland_protocol::InventoryLocation::Worn { .. }
2034        );
2035        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2036        if to_person && from_placed && unit_mass > 0.0 {
2037            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2038            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2039                limit = 0;
2040            } else {
2041                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2042            }
2043        }
2044
2045        limit.max(0).min(stack_qty)
2046    }
2047
2048    pub fn move_picker_max_at_selection(&self) -> u32 {
2049        let Some(picker) = &self.move_picker else {
2050            return 1;
2051        };
2052        let Some(opt) = picker.options.get(self.move_picker_index) else {
2053            return picker.stack_quantity;
2054        };
2055        match &opt.kind {
2056            MoveOptionKind::Cancel
2057            | MoveOptionKind::Drop
2058            | MoveOptionKind::Use
2059            | MoveOptionKind::GrantApply
2060            | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
2061            MoveOptionKind::Move {
2062                location,
2063                parent_instance_id,
2064            } => self.max_movable_to(
2065                &picker.template_id,
2066                picker.stack_quantity,
2067                &picker.from,
2068                location,
2069                *parent_instance_id,
2070            ),
2071        }
2072    }
2073
2074    pub fn clamp_move_picker_quantity(&mut self) {
2075        let max = self.move_picker_max_at_selection();
2076        if let Some(picker) = &mut self.move_picker {
2077            if max == 0 {
2078                picker.quantity = 1;
2079            } else {
2080                picker.quantity = picker.quantity.clamp(1, max);
2081            }
2082        }
2083    }
2084
2085    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2086        let max = self.move_picker_max_at_selection().max(1);
2087        if let Some(picker) = &mut self.move_picker {
2088            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2089            picker.quantity = next as u32;
2090        }
2091    }
2092
2093    pub fn move_picker_set_quantity_max(&mut self) {
2094        let max = self.move_picker_max_at_selection();
2095        if let Some(picker) = &mut self.move_picker {
2096            picker.quantity = if max == 0 {
2097                1
2098            } else {
2099                max.min(picker.stack_quantity)
2100            };
2101        }
2102    }
2103
2104    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2105        if let Some(picker) = &mut self.destroy_picker {
2106            let max = picker.stack_quantity.max(1);
2107            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2108            picker.quantity = next as u32;
2109        }
2110    }
2111
2112    pub fn destroy_picker_set_quantity_max(&mut self) {
2113        if let Some(picker) = &mut self.destroy_picker {
2114            picker.quantity = picker.stack_quantity.max(1);
2115        }
2116    }
2117
2118    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2119        let have = self.inventory.get(template_id).copied().unwrap_or(0);
2120        (have, have >= need)
2121    }
2122
2123    pub fn currency_display(&self) -> String {
2124        crate::currency::currency_line(&self.inventory)
2125    }
2126
2127    /// True when standing in a shallow-water terrain zone from the segment snapshot.
2128    pub fn in_shallow_water(&self) -> bool {
2129        let (px, py) = self.player_position();
2130        self.terrain_at(px, py)
2131            .is_some_and(|k| k == TerrainKindView::ShallowWater)
2132    }
2133
2134    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2135        self.terrain_zone_at(x, y).map(|z| z.kind)
2136    }
2137
2138    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
2139    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2140        self.terrain_zones
2141            .iter()
2142            .enumerate()
2143            .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2144            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2145            .map(|(_, z)| z)
2146    }
2147
2148    /// Ground elevation from terrain zones (m).
2149    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2150        self.terrain_zone_at(x, y)
2151            .map(|z| z.elevation)
2152            .unwrap_or(0.0)
2153    }
2154
2155    /// Walkable z levels at a map column (terrain + platforms).
2156    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2157        const TOL: f32 = 0.35;
2158        let mut levels = vec![self.elevation_at(x, y)];
2159        for p in &self.z_platforms {
2160            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2161                levels.push(p.z);
2162            }
2163        }
2164        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2165        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2166        levels
2167    }
2168
2169    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2170        const TOL: f32 = 0.35;
2171        self.walkable_levels_at(x, y)
2172            .iter()
2173            .any(|&l| (l - z).abs() <= TOL)
2174    }
2175
2176    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2177        let mut top = self.elevation_at(x, y);
2178        for p in &self.z_platforms {
2179            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2180                top = top.max(p.z);
2181            }
2182        }
2183        top
2184    }
2185
2186    /// Authoritative interior context from the server (`inside_building` flag).
2187    pub fn effective_inside_building(&self) -> Option<String> {
2188        self.player_entity().and_then(|p| p.inside_building.clone())
2189    }
2190
2191    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2192        self.inventory_stacks = stacks.to_vec();
2193        self.inventory.clear();
2194        self.inventory_hints.clear();
2195        fn walk(
2196            stacks: &[flatland_protocol::ItemStack],
2197            inventory: &mut std::collections::HashMap<String, u32>,
2198            hints: &mut std::collections::HashMap<String, InventoryHint>,
2199        ) {
2200            for stack in stacks {
2201                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2202                if stack.display_name.is_some()
2203                    || stack.category.is_some()
2204                    || stack.base_mass.is_some()
2205                    || stack.base_volume.is_some()
2206                {
2207                    hints.insert(
2208                        stack.template_id.clone(),
2209                        InventoryHint {
2210                            display_name: stack
2211                                .display_name
2212                                .clone()
2213                                .unwrap_or_else(|| stack.template_id.clone()),
2214                            category: stack.category.clone().unwrap_or_default(),
2215                            base_mass: stack.base_mass,
2216                            base_volume: stack.base_volume,
2217                            capacity_volume: stack.capacity_volume,
2218                            stackable: stack.stackable.unwrap_or(true),
2219                        },
2220                    );
2221                }
2222                walk(&stack.contents, inventory, hints);
2223            }
2224        }
2225        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2226        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
2227        for item in self.worn.values() {
2228            walk(
2229                std::slice::from_ref(item),
2230                &mut self.inventory,
2231                &mut self.inventory_hints,
2232            );
2233        }
2234    }
2235
2236    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
2237    /// inventory UI updates before the next tick snapshot arrives.
2238    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2239        let subtract_items =
2240            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2241        for stack in &notice.inventory_delta {
2242            if stack.quantity == 0 {
2243                continue;
2244            }
2245            if subtract_items {
2246                crate::currency::drain_template_stacks(
2247                    &mut self.inventory_stacks,
2248                    &stack.template_id,
2249                    stack.quantity,
2250                );
2251                continue;
2252            }
2253            let stackable = self
2254                .inventory_hints
2255                .get(&stack.template_id)
2256                .map(|h| h.stackable)
2257                .or(stack.stackable)
2258                .unwrap_or(true);
2259            if stackable {
2260                if let Some(existing) = self
2261                    .inventory_stacks
2262                    .iter_mut()
2263                    .find(|s| s.template_id == stack.template_id)
2264                {
2265                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
2266                    if stack.display_name.is_some() {
2267                        existing.display_name = stack.display_name.clone();
2268                    }
2269                    if stack.category.is_some() {
2270                        existing.category = stack.category.clone();
2271                    }
2272                    continue;
2273                }
2274            }
2275            self.inventory_stacks.push(stack.clone());
2276        }
2277        if notice.coins_delta != 0 {
2278            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2279        }
2280        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2281            let stacks = self.inventory_stacks.clone();
2282            self.sync_inventory_from_stacks(&stacks);
2283        }
2284        self.record_shop_trade_notice(notice);
2285    }
2286
2287    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
2288    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
2289    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
2290    /// iteration alone gives a stable row order.
2291    pub fn worn_rows(&self) -> Vec<InventoryRow> {
2292        let mut rows = Vec::new();
2293        for (slot, item) in &self.worn {
2294            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2295            rows.push(InventoryRow {
2296                depth: 0,
2297                stack: item.clone(),
2298                from: from.clone(),
2299                from_parent_instance_id: None,
2300                is_equip_shell: true,
2301                is_chest_shell: false,
2302                section: InventorySection::Worn,
2303            });
2304            for child in &item.contents {
2305                push_inventory_rows(
2306                    &mut rows,
2307                    1,
2308                    child,
2309                    &from,
2310                    item.item_instance_id,
2311                    InventorySection::Worn,
2312                );
2313            }
2314        }
2315        rows
2316    }
2317
2318    /// Root on-person stacks that can be handed to a hired worker.
2319    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2320        self.inventory_stacks
2321            .iter()
2322            .filter_map(|stack| {
2323                let item_instance_id = stack.item_instance_id?;
2324                let label = stack
2325                    .display_name
2326                    .clone()
2327                    .unwrap_or_else(|| stack.template_id.clone());
2328                let label = if stack.quantity > 1 {
2329                    format!("{label} ×{}", stack.quantity)
2330                } else {
2331                    label
2332                };
2333                Some(WorkerGiveOption {
2334                    item_instance_id,
2335                    label,
2336                    quantity: stack.quantity,
2337                    template_id: stack.template_id.clone(),
2338                })
2339            })
2340            .collect()
2341    }
2342
2343    /// Employer-known blueprints the selected worker does not yet know.
2344    pub fn teachable_blueprint_options(
2345        &self,
2346        worker: &flatland_protocol::HiredWorkerView,
2347    ) -> Vec<WorkerTeachOption> {
2348        let copper = crate::currency::copper_from_counts(&self.inventory);
2349        let mut options: Vec<WorkerTeachOption> = self
2350            .blueprints
2351            .iter()
2352            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2353            .map(|bp| {
2354                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2355                let cost = bp.worker_train_copper;
2356                WorkerTeachOption {
2357                    blueprint_id: bp.id.clone(),
2358                    label: if bp.label.is_empty() {
2359                        bp.id.clone()
2360                    } else {
2361                        bp.label.clone()
2362                    },
2363                    cost_copper: cost,
2364                    min_level,
2365                    worker_level: worker.level,
2366                    can_afford: copper >= cost,
2367                    level_ok: worker.level >= min_level,
2368                }
2369            })
2370            .collect();
2371        options.sort_by(|a, b| a.label.cmp(&b.label));
2372        options
2373    }
2374
2375    /// Loose on-person inventory (not worn, not inside a placed chest).
2376    /// Root stacks are ordered by category group; nested contents stay under their parent.
2377    pub fn person_rows(&self) -> Vec<InventoryRow> {
2378        self.person_rows_filtered("")
2379    }
2380
2381    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2382        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2383        roots.sort_by(|a, b| {
2384            let ca = a
2385                .category
2386                .as_deref()
2387                .or_else(|| self.inventory_item_category(&a.template_id))
2388                .unwrap_or("");
2389            let cb = b
2390                .category
2391                .as_deref()
2392                .or_else(|| self.inventory_item_category(&b.template_id))
2393                .unwrap_or("");
2394            let ga = inventory_category_group(ca).1;
2395            let gb = inventory_category_group(cb).1;
2396            ga.cmp(&gb).then_with(|| {
2397                let na = a
2398                    .display_name
2399                    .as_deref()
2400                    .unwrap_or(a.template_id.as_str());
2401                let nb = b
2402                    .display_name
2403                    .as_deref()
2404                    .unwrap_or(b.template_id.as_str());
2405                na.cmp(nb)
2406            })
2407        });
2408        let mut rows = Vec::new();
2409        for stack in roots {
2410            push_inventory_rows_filtered(
2411                &mut rows,
2412                0,
2413                stack,
2414                &flatland_protocol::InventoryLocation::Root,
2415                None,
2416                InventorySection::Person,
2417                filter,
2418            );
2419        }
2420        rows
2421    }
2422
2423    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2424        if filter.is_empty() {
2425            return self.worn_rows();
2426        }
2427        let mut rows = Vec::new();
2428        for (slot, item) in &self.worn {
2429            if !stack_matches_filter(item, filter) {
2430                continue;
2431            }
2432            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2433            let self_hit = {
2434                let f = filter.to_ascii_lowercase();
2435                let name = item
2436                    .display_name
2437                    .as_deref()
2438                    .unwrap_or("")
2439                    .to_ascii_lowercase();
2440                let tid = item.template_id.to_ascii_lowercase();
2441                name.contains(&f) || tid.contains(&f)
2442            };
2443            rows.push(InventoryRow {
2444                depth: 0,
2445                stack: item.clone(),
2446                from: from.clone(),
2447                from_parent_instance_id: None,
2448                is_equip_shell: true,
2449                is_chest_shell: false,
2450                section: InventorySection::Worn,
2451            });
2452            for child in &item.contents {
2453                if self_hit || stack_matches_filter(child, filter) {
2454                    push_inventory_rows_filtered(
2455                        &mut rows,
2456                        1,
2457                        child,
2458                        &from,
2459                        item.item_instance_id,
2460                        InventorySection::Worn,
2461                        if self_hit { "" } else { filter },
2462                    );
2463                }
2464            }
2465        }
2466        rows
2467    }
2468
2469    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
2470    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
2471        let mut rows = self.worn_rows();
2472        rows.extend(self.person_rows());
2473        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
2474    }
2475
2476    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
2477    /// populated when `accessible` — this is what makes a chest's contents
2478    /// disappear the moment you walk away or it's locked without your key.
2479    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
2480        let (px, py) = self.player_position();
2481        let mut list: Vec<NearbyContainer> = self
2482            .placed_containers
2483            .iter()
2484            .filter_map(|c| {
2485                let distance_m = (c.x - px).hypot(c.y - py);
2486                if distance_m > CONTAINER_RANGE_M {
2487                    return None;
2488                }
2489                let mut rows = Vec::new();
2490                let from = flatland_protocol::InventoryLocation::Placed {
2491                    container_id: c.id.clone(),
2492                };
2493                rows.push(InventoryRow {
2494                    depth: 0,
2495                    stack: flatland_protocol::ItemStack {
2496                        template_id: c.template_id.clone(),
2497                        quantity: 1,
2498                        item_instance_id: c.item_instance_id,
2499                        props: Default::default(),
2500                        status_bindings: Vec::new(),
2501                        contents: Vec::new(),
2502                        display_name: Some(c.display_name.clone()),
2503                        category: Some("container".into()),
2504                        capacity_volume: c.capacity_volume,
2505                        worker_lodging_capacity: c.worker_lodging_capacity,
2506                        ..Default::default()
2507                    },
2508                    from: from.clone(),
2509                    from_parent_instance_id: None,
2510                    is_equip_shell: false,
2511                    is_chest_shell: true,
2512                    section: InventorySection::Nearby,
2513                });
2514                if c.accessible {
2515                    for child in &c.contents {
2516                        push_inventory_rows(
2517                            &mut rows,
2518                            1,
2519                            child,
2520                            &from,
2521                            c.item_instance_id,
2522                            InventorySection::Nearby,
2523                        );
2524                    }
2525                }
2526                Some(NearbyContainer {
2527                    view: c.clone(),
2528                    distance_m,
2529                    rows,
2530                })
2531            })
2532            .collect();
2533        list.sort_by(|a, b| {
2534            a.distance_m
2535                .partial_cmp(&b.distance_m)
2536                .unwrap_or(std::cmp::Ordering::Equal)
2537        });
2538        list
2539    }
2540
2541    /// Nearest placed chest within `max_dist`, regardless of accessibility.
2542    pub fn nearest_placed_container(
2543        &self,
2544        max_dist: f32,
2545    ) -> Option<flatland_protocol::PlacedContainerView> {
2546        let (px, py) = self.player_position();
2547        self.placed_containers
2548            .iter()
2549            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
2550            .min_by(|a, b| {
2551                let da = (a.x - px).hypot(a.y - py);
2552                let db = (b.x - px).hypot(b.y - py);
2553                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2554            })
2555            .cloned()
2556    }
2557
2558    /// Selectable rows for the **active inventory tab** (and current filter).
2559    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
2560    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
2561        let filter = self.inventory_filter.as_str();
2562        match self.inventory_tab {
2563            InventoryTab::OnPerson => {
2564                let mut rows = self.worn_rows_filtered(filter);
2565                rows.extend(self.person_rows_filtered(filter));
2566                rows
2567            }
2568            InventoryTab::Nearby => {
2569                let mut rows = Vec::new();
2570                for nc in self.nearby_containers() {
2571                    if filter.is_empty() {
2572                        rows.extend(nc.rows);
2573                        continue;
2574                    }
2575                    let shell = nc.rows.first().cloned();
2576                    let contents: Vec<_> = nc
2577                        .rows
2578                        .iter()
2579                        .skip(1)
2580                        .filter(|r| stack_matches_filter(&r.stack, filter))
2581                        .cloned()
2582                        .collect();
2583                    let shell_hit = shell
2584                        .as_ref()
2585                        .map(|s| stack_matches_filter(&s.stack, filter))
2586                        .unwrap_or(false);
2587                    if shell_hit || !contents.is_empty() {
2588                        if let Some(s) = shell {
2589                            rows.push(s);
2590                        }
2591                        if shell_hit {
2592                            rows.extend(nc.rows.into_iter().skip(1));
2593                        } else {
2594                            rows.extend(contents);
2595                        }
2596                    }
2597                }
2598                rows
2599            }
2600        }
2601    }
2602
2603    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
2604        self.inventory_selectable_rows()
2605            .into_iter()
2606            .nth(self.inventory_menu_index)
2607    }
2608
2609    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
2610    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
2611        let cat = self
2612            .inventory_item_category(&row.stack.template_id)
2613            .unwrap_or("");
2614        let label = if cat == "key" {
2615            self.key_inventory_label(&row.stack)
2616        } else {
2617            row.stack
2618                .display_name
2619                .clone()
2620                .unwrap_or_else(|| row.stack.template_id.clone())
2621        };
2622        let hint: String = if row.is_equip_shell {
2623            " [worn — Enter to unequip]".into()
2624        } else if row.is_chest_shell {
2625            let (locked, lodging_note) = match &row.from {
2626                flatland_protocol::InventoryLocation::Placed { container_id } => {
2627                    let locked = self
2628                        .placed_containers
2629                        .iter()
2630                        .find(|c| c.id == *container_id)
2631                        .map(|c| c.locked)
2632                        .unwrap_or(false);
2633                    let lodging_note = self
2634                        .lodging_occupancy_label(container_id)
2635                        .map(|who| format!(" [lodging: {who}]"))
2636                        .unwrap_or_default();
2637                    (locked, lodging_note)
2638                }
2639                _ => (false, String::new()),
2640            };
2641            if locked {
2642                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
2643            } else {
2644                format!(" [Enter pick up · l lock]{lodging_note}")
2645            }
2646        } else if cat == "key" {
2647            self.key_inventory_hint(&row.stack)
2648        } else {
2649            match cat {
2650                "weapon" => " [weapon]".into(),
2651                "container" => " [bag/chest/belt]".into(),
2652                "lodging" => " [worker lodging]".into(),
2653                "armor" => " [armor]".into(),
2654                _ => String::new(),
2655            }
2656        };
2657        let qty = if row.stack.quantity > 1 {
2658            format!(" ×{}", row.stack.quantity)
2659        } else {
2660            String::new()
2661        };
2662        let bindings = format_status_bindings_suffix(
2663            &row.stack.status_bindings,
2664            self.tick,
2665            DEFAULT_TICK_HZ,
2666        );
2667        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
2668            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
2669            let mode = Self::grant_mode(&row.stack);
2670            format!(" [grant {effect} · {mode} — e apply]")
2671        } else {
2672            String::new()
2673        };
2674        let mass = self.stack_mass(&row.stack);
2675        let mass_kg = (mass >= 0.05).then_some(mass);
2676        let mass_str = mass_kg
2677            .map(|m| format!("  {m:.1} kg"))
2678            .unwrap_or_default();
2679        let volume = self.container_volume_stats(row);
2680        let vol_str = self.container_volume_label(row);
2681
2682        let mut title = label.clone();
2683        if let Some(id) = row.stack.item_instance_id {
2684            let hex: String = id
2685                .as_simple()
2686                .to_string()
2687                .chars()
2688                .filter(|c| c.is_ascii_hexdigit())
2689                .collect();
2690            if hex.len() >= 4 {
2691                title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
2692            }
2693        }
2694        title.push_str(&qty);
2695        if row.is_equip_shell {
2696            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2697                title.push_str(&format!(" ({})", body_slot_label(slot)));
2698            }
2699        }
2700
2701        InventoryRowView {
2702            depth: row.depth,
2703            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
2704            title: format!("{title}{grant_hint}{bindings}"),
2705            mass_kg,
2706            volume,
2707        }
2708    }
2709
2710    fn push_browser_item(
2711        &self,
2712        lines: &mut Vec<InventoryBrowserLine>,
2713        row: &InventoryRow,
2714        global_idx: &mut usize,
2715        target: usize,
2716        highlight: bool,
2717    ) {
2718        let view = self.format_inventory_row(row);
2719        lines.push(InventoryBrowserLine::Item {
2720            selectable_index: *global_idx,
2721            selected: highlight && *global_idx == target,
2722            depth: view.depth,
2723            text: view.text,
2724            title: view.title,
2725            mass_kg: view.mass_kg,
2726            volume: view.volume,
2727        });
2728        *global_idx += 1;
2729    }
2730
2731    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
2732    /// `selectable_index` matching `inventory_menu_index`.
2733    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
2734        let mut lines = Vec::new();
2735        let target = self.inventory_menu_index;
2736        let highlight = !self.show_move_picker && !self.show_grant_picker;
2737        let filter = self.inventory_filter.as_str();
2738        let mut global_idx = 0usize;
2739
2740        match self.inventory_tab {
2741            InventoryTab::OnPerson => {
2742                lines.push(InventoryBrowserLine::Section("— Worn —".into()));
2743                let worn = self.worn_rows_filtered(filter);
2744                if worn.is_empty() {
2745                    lines.push(InventoryBrowserLine::Hint(
2746                        "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
2747                    ));
2748                } else {
2749                    for row in &worn {
2750                        if row.is_equip_shell {
2751                            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2752                                lines.push(InventoryBrowserLine::SlotLabel(format!(
2753                                    "  {}:",
2754                                    body_slot_label(slot)
2755                                )));
2756                            }
2757                        }
2758                        self.push_browser_item(
2759                            &mut lines,
2760                            row,
2761                            &mut global_idx,
2762                            target,
2763                            highlight,
2764                        );
2765                    }
2766                }
2767
2768                lines.push(InventoryBrowserLine::Blank);
2769                lines.push(InventoryBrowserLine::Section(
2770                    "— On you (loose, not worn) —".into(),
2771                ));
2772                let person = self.person_rows_filtered(filter);
2773                if person.is_empty() {
2774                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
2775                } else {
2776                    let mut last_group: Option<&'static str> = None;
2777                    for row in &person {
2778                        if row.depth == 0 {
2779                            let cat = row
2780                                .stack
2781                                .category
2782                                .as_deref()
2783                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
2784                                .unwrap_or("");
2785                            let (group, _) = inventory_category_group(cat);
2786                            if last_group != Some(group) {
2787                                lines.push(InventoryBrowserLine::SlotLabel(format!(
2788                                    "  {group}"
2789                                )));
2790                                last_group = Some(group);
2791                            }
2792                        }
2793                        self.push_browser_item(
2794                            &mut lines,
2795                            row,
2796                            &mut global_idx,
2797                            target,
2798                            highlight,
2799                        );
2800                    }
2801                }
2802            }
2803            InventoryTab::Nearby => {
2804                let nearby = self.nearby_containers();
2805                if nearby.is_empty() {
2806                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2807                    lines.push(InventoryBrowserLine::Hint(
2808                        "  (none within reach — walk up to a chest)".into(),
2809                    ));
2810                    lines.push(InventoryBrowserLine::Hint(
2811                        "  Select an on-person item, then m / Enter → move into chest.".into(),
2812                    ));
2813                } else {
2814                    let mut any_visible = false;
2815                    for nc in &nearby {
2816                        let shell = nc.rows.first();
2817                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
2818                            nc.rows.iter().skip(1).collect()
2819                        } else {
2820                            let shell_hit = shell
2821                                .map(|s| {
2822                                    let f = filter.to_ascii_lowercase();
2823                                    let name = s
2824                                        .stack
2825                                        .display_name
2826                                        .as_deref()
2827                                        .unwrap_or("")
2828                                        .to_ascii_lowercase();
2829                                    let tid = s.stack.template_id.to_ascii_lowercase();
2830                                    name.contains(&f) || tid.contains(&f)
2831                                })
2832                                .unwrap_or(false);
2833                            if shell_hit {
2834                                nc.rows.iter().skip(1).collect()
2835                            } else {
2836                                nc.rows
2837                                    .iter()
2838                                    .skip(1)
2839                                    .filter(|r| stack_matches_filter(&r.stack, filter))
2840                                    .collect()
2841                            }
2842                        };
2843                        let shell_visible = filter.is_empty()
2844                            || shell
2845                                .map(|s| stack_matches_filter(&s.stack, filter))
2846                                .unwrap_or(false)
2847                            || !contents.is_empty();
2848                        if !shell_visible && shell.is_some() {
2849                            continue;
2850                        }
2851                        any_visible = true;
2852                        lines.push(InventoryBrowserLine::Blank);
2853                        let lock_note = if nc.view.locked && nc.view.accessible {
2854                            "  unlocked with your key"
2855                        } else if nc.view.locked {
2856                            "  locked"
2857                        } else {
2858                            ""
2859                        };
2860                        lines.push(InventoryBrowserLine::Section(format!(
2861                            "— {} ({:.0}m away){lock_note} —",
2862                            nc.view.display_name, nc.distance_m
2863                        )));
2864                        if !nc.view.accessible {
2865                            lines.push(InventoryBrowserLine::Hint(
2866                                "  locked — need the matching key (l to try)".into(),
2867                            ));
2868                        } else if nc.rows.is_empty() {
2869                            lines.push(InventoryBrowserLine::Hint(
2870                                "  (empty — switch to On person, select an item, m to move in)"
2871                                    .into(),
2872                            ));
2873                        } else if let Some(shell_row) = shell {
2874                            self.push_browser_item(
2875                                &mut lines,
2876                                shell_row,
2877                                &mut global_idx,
2878                                target,
2879                                highlight,
2880                            );
2881                            for row in contents {
2882                                self.push_browser_item(
2883                                    &mut lines,
2884                                    row,
2885                                    &mut global_idx,
2886                                    target,
2887                                    highlight,
2888                                );
2889                            }
2890                        }
2891                    }
2892                    if !any_visible {
2893                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2894                        lines.push(InventoryBrowserLine::Hint(
2895                            "  (no matching items — clear filter with Esc)".into(),
2896                        ));
2897                    }
2898                }
2899            }
2900        }
2901        lines
2902    }
2903
2904    /// Destinations for picking up a placed chest/crate into inventory.
2905    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
2906        let mut opts = Vec::new();
2907        opts.push(MoveOption {
2908            label: "On your person (loose)".into(),
2909            kind: MoveOptionKind::PickupPlaced {
2910                container_id: container_id.to_string(),
2911                nest_location: flatland_protocol::InventoryLocation::Root,
2912                nest_parent_instance_id: None,
2913            },
2914        });
2915        for (slot, item) in &self.worn {
2916            if item.category.as_deref() != Some("container") {
2917                continue;
2918            }
2919            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
2920                continue;
2921            }
2922            let Some(parent_id) = item.item_instance_id else {
2923                continue;
2924            };
2925            let shell_name = item
2926                .display_name
2927                .clone()
2928                .unwrap_or_else(|| item.template_id.clone());
2929            opts.push(MoveOption {
2930                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
2931                kind: MoveOptionKind::PickupPlaced {
2932                    container_id: container_id.to_string(),
2933                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
2934                    nest_parent_instance_id: Some(parent_id),
2935                },
2936            });
2937            // Nested pouches inside the worn bag.
2938            Self::append_chest_pickup_nested(
2939                &mut opts,
2940                container_id,
2941                flatland_protocol::InventoryLocation::Worn { slot: *slot },
2942                item,
2943                &format!("in {shell_name}"),
2944            );
2945        }
2946        opts.push(MoveOption {
2947            label: "Cancel".into(),
2948            kind: MoveOptionKind::Cancel,
2949        });
2950        opts
2951    }
2952
2953    fn append_chest_pickup_nested(
2954        opts: &mut Vec<MoveOption>,
2955        container_id: &str,
2956        location: flatland_protocol::InventoryLocation,
2957        parent: &flatland_protocol::ItemStack,
2958        context: &str,
2959    ) {
2960        for child in &parent.contents {
2961            if child.category.as_deref() != Some("container") {
2962                continue;
2963            }
2964            if !Self::is_volume_container_stack(child) {
2965                continue;
2966            }
2967            // Skip placeable nested chests — relocating into another chest is not useful.
2968            if child.world_placeable == Some(true) {
2969                continue;
2970            }
2971            let Some(child_id) = child.item_instance_id else {
2972                continue;
2973            };
2974            let name = child
2975                .display_name
2976                .clone()
2977                .unwrap_or_else(|| child.template_id.clone());
2978            opts.push(MoveOption {
2979                label: format!("{name} ({context})"),
2980                kind: MoveOptionKind::PickupPlaced {
2981                    container_id: container_id.to_string(),
2982                    nest_location: location.clone(),
2983                    nest_parent_instance_id: Some(child_id),
2984                },
2985            });
2986            Self::append_chest_pickup_nested(
2987                opts,
2988                container_id,
2989                location.clone(),
2990                child,
2991                &format!("in {name}"),
2992            );
2993        }
2994    }
2995
2996    /// Build the "move to…" destination list for an item currently at `from`.
2997    pub fn move_destinations_for(
2998        &self,
2999        from: &flatland_protocol::InventoryLocation,
3000        from_parent_instance_id: Option<uuid::Uuid>,
3001        moving_instance_id: Option<uuid::Uuid>,
3002        moving_template_id: &str,
3003    ) -> Vec<MoveOption> {
3004        let mut opts = Vec::new();
3005        if *from != flatland_protocol::InventoryLocation::Root {
3006            opts.push(MoveOption {
3007                label: "On your person (loose)".into(),
3008                kind: MoveOptionKind::Move {
3009                    location: flatland_protocol::InventoryLocation::Root,
3010                    parent_instance_id: None,
3011                },
3012            });
3013        }
3014        for (slot, item) in &self.worn {
3015            if item.category.as_deref() != Some("container") {
3016                continue;
3017            }
3018            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3019            let shell_name = item
3020                .display_name
3021                .clone()
3022                .unwrap_or_else(|| item.template_id.clone());
3023
3024            // Backpack and other worn volume containers — store directly inside the shell.
3025            if *slot != BodySlot::Waist
3026                && item.item_instance_id != moving_instance_id
3027                && Self::is_volume_container_stack(item)
3028            {
3029                Self::push_move_destination(
3030                    &mut opts,
3031                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
3032                    location.clone(),
3033                    item.item_instance_id,
3034                    from,
3035                    from_parent_instance_id,
3036                );
3037            }
3038
3039            // Belt loops only accept pouch attachments — not loose materials.
3040            if *slot == BodySlot::Waist
3041                && Self::attaches_to_belt_loop(moving_template_id)
3042                && item.item_instance_id != moving_instance_id
3043            {
3044                Self::push_move_destination(
3045                    &mut opts,
3046                    format!("{shell_name} (belt loop)"),
3047                    location.clone(),
3048                    item.item_instance_id,
3049                    from,
3050                    from_parent_instance_id,
3051                );
3052            }
3053
3054            let context = if *slot == BodySlot::Waist {
3055                format!("on {shell_name}")
3056            } else {
3057                format!("in {shell_name}")
3058            };
3059            Self::append_nested_container_destinations(
3060                &mut opts,
3061                location,
3062                item,
3063                &context,
3064                from,
3065                from_parent_instance_id,
3066                moving_instance_id,
3067            );
3068        }
3069        for nc in self.nearby_containers() {
3070            if !nc.view.accessible {
3071                continue;
3072            }
3073            let location = flatland_protocol::InventoryLocation::Placed {
3074                container_id: nc.view.id.clone(),
3075            };
3076            Self::push_move_destination(
3077                &mut opts,
3078                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3079                location,
3080                nc.view.item_instance_id,
3081                from,
3082                from_parent_instance_id,
3083            );
3084        }
3085        let allow_drop = moving_instance_id
3086            .and_then(|id| self.stack_for_instance(id))
3087            .map(|stack| !self.key_drop_blocked(&stack))
3088            .unwrap_or(moving_template_id != KEY_TEMPLATE);
3089        if allow_drop {
3090            opts.push(MoveOption {
3091                label: "Drop on the ground".into(),
3092                kind: MoveOptionKind::Drop,
3093            });
3094        }
3095        opts.push(MoveOption {
3096            label: "Cancel".into(),
3097            kind: MoveOptionKind::Cancel,
3098        });
3099        opts
3100    }
3101
3102    fn is_same_container_dest(
3103        dest_location: &flatland_protocol::InventoryLocation,
3104        dest_parent: Option<uuid::Uuid>,
3105        from: &flatland_protocol::InventoryLocation,
3106        from_parent: Option<uuid::Uuid>,
3107    ) -> bool {
3108        dest_location == from && dest_parent == from_parent
3109    }
3110
3111    fn push_move_destination(
3112        opts: &mut Vec<MoveOption>,
3113        label: String,
3114        location: flatland_protocol::InventoryLocation,
3115        parent_instance_id: Option<uuid::Uuid>,
3116        from: &flatland_protocol::InventoryLocation,
3117        from_parent_instance_id: Option<uuid::Uuid>,
3118    ) {
3119        if Self::is_same_container_dest(
3120            &location,
3121            parent_instance_id,
3122            from,
3123            from_parent_instance_id,
3124        ) {
3125            return;
3126        }
3127        opts.push(MoveOption {
3128            label,
3129            kind: MoveOptionKind::Move {
3130                location,
3131                parent_instance_id,
3132            },
3133        });
3134    }
3135
3136    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3137        stack.capacity_volume.is_some_and(|c| c > 0.0)
3138    }
3139
3140    fn attaches_to_belt_loop(template_id: &str) -> bool {
3141        matches!(template_id, "leather_pouch" | "dimensional_pouch")
3142    }
3143
3144    fn append_nested_container_destinations(
3145        opts: &mut Vec<MoveOption>,
3146        location: flatland_protocol::InventoryLocation,
3147        container: &flatland_protocol::ItemStack,
3148        context: &str,
3149        from: &flatland_protocol::InventoryLocation,
3150        from_parent_instance_id: Option<uuid::Uuid>,
3151        moving_instance_id: Option<uuid::Uuid>,
3152    ) {
3153        for child in &container.contents {
3154            if Self::is_volume_container_stack(child)
3155                && child.item_instance_id != moving_instance_id
3156            {
3157                let name = child
3158                    .display_name
3159                    .clone()
3160                    .unwrap_or_else(|| child.template_id.clone());
3161                Self::push_move_destination(
3162                    opts,
3163                    format!("{name} ({context})"),
3164                    location.clone(),
3165                    child.item_instance_id,
3166                    from,
3167                    from_parent_instance_id,
3168                );
3169            }
3170            let nested_context = format!(
3171                "in {}",
3172                child.display_name.as_deref().unwrap_or(&child.template_id)
3173            );
3174            Self::append_nested_container_destinations(
3175                opts,
3176                location.clone(),
3177                child,
3178                &nested_context,
3179                from,
3180                from_parent_instance_id,
3181                moving_instance_id,
3182            );
3183        }
3184    }
3185
3186    fn clamp_inventory_indices(&mut self) {
3187        let n = self.inventory_selectable_rows().len();
3188        self.inventory_menu_index = if n == 0 {
3189            0
3190        } else {
3191            self.inventory_menu_index.min(n - 1)
3192        };
3193        if let Some(picker) = &self.move_picker {
3194            let pn = picker.options.len();
3195            self.move_picker_index = if pn == 0 {
3196                0
3197            } else {
3198                self.move_picker_index.min(pn - 1)
3199            };
3200        }
3201    }
3202
3203    /// Drop interior map layers whenever the player is outdoors.
3204    fn sync_interior_map_context(&mut self) {
3205        if self.effective_inside_building().is_none() {
3206            self.interior_map = None;
3207        }
3208    }
3209
3210    fn apply_snapshot_fields(
3211        &mut self,
3212        snapshot: &flatland_protocol::Snapshot,
3213        entity_id: EntityId,
3214    ) {
3215        self.tick = snapshot.tick;
3216        self.chunk_rev = snapshot.chunk_rev;
3217        self.content_rev = snapshot.content_rev;
3218        self.publish_rev = snapshot.publish_rev;
3219        self.resource_nodes = snapshot.resource_nodes.clone();
3220        self.ground_drops = snapshot.ground_drops.clone();
3221        self.placed_containers = snapshot.placed_containers.clone();
3222        self.world_width_m = snapshot.world_width_m;
3223        self.world_height_m = snapshot.world_height_m;
3224        self.world_clock = snapshot.world_clock;
3225        self.terrain_zones = snapshot.terrain_zones.clone();
3226        self.z_platforms = snapshot.z_platforms.clone();
3227        self.z_transitions = snapshot.z_transitions.clone();
3228        self.buildings = snapshot.buildings.clone();
3229        self.doors = snapshot.doors.clone();
3230        self.interior_map = snapshot.interior_map.clone();
3231        self.npcs = snapshot.npcs.clone();
3232        self.blueprints = snapshot.blueprints.clone();
3233        self.sync_inventory_from_stacks(&snapshot.inventory);
3234        self.player = snapshot
3235            .entities
3236            .iter()
3237            .find(|e| e.id == entity_id)
3238            .cloned();
3239        self.entities = snapshot.entities.clone();
3240        self.quest_log = snapshot.quest_log.clone();
3241        self.apply_hired_workers(snapshot.hired_workers.clone());
3242        self.interactables = snapshot.interactables.clone();
3243        self.ledger = snapshot.ledger.clone();
3244        self.career = snapshot.career.clone();
3245        self.combat_fx = snapshot.combat_fx.clone();
3246        self.sync_interior_map_context();
3247    }
3248
3249    /// Re-derive inventory selection state after a snapshot/tick — chests that
3250    /// went out of range or got locked simply vanish from the row list, and the
3251    /// move picker (if any) closes once its item is no longer reachable.
3252    fn refresh_inventory_ui(&mut self) {
3253        if let Some(picker) = &self.move_picker {
3254            let instance_id = picker.item_instance_id;
3255            let still_exists = self
3256                .inventory_selectable_rows()
3257                .iter()
3258                .any(|r| r.stack.item_instance_id == Some(instance_id));
3259            if !still_exists {
3260                self.move_picker = None;
3261                self.show_move_picker = false;
3262            }
3263        }
3264        if let Some(picker) = &self.destroy_picker {
3265            let instance_id = picker.item_instance_id;
3266            let still_exists = self
3267                .inventory_selectable_rows()
3268                .iter()
3269                .any(|r| r.stack.item_instance_id == Some(instance_id));
3270            if !still_exists {
3271                self.destroy_picker = None;
3272                self.show_destroy_picker = false;
3273                self.destroy_confirm_pending = false;
3274            }
3275        }
3276        self.clamp_inventory_indices();
3277    }
3278
3279    /// Replace the hired-worker list from a snapshot/delta.
3280    ///
3281    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
3282    /// selected worker (and its step line) does not jump when the server rebuilds
3283    /// the list.
3284    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3285        let selected_id = self
3286            .hired_workers
3287            .get(self.workers_menu_index)
3288            .map(|w| w.instance_id.clone());
3289        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3290        let now = Instant::now();
3291        for w in &workers {
3292            let prev_err = self
3293                .hired_workers
3294                .iter()
3295                .find(|p| p.instance_id == w.instance_id)
3296                .and_then(|p| p.last_error.as_deref());
3297            let new_err = w.last_error.as_deref();
3298            if new_err != prev_err {
3299                if let Some(err) = new_err {
3300                    if !worker_error_is_transient(err) {
3301                        self.push_log(format!("Worker {}: {err}", w.label));
3302                    }
3303                }
3304            }
3305        }
3306        let mut next_display = BTreeMap::new();
3307        let mut next_errors = BTreeMap::new();
3308        for w in &workers {
3309            let mut sticky = self
3310                .worker_step_display
3311                .remove(&w.instance_id)
3312                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3313            sticky.observe(&w.step_label, now);
3314            next_display.insert(w.instance_id.clone(), sticky);
3315
3316            let mut err_sticky = self
3317                .worker_error_display
3318                .remove(&w.instance_id)
3319                .unwrap_or_default();
3320            err_sticky.observe(w.last_error.as_deref(), now);
3321            if err_sticky.shown(now).is_some() {
3322                next_errors.insert(w.instance_id.clone(), err_sticky);
3323            }
3324        }
3325        self.worker_step_display = next_display;
3326        self.worker_error_display = next_errors;
3327        self.hired_workers = workers;
3328        if let Some(id) = selected_id {
3329            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
3330                self.workers_menu_index = idx;
3331                return;
3332            }
3333        }
3334        if self.workers_menu_index >= self.hired_workers.len() {
3335            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
3336        }
3337    }
3338
3339    /// Held coarse step label for the workers menu (`step:` line).
3340    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
3341        self.worker_step_display
3342            .get(worker_instance_id)
3343            .map(|s| s.shown.as_str())
3344            .or_else(|| {
3345                self.hired_workers
3346                    .iter()
3347                    .find(|w| w.instance_id == worker_instance_id)
3348                    .map(|w| w.step_label.as_str())
3349            })
3350            .unwrap_or("")
3351    }
3352
3353    /// Held error line for workers UI (detail + compact).
3354    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
3355        let now = Instant::now();
3356        self.worker_error_display
3357            .get(worker_instance_id)
3358            .and_then(|s| s.shown(now))
3359            .or_else(|| {
3360                self.hired_workers
3361                    .iter()
3362                    .find(|w| w.instance_id == worker_instance_id)
3363                    .and_then(|w| w.last_error.as_deref())
3364                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
3365            })
3366            .filter(|e| !worker_error_is_hud_noise(e))
3367    }
3368
3369    fn apply_combat_hud(&mut self, combat: &CombatHud) {
3370        self.in_combat = combat.in_combat;
3371        self.auto_attack = combat.auto_attack;
3372        self.combat_has_los = combat.has_los;
3373        self.attack_cd_ticks = combat.attack_cd_ticks;
3374        self.gcd_ticks = combat.gcd_ticks;
3375        self.weapon_ability_id = combat.ability_id.clone();
3376        self.mainhand_template_id = combat.mainhand_template_id.clone();
3377        self.mainhand_label = combat.mainhand_label.clone();
3378        self.offhand_template_id = combat.offhand_template_id.clone();
3379        self.offhand_label = combat.offhand_label.clone();
3380        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
3381            1
3382        } else {
3383            combat.mainhand_hand_slots
3384        };
3385        self.defense = combat.defense.clone();
3386        self.worn = combat.worn.iter().cloned().collect();
3387        self.carry_mass = combat.carry_mass;
3388        self.carry_mass_max = combat.carry_mass_max;
3389        self.encumbrance = combat.encumbrance;
3390        self.cast_progress = combat.cast.clone();
3391        self.ability_cooldowns = combat.ability_cooldowns.clone();
3392        self.blocking_active = combat.blocking_active;
3393        self.max_target_slots = combat.max_target_slots.max(1);
3394        self.combat_slots = combat.slots.clone();
3395        self.rotation_presets = combat.rotation_presets.clone();
3396        self.known_abilities = combat.known_abilities.clone();
3397        self.hotbar = combat.hotbar.clone();
3398        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
3399        self.keychain_stacks = combat.keychain.clone();
3400        self.combat_target_detail = combat.target.clone();
3401        self.statuses = combat.statuses.clone();
3402        self.combat_target = combat.target_entity_id;
3403        if combat.progression_xp_base > 0.0 {
3404            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
3405                baseline_display: combat.progression_baseline,
3406                xp_base: combat.progression_xp_base,
3407                xp_growth: combat.progression_xp_growth,
3408            });
3409        }
3410        if let Some(xp) = &combat.progression_xp {
3411            if let Some(player) = &mut self.player {
3412                player.progression_xp = Some(xp.clone());
3413                if let Some(attrs) = combat.attributes {
3414                    player.attributes = Some(attrs);
3415                }
3416                if let Some(skills) = &combat.skills {
3417                    player.skills = Some(skills.clone());
3418                }
3419            }
3420        }
3421        if let Some(label) = &combat.target_label {
3422            self.combat_target_label = Some(label.clone());
3423        } else if let Some(id) = combat.target_entity_id {
3424            self.combat_target_label = self
3425                .entities
3426                .iter()
3427                .find(|e| e.id == id)
3428                .map(|e| e.label.clone())
3429                .or_else(|| self.combat_target_label.clone());
3430        }
3431        self.refresh_inventory_ui();
3432    }
3433
3434    /// Entity id assigned to a combat slot (from server HUD).
3435    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
3436        self.combat_slots
3437            .iter()
3438            .find(|s| s.slot_index == slot)
3439            .and_then(|s| s.target_entity_id)
3440            .or_else(|| if slot == 1 { self.combat_target } else { None })
3441    }
3442
3443    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
3444    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
3445        if !(1..=9).contains(&slot_1_to_9) {
3446            return None;
3447        }
3448        self.hotbar
3449            .get((slot_1_to_9 - 1) as usize)
3450            .and_then(|a| a.as_deref())
3451            .filter(|id| !id.is_empty())
3452    }
3453
3454    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
3455    pub fn loadout_ability_choices(&self) -> Vec<String> {
3456        let mut out = self.known_abilities.clone();
3457        let weapon = self.weapon_ability_id.trim();
3458        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
3459            out.push(weapon.to_string());
3460        }
3461        out
3462    }
3463
3464    /// Hostile wildlife / monsters for T1 (`Tab`).
3465    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
3466        self.combat_candidates()
3467    }
3468
3469    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
3470    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
3471        let (px, py) = self.player_position();
3472        let dist = |id: EntityId| {
3473            self.entities
3474                .iter()
3475                .find(|e| e.id == id)
3476                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3477                .unwrap_or(f32::MAX)
3478        };
3479
3480        let mut allies = Vec::new();
3481        // Self first — heal_touch on T2.
3482        if let Some(me) = self.player.as_ref() {
3483            let alive = me
3484                .vitals
3485                .as_ref()
3486                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3487                .unwrap_or(true);
3488            if alive {
3489                allies.push((self.entity_id, "Yourself".into()));
3490            }
3491        }
3492        for entity in &self.entities {
3493            if entity.id == self.entity_id {
3494                continue;
3495            }
3496            if entity.vitals.is_some() {
3497                let alive = entity
3498                    .vitals
3499                    .as_ref()
3500                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3501                    .unwrap_or(true);
3502                if alive {
3503                    allies.push((entity.id, entity.label.clone()));
3504                }
3505            }
3506        }
3507        allies.sort_by(|(a, _), (b, _)| {
3508            if *a == self.entity_id {
3509                return std::cmp::Ordering::Less;
3510            }
3511            if *b == self.entity_id {
3512                return std::cmp::Ordering::Greater;
3513            }
3514            dist(*a)
3515                .partial_cmp(&dist(*b))
3516                .unwrap_or(std::cmp::Ordering::Equal)
3517        });
3518
3519        let mut monsters = self.combat_candidates();
3520        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
3521        allies.into_iter().chain(monsters).collect()
3522    }
3523
3524    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
3525        match slot_index {
3526            2 => self.t2_candidates(),
3527            _ => self.t1_candidates(),
3528        }
3529    }
3530
3531    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
3532    pub fn pick_combat_target_at(
3533        &self,
3534        wx: f32,
3535        wy: f32,
3536        slot_index: u8,
3537        radius_m: f32,
3538    ) -> Option<(EntityId, String)> {
3539        let mut best: Option<(f32, EntityId, String)> = None;
3540        for (id, label) in self.candidates_for_slot(slot_index) {
3541            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
3542                // NPCs may only appear in NpcView — fall back to npc list coords.
3543                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
3544                    let d = distance(wx, wy, npc.x, npc.y);
3545                    if d <= radius_m {
3546                        best = match best {
3547                            Some((bd, _, _)) if bd <= d => best,
3548                            _ => Some((d, id, label)),
3549                        };
3550                    }
3551                }
3552                continue;
3553            };
3554            let d = distance(
3555                wx,
3556                wy,
3557                entity.transform.position.x,
3558                entity.transform.position.y,
3559            );
3560            if d <= radius_m {
3561                best = match best {
3562                    Some((bd, _, _)) if bd <= d => best,
3563                    _ => Some((d, id, label)),
3564                };
3565            }
3566        }
3567        best.map(|(_, id, label)| (id, label))
3568    }
3569
3570    /// Full client reset after Welcome (initial connect or reconnect).
3571    pub(crate) fn restore_from_welcome(
3572        &mut self,
3573        session_id: SessionId,
3574        entity_id: EntityId,
3575        snapshot: &flatland_protocol::Snapshot,
3576    ) {
3577        self.clear_harvest_state();
3578        self.disconnect_reason = None;
3579        self.show_stats = false;
3580        self.show_craft_menu = false;
3581        self.show_shop_menu = false;
3582        self.shop_catalog = None;
3583        self.show_inventory_menu = false;
3584        self.session_id = session_id;
3585        self.entity_id = entity_id;
3586        self.connected = true;
3587        self.apply_snapshot_fields(snapshot, entity_id);
3588        if let Some(combat) = &snapshot.combat {
3589            self.apply_combat_hud(combat);
3590            let stacks = self.inventory_stacks.clone();
3591            self.sync_inventory_from_stacks(&stacks);
3592        }
3593    }
3594
3595    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
3596        self.tick = delta.tick;
3597        self.world_clock = delta.world_clock;
3598
3599        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
3600        if delta.entities.is_empty() {
3601            self.combat_fx = delta.combat_fx.clone();
3602            if let Some(combat) = &delta.combat {
3603                self.apply_combat_hud(combat);
3604                let stacks = self.inventory_stacks.clone();
3605                self.sync_inventory_from_stacks(&stacks);
3606            }
3607            return;
3608        }
3609        if !delta.buildings.is_empty() {
3610            self.buildings = delta.buildings.clone();
3611        }
3612        if !delta.blueprints.is_empty() {
3613            self.blueprints = delta.blueprints.clone();
3614        }
3615        self.sync_inventory_from_stacks(&delta.inventory);
3616
3617        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
3618            self.player = Some(updated.clone());
3619        }
3620        self.entities = delta.entities.clone();
3621        if self.player.is_none() {
3622            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
3623        }
3624
3625        self.sync_interior_map_context();
3626
3627        // Static world layers: ticks often omit these (indoors, or unchanged).
3628        // Never wipe the welcome snapshot with an empty vec — except when indoors,
3629        // where empty means "no nodes in this building" (clear outdoor leftovers).
3630        if !delta.resource_nodes.is_empty() {
3631            self.resource_nodes = delta.resource_nodes.clone();
3632        } else if delta.interior_map.is_some()
3633            || self.effective_inside_building().is_some()
3634        {
3635            self.resource_nodes = delta.resource_nodes.clone();
3636        }
3637        if self
3638            .player
3639            .as_ref()
3640            .is_none_or(|p| p.inside_building.is_none())
3641        {
3642            self.ground_drops = delta.ground_drops.clone();
3643            self.placed_containers = delta.placed_containers.clone();
3644        }
3645        if !delta.doors.is_empty() {
3646            self.doors = delta.doors.clone();
3647        }
3648        if self.effective_inside_building().is_some() {
3649            if let Some(map) = &delta.interior_map {
3650                self.interior_map = Some(map.clone());
3651            }
3652        } else {
3653            self.interior_map = None;
3654        }
3655        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
3656        self.npcs = delta.npcs.clone();
3657        if !delta.quest_log.is_empty() {
3658            self.quest_log = delta.quest_log.clone();
3659        }
3660        self.apply_hired_workers(delta.hired_workers.clone());
3661        if !delta.interactables.is_empty() {
3662            self.interactables = delta.interactables.clone();
3663        }
3664        if delta.ledger.is_some() {
3665            self.ledger = delta.ledger.clone();
3666        }
3667        if delta.career.is_some() {
3668            self.career = delta.career.clone();
3669        }
3670        self.combat_fx = delta.combat_fx.clone();
3671        if let Some(combat) = &delta.combat {
3672            self.apply_combat_hud(combat);
3673            let stacks = self.inventory_stacks.clone();
3674            self.sync_inventory_from_stacks(&stacks);
3675        } else {
3676            self.refresh_inventory_ui();
3677        }
3678    }
3679
3680    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
3681    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
3682        let (px, py) = self.player_position();
3683        let mut out = Vec::new();
3684        for npc in &self.npcs {
3685            let Some(eid) = npc.entity_id else {
3686                continue;
3687            };
3688            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
3689            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
3690            if alive && has_hp {
3691                out.push((eid, npc.label.clone()));
3692            }
3693        }
3694        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
3695            let dist = |id: EntityId| {
3696                self.entities
3697                    .iter()
3698                    .find(|e| e.id == id)
3699                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3700                    .unwrap_or(f32::MAX)
3701            };
3702            dist(*a_id)
3703                .partial_cmp(&dist(*b_id))
3704                .unwrap_or(std::cmp::Ordering::Equal)
3705                .then_with(|| a_label.cmp(b_label))
3706                .then_with(|| a_id.cmp(b_id))
3707        });
3708        out
3709    }
3710
3711    pub fn refresh_combat_target_label(&mut self) {
3712        let Some(id) = self.combat_target else {
3713            return;
3714        };
3715        if let Some((_, label)) = self
3716            .combat_candidates()
3717            .into_iter()
3718            .find(|(eid, _)| *eid == id)
3719        {
3720            self.combat_target_label = Some(label);
3721        } else if let Some(label) = self
3722            .entities
3723            .iter()
3724            .find(|e| e.id == id)
3725            .map(|e| e.label.clone())
3726        {
3727            self.combat_target_label = Some(label);
3728        }
3729    }
3730
3731    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
3732        self.quest_log
3733            .iter()
3734            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
3735            .collect()
3736    }
3737
3738    /// True when the observer has at least one free lodging slot (max workers = beds).
3739    pub fn has_worker_lodging(&self) -> bool {
3740        self.free_worker_lodging_slots() > 0
3741    }
3742
3743    /// Owned lodging capacity minus currently hired workers.
3744    pub fn free_worker_lodging_slots(&self) -> i64 {
3745        let slots: u32 = self
3746            .placed_containers
3747            .iter()
3748            .filter(|c| match (self.character_id, c.owner_character_id) {
3749                (Some(me), Some(owner)) => me == owner,
3750                (Some(_), None) => false,
3751                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
3752            })
3753            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
3754            .sum();
3755        let used = self.hired_workers.len() as u32;
3756        slots as i64 - used as i64
3757    }
3758
3759    /// Display names of hired workers assigned to this lodging container.
3760    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
3761        let mut names: Vec<String> = self
3762            .hired_workers
3763            .iter()
3764            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
3765            .map(|w| w.label.clone())
3766            .collect();
3767        names.sort();
3768        names
3769    }
3770
3771    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
3772    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
3773        let is_lodging = self
3774            .placed_containers
3775            .iter()
3776            .find(|c| c.id == container_id)
3777            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
3778        if !is_lodging {
3779            return None;
3780        }
3781        let names = self.lodging_occupant_labels(container_id);
3782        Some(if names.is_empty() {
3783            "vacant".into()
3784        } else {
3785            names.join(", ")
3786        })
3787    }
3788
3789    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
3790        self.quest_log
3791            .iter()
3792            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
3793            .or_else(|| {
3794                self.quest_log
3795                    .iter()
3796                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
3797            })
3798    }
3799
3800    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
3801    pub fn nearest_interact_target(&self) -> Option<String> {
3802        let (px, py) = self.player_position();
3803        let inside = self.effective_inside_building();
3804
3805        #[derive(Clone, Copy, PartialEq, Eq)]
3806        enum Kind {
3807            Npc,
3808            QuestBoard,
3809            ExitDoor,
3810            EnterDoor,
3811            Well,
3812            Water,
3813        }
3814
3815        fn kind_priority(kind: Kind) -> u8 {
3816            match kind {
3817                Kind::Npc => 0,
3818                Kind::QuestBoard => 1,
3819                Kind::ExitDoor => 2,
3820                Kind::EnterDoor => 3,
3821                Kind::Well => 4,
3822                Kind::Water => 5,
3823            }
3824        }
3825
3826        let mut best: Option<(f32, Kind, String)> = None;
3827
3828        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
3829            if dist > max {
3830                return;
3831            }
3832            let replace = match best {
3833                None => true,
3834                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
3835                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
3836                    kind_priority(kind) < kind_priority(bk)
3837                }
3838                _ => false,
3839            };
3840            if replace {
3841                best = Some((dist, kind, id));
3842            }
3843        };
3844
3845        for npc in &self.npcs {
3846            consider(
3847                distance(px, py, npc.x, npc.y),
3848                INTERACTION_RADIUS_M,
3849                Kind::Npc,
3850                npc.id.clone(),
3851            );
3852        }
3853
3854        for door in &self.doors {
3855            if let Some(ref bid) = inside {
3856                if door.building_id != *bid {
3857                    continue;
3858                }
3859                let is_exit = door.portal.is_some();
3860                let max = if is_exit {
3861                    INTERACTION_RADIUS_M
3862                } else {
3863                    DOOR_INTERACTION_RADIUS_M
3864                };
3865                let kind = if is_exit {
3866                    Kind::ExitDoor
3867                } else {
3868                    Kind::EnterDoor
3869                };
3870                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
3871                continue;
3872            }
3873            consider(
3874                distance(px, py, door.x, door.y),
3875                DOOR_INTERACTION_RADIUS_M,
3876                Kind::EnterDoor,
3877                door.id.clone(),
3878            );
3879        }
3880
3881        if inside.is_none() {
3882            for inter in &self.interactables {
3883                if inter.kind == "quest_board" {
3884                    consider(
3885                        distance(px, py, inter.x, inter.y),
3886                        QUEST_BOARD_INTERACTION_RADIUS_M,
3887                        Kind::QuestBoard,
3888                        inter.id.clone(),
3889                    );
3890                }
3891            }
3892            for building in &self.buildings {
3893                if !building.tags.iter().any(|t| t == "well") {
3894                    continue;
3895                }
3896                consider(
3897                    distance(px, py, building.x, building.y),
3898                    INTERACTION_RADIUS_M,
3899                    Kind::Well,
3900                    building.id.clone(),
3901                );
3902            }
3903            if self.in_shallow_water() {
3904                consider(
3905                    0.0,
3906                    INTERACTION_RADIUS_M,
3907                    Kind::Water,
3908                    "water_source".into(),
3909                );
3910            }
3911        }
3912
3913        best.map(|(_, _, id)| id)
3914    }
3915
3916    /// Nearest quest board and distance (any distance), for out-of-range feedback.
3917    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
3918        if self.effective_inside_building().is_some() {
3919            return None;
3920        }
3921        let (px, py) = self.player_position();
3922        self.interactables
3923            .iter()
3924            .filter(|i| i.kind == "quest_board")
3925            .map(|i| {
3926                let label = if i.label.is_empty() {
3927                    "Quest board".to_string()
3928                } else {
3929                    i.label.clone()
3930                };
3931                (label, distance(px, py, i.x, i.y))
3932            })
3933            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
3934    }
3935
3936    /// Human-readable template name from synced catalog hints.
3937    pub fn template_display_name(&self, template_id: &str) -> String {
3938        self.inventory_hints
3939            .get(template_id)
3940            .map(|h| h.display_name.clone())
3941            .filter(|n| !n.is_empty())
3942            .unwrap_or_else(|| humanize_template_id(template_id))
3943    }
3944
3945    /// Chest label for the location panel — generic type unless this player owns it.
3946    pub fn placed_container_public_label(
3947        &self,
3948        c: &flatland_protocol::PlacedContainerView,
3949    ) -> String {
3950        let is_owner = match (self.character_id, c.owner_character_id) {
3951            (Some(me), Some(owner)) => me == owner,
3952            _ => false,
3953        };
3954        if is_owner {
3955            c.display_name.clone()
3956        } else {
3957            self.template_display_name(&c.template_id)
3958        }
3959    }
3960
3961    /// Keys on person and stowed on the keychain for the keychain overlay.
3962    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
3963        let mut out = Vec::new();
3964        for stack in &self.inventory_stacks {
3965            if stack.template_id == KEY_TEMPLATE {
3966                out.push(KeychainEntry {
3967                    stack: stack.clone(),
3968                    stowed: false,
3969                });
3970            }
3971        }
3972        for stack in &self.keychain_stacks {
3973            if stack.template_id == KEY_TEMPLATE {
3974                out.push(KeychainEntry {
3975                    stack: stack.clone(),
3976                    stowed: true,
3977                });
3978            }
3979        }
3980        out
3981    }
3982
3983    /// Display name of the chest a `container_key` opens, when known on the client.
3984    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
3985        if stack.template_id != KEY_TEMPLATE {
3986            return None;
3987        }
3988        if let Some(name) = stack
3989            .props
3990            .get(PROP_OPENS_CONTAINER_NAME)
3991            .filter(|n| !n.is_empty())
3992        {
3993            return Some(name.clone());
3994        }
3995        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
3996        self.container_name_for_lock_id(opens)
3997    }
3998
3999    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
4000    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4001        if stack.template_id == KEY_TEMPLATE {
4002            self.template_display_name(KEY_TEMPLATE)
4003        } else {
4004            stack
4005                .display_name
4006                .clone()
4007                .unwrap_or_else(|| stack.template_id.clone())
4008        }
4009    }
4010
4011    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
4012    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4013        if stack.template_id != KEY_TEMPLATE {
4014            return String::new();
4015        }
4016        match self.key_pair_chest_label(stack) {
4017            Some(chest) if self.key_drop_blocked(stack) => {
4018                format!(" [key for {chest} — can't drop while locked]")
4019            }
4020            Some(chest) => format!(" [key for {chest}]"),
4021            None => " [key — unpaired]".into(),
4022        }
4023    }
4024
4025    /// Resolve a lock id to a container label (placed chest or one still on your person).
4026    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4027        for c in &self.placed_containers {
4028            if c.lock_id.as_deref() == Some(lock) {
4029                return Some(c.display_name.clone());
4030            }
4031        }
4032        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4033            self.worn
4034                .values()
4035                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4036        })
4037    }
4038
4039    /// Keys cannot be dropped while their paired chest is locked.
4040    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4041        if stack.template_id != KEY_TEMPLATE {
4042            return false;
4043        }
4044        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4045            return false;
4046        };
4047        for c in &self.placed_containers {
4048            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4049                return true;
4050            }
4051        }
4052        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4053            return true;
4054        }
4055        self.worn
4056            .values()
4057            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4058    }
4059
4060    fn container_name_in_stacks(
4061        stacks: &[flatland_protocol::ItemStack],
4062        lock: &str,
4063    ) -> Option<String> {
4064        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
4065            for s in stacks {
4066                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
4067                    return Some(GameState::stack_container_label(s));
4068                }
4069                if let Some(name) = walk(&s.contents, lock) {
4070                    return Some(name);
4071                }
4072            }
4073            None
4074        }
4075        walk(stacks, lock)
4076    }
4077
4078    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
4079        stack
4080            .props
4081            .get(PROP_CUSTOM_NAME)
4082            .cloned()
4083            .or_else(|| stack.display_name.clone())
4084            .unwrap_or_else(|| stack.template_id.clone())
4085    }
4086
4087    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4088        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4089            for s in stacks {
4090                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
4091                    return true;
4092                }
4093                if walk(&s.contents, lock) {
4094                    return true;
4095                }
4096            }
4097            false
4098        }
4099        walk(stacks, lock)
4100    }
4101
4102    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
4103        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
4104            return Some(stack.clone());
4105        }
4106        for worn in self.worn.values() {
4107            if worn.item_instance_id == Some(instance_id) {
4108                return Some(worn.clone());
4109            }
4110            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
4111                return Some(stack.clone());
4112            }
4113        }
4114        None
4115    }
4116
4117    /// Terrain, interactables, and map objects near the player for the HUD location panel.
4118    pub fn location_context_lines(&self) -> Vec<ContextLine> {
4119        let (px, py) = self.player_position();
4120        let inside = self.effective_inside_building();
4121        let mut lines = Vec::new();
4122
4123        if let Some(kind) = self.terrain_at(px, py) {
4124            lines.push(ContextLine {
4125                on_top: true,
4126                text: format!("Terrain: {}", terrain_kind_label(kind)),
4127            });
4128        }
4129
4130        if let Some(id) = inside.as_ref() {
4131            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
4132                lines.push(ContextLine {
4133                    on_top: true,
4134                    text: format!("Inside: {}", b.label),
4135                });
4136            }
4137        }
4138
4139        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
4140
4141        for node in &self.resource_nodes {
4142            let dist = distance(px, py, node.x, node.y);
4143            if dist > NEARBY_SCAN_M {
4144                continue;
4145            }
4146            let on_top = dist <= ON_TOP_RADIUS_M;
4147            let state = match node.state {
4148                flatland_protocol::ResourceNodeState::Available => " — f harvest",
4149                flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
4150                flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
4151            };
4152            let prefix = if on_top { "On" } else { "Near" };
4153            nearby.push((
4154                dist,
4155                ContextLine {
4156                    on_top,
4157                    text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
4158                },
4159            ));
4160        }
4161
4162        for drop in &self.ground_drops {
4163            let dist = distance(px, py, drop.x, drop.y);
4164            if dist > INTERACTION_RADIUS_M {
4165                continue;
4166            }
4167            let on_top = dist <= ON_TOP_RADIUS_M;
4168            let name = self.template_display_name(&drop.template_id);
4169            let prefix = if on_top { "On" } else { "Near" };
4170            let qty = if drop.quantity > 1 {
4171                format!(" ×{}", drop.quantity)
4172            } else {
4173                String::new()
4174            };
4175            nearby.push((
4176                dist,
4177                ContextLine {
4178                    on_top,
4179                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
4180                },
4181            ));
4182        }
4183
4184        for c in &self.placed_containers {
4185            let dist = distance(px, py, c.x, c.y);
4186            if dist > CONTAINER_RANGE_M {
4187                continue;
4188            }
4189            let on_top = dist <= ON_TOP_RADIUS_M;
4190            let name = self.placed_container_public_label(c);
4191            let lock = if c.locked { " [locked]" } else { "" };
4192            let prefix = if on_top { "On" } else { "Near" };
4193            nearby.push((
4194                dist,
4195                ContextLine {
4196                    on_top,
4197                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
4198                },
4199            ));
4200        }
4201
4202        for npc in &self.npcs {
4203            let dist = distance(px, py, npc.x, npc.y);
4204            if dist > NEARBY_SCAN_M {
4205                continue;
4206            }
4207            let on_top = dist <= ON_TOP_RADIUS_M;
4208            let prefix = if on_top { "On" } else { "Near" };
4209            nearby.push((
4210                dist,
4211                ContextLine {
4212                    on_top,
4213                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
4214                },
4215            ));
4216        }
4217
4218        for door in &self.doors {
4219            let dist = distance(px, py, door.x, door.y);
4220            if dist > DOOR_INTERACTION_RADIUS_M {
4221                continue;
4222            }
4223            let building = self
4224                .buildings
4225                .iter()
4226                .find(|b| b.id == door.building_id)
4227                .map(|b| b.label.as_str())
4228                .unwrap_or(door.building_id.as_str());
4229            let action = if inside.is_some() && door.portal.is_some() {
4230                "exit"
4231            } else {
4232                "enter"
4233            };
4234            nearby.push((
4235                dist,
4236                ContextLine {
4237                    on_top: dist <= ON_TOP_RADIUS_M,
4238                    text: format!("{building} door ({dist:.1}m) — f {action}"),
4239                },
4240            ));
4241        }
4242
4243        if inside.is_none() {
4244            for inter in &self.interactables {
4245                if inter.kind != "quest_board" {
4246                    continue;
4247                }
4248                let dist = distance(px, py, inter.x, inter.y);
4249                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
4250                    continue;
4251                }
4252                let on_top = dist <= ON_TOP_RADIUS_M;
4253                let prefix = if on_top { "On" } else { "Near" };
4254                let label = if inter.label.is_empty() {
4255                    "Quest board".to_string()
4256                } else {
4257                    inter.label.clone()
4258                };
4259                nearby.push((
4260                    dist,
4261                    ContextLine {
4262                        on_top,
4263                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
4264                    },
4265                ));
4266            }
4267        }
4268
4269        if self.in_shallow_water() {
4270            let already = self
4271                .terrain_at(px, py)
4272                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
4273            if !already {
4274                nearby.push((
4275                    0.0,
4276                    ContextLine {
4277                        on_top: true,
4278                        text: "Shallow water — f fill bottle".into(),
4279                    },
4280                ));
4281            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
4282                line.text.push_str(" — f fill bottle");
4283            }
4284        }
4285
4286        for entity in &self.entities {
4287            if entity.id == self.entity_id {
4288                continue;
4289            }
4290            let dist = distance(
4291                px,
4292                py,
4293                entity.transform.position.x,
4294                entity.transform.position.y,
4295            );
4296            if dist > NEARBY_SCAN_M {
4297                continue;
4298            }
4299            let label = if entity.label.is_empty() {
4300                format!("entity {}", entity.id)
4301            } else {
4302                entity.label.clone()
4303            };
4304            nearby.push((
4305                dist,
4306                ContextLine {
4307                    on_top: dist <= ON_TOP_RADIUS_M,
4308                    text: format!("Near: {label} ({dist:.1}m)"),
4309                },
4310            ));
4311        }
4312
4313        nearby.sort_by(|a, b| {
4314            a.0.partial_cmp(&b.0)
4315                .unwrap_or(std::cmp::Ordering::Equal)
4316                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
4317        });
4318        lines.extend(nearby.into_iter().map(|(_, l)| l));
4319
4320        if lines.is_empty() {
4321            lines.push(ContextLine {
4322                on_top: false,
4323                text: "(nothing notable nearby)".into(),
4324            });
4325        }
4326
4327        lines
4328    }
4329}
4330
4331/// HUD line for the location / nearby panel.
4332#[derive(Debug, Clone)]
4333pub struct ContextLine {
4334    pub on_top: bool,
4335    pub text: String,
4336}
4337
4338const ON_TOP_RADIUS_M: f32 = 0.65;
4339const NEARBY_SCAN_M: f32 = 5.0;
4340
4341fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
4342    use flatland_protocol::TerrainKindView;
4343    match kind {
4344        TerrainKindView::Grass => "Grass",
4345        TerrainKindView::Dirt => "Dirt",
4346        TerrainKindView::Tilled => "Tilled",
4347        TerrainKindView::Desert => "Desert",
4348        TerrainKindView::Hill => "Hills",
4349        TerrainKindView::Bog => "Bog",
4350        TerrainKindView::ShallowWater => "Shallow water",
4351        TerrainKindView::DeepWater => "Deep water",
4352        TerrainKindView::Trail => "Trail",
4353        TerrainKindView::Road => "Road",
4354        TerrainKindView::Rock => "Rock",
4355    }
4356}
4357
4358fn humanize_template_id(template_id: &str) -> String {
4359    template_id
4360        .split('_')
4361        .map(|word| {
4362            let mut chars = word.chars();
4363            match chars.next() {
4364                None => String::new(),
4365                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4366            }
4367        })
4368        .collect::<Vec<_>>()
4369        .join(" ")
4370}
4371
4372/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
4373const HARVEST_RANGE_M: f32 = 1.5;
4374
4375pub struct GameClient<S: PlayConnection> {
4376    session: S,
4377    seq: Seq,
4378    pub state: GameState,
4379    last_move_forward: f32,
4380    last_move_strafe: f32,
4381}
4382
4383impl<S: PlayConnection> GameClient<S> {
4384    pub fn new(session: S) -> Self {
4385        let session_id = session.session_id();
4386        let entity_id = session.entity_id();
4387        Self {
4388            session,
4389            seq: 0,
4390            last_move_forward: 0.0,
4391            last_move_strafe: 0.0,
4392            state: GameState {
4393                session_id,
4394                entity_id,
4395                character_id: None,
4396                tick: 0,
4397                chunk_rev: 0,
4398                content_rev: 0,
4399                publish_rev: 0,
4400                entities: Vec::new(),
4401                player: None,
4402                resource_nodes: Vec::new(),
4403                ground_drops: Vec::new(),
4404                placed_containers: Vec::new(),
4405                buildings: Vec::new(),
4406                doors: Vec::new(),
4407                interior_map: None,
4408                npcs: Vec::new(),
4409                blueprints: Vec::new(),
4410                world_width_m: 0.0,
4411                world_height_m: 0.0,
4412                terrain_zones: Vec::new(),
4413                z_platforms: Vec::new(),
4414                z_transitions: Vec::new(),
4415                world_clock: flatland_protocol::WorldClock::default(),
4416                inventory: std::collections::HashMap::new(),
4417                inventory_hints: std::collections::HashMap::new(),
4418                logs: VecDeque::new(),
4419                intents_sent: 0,
4420                ticks_received: 0,
4421                connected: false,
4422                disconnect_reason: None,
4423                show_stats: false,
4424                show_equip_menu: false,
4425                equip_menu_index: 0,
4426                show_craft_menu: false,
4427                craft_menu_index: 0,
4428                craft_batch_quantity: 1,
4429                show_shop_menu: false,
4430                shop_catalog: None,
4431                bank_panel: None,
4432                bank_menu_index: 0,
4433                bank_ui_mode: BankUiMode::Menu,
4434                storage_panel: None,
4435                storage_menu_index: 0,
4436                storage_ui_mode: StorageUiMode::Menu,
4437                shop_tab: ShopTab::default(),
4438                shop_menu_index: 0,
4439                shop_quantity: 1,
4440                shop_trade_log: VecDeque::new(),
4441                show_npc_verb_menu: false,
4442                npc_verb_target: None,
4443                npc_verb_index: 0,
4444                show_npc_chat: false,
4445                npc_chat: None,
4446                show_inventory_menu: false,
4447                inventory_menu_index: 0,
4448                inventory_tab: InventoryTab::OnPerson,
4449                inventory_filter: String::new(),
4450                inventory_filter_focused: false,
4451                show_move_picker: false,
4452                show_rename_prompt: false,
4453                show_worker_rename: false,
4454                rename_buffer: String::new(),
4455                move_picker_index: 0,
4456                move_picker: None,
4457                show_grant_picker: false,
4458                grant_picker_index: 0,
4459                grant_picker: None,
4460                show_destroy_picker: false,
4461                destroy_confirm_pending: false,
4462                destroy_picker: None,
4463                combat_target: None,
4464                combat_target_label: None,
4465                combat_fx: Vec::new(),
4466                in_combat: false,
4467                auto_attack: true,
4468                combat_has_los: false,
4469                attack_cd_ticks: 0,
4470                gcd_ticks: 0,
4471                weapon_ability_id: "unarmed".into(),
4472                mainhand_template_id: None,
4473                mainhand_label: None,
4474                offhand_template_id: None,
4475                offhand_label: None,
4476                mainhand_hand_slots: 1,
4477                defense: None,
4478                worn: BTreeMap::new(),
4479                carry_mass: 0.0,
4480                carry_mass_max: 0.0,
4481                encumbrance: flatland_protocol::EncumbranceState::Light,
4482                inventory_stacks: Vec::new(),
4483                keychain_stacks: Vec::new(),
4484                combat_target_detail: None,
4485                statuses: Vec::new(),
4486                cast_progress: None,
4487                ability_cooldowns: Vec::new(),
4488                blocking_active: false,
4489                max_target_slots: 1,
4490                combat_slots: Vec::new(),
4491                rotation_presets: Vec::new(),
4492                known_abilities: Vec::new(),
4493                hotbar: vec![None; 9],
4494                max_abilities_per_rotation: 0,
4495                show_loadout_menu: false,
4496                show_keychain_menu: false,
4497                keychain_menu_index: 0,
4498                show_rotation_editor: false,
4499                loadout_menu_index: 0,
4500                loadout_hotbar_slot: 1,
4501                loadout_ability_index: 0,
4502                loadout_focus_presets: false,
4503                rotation_editor: RotationEditorState::default(),
4504                harvest_in_progress: false,
4505                harvest_started_at: None,
4506                pending_craft_ack: None,
4507                pending_worker_job_ack: None,
4508                quest_log: Vec::new(),
4509                interactables: Vec::new(),
4510                ledger: None,
4511                career: None,
4512                character_sheet_tab: CharacterSheetTab::Character,
4513                ledger_period: LedgerPeriod::Day,
4514                show_quest_offer: false,
4515                pending_quest_offer: None,
4516                show_quest_menu: false,
4517                quest_menu_index: 0,
4518                quest_withdraw_confirm: false,
4519                hired_workers: Vec::new(),
4520                show_workers_menu: false,
4521                workers_menu_index: 0,
4522                workers_menu_compact: false,
4523                worker_step_display: BTreeMap::new(),
4524                worker_error_display: BTreeMap::new(),
4525                show_worker_give_picker: false,
4526                worker_give_picker_index: 0,
4527                worker_give_picker: None,
4528                show_worker_give_target_picker: false,
4529                worker_give_target_picker_index: 0,
4530                worker_give_target_picker: None,
4531                show_worker_take_picker: false,
4532                worker_take_picker_index: 0,
4533                worker_take_picker: None,
4534                show_worker_teach_picker: false,
4535                worker_teach_picker_index: 0,
4536                worker_teach_picker: None,
4537                worker_route_editor: None,
4538                progression_curve: None,
4539            },
4540        }
4541    }
4542
4543    pub fn entity_id(&self) -> EntityId {
4544        self.state.entity_id
4545    }
4546
4547    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
4548        if self.state.connected {
4549            return Ok(());
4550        }
4551
4552        loop {
4553            match self.session.next_event().await {
4554                Some(SessionEvent::Welcome {
4555                    session_id,
4556                    entity_id,
4557                    snapshot,
4558                }) => {
4559                    self.state
4560                        .restore_from_welcome(session_id, entity_id, &snapshot);
4561                    self.state.push_log(format!(
4562                        "Connected — session {session_id}, entity {entity_id}"
4563                    ));
4564                    return Ok(());
4565                }
4566                Some(SessionEvent::Disconnected { .. }) => {
4567                    anyhow::bail!("disconnected before welcome");
4568                }
4569                Some(_) => continue,
4570                None => anyhow::bail!("session closed before welcome"),
4571            }
4572        }
4573    }
4574
4575    /// Drain all pending server events (non-blocking).
4576    pub fn drain_events(&mut self) {
4577        while let Some(event) = self.session.try_next_event() {
4578            if self.handle_event_sync(event).is_err() {
4579                break;
4580            }
4581        }
4582    }
4583
4584    /// Wait for the next server event.
4585    pub async fn next_event(&mut self) -> Option<SessionEvent> {
4586        self.session.next_event().await
4587    }
4588
4589    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4590        self.handle_event_sync(event)
4591    }
4592
4593    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4594        match event {
4595            SessionEvent::Welcome {
4596                session_id,
4597                entity_id,
4598                snapshot,
4599            } => {
4600                let resumed = self.state.connected;
4601                self.state
4602                    .restore_from_welcome(session_id, entity_id, &snapshot);
4603                if resumed {
4604                    self.state.push_log(format!(
4605                        "Session restored — session {session_id}, entity {entity_id}"
4606                    ));
4607                }
4608            }
4609            SessionEvent::ContentUpdated { snapshot } => {
4610                self.state
4611                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
4612                self.state.push_log(format!(
4613                    "World updated (content rev {})",
4614                    snapshot.content_rev
4615                ));
4616            }
4617            SessionEvent::Tick(delta) => {
4618                self.state.apply_tick_fields(&delta, self.state.entity_id);
4619                self.state.ticks_received += 1;
4620            }
4621            SessionEvent::IntentAck {
4622                entity_id,
4623                seq,
4624                tick,
4625            } => {
4626                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
4627                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
4628                    if *craft_seq == seq {
4629                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
4630                        if batches > 1 {
4631                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
4632                        } else {
4633                            self.state.push_log(format!("Crafting {label}…"));
4634                        }
4635                    }
4636                }
4637                if self
4638                    .state
4639                    .pending_worker_job_ack
4640                    .as_ref()
4641                    .is_some_and(|p| p.seq == seq)
4642                {
4643                    let pending = self.state.pending_worker_job_ack.take().unwrap();
4644                    if pending.idle {
4645                        self.state.push_log(format!(
4646                            "Route cleared for {} — worker idle",
4647                            pending.worker_label
4648                        ));
4649                    } else {
4650                        self.state.push_log(format!(
4651                            "Route saved for {} — {} stop(s), job loop active",
4652                            pending.worker_label, pending.stop_count
4653                        ));
4654                    }
4655                    if self
4656                        .state
4657                        .worker_route_editor
4658                        .as_ref()
4659                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
4660                    {
4661                        self.close_worker_route_editor();
4662                    }
4663                }
4664            }
4665            SessionEvent::Chat(msg) => {
4666                let label = match msg.channel {
4667                    flatland_protocol::ChatChannel::Nearby => "nearby",
4668                    flatland_protocol::ChatChannel::WhisperStone => "whisper",
4669                };
4670                self.state
4671                    .push_log(format!("[{label}] {}: {}", msg.from_name, msg.text));
4672            }
4673            SessionEvent::HarvestResult(result) => {
4674                self.state.clear_harvest_state();
4675                crate::harvest_trace!(
4676                    entity_id = self.state.entity_id,
4677                    node_id = %result.node_id,
4678                    template = %result.item_template,
4679                    quantity = result.quantity,
4680                    client_tick = self.state.tick,
4681                    "client applied harvest result"
4682                );
4683                self.state.push_log(format!(
4684                    "Harvested {} x{} (on the ground — press P to pick up)",
4685                    result.item_template, result.quantity
4686                ));
4687            }
4688            SessionEvent::CraftResult(result) => {
4689                for stack in &result.consumed {
4690                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
4691                        *qty = qty.saturating_sub(stack.quantity);
4692                        if *qty == 0 {
4693                            self.state.inventory.remove(&stack.template_id);
4694                        }
4695                    }
4696                }
4697                for stack in &result.outputs {
4698                    *self
4699                        .state
4700                        .inventory
4701                        .entry(stack.template_id.clone())
4702                        .or_insert(0) += stack.quantity;
4703                }
4704                if let Some(output) = result.outputs.first() {
4705                    if result.batch_total > 1 {
4706                        self.state.push_log(format!(
4707                            "Crafted {} x{} ({}/{})",
4708                            output.template_id,
4709                            output.quantity,
4710                            result.batch_index,
4711                            result.batch_total
4712                        ));
4713                    } else {
4714                        self.state.push_log(format!(
4715                            "Crafted {} x{}",
4716                            output.template_id, output.quantity
4717                        ));
4718                    }
4719                } else {
4720                    self.state
4721                        .push_log(format!("Craft finished: {}", result.blueprint_id));
4722                }
4723            }
4724            SessionEvent::Death(notice) => {
4725                self.state.clear_harvest_state();
4726                self.state.push_log(notice.message.clone());
4727                self.state.push_log(format!(
4728                    "Respawned at ({:.1}, {:.1})",
4729                    notice.respawn_x, notice.respawn_y
4730                ));
4731            }
4732            SessionEvent::Interaction(notice) => {
4733                if notice.message.starts_with("Harvest failed:") {
4734                    self.state.clear_harvest_state();
4735                }
4736                if notice.message.starts_with("Can't do that:") {
4737                    self.state.pending_craft_ack = None;
4738                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
4739                        if let Some(w) = self
4740                            .state
4741                            .hired_workers
4742                            .iter_mut()
4743                            .find(|w| w.instance_id == pending.worker_instance_id)
4744                        {
4745                            w.route = pending.prev_route;
4746                            w.mode = pending.prev_mode;
4747                            w.step_label = pending.prev_step_label;
4748                            w.last_error = pending.prev_last_error;
4749                        }
4750                        let reason = notice
4751                            .message
4752                            .strip_prefix("Can't do that:")
4753                            .unwrap_or(&notice.message)
4754                            .trim();
4755                        self.state.push_log(format!(
4756                            "Route save failed for {}: {reason}",
4757                            pending.worker_label
4758                        ));
4759                    }
4760                }
4761                if notice.message.starts_with("Cast failed:") {
4762                    self.state.cast_progress = None;
4763                }
4764                if notice.message.contains("slain the") {
4765                    self.state.combat_target = None;
4766                    self.state.combat_target_label = None;
4767                }
4768                self.state.apply_interaction_notice(&notice);
4769                self.state.push_log(notice.message.clone());
4770            }
4771            SessionEvent::ShopOpened(catalog) => {
4772                self.state.apply_shop_catalog(catalog);
4773            }
4774            SessionEvent::BankOpened(panel) => {
4775                self.state.apply_bank_panel(panel);
4776            }
4777            SessionEvent::StorageOpened(panel) => {
4778                self.state.apply_storage_panel(panel);
4779            }
4780            SessionEvent::NpcTalkOpened(opened) => {
4781                self.state.show_npc_verb_menu = false;
4782                if self.state.npc_verb_target.is_none() {
4783                    self.state.npc_verb_target = Some(opened.npc_id.clone());
4784                }
4785                let label = opened.npc_label.clone();
4786                let banner = if !opened.trade_allowed {
4787                    Some("Trade is unavailable right now.".to_string())
4788                } else {
4789                    None
4790                };
4791                self.state.show_npc_chat = true;
4792                self.state.npc_chat = Some(NpcChatState {
4793                    npc_id: opened.npc_id,
4794                    npc_label: opened.npc_label,
4795                    lines: if opened.greeting.is_empty() {
4796                        vec![]
4797                    } else {
4798                        vec![format!("{label}: {}", opened.greeting)]
4799                    },
4800                    input: String::new(),
4801                    pending: opened.greeting.is_empty(),
4802                    talk_depth: opened.talk_depth,
4803                    trade_allowed: opened.trade_allowed,
4804                    banner,
4805                });
4806            }
4807            SessionEvent::NpcTalkPending(_) => {
4808                if let Some(chat) = self.state.npc_chat.as_mut() {
4809                    chat.pending = true;
4810                }
4811            }
4812            SessionEvent::NpcTalkReply(reply) => {
4813                if let Some(chat) = self.state.npc_chat.as_mut() {
4814                    if chat.npc_id == reply.npc_id {
4815                        chat.pending = false;
4816                        if reply.trade_disabled {
4817                            chat.trade_allowed = false;
4818                            chat.banner = Some("Trade is unavailable right now.".to_string());
4819                        }
4820                        if reply.wind_down {
4821                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
4822                            if chat.banner.is_none() {
4823                                chat.banner =
4824                                    Some("They're wrapping up — keep it brief.".to_string());
4825                            }
4826                        }
4827                        chat.lines
4828                            .push(format!("{}: {}", chat.npc_label, reply.line));
4829                    }
4830                }
4831            }
4832            SessionEvent::NpcTalkClosed(closed) => {
4833                if self
4834                    .state
4835                    .npc_chat
4836                    .as_ref()
4837                    .is_some_and(|c| c.npc_id == closed.npc_id)
4838                {
4839                    self.state.show_npc_chat = false;
4840                    self.state.npc_chat = None;
4841                }
4842            }
4843            SessionEvent::NpcTalkError(err) => {
4844                self.state.push_log(format!("Talk failed: {}", err.reason));
4845                if let Some(chat) = self.state.npc_chat.as_mut() {
4846                    chat.pending = false;
4847                }
4848            }
4849            SessionEvent::UseResult(result) => {
4850                // Inventory count hint only — the Interaction notice already logs
4851                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
4852                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
4853                    *qty = qty.saturating_sub(1);
4854                    if *qty == 0 {
4855                        self.state.inventory.remove(&result.template_id);
4856                    }
4857                }
4858            }
4859            SessionEvent::QuestOffer(offer) => {
4860                self.state.pending_quest_offer = Some(offer.clone());
4861                self.state.show_quest_offer = true;
4862                self.state
4863                    .push_log(format!("Quest offered: {}", offer.title));
4864            }
4865            SessionEvent::QuestAccepted(notice) => {
4866                self.state.show_quest_offer = false;
4867                self.state.pending_quest_offer = None;
4868                self.state.push_log(notice.message);
4869            }
4870            SessionEvent::QuestWithdrawn(notice) => {
4871                self.state.show_quest_menu = false;
4872                self.state.quest_withdraw_confirm = false;
4873                self.state.push_log(notice.message);
4874            }
4875            SessionEvent::QuestStepCompleted(notice) => {
4876                self.state.push_log(notice.message);
4877            }
4878            SessionEvent::QuestCompleted(notice) => {
4879                self.state.push_log(notice.message);
4880            }
4881            SessionEvent::Disconnected { reason } => {
4882                self.state.clear_harvest_state();
4883                self.state.connected = false;
4884                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
4885                if let Some(r) = &self.state.disconnect_reason {
4886                    self.state.push_log(format!("Disconnected: {r}"));
4887                } else {
4888                    self.state.push_log("Disconnected from server");
4889                }
4890            }
4891        }
4892        Ok(())
4893    }
4894
4895    pub fn is_connected(&self) -> bool {
4896        self.state.connected
4897    }
4898
4899    pub fn close_overlays(&mut self) {
4900        self.state.show_stats = false;
4901        self.state.show_craft_menu = false;
4902        self.state.show_shop_menu = false;
4903        self.state.shop_catalog = None;
4904        self.state.show_npc_verb_menu = false;
4905        self.state.npc_verb_target = None;
4906        self.state.show_npc_chat = false;
4907        self.state.npc_chat = None;
4908        self.state.show_inventory_menu = false;
4909        self.state.show_loadout_menu = false;
4910        self.state.show_rotation_editor = false;
4911        self.state.rotation_editor.reset();
4912        self.state.show_rename_prompt = false;
4913        self.state.show_worker_rename = false;
4914        self.state.rename_buffer.clear();
4915        self.state.show_move_picker = false;
4916        self.state.move_picker = None;
4917        self.state.show_destroy_picker = false;
4918        self.state.destroy_confirm_pending = false;
4919        self.state.destroy_picker = None;
4920        self.state.show_quest_offer = false;
4921        self.state.pending_quest_offer = None;
4922        self.state.show_quest_menu = false;
4923        self.state.quest_withdraw_confirm = false;
4924        self.state.show_workers_menu = false;
4925        self.close_worker_give_picker();
4926        self.close_worker_give_target_picker();
4927        self.close_worker_take_picker();
4928        self.close_worker_teach_picker();
4929        self.state.worker_route_editor = None;
4930    }
4931
4932    /// Esc / back — pop one UI layer instead of closing every overlay at once.
4933    pub fn back_on_esc(&mut self) -> bool {
4934        if self.state.show_rename_prompt {
4935            self.cancel_rename_prompt();
4936            return true;
4937        }
4938        if self.state.show_worker_rename {
4939            self.cancel_worker_rename();
4940            return true;
4941        }
4942        if self.state.show_destroy_picker {
4943            if self.state.destroy_confirm_pending {
4944                self.cancel_destroy_confirm();
4945            } else {
4946                self.close_destroy_picker();
4947            }
4948            return true;
4949        }
4950        if self.state.show_move_picker {
4951            self.close_move_picker();
4952            return true;
4953        }
4954        if self.state.show_rotation_editor {
4955            match self.state.rotation_editor.mode {
4956                RotationEditorMode::List => {
4957                    self.state.show_rotation_editor = false;
4958                    self.state.rotation_editor.reset();
4959                }
4960                RotationEditorMode::EditLabel => {
4961                    self.state.rotation_editor.label_buffer.clear();
4962                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
4963                }
4964                RotationEditorMode::PickAbility => {
4965                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
4966                }
4967                RotationEditorMode::EditSequence => {
4968                    self.state.rotation_editor.draft = None;
4969                    self.state.rotation_editor.mode = RotationEditorMode::List;
4970                }
4971            }
4972            return true;
4973        }
4974        if self.state.show_inventory_menu {
4975            self.close_inventory_menu();
4976            return true;
4977        }
4978        if self.state.show_craft_menu {
4979            self.close_craft_menu();
4980            return true;
4981        }
4982        if self.state.show_quest_offer {
4983            self.quest_offer_decline();
4984            return true;
4985        }
4986        if self.state.show_shop_menu {
4987            // Caller must await shop close (npc_interaction_back / play-loop).
4988            return false;
4989        }
4990        if self.state.bank_panel.is_some() {
4991            return false;
4992        }
4993        if self.state.storage_panel.is_some() {
4994            return false;
4995        }
4996        if self.state.show_npc_chat {
4997            // play.rs calls npc_interaction_back().await on Esc
4998            return false;
4999        }
5000        if self.state.show_npc_verb_menu {
5001            self.state.show_npc_verb_menu = false;
5002            self.state.npc_verb_target = None;
5003            return true;
5004        }
5005        if self.state.show_quest_menu {
5006            if self.state.quest_withdraw_confirm {
5007                self.state.quest_withdraw_confirm = false;
5008            } else {
5009                self.state.show_quest_menu = false;
5010            }
5011            return true;
5012        }
5013        if self.state.worker_route_editor.is_some() {
5014            // Pop one sheet level; only close the editor at the root stop list.
5015            if self.re_at_root_sheet() {
5016                self.close_worker_route_editor();
5017            } else {
5018                self.re_sheet_back();
5019            }
5020            return true;
5021        }
5022        if self.state.show_worker_give_picker {
5023            self.close_worker_give_picker();
5024            return true;
5025        }
5026        if self.state.show_worker_give_target_picker {
5027            self.close_worker_give_target_picker();
5028            return true;
5029        }
5030        if self.state.show_worker_take_picker {
5031            self.close_worker_take_picker();
5032            return true;
5033        }
5034        if self.state.show_worker_teach_picker {
5035            self.close_worker_teach_picker();
5036            return true;
5037        }
5038        if self.state.show_workers_menu {
5039            self.state.show_workers_menu = false;
5040            return true;
5041        }
5042        if self.state.show_loadout_menu {
5043            self.state.show_loadout_menu = false;
5044            return true;
5045        }
5046        if self.state.show_stats {
5047            self.state.show_stats = false;
5048            return true;
5049        }
5050        if self.state.show_equip_menu {
5051            self.state.show_equip_menu = false;
5052            return true;
5053        }
5054        false
5055    }
5056
5057    pub fn toggle_stats(&mut self) {
5058        self.state.show_stats = !self.state.show_stats;
5059        if self.state.show_stats {
5060            self.state.character_sheet_tab = CharacterSheetTab::Character;
5061            self.state.show_craft_menu = false;
5062            self.state.show_shop_menu = false;
5063            self.state.shop_catalog = None;
5064            self.state.show_inventory_menu = false;
5065            self.state.show_equip_menu = false;
5066        }
5067    }
5068
5069    pub fn toggle_equip_menu(&mut self) {
5070        self.state.show_equip_menu = !self.state.show_equip_menu;
5071        if self.state.show_equip_menu {
5072            self.state.show_stats = false;
5073            self.state.show_craft_menu = false;
5074            self.state.show_shop_menu = false;
5075            self.state.shop_catalog = None;
5076            self.state.show_inventory_menu = false;
5077            self.state.show_loadout_menu = false;
5078        }
5079    }
5080
5081    pub fn cycle_character_sheet_tab(&mut self) {
5082        if self.state.show_stats {
5083            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
5084        }
5085    }
5086
5087    pub fn set_ledger_period_digit(&mut self, c: char) {
5088        if self.state.show_stats {
5089            if let Some(p) = LedgerPeriod::from_digit(c) {
5090                self.state.ledger_period = p;
5091                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
5092            }
5093        }
5094    }
5095
5096    pub fn cycle_ledger_period(&mut self) {
5097        if self.state.show_stats
5098            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
5099        {
5100            self.state.ledger_period = self.state.ledger_period.cycle();
5101        }
5102    }
5103
5104    pub fn open_inventory_menu(&mut self) {
5105        self.state.show_inventory_menu = true;
5106        self.state.show_craft_menu = false;
5107        self.state.show_shop_menu = false;
5108        self.state.shop_catalog = None;
5109        self.state.show_stats = false;
5110        self.state.show_move_picker = false;
5111        self.state.move_picker = None;
5112        self.state.show_destroy_picker = false;
5113        self.state.destroy_confirm_pending = false;
5114        self.state.destroy_picker = None;
5115        self.state.show_rename_prompt = false;
5116        self.state.rename_buffer.clear();
5117        self.state.inventory_filter_focused = false;
5118        self.state.clamp_inventory_indices();
5119    }
5120
5121    pub fn close_inventory_menu(&mut self) {
5122        self.state.show_inventory_menu = false;
5123        self.state.show_move_picker = false;
5124        self.state.move_picker = None;
5125        self.close_grant_picker();
5126        self.state.show_destroy_picker = false;
5127        self.state.destroy_confirm_pending = false;
5128        self.state.destroy_picker = None;
5129        self.state.show_rename_prompt = false;
5130        self.state.rename_buffer.clear();
5131        self.state.inventory_filter_focused = false;
5132    }
5133
5134    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
5135        let Some(row) = self.state.inventory_selected_row() else {
5136            anyhow::bail!("inventory empty");
5137        };
5138        if !self.state.row_is_renameable_container(&row) {
5139            anyhow::bail!("only storage containers can be renamed");
5140        }
5141        let current = row
5142            .stack
5143            .display_name
5144            .clone()
5145            .unwrap_or_else(|| row.stack.template_id.clone());
5146        self.state.rename_buffer = current;
5147        self.state.show_rename_prompt = true;
5148        self.state.show_worker_rename = false;
5149        self.state.show_move_picker = false;
5150        self.state.show_destroy_picker = false;
5151        self.state.destroy_confirm_pending = false;
5152        Ok(())
5153    }
5154
5155    pub fn cancel_rename_prompt(&mut self) {
5156        self.state.show_rename_prompt = false;
5157        self.state.rename_buffer.clear();
5158    }
5159
5160    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
5161        let name = self.state.rename_buffer.trim().to_string();
5162        if name.is_empty() {
5163            anyhow::bail!("name cannot be empty");
5164        }
5165        let Some(row) = self.state.inventory_selected_row() else {
5166            anyhow::bail!("inventory empty");
5167        };
5168        let Some(instance_id) = row.stack.item_instance_id else {
5169            anyhow::bail!("item has no instance id");
5170        };
5171        self.seq += 1;
5172        self.session
5173            .submit_intent(Intent::RenameContainer {
5174                entity_id: self.state.entity_id,
5175                item_instance_id: instance_id,
5176                location: row.from.clone(),
5177                name,
5178                seq: self.seq,
5179            })
5180            .await?;
5181        self.state.intents_sent += 1;
5182        self.state.show_rename_prompt = false;
5183        self.state.rename_buffer.clear();
5184        Ok(())
5185    }
5186
5187    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
5188        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5189            anyhow::bail!("no worker selected");
5190        };
5191        self.state.rename_buffer = worker.label.clone();
5192        self.state.show_worker_rename = true;
5193        self.state.show_rename_prompt = false;
5194        Ok(())
5195    }
5196
5197    pub fn cancel_worker_rename(&mut self) {
5198        self.state.show_worker_rename = false;
5199        self.state.rename_buffer.clear();
5200    }
5201
5202    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
5203        let name = self.state.rename_buffer.trim().to_string();
5204        if name.is_empty() {
5205            anyhow::bail!("name cannot be empty");
5206        }
5207        if name.chars().count() > 32 {
5208            anyhow::bail!("name must be 1–32 characters");
5209        }
5210        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5211            anyhow::bail!("no worker selected");
5212        };
5213        let worker_instance_id = worker.instance_id.clone();
5214        self.seq += 1;
5215        self.session
5216            .submit_intent(Intent::RenameHiredWorker {
5217                entity_id: self.state.entity_id,
5218                worker_instance_id: worker_instance_id.clone(),
5219                name: name.clone(),
5220                seq: self.seq,
5221            })
5222            .await?;
5223        self.state.intents_sent += 1;
5224        if let Some(w) = self
5225            .state
5226            .hired_workers
5227            .iter_mut()
5228            .find(|w| w.instance_id == worker_instance_id)
5229        {
5230            w.label = name.clone();
5231        }
5232        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5233            if ed.worker_instance_id == worker_instance_id {
5234                ed.worker_label = name.clone();
5235            }
5236        }
5237        self.state.show_worker_rename = false;
5238        self.state.rename_buffer.clear();
5239        self.state.push_log(format!("Renamed worker to \"{name}\""));
5240        Ok(())
5241    }
5242
5243    pub fn toggle_inventory_menu(&mut self) {
5244        if self.state.show_inventory_menu {
5245            self.close_inventory_menu();
5246        } else {
5247            self.open_inventory_menu();
5248        }
5249    }
5250
5251    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
5252    pub fn inventory_menu_move(&mut self, delta: i32) {
5253        if self.state.show_grant_picker {
5254            let Some(picker) = self.state.grant_picker.as_ref() else {
5255                return;
5256            };
5257            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5258            let filter = picker.filter.clone();
5259            let n = labels.len();
5260            if n == 0 {
5261                return;
5262            }
5263            self.state.grant_picker_index = step_filtered_index(
5264                self.state.grant_picker_index,
5265                delta,
5266                n,
5267                |i| list_label_matches(&labels[i], &filter),
5268            );
5269            return;
5270        }
5271        if self.state.show_move_picker {
5272            let Some(picker) = self.state.move_picker.as_ref() else {
5273                return;
5274            };
5275            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5276            let filter = picker.filter.clone();
5277            let n = labels.len();
5278            if n == 0 {
5279                return;
5280            }
5281            self.state.move_picker_index = step_filtered_index(
5282                self.state.move_picker_index,
5283                delta,
5284                n,
5285                |i| list_label_matches(&labels[i], &filter),
5286            );
5287            self.state.clamp_move_picker_quantity();
5288            return;
5289        }
5290        let n = self.state.inventory_selectable_rows().len();
5291        if n == 0 {
5292            return;
5293        }
5294        let idx = self.state.inventory_menu_index as i32;
5295        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5296    }
5297
5298    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
5299    pub fn inventory_menu_page(&mut self, pages: i32) {
5300        if self.state.show_grant_picker {
5301            let Some(picker) = self.state.grant_picker.as_ref() else {
5302                return;
5303            };
5304            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5305            let filter = picker.filter.clone();
5306            let n = labels.len();
5307            self.state.grant_picker_index = page_filtered_index(
5308                self.state.grant_picker_index,
5309                pages,
5310                n,
5311                |i| list_label_matches(&labels[i], &filter),
5312            );
5313            return;
5314        }
5315        if self.state.show_move_picker {
5316            let Some(picker) = self.state.move_picker.as_ref() else {
5317                return;
5318            };
5319            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5320            let filter = picker.filter.clone();
5321            let n = labels.len();
5322            self.state.move_picker_index = page_filtered_index(
5323                self.state.move_picker_index,
5324                pages,
5325                n,
5326                |i| list_label_matches(&labels[i], &filter),
5327            );
5328            self.state.clamp_move_picker_quantity();
5329            return;
5330        }
5331        let n = self.state.inventory_selectable_rows().len();
5332        self.state.inventory_menu_index =
5333            page_list_index(self.state.inventory_menu_index, pages, n);
5334    }
5335
5336    pub fn cycle_inventory_tab(&mut self, forward: bool) {
5337        if self.state.show_move_picker
5338            || self.state.show_grant_picker
5339            || self.state.show_destroy_picker
5340            || self.state.show_rename_prompt
5341            || self.state.inventory_filter_focused
5342        {
5343            return;
5344        }
5345        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
5346        self.state.inventory_menu_index = 0;
5347        self.state.clamp_inventory_indices();
5348    }
5349
5350    pub fn focus_inventory_filter(&mut self) {
5351        if self.state.show_grant_picker {
5352            if let Some(p) = self.state.grant_picker.as_mut() {
5353                p.filter_focused = true;
5354            }
5355            return;
5356        }
5357        if self.state.show_move_picker {
5358            if let Some(p) = self.state.move_picker.as_mut() {
5359                p.filter_focused = true;
5360            }
5361            return;
5362        }
5363        self.state.inventory_filter_focused = true;
5364    }
5365
5366    pub fn set_inventory_filter(&mut self, filter: String) {
5367        self.state.inventory_filter = filter;
5368        self.state.inventory_menu_index = 0;
5369        self.state.clamp_inventory_indices();
5370    }
5371
5372    pub fn append_inventory_filter_char(&mut self, ch: char) {
5373        if ch.is_control() {
5374            return;
5375        }
5376        if self.state.show_grant_picker {
5377            if let Some(p) = self.state.grant_picker.as_mut() {
5378                if p.filter_focused {
5379                    p.filter.push(ch);
5380                    self.state.grant_picker_index = 0;
5381                }
5382            }
5383            return;
5384        }
5385        if self.state.show_move_picker {
5386            if let Some(p) = self.state.move_picker.as_mut() {
5387                if p.filter_focused {
5388                    p.filter.push(ch);
5389                    self.state.move_picker_index = 0;
5390                    self.state.clamp_move_picker_quantity();
5391                }
5392            }
5393            return;
5394        }
5395        if !self.state.inventory_filter_focused {
5396            return;
5397        }
5398        self.state.inventory_filter.push(ch);
5399        self.state.inventory_menu_index = 0;
5400        self.state.clamp_inventory_indices();
5401    }
5402
5403    pub fn inventory_filter_backspace(&mut self) {
5404        if self.state.show_grant_picker {
5405            if let Some(p) = self.state.grant_picker.as_mut() {
5406                if p.filter_focused {
5407                    p.filter.pop();
5408                    self.state.grant_picker_index = 0;
5409                }
5410            }
5411            return;
5412        }
5413        if self.state.show_move_picker {
5414            if let Some(p) = self.state.move_picker.as_mut() {
5415                if p.filter_focused {
5416                    p.filter.pop();
5417                    self.state.move_picker_index = 0;
5418                    self.state.clamp_move_picker_quantity();
5419                }
5420            }
5421            return;
5422        }
5423        if !self.state.inventory_filter_focused {
5424            return;
5425        }
5426        self.state.inventory_filter.pop();
5427        self.state.inventory_menu_index = 0;
5428        self.state.clamp_inventory_indices();
5429    }
5430
5431    /// Esc while filter focused: clear filter then blur. Returns true if handled.
5432    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
5433        if self.state.show_grant_picker {
5434            if let Some(p) = self.state.grant_picker.as_mut() {
5435                if p.filter_focused {
5436                    if !p.filter.is_empty() {
5437                        p.filter.clear();
5438                        self.state.grant_picker_index = 0;
5439                    } else {
5440                        p.filter_focused = false;
5441                    }
5442                    return true;
5443                }
5444                if !p.filter.is_empty() {
5445                    p.filter.clear();
5446                    self.state.grant_picker_index = 0;
5447                    return true;
5448                }
5449            }
5450            return false;
5451        }
5452        if self.state.show_move_picker {
5453            if let Some(p) = self.state.move_picker.as_mut() {
5454                if p.filter_focused {
5455                    if !p.filter.is_empty() {
5456                        p.filter.clear();
5457                        self.state.move_picker_index = 0;
5458                        self.state.clamp_move_picker_quantity();
5459                    } else {
5460                        p.filter_focused = false;
5461                    }
5462                    return true;
5463                }
5464                if !p.filter.is_empty() {
5465                    p.filter.clear();
5466                    self.state.move_picker_index = 0;
5467                    self.state.clamp_move_picker_quantity();
5468                    return true;
5469                }
5470            }
5471            return false;
5472        }
5473        if self.state.inventory_filter_focused {
5474            if !self.state.inventory_filter.is_empty() {
5475                self.state.inventory_filter.clear();
5476                self.state.inventory_menu_index = 0;
5477                self.state.clamp_inventory_indices();
5478            } else {
5479                self.state.inventory_filter_focused = false;
5480            }
5481            return true;
5482        }
5483        if !self.state.inventory_filter.is_empty() {
5484            self.state.inventory_filter.clear();
5485            self.state.inventory_menu_index = 0;
5486            self.state.clamp_inventory_indices();
5487            return true;
5488        }
5489        false
5490    }
5491
5492    pub fn craft_menu_page(&mut self, pages: i32) {
5493        let n = self.state.blueprints.len();
5494        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
5495        self.state.clamp_craft_batch_quantity();
5496    }
5497
5498    pub fn shop_menu_page(&mut self, pages: i32) {
5499        let n = self.state.shop_list_len();
5500        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
5501        self.state.clamp_shop_quantity();
5502    }
5503
5504    pub fn workers_menu_page(&mut self, pages: i32) {
5505        let n = self.state.hired_workers.len();
5506        self.state.workers_menu_index =
5507            page_list_index(self.state.workers_menu_index, pages, n);
5508    }
5509
5510    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
5511    /// chest — or fall back to the "move to…" destination picker (including
5512    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
5513    /// Placed chest shells open a pick-up destination picker (`l` still locks).
5514    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
5515        if self.state.show_destroy_picker {
5516            if self.state.destroy_confirm_pending {
5517                return self.confirm_destroy_item().await;
5518            }
5519            return self.request_destroy_confirm();
5520        }
5521        if self.state.show_grant_picker {
5522            return self.confirm_grant_picker().await;
5523        }
5524        if self.state.show_move_picker {
5525            return self.confirm_move_picker().await;
5526        }
5527        let Some(row) = self.state.inventory_selected_row() else {
5528            anyhow::bail!("inventory empty");
5529        };
5530        if row.is_equip_shell {
5531            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
5532                anyhow::bail!("not a worn item");
5533            };
5534            return self.equip_worn(slot, None).await;
5535        }
5536        if row.is_chest_shell {
5537            return self.open_chest_pickup_picker();
5538        }
5539        let template_id = row.stack.template_id.clone();
5540        let instance_id = row.stack.item_instance_id;
5541        let category = self.state.inventory_item_category(&template_id);
5542        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5543
5544        if category == Some("weapon") {
5545            return self.equip_mainhand(Some(template_id)).await;
5546        }
5547        if category == Some("lodging") && on_person {
5548            if let Some(inst) = instance_id {
5549                return self.place_container(inst).await;
5550            }
5551        }
5552        if (category == Some("container") || category == Some("armor")) && on_person {
5553            if let Some(inst) = instance_id {
5554                let world_placeable = row.stack.world_placeable == Some(true)
5555                    || template_id.contains("chest");
5556                if world_placeable {
5557                    return self.place_container(inst).await;
5558                }
5559                // Pouches no longer equip directly — they clip onto a worn belt's loops
5560                // instead, so fall through to the move picker (offers "belt loop" when a
5561                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
5562                if let Some(slot) = guess_body_slot(&template_id) {
5563                    return self.equip_worn(slot, Some(inst)).await;
5564                }
5565            }
5566        }
5567        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
5568        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
5569        // instead of guessing.
5570        self.open_move_picker()
5571    }
5572
5573    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
5574    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
5575        let Some(row) = self.state.inventory_selected_row() else {
5576            anyhow::bail!("inventory empty");
5577        };
5578        if row.from != flatland_protocol::InventoryLocation::Root {
5579            anyhow::bail!("select a consumable on your person");
5580        }
5581        if GameState::stack_is_item_grant(&row.stack) {
5582            return self.open_grant_target_picker();
5583        }
5584        let category = self
5585            .state
5586            .inventory_item_category(&row.stack.template_id);
5587        if category != Some("consumable") {
5588            anyhow::bail!("selected item is not consumable");
5589        }
5590        self.use_item(&row.stack.template_id).await
5591    }
5592
5593    /// Open picker: apply selected grant item onto inventory / worn gear.
5594    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
5595        let Some(row) = self.state.inventory_selected_row() else {
5596            anyhow::bail!("inventory empty");
5597        };
5598        if row.from != flatland_protocol::InventoryLocation::Root {
5599            anyhow::bail!("select a grant item on your person");
5600        }
5601        if !GameState::stack_is_item_grant(&row.stack) {
5602            anyhow::bail!("selected item does not grant onto gear");
5603        }
5604        let Some(grant_instance_id) = row.stack.item_instance_id else {
5605            anyhow::bail!("grant has no instance id");
5606        };
5607        let effect_id = GameState::grant_effect_id(&row.stack)
5608            .unwrap_or("?")
5609            .to_string();
5610        let mode = GameState::grant_mode(&row.stack).to_string();
5611        let options = self.state.grant_target_options(&row.stack);
5612        if options.is_empty() {
5613            anyhow::bail!("no valid gear to apply {effect_id} to");
5614        }
5615        let grant_label = row
5616            .stack
5617            .display_name
5618            .clone()
5619            .unwrap_or_else(|| row.stack.template_id.clone());
5620        self.state.show_grant_picker = true;
5621        self.state.grant_picker_index = 0;
5622        self.state.grant_picker = Some(GrantTargetPicker {
5623            grant_instance_id,
5624            grant_label,
5625            effect_id,
5626            mode,
5627            options,
5628            filter: String::new(),
5629            filter_focused: false,
5630        });
5631        Ok(())
5632    }
5633
5634    pub fn close_grant_picker(&mut self) {
5635        self.state.show_grant_picker = false;
5636        self.state.grant_picker = None;
5637        self.state.grant_picker_index = 0;
5638    }
5639
5640    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
5641        let Some(picker) = self.state.grant_picker.clone() else {
5642            self.close_grant_picker();
5643            return Ok(());
5644        };
5645        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
5646            self.close_grant_picker();
5647            return Ok(());
5648        };
5649        self.close_grant_picker();
5650        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
5651            .await?;
5652        self.state.push_log(format!(
5653            "Applying {} onto {}…",
5654            picker.effect_id, opt.label
5655        ));
5656        Ok(())
5657    }
5658
5659    /// `m`: always open the "move to…" picker for the selected item, even for
5660    /// weapons/wearables that Enter would otherwise equip/wear directly.
5661    /// Placed chests open the pick-up destination picker instead.
5662    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
5663        let Some(row) = self.state.inventory_selected_row() else {
5664            anyhow::bail!("inventory empty");
5665        };
5666        if row.is_equip_shell {
5667            anyhow::bail!("this is a worn bag — press Enter to unequip it");
5668        }
5669        if row.is_chest_shell {
5670            return self.open_chest_pickup_picker();
5671        }
5672        let Some(instance_id) = row.stack.item_instance_id else {
5673            anyhow::bail!("item has no instance id");
5674        };
5675        let mut options = self.state.move_destinations_for(
5676            &row.from,
5677            row.from_parent_instance_id,
5678            row.stack.item_instance_id,
5679            &row.stack.template_id,
5680        );
5681        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5682        let category = self.state.inventory_item_category(&row.stack.template_id);
5683        if on_person && category == Some("consumable") {
5684            if GameState::stack_is_item_grant(&row.stack) {
5685                options.insert(
5686                    0,
5687                    MoveOption {
5688                        label: "Apply onto gear…".into(),
5689                        kind: MoveOptionKind::GrantApply,
5690                    },
5691                );
5692            } else {
5693                options.insert(
5694                    0,
5695                    MoveOption {
5696                        label: "Use (eat / drink)".into(),
5697                        kind: MoveOptionKind::Use,
5698                    },
5699                );
5700            }
5701        }
5702        let item_label = row
5703            .stack
5704            .display_name
5705            .clone()
5706            .unwrap_or_else(|| row.stack.template_id.clone());
5707        // Default to 1 so withdrawing from storage is partial unless the player
5708        // presses `a` for max fit (full stack when the destination allows it).
5709        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
5710        self.state.move_picker = Some(MovePicker {
5711            item_instance_id: instance_id,
5712            from: row.from,
5713            item_label,
5714            template_id: row.stack.template_id.clone(),
5715            stack_quantity: row.stack.quantity,
5716            quantity: initial_qty.max(1),
5717            options,
5718            filter: String::new(),
5719            filter_focused: false,
5720        });
5721        self.state.move_picker_index = 0;
5722        self.state.show_move_picker = true;
5723        self.state.show_destroy_picker = false;
5724        self.state.destroy_confirm_pending = false;
5725        self.state.destroy_picker = None;
5726        self.state.clamp_move_picker_quantity();
5727        Ok(())
5728    }
5729
5730    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
5731    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
5732        let Some(row) = self.state.inventory_selected_row() else {
5733            anyhow::bail!("inventory empty");
5734        };
5735        if !row.is_chest_shell {
5736            anyhow::bail!("not a placed chest");
5737        }
5738        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
5739            anyhow::bail!("not a placed chest");
5740        };
5741        let Some(instance_id) = row.stack.item_instance_id else {
5742            anyhow::bail!("chest has no instance id");
5743        };
5744        let chest = self
5745            .state
5746            .placed_containers
5747            .iter()
5748            .find(|c| c.id == *container_id)
5749            .cloned()
5750            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
5751        let (px, py) = self.state.player_position();
5752        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
5753            anyhow::bail!("too far from {}", chest.display_name);
5754        }
5755        if chest.locked && !chest.accessible {
5756            anyhow::bail!(
5757                "need the matching key for {} before picking it up",
5758                chest.display_name
5759            );
5760        }
5761        let options = self.state.chest_pickup_destinations(container_id);
5762        let item_label = row
5763            .stack
5764            .display_name
5765            .clone()
5766            .unwrap_or_else(|| row.stack.template_id.clone());
5767        self.state.move_picker = Some(MovePicker {
5768            item_instance_id: instance_id,
5769            from: row.from.clone(),
5770            item_label,
5771            template_id: row.stack.template_id.clone(),
5772            stack_quantity: 1,
5773            quantity: 1,
5774            options,
5775            filter: String::new(),
5776            filter_focused: false,
5777        });
5778        self.state.move_picker_index = 0;
5779        self.state.show_move_picker = true;
5780        self.state.show_destroy_picker = false;
5781        self.state.destroy_confirm_pending = false;
5782        self.state.destroy_picker = None;
5783        Ok(())
5784    }
5785
5786    pub fn close_move_picker(&mut self) {
5787        self.state.show_move_picker = false;
5788        self.state.move_picker = None;
5789    }
5790
5791    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
5792        self.state.move_picker_adjust_quantity(delta);
5793    }
5794
5795    pub fn move_picker_set_quantity_max(&mut self) {
5796        self.state.move_picker_set_quantity_max();
5797    }
5798
5799    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
5800        self.state.destroy_picker_adjust_quantity(delta);
5801    }
5802
5803    pub fn destroy_picker_set_quantity_max(&mut self) {
5804        self.state.destroy_picker_set_quantity_max();
5805    }
5806
5807    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
5808        let Some(picker) = self.state.move_picker.clone() else {
5809            self.close_move_picker();
5810            return Ok(());
5811        };
5812        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
5813            self.close_move_picker();
5814            return Ok(());
5815        };
5816        match option.kind {
5817            MoveOptionKind::Cancel => {
5818                self.close_move_picker();
5819            }
5820            MoveOptionKind::Use => {
5821                self.close_move_picker();
5822                self.use_item(&picker.template_id).await?;
5823            }
5824            MoveOptionKind::GrantApply => {
5825                self.close_move_picker();
5826                self.open_grant_target_picker()?;
5827            }
5828            MoveOptionKind::Drop => {
5829                self.close_move_picker();
5830                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
5831                    if self.state.key_drop_blocked(&stack) {
5832                        anyhow::bail!("cannot drop the key while its chest is locked");
5833                    }
5834                }
5835                self.drop_item(picker.item_instance_id, picker.from).await?;
5836                self.state
5837                    .push_log(format!("Dropped {}", picker.item_label));
5838            }
5839            MoveOptionKind::PickupPlaced {
5840                container_id,
5841                nest_location,
5842                nest_parent_instance_id,
5843            } => {
5844                self.close_move_picker();
5845                self.pickup_container(container_id.clone()).await?;
5846                let nest_into_bag = nest_parent_instance_id.is_some()
5847                    || !matches!(
5848                        nest_location,
5849                        flatland_protocol::InventoryLocation::Root
5850                    );
5851                if nest_into_bag {
5852                    self.move_item(
5853                        picker.item_instance_id,
5854                        flatland_protocol::InventoryLocation::Root,
5855                        nest_location,
5856                        nest_parent_instance_id,
5857                        None,
5858                    )
5859                    .await?;
5860                    self.state
5861                        .push_log(format!("Picked up {} into bag", picker.item_label));
5862                } else {
5863                    self.state
5864                        .push_log(format!("Picked up {}", picker.item_label));
5865                }
5866            }
5867            MoveOptionKind::Move {
5868                location,
5869                parent_instance_id,
5870            } => {
5871                self.close_move_picker();
5872                let qty = if picker.quantity >= picker.stack_quantity {
5873                    None
5874                } else {
5875                    Some(picker.quantity)
5876                };
5877                self.move_item(
5878                    picker.item_instance_id,
5879                    picker.from,
5880                    location,
5881                    parent_instance_id,
5882                    qty,
5883                )
5884                .await?;
5885                let moved = qty.unwrap_or(picker.stack_quantity);
5886                if moved >= picker.stack_quantity {
5887                    self.state.push_log(format!("Moved {}", picker.item_label));
5888                } else {
5889                    self.state.push_log(format!(
5890                        "Moved {} ×{} of {}",
5891                        picker.item_label, moved, picker.stack_quantity
5892                    ));
5893                }
5894            }
5895        }
5896        Ok(())
5897    }
5898
5899    /// `d`: drop the selected item on the ground immediately (no picker).
5900    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
5901        let Some(row) = self.state.inventory_selected_row() else {
5902            anyhow::bail!("inventory empty");
5903        };
5904        if row.is_equip_shell {
5905            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
5906        }
5907        if row.is_chest_shell {
5908            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
5909        }
5910        let Some(inst) = row.stack.item_instance_id else {
5911            anyhow::bail!("item has no instance id");
5912        };
5913        if self.state.key_drop_blocked(&row.stack) {
5914            anyhow::bail!("cannot drop the key while its chest is locked");
5915        }
5916        let label = row
5917            .stack
5918            .display_name
5919            .clone()
5920            .unwrap_or_else(|| row.stack.template_id.clone());
5921        self.drop_item(inst, row.from).await?;
5922        self.state.push_log(format!("Dropped {label}"));
5923        Ok(())
5924    }
5925
5926    pub async fn drop_item(
5927        &mut self,
5928        item_instance_id: uuid::Uuid,
5929        from: flatland_protocol::InventoryLocation,
5930    ) -> anyhow::Result<()> {
5931        self.seq += 1;
5932        self.session
5933            .submit_intent(Intent::DropItem {
5934                entity_id: self.state.entity_id,
5935                item_instance_id,
5936                from,
5937                seq: self.seq,
5938            })
5939            .await?;
5940        self.state.intents_sent += 1;
5941        Ok(())
5942    }
5943
5944    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
5945    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
5946        let Some(row) = self.state.inventory_selected_row() else {
5947            anyhow::bail!("inventory empty");
5948        };
5949        if row.is_equip_shell {
5950            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
5951        }
5952        if row.is_chest_shell {
5953            anyhow::bail!("can't destroy a placed chest from the inventory list");
5954        }
5955        let Some(instance_id) = row.stack.item_instance_id else {
5956            anyhow::bail!("item has no instance id");
5957        };
5958        if self.state.key_drop_blocked(&row.stack) {
5959            anyhow::bail!("cannot destroy the key while its chest is locked");
5960        }
5961        let item_label = row
5962            .stack
5963            .display_name
5964            .clone()
5965            .unwrap_or_else(|| row.stack.template_id.clone());
5966        self.state.destroy_picker = Some(DestroyPicker {
5967            item_instance_id: instance_id,
5968            from: row.from,
5969            item_label,
5970            stack_quantity: row.stack.quantity,
5971            quantity: row.stack.quantity,
5972        });
5973        self.state.destroy_confirm_pending = false;
5974        self.state.show_destroy_picker = true;
5975        self.state.show_move_picker = false;
5976        self.state.move_picker = None;
5977        Ok(())
5978    }
5979
5980    pub fn close_destroy_picker(&mut self) {
5981        self.state.show_destroy_picker = false;
5982        self.state.destroy_confirm_pending = false;
5983        self.state.destroy_picker = None;
5984    }
5985
5986    pub fn cancel_destroy_confirm(&mut self) {
5987        self.state.destroy_confirm_pending = false;
5988    }
5989
5990    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
5991        if self.state.destroy_picker.is_none() {
5992            self.close_destroy_picker();
5993            return Ok(());
5994        }
5995        self.state.destroy_confirm_pending = true;
5996        Ok(())
5997    }
5998
5999    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
6000        let Some(picker) = self.state.destroy_picker.clone() else {
6001            self.close_destroy_picker();
6002            return Ok(());
6003        };
6004        let qty = if picker.quantity >= picker.stack_quantity {
6005            None
6006        } else {
6007            Some(picker.quantity)
6008        };
6009        self.destroy_item(picker.item_instance_id, picker.from, qty)
6010            .await?;
6011        let destroyed = qty.unwrap_or(picker.stack_quantity);
6012        if destroyed >= picker.stack_quantity {
6013            self.state
6014                .push_log(format!("Destroyed {}", picker.item_label));
6015        } else {
6016            self.state.push_log(format!(
6017                "Destroyed {} ×{} of {}",
6018                picker.item_label, destroyed, picker.stack_quantity
6019            ));
6020        }
6021        self.close_destroy_picker();
6022        Ok(())
6023    }
6024
6025    pub async fn destroy_item(
6026        &mut self,
6027        item_instance_id: uuid::Uuid,
6028        from: flatland_protocol::InventoryLocation,
6029        quantity: Option<u32>,
6030    ) -> anyhow::Result<()> {
6031        self.seq += 1;
6032        self.session
6033            .submit_intent(Intent::DestroyItem {
6034                entity_id: self.state.entity_id,
6035                item_instance_id,
6036                from,
6037                quantity,
6038                seq: self.seq,
6039            })
6040            .await?;
6041        self.state.intents_sent += 1;
6042        Ok(())
6043    }
6044
6045    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
6046    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
6047        if let Some(row) = self.state.inventory_selected_row() {
6048            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
6049                return self.toggle_placed_chest_lock(container_id).await;
6050            }
6051        }
6052        self.toggle_nearby_chest_lock().await
6053    }
6054
6055    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
6056        let chest = self
6057            .state
6058            .placed_containers
6059            .iter()
6060            .find(|c| c.id == container_id)
6061            .cloned()
6062            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
6063        let (px, py) = self.state.player_position();
6064        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
6065            anyhow::bail!("too far from {}", chest.display_name);
6066        }
6067        if !chest.accessible && chest.locked {
6068            anyhow::bail!(
6069                "need the matching key for {} (each crafted chest has its own key)",
6070                chest.display_name
6071            );
6072        }
6073        let lock = !chest.locked;
6074        self.set_container_locked(
6075            flatland_protocol::InventoryLocation::Placed {
6076                container_id: chest.id.clone(),
6077            },
6078            lock,
6079        )
6080        .await?;
6081        self.state.push_log(if lock {
6082            format!("Locked {}", chest.display_name)
6083        } else {
6084            format!("Unlocked {}", chest.display_name)
6085        });
6086        Ok(())
6087    }
6088
6089    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
6090    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
6091        let chest = self
6092            .state
6093            .nearest_placed_container(CONTAINER_RANGE_M)
6094            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
6095        self.toggle_placed_chest_lock(&chest.id).await
6096    }
6097
6098    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
6099        self.equip_mainhand(None).await
6100    }
6101
6102    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
6103        if !self.state.is_alive() {
6104            anyhow::bail!("you are dead");
6105        }
6106        self.seq += 1;
6107        self.session
6108            .submit_intent(Intent::EquipOffhand {
6109                entity_id: self.state.entity_id,
6110                template_id,
6111                instance_id: None,
6112                seq: self.seq,
6113            })
6114            .await?;
6115        self.state.intents_sent += 1;
6116        Ok(())
6117    }
6118
6119    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
6120        self.equip_offhand(None).await
6121    }
6122
6123    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
6124        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
6125        for slot in slots {
6126            self.equip_worn(slot, None).await?;
6127        }
6128        Ok(())
6129    }
6130
6131    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
6132        let (px, py) = self.state.player_position();
6133        let nearest = self
6134            .state
6135            .placed_containers
6136            .iter()
6137            .min_by(|a, b| {
6138                let da = (a.x - px).hypot(a.y - py);
6139                let db = (b.x - px).hypot(b.y - py);
6140                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6141            })
6142            .cloned();
6143        let Some(chest) = nearest else {
6144            anyhow::bail!("no chest nearby");
6145        };
6146        if (chest.x - px).hypot(chest.y - py) > 2.0 {
6147            anyhow::bail!("too far from chest");
6148        }
6149        self.pickup_container(chest.id).await
6150    }
6151
6152    pub async fn equip_worn(
6153        &mut self,
6154        slot: BodySlot,
6155        instance_id: Option<uuid::Uuid>,
6156    ) -> anyhow::Result<()> {
6157        self.seq += 1;
6158        self.session
6159            .submit_intent(Intent::EquipWorn {
6160                entity_id: self.state.entity_id,
6161                slot,
6162                instance_id,
6163                seq: self.seq,
6164            })
6165            .await?;
6166        self.state.intents_sent += 1;
6167        Ok(())
6168    }
6169
6170    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
6171        self.seq += 1;
6172        self.session
6173            .submit_intent(Intent::PlaceContainer {
6174                entity_id: self.state.entity_id,
6175                item_instance_id,
6176                seq: self.seq,
6177            })
6178            .await?;
6179        self.state.intents_sent += 1;
6180        Ok(())
6181    }
6182
6183    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
6184        self.seq += 1;
6185        self.session
6186            .submit_intent(Intent::PickupContainer {
6187                entity_id: self.state.entity_id,
6188                container_id,
6189                seq: self.seq,
6190            })
6191            .await?;
6192        self.state.intents_sent += 1;
6193        Ok(())
6194    }
6195
6196    pub async fn move_item(
6197        &mut self,
6198        item_instance_id: uuid::Uuid,
6199        from: flatland_protocol::InventoryLocation,
6200        to: flatland_protocol::InventoryLocation,
6201        to_parent_instance_id: Option<uuid::Uuid>,
6202        quantity: Option<u32>,
6203    ) -> anyhow::Result<()> {
6204        self.seq += 1;
6205        self.session
6206            .submit_intent(Intent::MoveItem {
6207                entity_id: self.state.entity_id,
6208                item_instance_id,
6209                from,
6210                to,
6211                to_parent_instance_id,
6212                quantity,
6213                seq: self.seq,
6214            })
6215            .await?;
6216        self.state.intents_sent += 1;
6217        Ok(())
6218    }
6219
6220    pub async fn set_container_locked(
6221        &mut self,
6222        location: flatland_protocol::InventoryLocation,
6223        locked: bool,
6224    ) -> anyhow::Result<()> {
6225        self.seq += 1;
6226        self.session
6227            .submit_intent(Intent::SetContainerLocked {
6228                entity_id: self.state.entity_id,
6229                location,
6230                locked,
6231                seq: self.seq,
6232            })
6233            .await?;
6234        self.state.intents_sent += 1;
6235        Ok(())
6236    }
6237
6238    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
6239        if !self.state.is_alive() {
6240            anyhow::bail!("you are dead");
6241        }
6242        self.seq += 1;
6243        self.session
6244            .submit_intent(Intent::Use {
6245                entity_id: self.state.entity_id,
6246                template_id: template_id.to_string(),
6247                seq: self.seq,
6248            })
6249            .await?;
6250        self.state.intents_sent += 1;
6251        Ok(())
6252    }
6253
6254    /// Bind a grant consumable onto a specific item instance (unique gear status).
6255    pub async fn use_grant(
6256        &mut self,
6257        grant_instance_id: uuid::Uuid,
6258        target_instance_id: uuid::Uuid,
6259    ) -> anyhow::Result<()> {
6260        if !self.state.is_alive() {
6261            anyhow::bail!("you are dead");
6262        }
6263        self.seq += 1;
6264        self.session
6265            .submit_intent(Intent::UseGrant {
6266                entity_id: self.state.entity_id,
6267                grant_instance_id,
6268                target_instance_id,
6269                seq: self.seq,
6270            })
6271            .await?;
6272        self.state.intents_sent += 1;
6273        Ok(())
6274    }
6275
6276    pub fn open_craft_menu(&mut self) {
6277        self.state.show_craft_menu = true;
6278        self.state.show_shop_menu = false;
6279        self.state.shop_catalog = None;
6280        self.state.show_stats = false;
6281        self.state.show_inventory_menu = false;
6282        if self.state.blueprints.is_empty() {
6283            self.state.craft_menu_index = 0;
6284            self.state.craft_batch_quantity = 1;
6285            return;
6286        }
6287        self.state.craft_menu_index = self
6288            .state
6289            .craft_menu_index
6290            .min(self.state.blueprints.len() - 1);
6291        if let Some(idx) = self
6292            .state
6293            .blueprints
6294            .iter()
6295            .position(|bp| self.state.can_craft_blueprint(bp))
6296        {
6297            self.state.craft_menu_index = idx;
6298        }
6299        self.state.clamp_craft_batch_quantity();
6300    }
6301
6302    pub fn close_craft_menu(&mut self) {
6303        self.state.show_craft_menu = false;
6304    }
6305
6306    pub fn toggle_keychain_menu(&mut self) {
6307        if self.state.show_keychain_menu {
6308            self.close_keychain_menu();
6309        } else {
6310            self.state.show_keychain_menu = true;
6311            self.state.show_craft_menu = false;
6312            self.state.show_shop_menu = false;
6313            self.state.show_inventory_menu = false;
6314            let n = self.state.keychain_entries().len();
6315            if n == 0 {
6316                self.state.keychain_menu_index = 0;
6317            } else {
6318                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
6319            }
6320        }
6321    }
6322
6323    pub fn close_keychain_menu(&mut self) {
6324        self.state.show_keychain_menu = false;
6325    }
6326
6327    pub fn keychain_menu_move(&mut self, delta: i32) {
6328        let n = self.state.keychain_entries().len();
6329        if n == 0 {
6330            self.state.keychain_menu_index = 0;
6331            return;
6332        }
6333        let idx = self.state.keychain_menu_index as i32 + delta;
6334        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
6335    }
6336
6337    pub fn keychain_menu_page(&mut self, pages: i32) {
6338        let n = self.state.keychain_entries().len();
6339        self.state.keychain_menu_index =
6340            page_list_index(self.state.keychain_menu_index, pages, n);
6341    }
6342
6343    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
6344        if !self.state.is_alive() {
6345            anyhow::bail!("you are dead");
6346        }
6347        let entries = self.state.keychain_entries();
6348        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
6349            anyhow::bail!("nothing selected");
6350        };
6351        let Some(instance_id) = entry.stack.item_instance_id else {
6352            anyhow::bail!("key has no instance id");
6353        };
6354        if entry.stowed {
6355            self.move_item(
6356                instance_id,
6357                flatland_protocol::InventoryLocation::Keychain,
6358                flatland_protocol::InventoryLocation::Root,
6359                None,
6360                Some(1),
6361            )
6362            .await
6363        } else {
6364            self.move_item(
6365                instance_id,
6366                flatland_protocol::InventoryLocation::Root,
6367                flatland_protocol::InventoryLocation::Keychain,
6368                None,
6369                Some(1),
6370            )
6371            .await
6372        }
6373    }
6374
6375    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
6376        let npc_id = self
6377            .state
6378            .shop_catalog
6379            .as_ref()
6380            .map(|c| c.npc_id.clone());
6381        self.state.show_shop_menu = false;
6382        self.state.shop_catalog = None;
6383        self.state.clear_shop_trade_log();
6384        if let Some(npc_id) = npc_id {
6385            self.seq += 1;
6386            self.session
6387                .submit_intent(Intent::ShopClose {
6388                    entity_id: self.state.entity_id,
6389                    npc_id,
6390                    seq: self.seq,
6391                })
6392                .await?;
6393            self.state.intents_sent += 1;
6394        }
6395        Ok(())
6396    }
6397
6398    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6399        let Some(panel) = self.state.bank_panel.clone() else {
6400            return Ok(());
6401        };
6402        self.seq += 1;
6403        self.session
6404            .submit_intent(Intent::BankDeposit {
6405                entity_id: self.state.entity_id,
6406                npc_id: panel.npc_id,
6407                amount_copper,
6408                seq: self.seq,
6409            })
6410            .await?;
6411        self.state.intents_sent += 1;
6412        Ok(())
6413    }
6414
6415    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6416        let Some(panel) = self.state.bank_panel.clone() else {
6417            return Ok(());
6418        };
6419        self.seq += 1;
6420        self.session
6421            .submit_intent(Intent::BankWithdraw {
6422                entity_id: self.state.entity_id,
6423                npc_id: panel.npc_id,
6424                amount_copper,
6425                seq: self.seq,
6426            })
6427            .await?;
6428        self.state.intents_sent += 1;
6429        Ok(())
6430    }
6431
6432    pub async fn bank_transfer(
6433        &mut self,
6434        to_character_id: Option<uuid::Uuid>,
6435        to_name: String,
6436        amount_copper: u64,
6437    ) -> anyhow::Result<()> {
6438        let Some(panel) = self.state.bank_panel.clone() else {
6439            return Ok(());
6440        };
6441        self.seq += 1;
6442        self.session
6443            .submit_intent(Intent::BankTransfer {
6444                entity_id: self.state.entity_id,
6445                npc_id: panel.npc_id,
6446                to_character_id,
6447                to_name,
6448                amount_copper,
6449                seq: self.seq,
6450            })
6451            .await?;
6452        self.state.intents_sent += 1;
6453        Ok(())
6454    }
6455
6456    pub fn bank_menu_move(&mut self, delta: i32) {
6457        let n = self.state.bank_menu_options().len();
6458        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
6459            return;
6460        }
6461        let idx = self.state.bank_menu_index as i32;
6462        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6463    }
6464
6465    pub fn storage_menu_move(&mut self, delta: i32) {
6466        let n = self.state.storage_menu_options().len();
6467        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
6468            return;
6469        }
6470        let idx = self.state.storage_menu_index as i32;
6471        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6472    }
6473
6474    pub fn storage_pick_move(&mut self, delta: i32) {
6475        let n = match &self.state.storage_ui_mode {
6476            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
6477            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
6478                self.state.storage_vault_options().len()
6479            }
6480            StorageUiMode::Menu
6481            | StorageUiMode::StoreAmount { .. }
6482            | StorageUiMode::TakeAmount { .. }
6483            | StorageUiMode::ShipAmount { .. } => 0,
6484        };
6485        if n == 0 {
6486            return;
6487        }
6488        match &mut self.state.storage_ui_mode {
6489            StorageUiMode::StorePick { index }
6490            | StorageUiMode::TakePick { index }
6491            | StorageUiMode::ShipPick { index, .. } => {
6492                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
6493            }
6494            StorageUiMode::Menu
6495            | StorageUiMode::StoreAmount { .. }
6496            | StorageUiMode::TakeAmount { .. }
6497            | StorageUiMode::ShipAmount { .. } => {}
6498        }
6499    }
6500
6501    pub fn storage_ui_back(&mut self) {
6502        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
6503            StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
6504                index: *pick_index,
6505            },
6506            StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
6507                index: *pick_index,
6508            },
6509            StorageUiMode::ShipAmount {
6510                dest_building_id,
6511                dest_label,
6512                pick_index,
6513                ..
6514            } => StorageUiMode::ShipPick {
6515                dest_building_id: dest_building_id.clone(),
6516                dest_label: dest_label.clone(),
6517                index: *pick_index,
6518            },
6519            StorageUiMode::StorePick { .. }
6520            | StorageUiMode::TakePick { .. }
6521            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
6522            StorageUiMode::Menu => StorageUiMode::Menu,
6523        };
6524    }
6525
6526    pub fn storage_amount_append_char(&mut self, c: char) {
6527        match &mut self.state.storage_ui_mode {
6528            StorageUiMode::StoreAmount { input, .. }
6529            | StorageUiMode::TakeAmount { input, .. }
6530            | StorageUiMode::ShipAmount { input, .. } => {
6531                if c.is_ascii_digit() && input.len() < 8 {
6532                    input.push(c);
6533                }
6534            }
6535            _ => {}
6536        }
6537    }
6538
6539    pub fn storage_amount_backspace(&mut self) {
6540        match &mut self.state.storage_ui_mode {
6541            StorageUiMode::StoreAmount { input, .. }
6542            | StorageUiMode::TakeAmount { input, .. }
6543            | StorageUiMode::ShipAmount { input, .. } => {
6544                input.pop();
6545            }
6546            _ => {}
6547        }
6548    }
6549
6550    pub fn storage_ui_typing(&self) -> bool {
6551        matches!(
6552            self.state.storage_ui_mode,
6553            StorageUiMode::StoreAmount { .. }
6554                | StorageUiMode::TakeAmount { .. }
6555                | StorageUiMode::ShipAmount { .. }
6556        )
6557    }
6558
6559    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
6560        match self.state.storage_ui_mode.clone() {
6561            StorageUiMode::Menu => {
6562                let index = self.state.storage_menu_index;
6563                match index {
6564                    0 => {
6565                        let opts = self.state.storage_store_options();
6566                        if opts.is_empty() {
6567                            self.state.push_log("Nothing loose to store.");
6568                            return Ok(());
6569                        }
6570                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
6571                    }
6572                    1 => {
6573                        let opts = self.state.storage_vault_options();
6574                        if opts.is_empty() {
6575                            self.state.push_log("Vault is empty.");
6576                            return Ok(());
6577                        }
6578                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
6579                    }
6580                    n => {
6581                        let dest = self
6582                            .state
6583                            .storage_panel
6584                            .as_ref()
6585                            .and_then(|p| p.ship_destinations.get(n - 2))
6586                            .cloned();
6587                        let Some(dest) = dest else {
6588                            return Ok(());
6589                        };
6590                        let opts = self.state.storage_vault_options();
6591                        if opts.is_empty() {
6592                            self.state
6593                                .push_log("Vault is empty — nothing to ship.");
6594                            return Ok(());
6595                        }
6596                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
6597                            dest_building_id: dest.building_id,
6598                            dest_label: dest.label,
6599                            index: 0,
6600                        };
6601                    }
6602                }
6603            }
6604            StorageUiMode::StorePick { index } => {
6605                let opts = self.state.storage_store_options();
6606                let Some(opt) = opts.get(index) else {
6607                    self.state.push_log("Nothing loose to store.");
6608                    self.state.storage_ui_mode = StorageUiMode::Menu;
6609                    return Ok(());
6610                };
6611                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
6612                    pick_index: index,
6613                    item_instance_id: opt.item_instance_id,
6614                    label: opt.label.clone(),
6615                    max_qty: opt.quantity.max(1),
6616                    input: String::new(),
6617                };
6618            }
6619            StorageUiMode::TakePick { index } => {
6620                let opts = self.state.storage_vault_options();
6621                let Some(opt) = opts.get(index) else {
6622                    self.state.push_log("Vault is empty.");
6623                    self.state.storage_ui_mode = StorageUiMode::Menu;
6624                    return Ok(());
6625                };
6626                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
6627                    pick_index: index,
6628                    item_instance_id: opt.item_instance_id,
6629                    label: opt.label.clone(),
6630                    max_qty: opt.quantity.max(1),
6631                    input: String::new(),
6632                };
6633            }
6634            StorageUiMode::ShipPick {
6635                dest_building_id,
6636                dest_label,
6637                index,
6638            } => {
6639                let opts = self.state.storage_vault_options();
6640                let Some(opt) = opts.get(index) else {
6641                    self.state
6642                        .push_log("Vault is empty — nothing to ship.");
6643                    self.state.storage_ui_mode = StorageUiMode::Menu;
6644                    return Ok(());
6645                };
6646                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
6647                    dest_building_id,
6648                    dest_label,
6649                    pick_index: index,
6650                    item_instance_id: opt.item_instance_id,
6651                    label: opt.label.clone(),
6652                    max_qty: opt.quantity.max(1),
6653                    input: String::new(),
6654                };
6655            }
6656            StorageUiMode::StoreAmount {
6657                item_instance_id,
6658                max_qty,
6659                input,
6660                ..
6661            } => {
6662                let Some(qty) = parse_storage_quantity(&input) else {
6663                    self.state
6664                        .push_log("Enter a quantity (blank or 0 = all).");
6665                    return Ok(());
6666                };
6667                let qty = qty.map(|n| n.min(max_qty).max(1));
6668                self.storage_store(item_instance_id, qty).await?;
6669                self.state.storage_ui_mode = StorageUiMode::Menu;
6670            }
6671            StorageUiMode::TakeAmount {
6672                item_instance_id,
6673                max_qty,
6674                input,
6675                ..
6676            } => {
6677                let Some(qty) = parse_storage_quantity(&input) else {
6678                    self.state
6679                        .push_log("Enter a quantity (blank or 0 = all).");
6680                    return Ok(());
6681                };
6682                let qty = qty.map(|n| n.min(max_qty).max(1));
6683                self.storage_take(item_instance_id, qty).await?;
6684                self.state.storage_ui_mode = StorageUiMode::Menu;
6685            }
6686            StorageUiMode::ShipAmount {
6687                dest_building_id,
6688                item_instance_id,
6689                max_qty,
6690                input,
6691                ..
6692            } => {
6693                let Some(qty) = parse_storage_quantity(&input) else {
6694                    self.state
6695                        .push_log("Enter a quantity (blank or 0 = all).");
6696                    return Ok(());
6697                };
6698                let qty = qty.map(|n| n.min(max_qty).max(1));
6699                self.storage_ship(dest_building_id, item_instance_id, qty)
6700                    .await?;
6701                self.state.storage_ui_mode = StorageUiMode::Menu;
6702            }
6703        }
6704        Ok(())
6705    }
6706
6707    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
6708        match self.state.bank_ui_mode.clone() {
6709            BankUiMode::Menu => {
6710                let choice = self
6711                    .state
6712                    .bank_menu_options()
6713                    .get(self.state.bank_menu_index)
6714                    .copied()
6715                    .unwrap_or("Deposit…");
6716                match choice {
6717                    "Withdraw…" => {
6718                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
6719                            input: String::new(),
6720                        };
6721                    }
6722                    "Deposit all" => self.bank_deposit(0).await?,
6723                    "Withdraw all" => self.bank_withdraw(0).await?,
6724                    "Transfer…" => {
6725                        self.state.bank_ui_mode = BankUiMode::TransferName {
6726                            input: String::new(),
6727                        };
6728                    }
6729                    _ => {
6730                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
6731                            input: String::new(),
6732                        };
6733                    }
6734                }
6735            }
6736            BankUiMode::DepositAmount { input } => {
6737                let Some(amount) = parse_bank_copper_amount(&input) else {
6738                    self.state
6739                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
6740                    return Ok(());
6741                };
6742                self.bank_deposit(amount).await?;
6743                self.state.bank_ui_mode = BankUiMode::Menu;
6744            }
6745            BankUiMode::WithdrawAmount { input } => {
6746                let Some(amount) = parse_bank_copper_amount(&input) else {
6747                    self.state
6748                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
6749                    return Ok(());
6750                };
6751                self.bank_withdraw(amount).await?;
6752                self.state.bank_ui_mode = BankUiMode::Menu;
6753            }
6754            BankUiMode::TransferName { input } => {
6755                let name = input.trim().to_string();
6756                if name.is_empty() {
6757                    self.state.push_log("Enter the recipient character name.");
6758                    return Ok(());
6759                }
6760                self.state.bank_ui_mode = BankUiMode::TransferAmount {
6761                    to_name: name,
6762                    input: String::new(),
6763                };
6764            }
6765            BankUiMode::TransferAmount { to_name, input } => {
6766                let amount: u64 = match input.trim().parse() {
6767                    Ok(v) if v > 0 => v,
6768                    _ => {
6769                        self.state
6770                            .push_log("Enter a positive copper amount to transfer.");
6771                        return Ok(());
6772                    }
6773                };
6774                self.bank_transfer(None, to_name, amount).await?;
6775                self.state.bank_ui_mode = BankUiMode::Menu;
6776            }
6777        }
6778        Ok(())
6779    }
6780
6781    pub fn bank_transfer_back(&mut self) {
6782        match &self.state.bank_ui_mode {
6783            BankUiMode::TransferAmount { to_name, .. } => {
6784                self.state.bank_ui_mode = BankUiMode::TransferName {
6785                    input: to_name.clone(),
6786                };
6787            }
6788            BankUiMode::TransferName { .. }
6789            | BankUiMode::DepositAmount { .. }
6790            | BankUiMode::WithdrawAmount { .. } => {
6791                self.state.bank_ui_mode = BankUiMode::Menu;
6792            }
6793            BankUiMode::Menu => {}
6794        }
6795    }
6796
6797    pub fn bank_transfer_append_char(&mut self, c: char) {
6798        match &mut self.state.bank_ui_mode {
6799            BankUiMode::TransferName { input } => {
6800                if input.len() < 32 && !c.is_control() {
6801                    input.push(c);
6802                }
6803            }
6804            BankUiMode::DepositAmount { input }
6805            | BankUiMode::WithdrawAmount { input }
6806            | BankUiMode::TransferAmount { input, .. } => {
6807                if c.is_ascii_digit() && input.len() < 12 {
6808                    input.push(c);
6809                }
6810            }
6811            BankUiMode::Menu => {}
6812        }
6813    }
6814
6815    pub fn bank_transfer_backspace(&mut self) {
6816        match &mut self.state.bank_ui_mode {
6817            BankUiMode::TransferName { input }
6818            | BankUiMode::DepositAmount { input }
6819            | BankUiMode::WithdrawAmount { input }
6820            | BankUiMode::TransferAmount { input, .. } => {
6821                input.pop();
6822            }
6823            BankUiMode::Menu => {}
6824        }
6825    }
6826
6827    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
6828        let npc_id = self
6829            .state
6830            .bank_panel
6831            .as_ref()
6832            .map(|p| p.npc_id.clone());
6833        self.state.clear_bank_panel();
6834        if let Some(npc_id) = npc_id {
6835            self.seq += 1;
6836            self.session
6837                .submit_intent(Intent::BankClose {
6838                    entity_id: self.state.entity_id,
6839                    npc_id,
6840                    seq: self.seq,
6841                })
6842                .await?;
6843            self.state.intents_sent += 1;
6844        }
6845        Ok(())
6846    }
6847
6848    pub async fn storage_store(
6849        &mut self,
6850        item_instance_id: uuid::Uuid,
6851        quantity: Option<u32>,
6852    ) -> anyhow::Result<()> {
6853        let Some(panel) = self.state.storage_panel.clone() else {
6854            return Ok(());
6855        };
6856        self.seq += 1;
6857        self.session
6858            .submit_intent(Intent::StorageStore {
6859                entity_id: self.state.entity_id,
6860                npc_id: panel.npc_id,
6861                item_instance_id,
6862                quantity,
6863                seq: self.seq,
6864            })
6865            .await?;
6866        self.state.intents_sent += 1;
6867        Ok(())
6868    }
6869
6870    pub async fn storage_take(
6871        &mut self,
6872        item_instance_id: uuid::Uuid,
6873        quantity: Option<u32>,
6874    ) -> anyhow::Result<()> {
6875        let Some(panel) = self.state.storage_panel.clone() else {
6876            return Ok(());
6877        };
6878        self.seq += 1;
6879        self.session
6880            .submit_intent(Intent::StorageTake {
6881                entity_id: self.state.entity_id,
6882                npc_id: panel.npc_id,
6883                item_instance_id,
6884                quantity,
6885                seq: self.seq,
6886            })
6887            .await?;
6888        self.state.intents_sent += 1;
6889        Ok(())
6890    }
6891
6892    pub async fn storage_ship(
6893        &mut self,
6894        dest_building_id: String,
6895        item_instance_id: uuid::Uuid,
6896        quantity: Option<u32>,
6897    ) -> anyhow::Result<()> {
6898        let Some(panel) = self.state.storage_panel.clone() else {
6899            return Ok(());
6900        };
6901        self.seq += 1;
6902        self.session
6903            .submit_intent(Intent::StorageShip {
6904                entity_id: self.state.entity_id,
6905                npc_id: panel.npc_id,
6906                dest_building_id,
6907                item_instance_id,
6908                quantity,
6909                seq: self.seq,
6910            })
6911            .await?;
6912        self.state.intents_sent += 1;
6913        Ok(())
6914    }
6915
6916    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
6917        let npc_id = self
6918            .state
6919            .storage_panel
6920            .as_ref()
6921            .map(|p| p.npc_id.clone());
6922        self.state.clear_storage_panel();
6923        if let Some(npc_id) = npc_id {
6924            self.seq += 1;
6925            self.session
6926                .submit_intent(Intent::StorageClose {
6927                    entity_id: self.state.entity_id,
6928                    npc_id,
6929                    seq: self.seq,
6930                })
6931                .await?;
6932            self.state.intents_sent += 1;
6933        }
6934        Ok(())
6935    }
6936
6937    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
6938    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
6939        let return_to_verbs = self.state.npc_verb_target.is_some();
6940        self.close_shop_menu().await?;
6941        if return_to_verbs {
6942            self.state.show_npc_verb_menu = true;
6943        }
6944        Ok(())
6945    }
6946
6947    pub fn shop_tab_toggle(&mut self) {
6948        self.state.shop_tab = match self.state.shop_tab {
6949            ShopTab::Buy => ShopTab::Sell,
6950            ShopTab::Sell => ShopTab::Buy,
6951        };
6952        self.state.shop_menu_index = 0;
6953        if self.state.shop_tab == ShopTab::Sell {
6954            self.state.shop_quantity_set_max();
6955        }
6956        self.state.clamp_shop_selection();
6957    }
6958
6959    pub fn shop_menu_move(&mut self, delta: i32) {
6960        self.state.shop_menu_move(delta);
6961    }
6962
6963    pub fn shop_quantity_adjust(&mut self, delta: i32) {
6964        self.state.shop_quantity_adjust(delta);
6965    }
6966
6967    pub fn shop_quantity_set_max(&mut self) {
6968        self.state.shop_quantity_set_max();
6969    }
6970
6971    pub fn toggle_quest_menu(&mut self) {
6972        self.state.show_quest_menu = !self.state.show_quest_menu;
6973        if self.state.show_quest_menu {
6974            self.state.quest_menu_index = 0;
6975            self.state.quest_withdraw_confirm = false;
6976            self.state.show_workers_menu = false;
6977        }
6978    }
6979
6980    pub fn toggle_workers_menu(&mut self) {
6981        self.state.show_workers_menu = !self.state.show_workers_menu;
6982        if self.state.show_workers_menu {
6983            self.state.workers_menu_index = 0;
6984            self.state.show_quest_menu = false;
6985            self.close_worker_give_picker();
6986            self.close_worker_give_target_picker();
6987            self.close_worker_take_picker();
6988            self.close_worker_teach_picker();
6989            self.cancel_worker_rename();
6990        } else {
6991            self.close_worker_give_picker();
6992            self.close_worker_give_target_picker();
6993            self.close_worker_take_picker();
6994            self.close_worker_teach_picker();
6995            self.cancel_worker_rename();
6996        }
6997    }
6998
6999    pub fn workers_menu_move(&mut self, delta: i32) {
7000        let n = self.state.hired_workers.len();
7001        if n == 0 {
7002            return;
7003        }
7004        let idx = self.state.workers_menu_index as i32;
7005        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7006    }
7007
7008    pub fn toggle_workers_menu_compact(&mut self) {
7009        self.state.workers_menu_compact = !self.state.workers_menu_compact;
7010    }
7011
7012    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
7013        let Some(worker) = self
7014            .state
7015            .hired_workers
7016            .get(self.state.workers_menu_index)
7017            .cloned()
7018        else {
7019            anyhow::bail!("no worker selected");
7020        };
7021        self.seq += 1;
7022        self.session
7023            .submit_intent(Intent::DismissWorker {
7024                entity_id: self.state.entity_id,
7025                worker_instance_id: worker.instance_id.clone(),
7026                seq: self.seq,
7027            })
7028            .await?;
7029        self.state.intents_sent += 1;
7030        self.state
7031            .hired_workers
7032            .retain(|w| w.instance_id != worker.instance_id);
7033        if self.state.workers_menu_index >= self.state.hired_workers.len() {
7034            self.state.workers_menu_index = self
7035                .state
7036                .hired_workers
7037                .len()
7038                .saturating_sub(1);
7039        }
7040        self.state.push_log(format!("Dismissed {}", worker.label));
7041        Ok(())
7042    }
7043
7044    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
7045        let Some(worker) = self
7046            .state
7047            .hired_workers
7048            .get(self.state.workers_menu_index)
7049            .cloned()
7050        else {
7051            anyhow::bail!("no worker selected");
7052        };
7053        let mode = match worker.mode {
7054            flatland_protocol::WorkerModeView::Companion => "job_loop",
7055            flatland_protocol::WorkerModeView::JobLoop => "idle",
7056            flatland_protocol::WorkerModeView::Idle => "companion",
7057        };
7058        self.seq += 1;
7059        self.session
7060            .submit_intent(Intent::SetWorkerMode {
7061                entity_id: self.state.entity_id,
7062                worker_instance_id: worker.instance_id,
7063                mode: mode.into(),
7064                seq: self.seq,
7065            })
7066            .await?;
7067        self.state.intents_sent += 1;
7068        Ok(())
7069    }
7070
7071    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
7072        if self.state.hired_workers.is_empty() {
7073            return self.hire_worker_laborer().await;
7074        }
7075        self.workers_toggle_mode_selected().await
7076    }
7077
7078    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
7079    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
7080    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7081        let row = self
7082            .state
7083            .inventory_selected_row()
7084            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
7085            .clone();
7086        if row.from != flatland_protocol::InventoryLocation::Root {
7087            anyhow::bail!("select a carried item to give");
7088        }
7089        let Some(instance_id) = row.stack.item_instance_id else {
7090            anyhow::bail!("that stack can't be given");
7091        };
7092        let options = self.nearby_worker_give_targets();
7093        if options.is_empty() {
7094            anyhow::bail!(
7095                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
7096            );
7097        }
7098        let item_label = row
7099            .stack
7100            .display_name
7101            .as_deref()
7102            .unwrap_or(&row.stack.template_id)
7103            .to_string();
7104        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
7105            item_instance_id: instance_id,
7106            item_label,
7107            quantity: None,
7108            options,
7109        });
7110        self.state.worker_give_target_picker_index = 0;
7111        self.state.show_worker_give_target_picker = true;
7112        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
7113        self.state.show_inventory_menu = false;
7114        Ok(())
7115    }
7116
7117    /// Hired workers within give/take range, nearest first.
7118    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
7119        let (px, py, _) = self.state.player_position_with_z();
7120        let mut options: Vec<WorkerGiveTargetOption> = self
7121            .state
7122            .hired_workers
7123            .iter()
7124            .filter_map(|w| {
7125                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
7126                if dist > WORKER_GIVE_RANGE_M {
7127                    return None;
7128                }
7129                Some(WorkerGiveTargetOption {
7130                    instance_id: w.instance_id.clone(),
7131                    label: w.label.clone(),
7132                    distance_m: dist,
7133                })
7134            })
7135            .collect();
7136        options.sort_by(|a, b| {
7137            a.distance_m
7138                .partial_cmp(&b.distance_m)
7139                .unwrap_or(std::cmp::Ordering::Equal)
7140        });
7141        options
7142    }
7143
7144    pub fn close_worker_give_target_picker(&mut self) {
7145        self.state.show_worker_give_target_picker = false;
7146        self.state.worker_give_target_picker = None;
7147        self.state.worker_give_target_picker_index = 0;
7148    }
7149
7150    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
7151        let Some(picker) = &self.state.worker_give_target_picker else {
7152            return;
7153        };
7154        let n = picker.options.len();
7155        if n == 0 {
7156            return;
7157        }
7158        let idx = self.state.worker_give_target_picker_index as i32;
7159        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7160    }
7161
7162    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7163        let Some(picker) = self.state.worker_give_target_picker.clone() else {
7164            anyhow::bail!("give target picker not open");
7165        };
7166        let Some(opt) = picker
7167            .options
7168            .get(self.state.worker_give_target_picker_index)
7169            .cloned()
7170        else {
7171            anyhow::bail!("no worker selected");
7172        };
7173        let Some(worker) = self
7174            .state
7175            .hired_workers
7176            .iter()
7177            .find(|w| w.instance_id == opt.instance_id)
7178            .cloned()
7179        else {
7180            self.close_worker_give_target_picker();
7181            anyhow::bail!("worker no longer hired");
7182        };
7183        self.give_item_to_worker(
7184            &worker.instance_id,
7185            &worker.label,
7186            worker.x,
7187            worker.y,
7188            picker.item_instance_id,
7189            &picker.item_label,
7190            picker.quantity,
7191        )
7192        .await?;
7193        self.close_worker_give_target_picker();
7194        Ok(())
7195    }
7196
7197    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
7198    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
7199        self.open_worker_give_target_picker()
7200    }
7201
7202    /// Open the workers-menu give picker for the selected worker (must be in range).
7203    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
7204        let Some(worker) = self
7205            .state
7206            .hired_workers
7207            .get(self.state.workers_menu_index)
7208            .cloned()
7209        else {
7210            anyhow::bail!("select a hired worker first");
7211        };
7212        let (px, py, _) = self.state.player_position_with_z();
7213        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7214        if dist > WORKER_GIVE_RANGE_M {
7215            anyhow::bail!(
7216                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
7217                worker.label
7218            );
7219        }
7220        let options = self.state.giveable_inventory_options();
7221        if options.is_empty() {
7222            anyhow::bail!("nothing in inventory to give");
7223        }
7224        self.state.worker_give_picker = Some(WorkerGivePicker {
7225            worker_instance_id: worker.instance_id,
7226            worker_label: worker.label,
7227            options,
7228        });
7229        self.state.worker_give_picker_index = 0;
7230        self.state.show_worker_give_picker = true;
7231        Ok(())
7232    }
7233
7234    pub fn close_worker_give_picker(&mut self) {
7235        self.state.show_worker_give_picker = false;
7236        self.state.worker_give_picker = None;
7237        self.state.worker_give_picker_index = 0;
7238    }
7239
7240    pub fn worker_give_picker_move(&mut self, delta: i32) {
7241        let Some(picker) = &self.state.worker_give_picker else {
7242            return;
7243        };
7244        let n = picker.options.len();
7245        if n == 0 {
7246            return;
7247        }
7248        let idx = self.state.worker_give_picker_index as i32;
7249        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7250    }
7251
7252    /// Confirm the selected row in the workers-menu give picker.
7253    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
7254        let Some(picker) = self.state.worker_give_picker.clone() else {
7255            anyhow::bail!("give picker not open");
7256        };
7257        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
7258            anyhow::bail!("no item selected");
7259        };
7260        let Some(worker) = self
7261            .state
7262            .hired_workers
7263            .iter()
7264            .find(|w| w.instance_id == picker.worker_instance_id)
7265            .cloned()
7266        else {
7267            self.close_worker_give_picker();
7268            anyhow::bail!("worker no longer hired");
7269        };
7270        self.give_item_to_worker(
7271            &worker.instance_id,
7272            &worker.label,
7273            worker.x,
7274            worker.y,
7275            opt.item_instance_id,
7276            &opt.label,
7277            None,
7278        )
7279        .await?;
7280        // Refresh options (stack may be gone / reduced) or close when empty.
7281        let options = self.state.giveable_inventory_options();
7282        if options.is_empty() {
7283            self.close_worker_give_picker();
7284        } else {
7285            self.state.worker_give_picker = Some(WorkerGivePicker {
7286                worker_instance_id: picker.worker_instance_id,
7287                worker_label: picker.worker_label,
7288                options,
7289            });
7290            if self.state.worker_give_picker_index
7291                >= self
7292                    .state
7293                    .worker_give_picker
7294                    .as_ref()
7295                    .map(|p| p.options.len())
7296                    .unwrap_or(0)
7297            {
7298                self.state.worker_give_picker_index = self
7299                    .state
7300                    .worker_give_picker
7301                    .as_ref()
7302                    .map(|p| p.options.len().saturating_sub(1))
7303                    .unwrap_or(0);
7304            }
7305        }
7306        Ok(())
7307    }
7308
7309    /// Open the workers-menu teach picker for the selected worker (must be in range).
7310    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7311        let Some(worker) = self
7312            .state
7313            .hired_workers
7314            .get(self.state.workers_menu_index)
7315            .cloned()
7316        else {
7317            anyhow::bail!("select a hired worker first");
7318        };
7319        let (px, py, _) = self.state.player_position_with_z();
7320        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7321        if dist > WORKER_GIVE_RANGE_M {
7322            anyhow::bail!(
7323                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
7324                worker.label
7325            );
7326        }
7327        let options = self.state.teachable_blueprint_options(&worker);
7328        if options.is_empty() {
7329            anyhow::bail!("no recipes you know that {} still needs", worker.label);
7330        }
7331        self.state.worker_teach_picker = Some(WorkerTeachPicker {
7332            worker_instance_id: worker.instance_id,
7333            worker_label: worker.label,
7334            worker_level: worker.level,
7335            options,
7336        });
7337        self.state.worker_teach_picker_index = 0;
7338        self.state.show_worker_teach_picker = true;
7339        Ok(())
7340    }
7341
7342    pub fn close_worker_teach_picker(&mut self) {
7343        self.state.show_worker_teach_picker = false;
7344        self.state.worker_teach_picker = None;
7345        self.state.worker_teach_picker_index = 0;
7346    }
7347
7348    pub fn worker_teach_picker_move(&mut self, delta: i32) {
7349        let Some(picker) = &self.state.worker_teach_picker else {
7350            return;
7351        };
7352        let n = picker.options.len();
7353        if n == 0 {
7354            return;
7355        }
7356        let idx = self.state.worker_teach_picker_index as i32;
7357        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7358    }
7359
7360    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7361        let Some(picker) = self.state.worker_teach_picker.clone() else {
7362            anyhow::bail!("teach picker not open");
7363        };
7364        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
7365            anyhow::bail!("nothing selected");
7366        };
7367        if !opt.level_ok {
7368            anyhow::bail!(
7369                "{} needs level {} (is level {})",
7370                picker.worker_label,
7371                opt.min_level,
7372                opt.worker_level
7373            );
7374        }
7375        if !opt.can_afford {
7376            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
7377        }
7378        let Some(worker) = self
7379            .state
7380            .hired_workers
7381            .iter()
7382            .find(|w| w.instance_id == picker.worker_instance_id)
7383            .cloned()
7384        else {
7385            anyhow::bail!("worker gone");
7386        };
7387        let (px, py, _) = self.state.player_position_with_z();
7388        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7389        if dist > WORKER_GIVE_RANGE_M {
7390            anyhow::bail!("worker {} too far — stand next to them", worker.label);
7391        }
7392        self.seq += 1;
7393        self.session
7394            .submit_intent(Intent::TeachWorkerBlueprint {
7395                entity_id: self.state.entity_id,
7396                worker_instance_id: picker.worker_instance_id.clone(),
7397                blueprint_id: opt.blueprint_id.clone(),
7398                seq: self.seq,
7399            })
7400            .await?;
7401        self.state.intents_sent += 1;
7402        self.state.push_log(format!(
7403            "Teaching {} to {} ({} cp)",
7404            opt.label, picker.worker_label, opt.cost_copper
7405        ));
7406        self.close_worker_teach_picker();
7407        Ok(())
7408    }
7409
7410    async fn give_item_to_worker(
7411        &mut self,
7412        worker_instance_id: &str,
7413        worker_label: &str,
7414        worker_x: f32,
7415        worker_y: f32,
7416        item_instance_id: uuid::Uuid,
7417        item_label: &str,
7418        quantity: Option<u32>,
7419    ) -> anyhow::Result<()> {
7420        let (px, py, _) = self.state.player_position_with_z();
7421        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7422        if dist > WORKER_GIVE_RANGE_M {
7423            anyhow::bail!("worker {worker_label} too far — stand next to them");
7424        }
7425        self.seq += 1;
7426        self.session
7427            .submit_intent(Intent::GiveWorkerItem {
7428                entity_id: self.state.entity_id,
7429                worker_instance_id: worker_instance_id.to_string(),
7430                item_instance_id,
7431                quantity,
7432                seq: self.seq,
7433            })
7434            .await?;
7435        self.state.intents_sent += 1;
7436        self.state
7437            .push_log(format!("Gave {item_label} to {worker_label}"));
7438        Ok(())
7439    }
7440
7441    /// Open the workers-menu take picker for the selected worker (must be in range).
7442    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
7443        let Some(worker) = self
7444            .state
7445            .hired_workers
7446            .get(self.state.workers_menu_index)
7447            .cloned()
7448        else {
7449            anyhow::bail!("select a hired worker first");
7450        };
7451        let (px, py, _) = self.state.player_position_with_z();
7452        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7453        if dist > WORKER_GIVE_RANGE_M {
7454            anyhow::bail!(
7455                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
7456                worker.label
7457            );
7458        }
7459        let options = Self::worker_inventory_options(&worker);
7460        if options.is_empty() {
7461            anyhow::bail!("{} isn't carrying anything", worker.label);
7462        }
7463        self.state.worker_take_picker = Some(WorkerTakePicker {
7464            worker_instance_id: worker.instance_id,
7465            worker_label: worker.label,
7466            options,
7467        });
7468        self.state.worker_take_picker_index = 0;
7469        self.state.show_worker_take_picker = true;
7470        Ok(())
7471    }
7472
7473    fn worker_inventory_options(
7474        worker: &flatland_protocol::HiredWorkerView,
7475    ) -> Vec<WorkerGiveOption> {
7476        worker
7477            .inventory
7478            .iter()
7479            .filter_map(|stack| {
7480                let item_instance_id = stack.item_instance_id?;
7481                let label = stack
7482                    .display_name
7483                    .clone()
7484                    .unwrap_or_else(|| stack.template_id.clone());
7485                let label = if stack.quantity > 1 {
7486                    format!("{label} ×{}", stack.quantity)
7487                } else {
7488                    label
7489                };
7490                Some(WorkerGiveOption {
7491                    item_instance_id,
7492                    label,
7493                    quantity: stack.quantity,
7494                    template_id: stack.template_id.clone(),
7495                })
7496            })
7497            .collect()
7498    }
7499
7500    pub fn close_worker_take_picker(&mut self) {
7501        self.state.show_worker_take_picker = false;
7502        self.state.worker_take_picker = None;
7503        self.state.worker_take_picker_index = 0;
7504    }
7505
7506    pub fn worker_take_picker_move(&mut self, delta: i32) {
7507        let Some(picker) = &self.state.worker_take_picker else {
7508            return;
7509        };
7510        let n = picker.options.len();
7511        if n == 0 {
7512            return;
7513        }
7514        let idx = self.state.worker_take_picker_index as i32;
7515        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7516    }
7517
7518    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
7519        let Some(picker) = self.state.worker_take_picker.clone() else {
7520            anyhow::bail!("take picker not open");
7521        };
7522        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
7523            anyhow::bail!("no item selected");
7524        };
7525        let Some(worker) = self
7526            .state
7527            .hired_workers
7528            .iter()
7529            .find(|w| w.instance_id == picker.worker_instance_id)
7530            .cloned()
7531        else {
7532            self.close_worker_take_picker();
7533            anyhow::bail!("worker no longer hired");
7534        };
7535        self.take_item_from_worker(
7536            &worker.instance_id,
7537            &worker.label,
7538            worker.x,
7539            worker.y,
7540            opt.item_instance_id,
7541            &opt.label,
7542            None,
7543        )
7544        .await?;
7545        // Refresh from live view after local optimistic wait — use last known
7546        // worker inventory minus the taken stack for immediate UI; next tick
7547        // will reconcile.
7548        if let Some(w) = self
7549            .state
7550            .hired_workers
7551            .iter()
7552            .find(|w| w.instance_id == picker.worker_instance_id)
7553        {
7554            let options = Self::worker_inventory_options(w)
7555                .into_iter()
7556                .filter(|o| o.item_instance_id != opt.item_instance_id)
7557                .collect::<Vec<_>>();
7558            if options.is_empty() {
7559                self.close_worker_take_picker();
7560            } else {
7561                self.state.worker_take_picker = Some(WorkerTakePicker {
7562                    worker_instance_id: picker.worker_instance_id,
7563                    worker_label: picker.worker_label,
7564                    options,
7565                });
7566                self.state.worker_take_picker_index = self
7567                    .state
7568                    .worker_take_picker_index
7569                    .min(
7570                        self.state
7571                            .worker_take_picker
7572                            .as_ref()
7573                            .map(|p| p.options.len().saturating_sub(1))
7574                            .unwrap_or(0),
7575                    );
7576            }
7577        } else {
7578            self.close_worker_take_picker();
7579        }
7580        Ok(())
7581    }
7582
7583    async fn take_item_from_worker(
7584        &mut self,
7585        worker_instance_id: &str,
7586        worker_label: &str,
7587        worker_x: f32,
7588        worker_y: f32,
7589        item_instance_id: uuid::Uuid,
7590        item_label: &str,
7591        quantity: Option<u32>,
7592    ) -> anyhow::Result<()> {
7593        let (px, py, _) = self.state.player_position_with_z();
7594        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7595        if dist > WORKER_GIVE_RANGE_M {
7596            anyhow::bail!("worker {worker_label} too far — stand next to them");
7597        }
7598        self.seq += 1;
7599        self.session
7600            .submit_intent(Intent::TakeWorkerItem {
7601                entity_id: self.state.entity_id,
7602                worker_instance_id: worker_instance_id.to_string(),
7603                item_instance_id,
7604                quantity,
7605                seq: self.seq,
7606            })
7607            .await?;
7608        self.state.intents_sent += 1;
7609        self.state
7610            .push_log(format!("Took {item_label} from {worker_label}"));
7611        Ok(())
7612    }
7613
7614    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
7615        if !self.state.has_worker_lodging() {
7616            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
7617        }
7618        self.seq += 1;
7619        self.session
7620            .submit_intent(Intent::HireWorker {
7621                entity_id: self.state.entity_id,
7622                def_id: "worker_laborer".into(),
7623                wage_copper_per_interval: 8,
7624                lodging_container_id: None,
7625                job_yaml: None,
7626                seq: self.seq,
7627            })
7628            .await?;
7629        self.state.intents_sent += 1;
7630        Ok(())
7631    }
7632
7633    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
7634        let Some(worker) = self
7635            .state
7636            .hired_workers
7637            .get(self.state.workers_menu_index)
7638            .cloned()
7639        else {
7640            anyhow::bail!("select a hired worker first");
7641        };
7642        let lodging = worker.lodging_container_id.clone().or_else(|| {
7643            crate::worker_route_editor::owned_lodging_container_ids(
7644                &self.state.placed_containers,
7645                self.state.character_id,
7646            )
7647            .into_iter()
7648            .next()
7649            .map(|(id, _)| id)
7650        });
7651        let label = worker.label.clone();
7652        let editor = if let Some(route) = &worker.route {
7653            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
7654                worker.instance_id,
7655                worker.label,
7656                route,
7657                lodging,
7658            )
7659        } else {
7660            crate::worker_route_editor::WorkerRouteEditorState::new(
7661                worker.instance_id,
7662                worker.label,
7663                lodging,
7664            )
7665        };
7666        self.state.worker_route_editor = Some(editor);
7667        self.state.show_workers_menu = false;
7668        self.state.push_log(format!(
7669            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
7670        ));
7671        Ok(())
7672    }
7673
7674    pub fn close_worker_route_editor(&mut self) {
7675        self.state.worker_route_editor = None;
7676    }
7677
7678    pub fn worker_route_editor_toggle_panel(&mut self) {
7679        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7680            ed.toggle_panel_collapsed();
7681        }
7682    }
7683
7684    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
7685        let n = {
7686            let Some(ed) = self.state.worker_route_editor.as_mut() else {
7687                return;
7688            };
7689            ed.append_waypoint(x, y, z);
7690            ed.stop_count()
7691        };
7692        self.state
7693            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
7694    }
7695
7696    // ---- picker candidate snapshots ----------------------------------
7697
7698    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
7699        let (px, py, _) = self.state.player_position_with_z();
7700        crate::worker_route_editor::owned_container_candidates(
7701            &self.state.placed_containers,
7702            self.state.character_id,
7703            px,
7704            py,
7705        )
7706    }
7707
7708    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
7709        let (px, py, _) = self.state.player_position_with_z();
7710        crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
7711    }
7712
7713    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
7714        let (px, py, _) = self.state.player_position_with_z();
7715        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
7716    }
7717
7718    fn re_template_candidates(&self) -> Vec<String> {
7719        let mut extra = Vec::new();
7720        if let Some(ed) = self.state.worker_route_editor.as_ref() {
7721            for stop in &ed.stops {
7722                match stop {
7723                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
7724                        filter: Some(filter),
7725                        ..
7726                    } => extra.extend(filter.iter().cloned()),
7727                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
7728                        extra.push(template.clone());
7729                    }
7730                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
7731                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
7732                            extra.push(bp.output.clone());
7733                            for input in &bp.inputs {
7734                                extra.push(input.template_id.clone());
7735                            }
7736                        }
7737                    }
7738                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
7739                        for it in items {
7740                            extra.push(it.template.clone());
7741                        }
7742                    }
7743                    _ => {}
7744                }
7745            }
7746            // Worker-known recipe outputs even if the player hasn't learned them yet.
7747            if let Some(worker) = self
7748                .state
7749                .hired_workers
7750                .iter()
7751                .find(|w| w.instance_id == ed.worker_instance_id)
7752            {
7753                for recipe in &worker.known_blueprint_ids {
7754                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
7755                        extra.push(bp.output.clone());
7756                    }
7757                }
7758            }
7759        }
7760        crate::worker_route_editor::route_item_template_candidates(
7761            &self.state.placed_containers,
7762            self.state.character_id,
7763            &self.state.inventory,
7764            &self.state.blueprints,
7765            &self.state.resource_nodes,
7766            &extra,
7767        )
7768    }
7769
7770    fn re_blueprint_ids(&self) -> Vec<String> {
7771        let worker_known: Option<&[String]> = self
7772            .state
7773            .worker_route_editor
7774            .as_ref()
7775            .and_then(|ed| {
7776                self.state
7777                    .hired_workers
7778                    .iter()
7779                    .find(|w| w.instance_id == ed.worker_instance_id)
7780            })
7781            .map(|w| w.known_blueprint_ids.as_slice());
7782        crate::worker_route_editor::worker_craft_blueprint_ids(
7783            &self.state.blueprints,
7784            worker_known,
7785        )
7786    }
7787
7788    fn re_bed_candidates(&self) -> Vec<(String, String)> {
7789        crate::worker_route_editor::owned_lodging_container_ids(
7790            &self.state.placed_containers,
7791            self.state.character_id,
7792        )
7793    }
7794
7795    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
7796        self.state
7797            .placed_containers
7798            .iter()
7799            .find(|c| c.id == container_id)
7800            .map(|c| c.contents.clone())
7801            .unwrap_or_default()
7802    }
7803
7804    // ---- sheet navigation (`plans/33`) --------------------------------
7805
7806    /// Row count of the current sheet (for cursor wrapping).
7807    pub fn re_sheet_row_count(&self) -> usize {
7808        use crate::worker_route_editor::RouteEditorSheet as S;
7809        let Some(ed) = self.state.worker_route_editor.as_ref() else {
7810            return 0;
7811        };
7812        match &ed.sheet {
7813            S::Stops => ed.stops.len(),
7814            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
7815            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
7816            S::WaypointMapPick => 0,
7817            S::HarvestPicker { .. } => self.re_node_candidates().len(),
7818            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
7819                self.re_container_candidates().len()
7820            }
7821            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
7822            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
7823            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
7824            S::SellItem { templates, .. } => templates.len() + 1, // + sell-all toggle
7825            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
7826            S::WaitEntry { .. } => 1,
7827            S::BedPicker { .. } => self.re_bed_candidates().len(),
7828        }
7829    }
7830
7831    /// Current sheet cursor index (0 for sheets without one).
7832    pub fn re_sheet_index(&self) -> usize {
7833        use crate::worker_route_editor::RouteEditorSheet as S;
7834        let Some(ed) = self.state.worker_route_editor.as_ref() else {
7835            return 0;
7836        };
7837        match &ed.sheet {
7838            S::AddMenu { index }
7839            | S::WaypointMenu { index }
7840            | S::HarvestPicker { index }
7841            | S::WithdrawContainers { index }
7842            | S::DepositContainers { index }
7843            | S::SellNpcs { index }
7844            | S::CraftBlueprint { index }
7845            | S::BedPicker { index }
7846            | S::WithdrawItems { index, .. }
7847            | S::DepositFilter { index, .. }
7848            | S::SellItem { index, .. } => *index,
7849            _ => 0,
7850        }
7851    }
7852
7853    /// Move the current sheet's cursor, wrapping within its rows.
7854    pub fn re_sheet_move(&mut self, delta: i32) {
7855        use crate::worker_route_editor::RouteEditorSheet as S;
7856        let count = self.re_sheet_row_count();
7857        if count == 0 {
7858            return;
7859        }
7860        let cur = self.re_sheet_index() as i32;
7861        let next = (cur + delta).rem_euclid(count as i32) as usize;
7862        let Some(ed) = self.state.worker_route_editor.as_mut() else {
7863            return;
7864        };
7865        match &mut ed.sheet {
7866            S::AddMenu { index }
7867            | S::WaypointMenu { index }
7868            | S::HarvestPicker { index }
7869            | S::WithdrawContainers { index }
7870            | S::DepositContainers { index }
7871            | S::SellNpcs { index }
7872            | S::CraftBlueprint { index }
7873            | S::BedPicker { index }
7874            | S::WithdrawItems { index, .. }
7875            | S::DepositFilter { index, .. }
7876            | S::SellItem { index, .. } => *index = next,
7877            _ => {}
7878        }
7879    }
7880
7881    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
7882    pub fn re_sheet_adjust(&mut self, delta: i32) {
7883        use crate::worker_route_editor::RouteEditorSheet as S;
7884        let index = self.re_sheet_index();
7885        let Some(ed) = self.state.worker_route_editor.as_mut() else {
7886            return;
7887        };
7888        match &mut ed.sheet {
7889            S::WithdrawItems { lines, .. } => {
7890                if let Some(line) = lines.get_mut(index) {
7891                    line.adjust_qty(delta);
7892                }
7893            }
7894            S::WaitEntry { ticks } => {
7895                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
7896            }
7897            _ => {}
7898        }
7899    }
7900
7901    pub fn re_sheet_back(&mut self) {
7902        let Some(ed) = self.state.worker_route_editor.as_mut() else {
7903            return;
7904        };
7905        use crate::worker_route_editor::RouteEditorSheet as S;
7906        let was_editing = ed.editing_index.is_some();
7907        let from_top_picker = matches!(
7908            ed.sheet,
7909            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
7910        );
7911        ed.sheet_back();
7912        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
7913            // Chest retarget commits on pick; Esc here just ends the edit sheet.
7914            self.state
7915                .push_log("Route: left edit sheet — press s to save current stops".to_string());
7916        }
7917    }
7918
7919    /// True when the editor is at its root sheet (Esc should close it).
7920    pub fn re_at_root_sheet(&self) -> bool {
7921        self.state
7922            .worker_route_editor
7923            .as_ref()
7924            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
7925    }
7926
7927    pub fn re_open_add_menu(&mut self) {
7928        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7929            ed.open_add_menu();
7930        }
7931    }
7932
7933    pub fn re_open_bed_picker(&mut self) {
7934        let beds = self.re_bed_candidates();
7935        if beds.is_empty() {
7936            self.state
7937                .push_log("Route: place a camp bed first".to_string());
7938            return;
7939        }
7940        let current = self
7941            .state
7942            .worker_route_editor
7943            .as_ref()
7944            .and_then(|ed| ed.lodging_container_id.clone());
7945        let index = current
7946            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
7947            .unwrap_or(0);
7948        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
7949    }
7950
7951    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
7952        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7953            ed.open_sheet(sheet);
7954        }
7955    }
7956
7957    /// Confirm a sheet's stop and log the outcome.
7958    fn re_confirm_stop(
7959        &mut self,
7960        stop: crate::worker_route_editor::WorkerRouteStop,
7961        what: String,
7962    ) {
7963        let appended = self
7964            .state
7965            .worker_route_editor
7966            .as_mut()
7967            .is_some_and(|ed| ed.confirm_stop(stop));
7968        if appended {
7969            self.state.push_log(format!("Route: + {what}"));
7970        } else {
7971            self.state
7972                .push_log(format!("Route: {what} already in route — selected it"));
7973        }
7974    }
7975
7976    fn re_open_withdraw_items(&mut self, container_id: String) {
7977        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
7978        let contents = self.re_container_contents(&container_id);
7979        // When editing, keep the stop's item picks even if the player retargets
7980        // to a different (possibly empty) chest — otherwise Done has nothing to
7981        // confirm and the edit appears to "revert".
7982        let existing = self
7983            .state
7984            .worker_route_editor
7985            .as_ref()
7986            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
7987            .and_then(|stop| match stop {
7988                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
7989                _ => None,
7990            })
7991            .unwrap_or_default();
7992        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
7993        // Commit the new chest immediately while editing so Esc-before-Done still
7994        // keeps the retarget (items update only when Done is pressed).
7995        if let Some(ed) = self.state.worker_route_editor.as_mut() {
7996            let _ = ed.retarget_withdraw_container(container_id.clone());
7997        }
7998        self.re_open_sheet(S::WithdrawItems {
7999            container_id,
8000            lines,
8001            index: 0,
8002        });
8003    }
8004
8005    fn re_withdraw_items_activate(&mut self, index: usize) {
8006        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8007        enum Outcome {
8008            Cycled,
8009            Confirmed(String),
8010            Empty,
8011        }
8012        let outcome = {
8013            let Some(ed) = self.state.worker_route_editor.as_mut() else {
8014                return;
8015            };
8016            let S::WithdrawItems {
8017                container_id,
8018                lines,
8019                index: sheet_index,
8020            } = &mut ed.sheet
8021            else {
8022                return;
8023            };
8024            *sheet_index = index;
8025            if index < lines.len() {
8026                lines[index].cycle();
8027                Outcome::Cycled
8028            } else {
8029                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
8030                if items.is_empty() {
8031                    Outcome::Empty
8032                } else {
8033                    let stop = WorkerRouteStop::WithdrawFrom {
8034                        container_id: container_id.clone(),
8035                        items,
8036                    };
8037                    let summary = stop.summary();
8038                    ed.confirm_stop(stop);
8039                    Outcome::Confirmed(summary)
8040                }
8041            }
8042        };
8043        match outcome {
8044            Outcome::Cycled => {}
8045            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
8046            Outcome::Empty => self
8047                .state
8048                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
8049        }
8050    }
8051
8052    fn re_open_deposit_filter(&mut self, container_id: String) {
8053        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8054        // Preserve filter when retargeting the deposit chest while editing.
8055        let existing_filter = self
8056            .state
8057            .worker_route_editor
8058            .as_ref()
8059            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8060            .and_then(|stop| match stop {
8061                WorkerRouteStop::DepositAt { filter, .. } => {
8062                    Some(filter.clone().unwrap_or_default())
8063                }
8064                _ => None,
8065            });
8066        let mut candidates = self.re_template_candidates();
8067        if let Some(ref chosen) = existing_filter {
8068            for t in chosen {
8069                if !candidates.iter().any(|c| c == t) {
8070                    candidates.push(t.clone());
8071                }
8072            }
8073            candidates.sort();
8074            candidates.dedup();
8075        }
8076        let rows: Vec<(String, bool)> = match existing_filter {
8077            Some(chosen) => candidates
8078                .iter()
8079                .map(|t| (t.clone(), chosen.contains(t)))
8080                .collect(),
8081            None => candidates.into_iter().map(|t| (t, false)).collect(),
8082        };
8083        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8084            let _ = ed.retarget_deposit_container(container_id.clone());
8085        }
8086        self.re_open_sheet(S::DepositFilter {
8087            container_id,
8088            rows,
8089            index: 0,
8090        });
8091    }
8092
8093    fn re_deposit_filter_activate(&mut self, index: usize) {
8094        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8095        let mut confirmed: Option<String> = None;
8096        {
8097            let Some(ed) = self.state.worker_route_editor.as_mut() else {
8098                return;
8099            };
8100            let S::DepositFilter {
8101                container_id,
8102                rows,
8103                index: sheet_index,
8104            } = &mut ed.sheet
8105            else {
8106                return;
8107            };
8108            *sheet_index = index;
8109            if index < rows.len() {
8110                rows[index].1 = !rows[index].1;
8111            } else {
8112                // "Done" row.
8113                let chosen: Vec<String> = rows
8114                    .iter()
8115                    .filter(|(_, on)| *on)
8116                    .map(|(t, _)| t.clone())
8117                    .collect();
8118                let filter = if chosen.is_empty() { None } else { Some(chosen) };
8119                let stop = WorkerRouteStop::DepositAt {
8120                    container_id: container_id.clone(),
8121                    filter,
8122                };
8123                confirmed = Some(stop.summary());
8124                ed.confirm_stop(stop);
8125            }
8126        }
8127        if let Some(what) = confirmed {
8128            self.state.push_log(format!("Route: + {what}"));
8129        }
8130    }
8131
8132    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
8133        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8134        let templates = self.re_template_candidates();
8135        if templates.is_empty() {
8136            self.state.push_log(
8137                "Route: no item templates available — learn a craft recipe or place a harvest node first"
8138                    .to_string(),
8139            );
8140            return;
8141        }
8142        // Prefill when editing an existing sell stop.
8143        let (pre_npc, pre_template, pre_all) = self
8144            .state
8145            .worker_route_editor
8146            .as_ref()
8147            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8148            .and_then(|stop| match stop {
8149                WorkerRouteStop::TradeWith {
8150                    npc_id,
8151                    template,
8152                    sell_all,
8153                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
8154                _ => None,
8155            })
8156            .unwrap_or((None, None, true));
8157        let npc_id = npc_id.or(pre_npc);
8158        let index = pre_template
8159            .and_then(|t| templates.iter().position(|x| x == &t))
8160            .map(|i| i + 1) // +1 for the sell-all toggle row
8161            .unwrap_or(1);
8162        self.re_open_sheet(S::SellItem {
8163            npc_id,
8164            templates,
8165            index,
8166            sell_all: pre_all,
8167        });
8168    }
8169
8170    fn re_sell_item_activate(&mut self, index: usize) {
8171        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8172        let mut confirmed: Option<String> = None;
8173        {
8174            let Some(ed) = self.state.worker_route_editor.as_mut() else {
8175                return;
8176            };
8177            let S::SellItem {
8178                npc_id,
8179                templates,
8180                index: sheet_index,
8181                sell_all,
8182            } = &mut ed.sheet
8183            else {
8184                return;
8185            };
8186            *sheet_index = index;
8187            if index == 0 {
8188                *sell_all = !*sell_all;
8189            } else if let Some(template) = templates.get(index - 1).cloned() {
8190                let stop = WorkerRouteStop::TradeWith {
8191                    npc_id: npc_id.clone(),
8192                    template,
8193                    sell_all: *sell_all,
8194                };
8195                confirmed = Some(stop.summary());
8196                ed.confirm_stop(stop);
8197            }
8198        }
8199        if let Some(what) = confirmed {
8200            self.state.push_log(format!("Route: + {what}"));
8201        }
8202    }
8203
8204    /// Enter on the stop list: open the selected stop's sheet, prefilled.
8205    pub fn re_edit_selected_stop(&mut self) {
8206        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8207        let Some(stop) = self
8208            .state
8209            .worker_route_editor
8210            .as_ref()
8211            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
8212        else {
8213            self.state
8214                .push_log("Route: no stop selected — press a to add one".to_string());
8215            return;
8216        };
8217        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8218            ed.begin_edit_selected();
8219        }
8220        match stop {
8221            WorkerRouteStop::Waypoint { .. } => {
8222                self.re_open_sheet(S::WaypointMenu { index: 0 });
8223            }
8224            WorkerRouteStop::HarvestNode { node_id } => {
8225                let nodes = self.re_node_candidates();
8226                let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
8227                if nodes.is_empty() {
8228                    self.re_cancel_edit();
8229                    self.state
8230                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
8231                } else {
8232                    self.re_open_sheet(S::HarvestPicker { index });
8233                }
8234            }
8235            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
8236                // Open the container picker so the player can retarget storage
8237                // (previously jumped straight to items on the same chest).
8238                let containers = self.re_container_candidates();
8239                if containers.is_empty() {
8240                    self.re_cancel_edit();
8241                    self.state
8242                        .push_log("Route: place a storage chest first".to_string());
8243                } else {
8244                    let index = containers
8245                        .iter()
8246                        .position(|c| c.id == container_id)
8247                        .unwrap_or(0);
8248                    self.re_open_sheet(S::WithdrawContainers { index });
8249                }
8250            }
8251            WorkerRouteStop::DepositAt { container_id, .. } => {
8252                let containers = self.re_container_candidates();
8253                if containers.is_empty() {
8254                    self.re_cancel_edit();
8255                    self.state
8256                        .push_log("Route: place a storage chest first".to_string());
8257                } else {
8258                    let index = containers
8259                        .iter()
8260                        .position(|c| c.id == container_id)
8261                        .unwrap_or(0);
8262                    self.re_open_sheet(S::DepositContainers { index });
8263                }
8264            }
8265            WorkerRouteStop::TradeWith { npc_id, .. } => {
8266                let npcs = self.re_npc_candidates();
8267                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
8268                let index = npc_id
8269                    .as_ref()
8270                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
8271                    .unwrap_or(0);
8272                self.re_open_sheet(S::SellNpcs { index });
8273            }
8274            WorkerRouteStop::CraftAt { blueprint, .. } => {
8275                let bps = self.re_blueprint_ids();
8276                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
8277                if bps.is_empty() {
8278                    self.re_cancel_edit();
8279                    self.state
8280                        .push_log("Route: no known blueprints to retarget".to_string());
8281                } else {
8282                    self.re_open_sheet(S::CraftBlueprint { index });
8283                }
8284            }
8285            WorkerRouteStop::RestIfNeeded => {
8286                self.re_cancel_edit();
8287                self.state
8288                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
8289            }
8290            WorkerRouteStop::Wait { wait_ticks } => {
8291                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
8292            }
8293        }
8294    }
8295
8296    fn re_cancel_edit(&mut self) {
8297        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8298            ed.editing_index = None;
8299        }
8300    }
8301
8302    // ---- mouse clicks inside the overlay ------------------------------
8303
8304    pub fn worker_route_editor_ui_click(
8305        &mut self,
8306        click: crate::worker_route_editor::RouteEditorClick,
8307    ) {
8308        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
8309        match click {
8310            RouteEditorClick::SelectStop(i) => {
8311                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8312                    ed.sheet = S::Stops;
8313                    ed.select_stop(i);
8314                }
8315            }
8316            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
8317            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
8318            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
8319        }
8320    }
8321
8322    /// Activate (Enter / click) a row of the current sheet.
8323    pub fn re_sheet_row_activate(&mut self, row: usize) {
8324        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8325        let Some(sheet) = self
8326            .state
8327            .worker_route_editor
8328            .as_ref()
8329            .map(|ed| ed.sheet.clone())
8330        else {
8331            return;
8332        };
8333        match sheet {
8334            S::Stops => {
8335                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8336                    ed.select_stop(row);
8337                }
8338            }
8339            S::AddMenu { .. } => match row {
8340                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
8341                1 => {
8342                    if self.re_node_candidates().is_empty() {
8343                        self.state
8344                            .push_log("Route: no harvestable nodes visible in this region".to_string());
8345                    } else {
8346                        self.re_open_sheet(S::HarvestPicker { index: 0 });
8347                    }
8348                }
8349                2 | 3 => {
8350                    if self.re_container_candidates().is_empty() {
8351                        self.state
8352                            .push_log("Route: place a storage chest first".to_string());
8353                    } else if row == 2 {
8354                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
8355                    } else {
8356                        self.re_open_sheet(S::DepositContainers { index: 0 });
8357                    }
8358                }
8359                4 => {
8360                    if self.re_template_candidates().is_empty() {
8361                        self.state.push_log(
8362                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
8363                                .to_string(),
8364                        );
8365                    } else {
8366                        self.re_open_sheet(S::SellNpcs { index: 0 });
8367                    }
8368                }
8369                5 => {
8370                    if self.re_blueprint_ids().is_empty() {
8371                        self.state.push_log(
8372                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
8373                                .to_string(),
8374                        );
8375                    } else {
8376                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
8377                    }
8378                }
8379                6 => self.re_confirm_stop(
8380                    WorkerRouteStop::RestIfNeeded,
8381                    "rest if needed".into(),
8382                ),
8383                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
8384                _ => {}
8385            },
8386            S::WaypointMenu { .. } => match row {
8387                0 => {
8388                    let (x, y, z) = self.state.player_position_with_z();
8389                    let stop = WorkerRouteStop::Waypoint { x, y, z };
8390                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8391                }
8392                1 => {
8393                    self.re_open_sheet(S::WaypointMapPick);
8394                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
8395                }
8396                _ => {}
8397            },
8398            S::HarvestPicker { .. } => {
8399                let nodes = self.re_node_candidates();
8400                if let Some(n) = nodes.get(row) {
8401                    let stop = WorkerRouteStop::HarvestNode {
8402                        node_id: n.id.clone(),
8403                    };
8404                    let label = n.label.clone();
8405                    self.re_confirm_stop(stop, format!("harvest {label}"));
8406                }
8407            }
8408            S::WithdrawContainers { .. } => {
8409                let containers = self.re_container_candidates();
8410                if let Some(c) = containers.get(row) {
8411                    let id = c.id.clone();
8412                    self.re_open_withdraw_items(id);
8413                }
8414            }
8415            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
8416            S::DepositContainers { .. } => {
8417                let containers = self.re_container_candidates();
8418                if let Some(c) = containers.get(row) {
8419                    let id = c.id.clone();
8420                    self.re_open_deposit_filter(id);
8421                }
8422            }
8423            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
8424            S::SellNpcs { .. } => {
8425                let npcs = self.re_npc_candidates();
8426                let npc_id = if row == 0 {
8427                    None
8428                } else {
8429                    npcs.get(row - 1).map(|n| n.id.clone())
8430                };
8431                if row == 0 || npc_id.is_some() {
8432                    self.re_open_sell_item(npc_id);
8433                }
8434            }
8435            S::SellItem { .. } => self.re_sell_item_activate(row),
8436            S::CraftBlueprint { .. } => {
8437                let bps = self.re_blueprint_ids();
8438                if let Some(bp) = bps.get(row) {
8439                    let stop = WorkerRouteStop::CraftAt {
8440                        device: "hand".into(),
8441                        blueprint: bp.clone(),
8442                        qty: None,
8443                    };
8444                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
8445                }
8446            }
8447            S::WaitEntry { ticks } => {
8448                let stop = WorkerRouteStop::Wait {
8449                    wait_ticks: ticks,
8450                };
8451                self.re_confirm_stop(stop, format!("wait {ticks}t"));
8452            }
8453            S::BedPicker { .. } => {
8454                let beds = self.re_bed_candidates();
8455                if let Some((id, name)) = beds.get(row) {
8456                    let (id, name) = (id.clone(), name.clone());
8457                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
8458                        ed.lodging_container_id = Some(id.clone());
8459                        ed.sheet = S::Stops;
8460                    }
8461                    self.state
8462                        .push_log(format!("Route: rest bed set to {name}"));
8463                }
8464            }
8465            S::WaypointMapPick => {}
8466        }
8467    }
8468
8469    // ---- map clicks (sheet-scoped accelerators) ------------------------
8470
8471    /// Map click while the route editor is open. When a picker sheet is open
8472    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
8473    /// container, NPC clicks the merchant, node clicks the harvest target);
8474    /// otherwise the click quick-adds the nearest target (context-aware).
8475    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
8476        use crate::worker_route_editor as wre;
8477        use wre::RouteEditorSheet as S;
8478        if self.state.worker_route_editor.is_none() {
8479            return;
8480        }
8481        let sheet = self
8482            .state
8483            .worker_route_editor
8484            .as_ref()
8485            .map(|ed| ed.sheet.clone())
8486            .unwrap_or(S::Stops);
8487        match sheet {
8488            S::WaypointMapPick => {
8489                let (_, _, z) = self.state.player_position_with_z();
8490                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
8491                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8492                // Stay in map-pick mode when adding (not editing) for fast pathing.
8493                let editing = self
8494                    .state
8495                    .worker_route_editor
8496                    .as_ref()
8497                    .is_some_and(|ed| ed.editing_index.is_some());
8498                if !editing {
8499                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
8500                        ed.sheet = S::WaypointMapPick;
8501                    }
8502                }
8503            }
8504            S::HarvestPicker { .. } => {
8505                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8506                    let stop = wre::WorkerRouteStop::HarvestNode {
8507                        node_id: node.id.clone(),
8508                    };
8509                    let label = node.label.clone();
8510                    self.re_confirm_stop(stop, format!("harvest {label}"));
8511                }
8512            }
8513            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
8514                // Chest click picks (or switches) the withdraw source.
8515                if let Some(cid) = wre::pick_storage_container_at(
8516                    &self.state.placed_containers,
8517                    self.state.character_id,
8518                    x,
8519                    y,
8520                ) {
8521                    self.re_open_withdraw_items(cid);
8522                }
8523            }
8524            S::DepositContainers { .. } | S::DepositFilter { .. } => {
8525                if let Some(cid) = wre::pick_storage_container_at(
8526                    &self.state.placed_containers,
8527                    self.state.character_id,
8528                    x,
8529                    y,
8530                ) {
8531                    self.re_open_deposit_filter(cid);
8532                }
8533            }
8534            S::SellNpcs { .. } => {
8535                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8536                    self.re_open_sell_item(Some(npc_id));
8537                }
8538            }
8539            S::SellItem { .. } => {
8540                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8541                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
8542                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
8543                            *slot = Some(npc_id.clone());
8544                        }
8545                    }
8546                    self.state
8547                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8548                }
8549            }
8550            // Stop list / other sheets: context-aware quick add.
8551            _ => self.worker_route_editor_quick_add_click(x, y),
8552        }
8553    }
8554
8555    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
8556    /// node wins; duplicates select the existing stop; a click on a merchant
8557    /// while a sell stop is selected pins it.
8558    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
8559        use crate::worker_route_editor as wre;
8560        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
8561            let dx = ax - bx;
8562            let dy = ay - by;
8563            (dx * dx + dy * dy).sqrt()
8564        };
8565
8566        // 1. Retarget the selected stop when the click names its target:
8567        //    sell stop → pin the merchant; withdraw stop → set the source chest.
8568        let selected_stop_kind = self
8569            .state
8570            .worker_route_editor
8571            .as_ref()
8572            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
8573            .map(|s| match s {
8574                wre::WorkerRouteStop::TradeWith { .. } => 1,
8575                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
8576                _ => 0,
8577            })
8578            .unwrap_or(0);
8579        if selected_stop_kind == 1 {
8580            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8581                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8582                    ed.set_selected_trade_npc(npc_id.clone());
8583                }
8584                self.state
8585                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8586                return;
8587            }
8588        }
8589        if selected_stop_kind == 2 {
8590            if let Some(cid) = wre::pick_storage_container_at(
8591                &self.state.placed_containers,
8592                self.state.character_id,
8593                x,
8594                y,
8595            ) {
8596                let name = self
8597                    .state
8598                    .placed_containers
8599                    .iter()
8600                    .find(|c| c.id == cid)
8601                    .map(|c| c.display_name.clone())
8602                    .unwrap_or_else(|| "container".into());
8603                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8604                    ed.set_selected_withdraw_container(cid.clone());
8605                }
8606                self.state
8607                    .push_log(format!("Route: withdraw source → {name}"));
8608                return;
8609            }
8610        }
8611
8612        // 2. Nearest candidate of any kind wins. Category order is the
8613        //    tie-break for exact overlaps (bed over chest over merchant over node).
8614        enum Target {
8615            Bed(String),
8616            Container(String),
8617            Npc(String, String),
8618            Node(String, String),
8619        }
8620        let mut best: Option<(f32, u8, Target)> = None;
8621        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
8622            let better = match best {
8623                None => true,
8624                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
8625            };
8626            if better {
8627                *best = Some((d, rank, t));
8628            }
8629        };
8630        if let Some(bed_id) = wre::pick_lodging_container_at(
8631            &self.state.placed_containers,
8632            self.state.character_id,
8633            x,
8634            y,
8635        ) {
8636            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
8637                // A bed that's already the rest bed doubles as a storage chest —
8638                // a second click on it means "deposit here", not "set bed again".
8639                let already_bed = self
8640                    .state
8641                    .worker_route_editor
8642                    .as_ref()
8643                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
8644                if already_bed {
8645                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
8646                } else {
8647                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
8648                }
8649            }
8650        }
8651        if let Some(cid) = wre::pick_storage_container_at(
8652            &self.state.placed_containers,
8653            self.state.character_id,
8654            x,
8655            y,
8656        ) {
8657            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
8658                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
8659            }
8660        }
8661        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8662            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
8663                consider(
8664                    dist(x, y, n.x, n.y),
8665                    2,
8666                    Target::Npc(npc_id, label),
8667                    &mut best,
8668                );
8669            }
8670        }
8671        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8672            let d = dist(x, y, node.x, node.y);
8673            consider(
8674                d,
8675                3,
8676                Target::Node(node.id.clone(), node.label.clone()),
8677                &mut best,
8678            );
8679        }
8680
8681        match best.map(|(_, _, t)| t) {
8682            Some(Target::Bed(bed_id)) => {
8683                let name = self
8684                    .state
8685                    .placed_containers
8686                    .iter()
8687                    .find(|c| c.id == bed_id)
8688                    .map(|c| c.display_name.clone())
8689                    .unwrap_or_else(|| "camp bed".into());
8690                if let Some(ed) = self.state.worker_route_editor.as_mut() {
8691                    ed.lodging_container_id = Some(bed_id.clone());
8692                }
8693                self.state
8694                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
8695            }
8696            Some(Target::Container(cid)) => {
8697                let name = self
8698                    .state
8699                    .placed_containers
8700                    .iter()
8701                    .find(|c| c.id == cid)
8702                    .map(|c| c.display_name.clone())
8703                    .unwrap_or_else(|| "container".into());
8704                let added = self
8705                    .state
8706                    .worker_route_editor
8707                    .as_mut()
8708                    .is_some_and(|ed| ed.append_deposit_at(&cid));
8709                if added {
8710                    self.state
8711                        .push_log(format!("Route: + deposit at {name} ({cid})"));
8712                } else {
8713                    self.state.push_log(format!(
8714                        "Route: {name} already in route — selected it (d to remove)"
8715                    ));
8716                }
8717            }
8718            Some(Target::Npc(npc_id, label)) => {
8719                // Quick-add sell: first storage template (the full sell flow
8720                // lives in the a → Sell sheet).
8721                let template = self.re_template_candidates().into_iter().next();
8722                let Some(template) = template else {
8723                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
8724                    return;
8725                };
8726                let added = self
8727                    .state
8728                    .worker_route_editor
8729                    .as_mut()
8730                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
8731                if added {
8732                    self.state
8733                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
8734                } else {
8735                    self.state.push_log(format!(
8736                        "Route: {label} already sells {template} — selected it (d to remove)"
8737                    ));
8738                }
8739            }
8740            Some(Target::Node(id, label)) => {
8741                let added = self
8742                    .state
8743                    .worker_route_editor
8744                    .as_mut()
8745                    .is_some_and(|ed| ed.append_harvest_node(&id));
8746                if added {
8747                    self.state
8748                        .push_log(format!("Route: + harvest node {label} ({id})"));
8749                } else {
8750                    self.state.push_log(format!(
8751                        "Route: {label} already in route — selected it (d to remove)"
8752                    ));
8753                }
8754            }
8755            None => {}
8756        }
8757    }
8758
8759    pub fn worker_route_editor_select(&mut self, delta: i32) {
8760        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8761            return;
8762        };
8763        if ed.stops.is_empty() {
8764            return;
8765        }
8766        let n = ed.stops.len() as i32;
8767        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
8768        ed.selected_stop_index = next;
8769    }
8770
8771    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
8772        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8773            return;
8774        };
8775        if delta < 0 {
8776            ed.move_selected_up();
8777        } else if delta > 0 {
8778            ed.move_selected_down();
8779        }
8780    }
8781
8782    pub fn worker_route_editor_delete_selected(&mut self) {
8783        let removed = self
8784            .state
8785            .worker_route_editor
8786            .as_mut()
8787            .is_some_and(|ed| {
8788                let before = ed.stop_count();
8789                ed.remove_selected_stop();
8790                ed.stop_count() < before
8791            });
8792        if removed {
8793            self.state.push_log("Route: removed selected stop");
8794        }
8795    }
8796
8797    /// Clear all stops. Saving afterwards parks the worker in idle mode
8798    /// instead of leaving it in a broken job loop.
8799    pub fn worker_route_editor_clear_stops(&mut self) {
8800        let Some(ed) = self.state.worker_route_editor.as_mut() else {
8801            return;
8802        };
8803        if ed.stops.is_empty() {
8804            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
8805            return;
8806        }
8807        ed.stops.clear();
8808        ed.selected_stop_index = 0;
8809        self.state
8810            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
8811    }
8812
8813    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
8814        if self.state.pending_worker_job_ack.is_some() {
8815            anyhow::bail!("route save still pending — wait for server ack");
8816        }
8817        let Some(ed) = self.state.worker_route_editor.clone() else {
8818            anyhow::bail!("route editor not open");
8819        };
8820        // Empty route = stand down: park the worker in idle mode rather than
8821        // erroring out or leaving it marching a ghost loop.
8822        let (job_yaml, idle) = if ed.stops.is_empty() {
8823            (ed.build_idle_job_yaml(), true)
8824        } else {
8825            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
8826        };
8827        let worker_id = ed.worker_instance_id.clone();
8828        let route_view = if idle {
8829            None
8830        } else {
8831            Some(ed.to_route_view())
8832        };
8833        let mode = if idle {
8834            flatland_protocol::WorkerModeView::Idle
8835        } else {
8836            flatland_protocol::WorkerModeView::JobLoop
8837        };
8838        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
8839            .state
8840            .hired_workers
8841            .iter()
8842            .find(|w| w.instance_id == worker_id)
8843            .map(|w| {
8844                (
8845                    w.route.clone(),
8846                    w.mode,
8847                    w.step_label.clone(),
8848                    w.last_error.clone(),
8849                )
8850            })
8851            .unwrap_or((
8852                None,
8853                flatland_protocol::WorkerModeView::Idle,
8854                String::new(),
8855                None,
8856            ));
8857        self.seq += 1;
8858        let seq = self.seq;
8859        self.session
8860            .submit_intent(Intent::SetWorkerJob {
8861                entity_id: self.state.entity_id,
8862                worker_instance_id: worker_id.clone(),
8863                job_yaml,
8864                seq,
8865            })
8866            .await?;
8867        self.state.intents_sent += 1;
8868        if let Some(w) = self
8869            .state
8870            .hired_workers
8871            .iter_mut()
8872            .find(|w| w.instance_id == worker_id)
8873        {
8874            w.route = route_view;
8875            w.mode = mode;
8876            w.last_error = None;
8877            if idle {
8878                w.step_label.clear();
8879            }
8880        }
8881        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
8882            seq,
8883            worker_instance_id: worker_id,
8884            worker_label: ed.worker_label.clone(),
8885            idle,
8886            stop_count: ed.stops.len(),
8887            prev_route,
8888            prev_mode,
8889            prev_step_label,
8890            prev_last_error,
8891        });
8892        self.state.push_log(format!(
8893            "Route: saving for {}… (waiting for server)",
8894            ed.worker_label
8895        ));
8896        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
8897        Ok(())
8898    }
8899    pub fn quest_menu_move(&mut self, delta: i32) {
8900        let n = self.state.active_quest_entries().len();
8901        if n == 0 {
8902            return;
8903        }
8904        let idx = self.state.quest_menu_index as i32;
8905        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8906    }
8907
8908    pub fn quest_menu_page(&mut self, pages: i32) {
8909        let n = self.state.active_quest_entries().len();
8910        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
8911    }
8912
8913    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
8914        let Some(offer) = self.state.pending_quest_offer.clone() else {
8915            anyhow::bail!("no quest offer");
8916        };
8917        self.seq += 1;
8918        let seq = self.seq;
8919        self.session
8920            .submit_intent(Intent::AcceptQuest {
8921                entity_id: self.state.entity_id,
8922                quest_id: offer.quest_id,
8923                seq,
8924            })
8925            .await?;
8926        self.state.intents_sent += 1;
8927        Ok(())
8928    }
8929
8930    pub fn quest_offer_decline(&mut self) {
8931        self.state.show_quest_offer = false;
8932        self.state.pending_quest_offer = None;
8933        if !self.state.show_npc_chat
8934            && !self.state.show_shop_menu
8935            && self.state.npc_verb_target.is_some()
8936        {
8937            self.state.show_npc_verb_menu = true;
8938        }
8939    }
8940
8941    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
8942        if !self.state.show_quest_menu {
8943            return Ok(());
8944        }
8945        let active: Vec<_> = self
8946            .state
8947            .active_quest_entries()
8948            .into_iter()
8949            .cloned()
8950            .collect();
8951        let Some(entry) = active.get(self.state.quest_menu_index) else {
8952            return Ok(());
8953        };
8954        if self.state.quest_withdraw_confirm {
8955            if !entry.can_withdraw {
8956                anyhow::bail!("quest cannot be withdrawn");
8957            }
8958            self.seq += 1;
8959            let seq = self.seq;
8960            self.session
8961                .submit_intent(Intent::WithdrawQuest {
8962                    entity_id: self.state.entity_id,
8963                    quest_id: entry.quest_id.clone(),
8964                    seq,
8965                })
8966                .await?;
8967            self.state.intents_sent += 1;
8968            self.state.quest_withdraw_confirm = false;
8969            return Ok(());
8970        }
8971        self.seq += 1;
8972        let seq = self.seq;
8973        self.session
8974            .submit_intent(Intent::TrackQuest {
8975                entity_id: self.state.entity_id,
8976                quest_id: entry.quest_id.clone(),
8977                seq,
8978            })
8979            .await?;
8980        self.state.intents_sent += 1;
8981        Ok(())
8982    }
8983
8984    pub fn quest_request_withdraw(&mut self) {
8985        if self.state.show_quest_menu {
8986            self.state.quest_withdraw_confirm = true;
8987        }
8988    }
8989
8990    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
8991        if !self.state.is_alive() {
8992            anyhow::bail!("you are dead");
8993        }
8994        let Some(catalog) = self.state.shop_catalog.clone() else {
8995            anyhow::bail!("no shop open");
8996        };
8997        self.seq += 1;
8998        let seq = self.seq;
8999        match self.state.shop_tab {
9000            ShopTab::Buy => {
9001                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
9002                    anyhow::bail!("nothing selected");
9003                };
9004                if offer.already_owned {
9005                    anyhow::bail!("already owned");
9006                }
9007                self.session
9008                    .submit_intent(Intent::ShopBuy {
9009                        entity_id: self.state.entity_id,
9010                        npc_id: catalog.npc_id.clone(),
9011                        offer_id: offer.offer_id.clone(),
9012                        quantity: self.state.shop_quantity,
9013                        seq,
9014                    })
9015                    .await?;
9016            }
9017            ShopTab::Sell => {
9018                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
9019                    anyhow::bail!("nothing to sell");
9020                };
9021                if line.quantity == 0 {
9022                    anyhow::bail!("you have no {}", line.label);
9023                }
9024                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
9025                self.session
9026                    .submit_intent(Intent::ShopSell {
9027                        entity_id: self.state.entity_id,
9028                        npc_id: catalog.npc_id.clone(),
9029                        template_id: line.template_id.clone(),
9030                        quantity,
9031                        seq,
9032                    })
9033                    .await?;
9034            }
9035        }
9036        self.state.intents_sent += 1;
9037        Ok(())
9038    }
9039
9040    pub fn craft_menu_move(&mut self, delta: i32) {
9041        let n = self.state.blueprints.len();
9042        if n == 0 {
9043            return;
9044        }
9045        let idx = self.state.craft_menu_index as i32;
9046        let next = (idx + delta).rem_euclid(n as i32);
9047        self.state.craft_menu_index = next as usize;
9048        self.state.clamp_craft_batch_quantity();
9049    }
9050
9051    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
9052        self.state.craft_batch_adjust_quantity(delta);
9053    }
9054
9055    pub fn craft_batch_set_max(&mut self) {
9056        self.state.craft_batch_set_max();
9057    }
9058
9059    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
9060        let Some(blueprint) = self
9061            .state
9062            .blueprints
9063            .get(self.state.craft_menu_index)
9064            .cloned()
9065        else {
9066            anyhow::bail!("no blueprints known");
9067        };
9068        if !self.state.can_craft_blueprint(&blueprint) {
9069            let hint = self
9070                .state
9071                .craft_missing_hint(&blueprint)
9072                .unwrap_or_else(|| "missing materials".into());
9073            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
9074        }
9075        let count = self.state.craft_batch_quantity;
9076        let max = self.state.max_craft_batches(&blueprint);
9077        if max == 0 {
9078            anyhow::bail!("cannot craft {}", blueprint.label);
9079        }
9080        let batches = count.min(max);
9081        self.craft(&blueprint.id, Some(batches)).await?;
9082        self.state.show_craft_menu = false;
9083        Ok(())
9084    }
9085
9086    pub async fn move_by(
9087        &mut self,
9088        forward: f32,
9089        strafe: f32,
9090        vertical: f32,
9091        sprint: bool,
9092    ) -> anyhow::Result<()> {
9093        if !self.state.is_alive() {
9094            anyhow::bail!("you are dead");
9095        }
9096        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
9097            self.last_move_forward = forward;
9098            self.last_move_strafe = strafe;
9099        }
9100        self.seq += 1;
9101        self.session
9102            .submit_intent(Intent::Move {
9103                entity_id: self.state.entity_id,
9104                forward,
9105                strafe,
9106                vertical,
9107                sprint,
9108                seq: self.seq,
9109            })
9110            .await?;
9111        self.state.intents_sent += 1;
9112        Ok(())
9113    }
9114
9115    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
9116        if !self.state.connected {
9117            crate::harvest_trace!("harvest_nearest rejected: not connected");
9118            anyhow::bail!("not connected");
9119        }
9120        if !self.state.is_alive() {
9121            crate::harvest_trace!("harvest_nearest rejected: player dead");
9122            anyhow::bail!("you are dead");
9123        }
9124        if self.state.harvest_in_progress {
9125            if self.state.harvest_state_stale() {
9126                self.state.clear_harvest_state();
9127            } else {
9128                anyhow::bail!("already harvesting");
9129            }
9130        }
9131        let (px, py) = self
9132            .state
9133            .player
9134            .as_ref()
9135            .map(|p| (p.transform.position.x, p.transform.position.y))
9136            .unwrap_or((0.0, 0.0));
9137
9138        let available = self
9139            .state
9140            .resource_nodes
9141            .iter()
9142            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9143            .count();
9144        let node_id = self
9145            .state
9146            .resource_nodes
9147            .iter()
9148            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9149            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
9150            .min_by(|a, b| {
9151                let da = distance(px, py, a.x, a.y);
9152                let db = distance(px, py, b.x, b.y);
9153                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9154            })
9155            .map(|n| n.id.clone());
9156
9157        let Some(node_id) = node_id else {
9158            let has_loot = self
9159                .state
9160                .ground_drops
9161                .iter()
9162                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9163            if has_loot {
9164                return self.pickup_nearest().await;
9165            }
9166            anyhow::bail!(
9167                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
9168            );
9169        };
9170
9171        self.seq += 1;
9172        let seq = self.seq;
9173        crate::harvest_trace!(
9174            entity_id = self.state.entity_id,
9175            node_id = %node_id,
9176            seq,
9177            px,
9178            py,
9179            available_nodes = available,
9180            "submitting harvest intent"
9181        );
9182        self.session
9183            .submit_intent(Intent::Harvest {
9184                entity_id: self.state.entity_id,
9185                node_id,
9186                seq,
9187            })
9188            .await?;
9189        self.state.intents_sent += 1;
9190        self.state.harvest_in_progress = true;
9191        self.state.harvest_started_at = Some(Instant::now());
9192        self.state.push_log("Harvesting…");
9193        crate::harvest_trace!(
9194            entity_id = self.state.entity_id,
9195            seq,
9196            "harvest intent queued to session"
9197        );
9198        Ok(())
9199    }
9200
9201    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
9202        if !self.state.is_alive() {
9203            anyhow::bail!("you are dead");
9204        }
9205        let blueprint_id = self
9206            .state
9207            .blueprints
9208            .iter()
9209            .find(|bp| self.state.can_craft_blueprint(bp))
9210            .map(|bp| bp.id.clone())
9211            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
9212        self.craft(&blueprint_id, None).await
9213    }
9214
9215    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
9216        if !self.state.is_alive() {
9217            anyhow::bail!("you are dead");
9218        }
9219        self.seq += 1;
9220        self.session
9221            .submit_intent(Intent::Craft {
9222                entity_id: self.state.entity_id,
9223                blueprint_id: blueprint_id.to_string(),
9224                count,
9225                seq: self.seq,
9226            })
9227            .await?;
9228        self.state.intents_sent += 1;
9229        let (label, batches) = self
9230            .state
9231            .blueprints
9232            .iter()
9233            .find(|b| b.id == blueprint_id)
9234            .map(|b| {
9235                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
9236                (b.label.as_str(), n)
9237            })
9238            .unwrap_or((blueprint_id, count.unwrap_or(1)));
9239        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
9240        Ok(())
9241    }
9242
9243    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
9244        if !self.state.is_alive() {
9245            anyhow::bail!("you are dead");
9246        }
9247        let target_id = match self.state.nearest_interact_target() {
9248            Some(id) => id,
9249            None => {
9250                anyhow::bail!("nothing to interact with nearby");
9251            }
9252        };
9253        if self.state.npcs.iter().any(|n| n.id == target_id) {
9254            self.state.show_npc_verb_menu = true;
9255            self.state.npc_verb_target = Some(target_id);
9256            self.state.npc_verb_index = 0;
9257            return Ok(());
9258        }
9259        self.seq += 1;
9260        self.session
9261            .submit_intent(Intent::Interact {
9262                entity_id: self.state.entity_id,
9263                target_id: target_id.clone(),
9264                seq: self.seq,
9265            })
9266            .await?;
9267        self.state.intents_sent += 1;
9268        Ok(())
9269    }
9270
9271    /// Context-sensitive world use: interact → pickup loot/chest → harvest/butcher.
9272    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
9273        if !self.state.is_alive() {
9274            anyhow::bail!("you are dead");
9275        }
9276        if self.state.nearest_interact_target().is_some() {
9277            return self.interact_nearest().await;
9278        }
9279        // Prefer a clear "move closer" when a board is visible but out of reach,
9280        // instead of silently falling through to harvest.
9281        if let Some((label, dist)) = self.state.nearest_quest_board() {
9282            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
9283                anyhow::bail!(
9284                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
9285                );
9286            }
9287        }
9288        let (px, py) = self.state.player_position();
9289        let has_loot = self
9290            .state
9291            .ground_drops
9292            .iter()
9293            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9294        if has_loot {
9295            return self.pickup_nearest().await;
9296        }
9297        if self
9298            .state
9299            .placed_containers
9300            .iter()
9301            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9302        {
9303            return self.pickup_nearest_container().await;
9304        }
9305        match self.harvest_nearest().await {
9306            Ok(()) => Ok(()),
9307            Err(err) => {
9308                let msg = err.to_string();
9309                if msg.contains("no harvestable")
9310                    || msg.contains("press p")
9311                    || msg.contains("press f")
9312                {
9313                    anyhow::bail!(
9314                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
9315                    );
9316                }
9317                Err(err)
9318            }
9319        }
9320    }
9321
9322    /// Cast the ability bound to hotbar slot `1`–`9` (server-persisted). Heals prefer T2/self; others prefer T1.
9323    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
9324        if !self.state.is_alive() {
9325            anyhow::bail!("you are dead");
9326        }
9327        let ability_id = self
9328            .state
9329            .hotbar_ability(slot)
9330            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
9331            .to_string();
9332        let target = if ability_id == "heal_touch" {
9333            Some(
9334                self.state
9335                    .target_for_slot(2)
9336                    .unwrap_or(self.state.entity_id),
9337            )
9338        } else {
9339            self.state
9340                .target_for_slot(1)
9341                .or_else(|| self.state.target_for_slot(2))
9342        };
9343        let Some(target_id) = target else {
9344            anyhow::bail!("no target — Tab to select, then press the hotbar key");
9345        };
9346        self.cast_ability(&ability_id, Some(target_id)).await
9347    }
9348
9349    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
9350    pub async fn set_hotbar_slot(
9351        &mut self,
9352        slot: u8,
9353        ability_id: Option<&str>,
9354    ) -> anyhow::Result<()> {
9355        if !self.state.is_alive() {
9356            anyhow::bail!("you are dead");
9357        }
9358        if !(1..=9).contains(&slot) {
9359            anyhow::bail!("hotbar slot must be 1–9");
9360        }
9361        let ability_id = ability_id
9362            .map(str::trim)
9363            .filter(|id| !id.is_empty())
9364            .map(str::to_string);
9365        self.seq += 1;
9366        self.session
9367            .submit_intent(Intent::SetHotbarSlot {
9368                entity_id: self.state.entity_id,
9369                slot,
9370                ability_id: ability_id.clone(),
9371                seq: self.seq,
9372            })
9373            .await?;
9374        self.state.intents_sent += 1;
9375        let idx = (slot - 1) as usize;
9376        if self.state.hotbar.len() < 9 {
9377            self.state.hotbar.resize(9, None);
9378        }
9379        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
9380            *slot_mut = ability_id.clone();
9381        }
9382        match ability_id {
9383            Some(id) => self.state.push_log(format!("Hotbar {slot} → {id}")),
9384            None => self.state.push_log(format!("Hotbar {slot} cleared")),
9385        }
9386        Ok(())
9387    }
9388
9389    pub fn npc_verb_options(&self) -> Vec<&'static str> {
9390        self.state.npc_verb_options()
9391    }
9392
9393    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
9394        let Some(npc_id) = self.state.npc_verb_target.clone() else {
9395            return Ok(());
9396        };
9397        let options = self.npc_verb_options();
9398        let choice = options
9399            .get(self.state.npc_verb_index)
9400            .copied()
9401            .unwrap_or("Talk");
9402        self.seq += 1;
9403        match choice {
9404            "Trade" | "Bank" | "Storage" => {
9405                self.session
9406                    .submit_intent(Intent::Interact {
9407                        entity_id: self.state.entity_id,
9408                        target_id: npc_id,
9409                        seq: self.seq,
9410                    })
9411                    .await?;
9412            }
9413            _ => {
9414                self.session
9415                    .submit_intent(Intent::NpcTalkOpen {
9416                        entity_id: self.state.entity_id,
9417                        npc_id,
9418                        seq: self.seq,
9419                    })
9420                    .await?;
9421            }
9422        }
9423        self.state.intents_sent += 1;
9424        Ok(())
9425    }
9426
9427    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
9428        let Some(chat) = self.state.npc_chat.clone() else {
9429            return Ok(());
9430        };
9431        let message = chat.input.trim().to_string();
9432        if message.is_empty() || chat.pending {
9433            return Ok(());
9434        }
9435        if let Some(c) = self.state.npc_chat.as_mut() {
9436            c.lines.push(format!("You: {message}"));
9437            c.input.clear();
9438            c.pending = true;
9439        }
9440        self.seq += 1;
9441        self.session
9442            .submit_intent(Intent::NpcTalkSay {
9443                entity_id: self.state.entity_id,
9444                npc_id: chat.npc_id,
9445                message,
9446                seq: self.seq,
9447            })
9448            .await?;
9449        self.state.intents_sent += 1;
9450        Ok(())
9451    }
9452
9453    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
9454        let return_to_verbs = self.state.npc_verb_target.is_some();
9455        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
9456            self.state.show_npc_chat = false;
9457            if return_to_verbs {
9458                self.state.show_npc_verb_menu = true;
9459            }
9460            return Ok(());
9461        };
9462        self.seq += 1;
9463        self.session
9464            .submit_intent(Intent::NpcTalkClose {
9465                entity_id: self.state.entity_id,
9466                npc_id,
9467                seq: self.seq,
9468            })
9469            .await?;
9470        self.state.intents_sent += 1;
9471        self.state.show_npc_chat = false;
9472        self.state.npc_chat = None;
9473        if return_to_verbs {
9474            self.state.show_npc_verb_menu = true;
9475        }
9476        Ok(())
9477    }
9478
9479    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
9480    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
9481        if self.state.show_quest_offer
9482            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
9483        {
9484            self.quest_offer_decline();
9485            return Ok(());
9486        }
9487        if self.state.show_npc_chat {
9488            return self.npc_talk_close().await;
9489        }
9490        if self.state.show_shop_menu {
9491            return self.back_from_shop_menu().await;
9492        }
9493        if self.state.bank_panel.is_some() {
9494            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9495                self.bank_transfer_back();
9496                return Ok(());
9497            }
9498            return self.close_bank_panel().await;
9499        }
9500        if self.state.storage_panel.is_some() {
9501            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9502                self.storage_ui_back();
9503                return Ok(());
9504            }
9505            return self.close_storage_panel().await;
9506        }
9507        if self.state.show_npc_verb_menu {
9508            self.state.show_npc_verb_menu = false;
9509            self.state.npc_verb_target = None;
9510        }
9511        Ok(())
9512    }
9513
9514    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
9515        self.seq += 1;
9516        self.session
9517            .submit_intent(Intent::TestDamage {
9518                entity_id: self.state.entity_id,
9519                amount,
9520                seq: self.seq,
9521            })
9522            .await?;
9523        self.state.intents_sent += 1;
9524        Ok(())
9525    }
9526
9527    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
9528        self.cycle_combat_target_slot(1, reverse).await
9529    }
9530
9531    pub async fn cycle_combat_target_slot(
9532        &mut self,
9533        slot_index: u8,
9534        reverse: bool,
9535    ) -> anyhow::Result<()> {
9536        if !self.state.is_alive() {
9537            anyhow::bail!("you are dead");
9538        }
9539        let candidates = self.state.candidates_for_slot(slot_index);
9540        if candidates.is_empty() {
9541            anyhow::bail!("no targets nearby");
9542        }
9543        let current = self.state.target_for_slot(slot_index);
9544        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
9545        let next_idx = match idx {
9546            None => 0,
9547            Some(i) if reverse => {
9548                if i == 0 {
9549                    candidates.len() - 1
9550                } else {
9551                    i - 1
9552                }
9553            }
9554            Some(i) => (i + 1) % candidates.len(),
9555        };
9556        if idx == Some(next_idx) && candidates.len() == 1 {
9557            self.clear_combat_target_slot(slot_index).await?;
9558            return Ok(());
9559        }
9560        let (target_id, label) = candidates[next_idx].clone();
9561        self.set_combat_target_slot(slot_index, target_id, &label)
9562            .await
9563    }
9564
9565    pub async fn set_combat_target_slot(
9566        &mut self,
9567        slot_index: u8,
9568        target_id: EntityId,
9569        label: &str,
9570    ) -> anyhow::Result<()> {
9571        if !self.state.is_alive() {
9572            anyhow::bail!("you are dead");
9573        }
9574        self.seq += 1;
9575        self.session
9576            .submit_intent(Intent::SetTargetSlot {
9577                entity_id: self.state.entity_id,
9578                slot_index,
9579                target_id,
9580                seq: self.seq,
9581            })
9582            .await?;
9583        self.state.intents_sent += 1;
9584        if slot_index == 1 {
9585            self.state.combat_target = Some(target_id);
9586            self.state.combat_target_label = Some(label.to_string());
9587        }
9588        self.state
9589            .push_log(format!("Slot {slot_index} target: {label}"));
9590        Ok(())
9591    }
9592
9593    pub async fn set_combat_target(
9594        &mut self,
9595        target_id: EntityId,
9596        label: &str,
9597    ) -> anyhow::Result<()> {
9598        self.set_combat_target_slot(1, target_id, label).await
9599    }
9600
9601    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9602        if slot_index == 1 && self.state.combat_target.is_none() {
9603            return Ok(());
9604        }
9605        self.seq += 1;
9606        self.session
9607            .submit_intent(Intent::ClearTargetSlot {
9608                entity_id: self.state.entity_id,
9609                slot_index,
9610                seq: self.seq,
9611            })
9612            .await?;
9613        if slot_index == 1 {
9614            self.state.combat_target = None;
9615            self.state.combat_target_label = None;
9616        }
9617        self.state.intents_sent += 1;
9618        self.state
9619            .push_log(format!("Slot {slot_index} target cleared"));
9620        Ok(())
9621    }
9622
9623    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
9624        self.clear_combat_target_slot(1).await
9625    }
9626
9627    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
9628        if !self.state.is_alive() {
9629            anyhow::bail!("you are dead");
9630        }
9631        self.seq += 1;
9632        self.session
9633            .submit_intent(Intent::AdvanceRotation {
9634                entity_id: self.state.entity_id,
9635                slot_index,
9636                seq: self.seq,
9637            })
9638            .await?;
9639        self.state.intents_sent += 1;
9640        Ok(())
9641    }
9642
9643    pub async fn assign_slot_preset(
9644        &mut self,
9645        slot_index: u8,
9646        preset_id: &str,
9647    ) -> anyhow::Result<()> {
9648        if !self.state.is_alive() {
9649            anyhow::bail!("you are dead");
9650        }
9651        self.seq += 1;
9652        self.session
9653            .submit_intent(Intent::AssignSlotPreset {
9654                entity_id: self.state.entity_id,
9655                slot_index,
9656                preset_id: preset_id.to_string(),
9657                seq: self.seq,
9658            })
9659            .await?;
9660        self.state.intents_sent += 1;
9661        if let Some(slot) = self
9662            .state
9663            .combat_slots
9664            .iter_mut()
9665            .find(|s| s.slot_index == slot_index)
9666        {
9667            slot.preset_id = Some(preset_id.to_string());
9668            if let Some(preset) = self
9669                .state
9670                .rotation_presets
9671                .iter()
9672                .find(|p| p.id == preset_id)
9673            {
9674                slot.preset_label = Some(preset.label.clone());
9675                slot.rotation = preset.abilities.clone();
9676                slot.rotation_index = 0;
9677            }
9678        }
9679        self.state
9680            .push_log(format!("T{slot_index} loadout → {preset_id}"));
9681        Ok(())
9682    }
9683
9684    pub async fn cast_ability(
9685        &mut self,
9686        ability_id: &str,
9687        target_id: Option<EntityId>,
9688    ) -> anyhow::Result<()> {
9689        if !self.state.is_alive() {
9690            anyhow::bail!("you are dead");
9691        }
9692        let target_id = target_id
9693            .or_else(|| self.state.target_for_slot(2))
9694            .or_else(|| self.state.target_for_slot(1))
9695            .unwrap_or(self.state.entity_id);
9696        self.seq += 1;
9697        self.session
9698            .submit_intent(Intent::Cast {
9699                entity_id: self.state.entity_id,
9700                ability_id: ability_id.to_string(),
9701                target_id,
9702                seq: self.seq,
9703            })
9704            .await?;
9705        self.state.intents_sent += 1;
9706        self.state
9707            .push_log(format!("Cast {ability_id} → {target_id}"));
9708        Ok(())
9709    }
9710
9711    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
9712        self.seq += 1;
9713        self.session
9714            .submit_intent(Intent::UpsertRotationPreset {
9715                entity_id: self.state.entity_id,
9716                preset: preset.clone(),
9717                seq: self.seq,
9718            })
9719            .await?;
9720        self.state.intents_sent += 1;
9721        if let Some(existing) = self
9722            .state
9723            .rotation_presets
9724            .iter_mut()
9725            .find(|p| p.id == preset.id)
9726        {
9727            *existing = preset.clone();
9728        } else {
9729            self.state.rotation_presets.push(preset.clone());
9730        }
9731        for slot in &mut self.state.combat_slots {
9732            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
9733                slot.preset_label = Some(preset.label.clone());
9734                slot.rotation = preset.abilities.clone();
9735            }
9736        }
9737        self.state
9738            .push_log(format!("Saved rotation: {}", preset.label));
9739        Ok(())
9740    }
9741
9742    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
9743        self.seq += 1;
9744        self.session
9745            .submit_intent(Intent::DeleteRotationPreset {
9746                entity_id: self.state.entity_id,
9747                preset_id: preset_id.to_string(),
9748                seq: self.seq,
9749            })
9750            .await?;
9751        self.state.intents_sent += 1;
9752        self.state.rotation_presets.retain(|p| p.id != preset_id);
9753        for slot in &mut self.state.combat_slots {
9754            if slot.preset_id.as_deref() == Some(preset_id) {
9755                slot.preset_id = None;
9756                slot.preset_label = None;
9757                slot.rotation.clear();
9758                slot.rotation_index = 0;
9759            }
9760        }
9761        self.state
9762            .push_log(format!("Deleted rotation: {preset_id}"));
9763        Ok(())
9764    }
9765
9766    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9767        if !self.state.is_alive() {
9768            anyhow::bail!("you are dead");
9769        }
9770        let enabled = !self
9771            .state
9772            .combat_slots
9773            .iter()
9774            .find(|s| s.slot_index == slot_index)
9775            .map(|s| s.auto_enabled)
9776            .unwrap_or(false);
9777        self.seq += 1;
9778        self.session
9779            .submit_intent(Intent::SetAutoAttack {
9780                entity_id: self.state.entity_id,
9781                slot_index,
9782                enabled,
9783                seq: self.seq,
9784            })
9785            .await?;
9786        if slot_index == 1 {
9787            self.state.auto_attack = enabled;
9788        }
9789        self.state.intents_sent += 1;
9790        self.state.push_log(format!(
9791            "T{slot_index} auto {}",
9792            if enabled { "ON" } else { "OFF" }
9793        ));
9794        Ok(())
9795    }
9796
9797    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
9798        if !self.state.connected {
9799            anyhow::bail!("not connected");
9800        }
9801        if !self.state.is_alive() {
9802            anyhow::bail!("you are dead");
9803        }
9804        let (px, py) = self.state.player_position();
9805        if self
9806            .state
9807            .ground_drops
9808            .iter()
9809            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
9810        {
9811            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
9812        }
9813        self.seq += 1;
9814        self.session
9815            .submit_intent(Intent::Pickup {
9816                entity_id: self.state.entity_id,
9817                drop_id: None,
9818                seq: self.seq,
9819            })
9820            .await?;
9821        self.state.intents_sent += 1;
9822        Ok(())
9823    }
9824
9825    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
9826        self.toggle_auto_attack_slot(1).await
9827    }
9828
9829    pub async fn dodge(&mut self) -> anyhow::Result<()> {
9830        if !self.state.is_alive() {
9831            anyhow::bail!("you are dead");
9832        }
9833        self.seq += 1;
9834        self.session
9835            .submit_intent(Intent::Dodge {
9836                entity_id: self.state.entity_id,
9837                seq: self.seq,
9838            })
9839            .await?;
9840        self.state.intents_sent += 1;
9841        self.state.push_log("Dodge!");
9842        Ok(())
9843    }
9844
9845    pub async fn lunge(&mut self) -> anyhow::Result<()> {
9846        if !self.state.is_alive() {
9847            anyhow::bail!("you are dead");
9848        }
9849        let (forward, strafe) = self.last_move_axes();
9850        self.seq += 1;
9851        self.session
9852            .submit_intent(Intent::Lunge {
9853                entity_id: self.state.entity_id,
9854                forward,
9855                strafe,
9856                seq: self.seq,
9857            })
9858            .await?;
9859        self.state.intents_sent += 1;
9860        self.state.push_log("Lunge!");
9861        Ok(())
9862    }
9863
9864    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
9865        if !self.state.is_alive() {
9866            anyhow::bail!("you are dead");
9867        }
9868        self.seq += 1;
9869        self.session
9870            .submit_intent(Intent::DirectionalJump {
9871                entity_id: self.state.entity_id,
9872                forward,
9873                strafe,
9874                seq: self.seq,
9875            })
9876            .await?;
9877        self.state.intents_sent += 1;
9878        self.state.push_log("Jump!");
9879        Ok(())
9880    }
9881
9882    /// Remembered WASD axes for lunge when not currently moving.
9883    pub fn last_move_axes(&self) -> (f32, f32) {
9884        (self.last_move_forward, self.last_move_strafe)
9885    }
9886
9887    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
9888        if !self.state.is_alive() {
9889            anyhow::bail!("you are dead");
9890        }
9891        self.seq += 1;
9892        self.session
9893            .submit_intent(Intent::Block {
9894                entity_id: self.state.entity_id,
9895                enabled,
9896                seq: self.seq,
9897            })
9898            .await?;
9899        self.state.intents_sent += 1;
9900        if enabled {
9901            self.state.push_log("Blocking");
9902        }
9903        Ok(())
9904    }
9905
9906    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9907        if !self.state.is_alive() {
9908            anyhow::bail!("you are dead");
9909        }
9910        self.seq += 1;
9911        self.session
9912            .submit_intent(Intent::EquipMainhand {
9913                entity_id: self.state.entity_id,
9914                template_id,
9915                instance_id: None,
9916                seq: self.seq,
9917            })
9918            .await?;
9919        self.state.intents_sent += 1;
9920        Ok(())
9921    }
9922
9923    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
9924    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
9925        let idx = self.state.equip_menu_index;
9926        let slots = equip_paperdoll_rows(&self.state);
9927        let Some(row) = slots.get(idx) else {
9928            return Ok(());
9929        };
9930        match row {
9931            EquipPaperdollRow::Body { slot, filled } => {
9932                if *filled {
9933                    self.equip_worn(*slot, None).await
9934                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
9935                    self.equip_worn(*slot, Some(inst)).await
9936                } else {
9937                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
9938                    Ok(())
9939                }
9940            }
9941            EquipPaperdollRow::Mainhand { filled } => {
9942                if *filled {
9943                    self.unequip_mainhand().await
9944                } else if let Some(tid) = first_inventory_weapon(&self.state) {
9945                    self.equip_mainhand(Some(tid)).await
9946                } else {
9947                    self.state.push_log("No weapon in inventory".to_string());
9948                    Ok(())
9949                }
9950            }
9951            EquipPaperdollRow::Offhand { filled, locked } => {
9952                if *locked {
9953                    self.state
9954                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
9955                    Ok(())
9956                } else if *filled {
9957                    self.unequip_offhand().await
9958                } else if let Some(tid) = first_inventory_offhand(&self.state) {
9959                    self.equip_offhand(Some(tid)).await
9960                } else {
9961                    self.state
9962                        .push_log("No offhand item in inventory".to_string());
9963                    Ok(())
9964                }
9965            }
9966        }
9967    }
9968
9969    pub async fn say(
9970        &mut self,
9971        channel: flatland_protocol::ChatChannel,
9972        text: &str,
9973    ) -> anyhow::Result<()> {
9974        self.seq += 1;
9975        self.session
9976            .submit_intent(Intent::Say {
9977                entity_id: self.state.entity_id,
9978                channel,
9979                text: text.to_string(),
9980                seq: self.seq,
9981            })
9982            .await?;
9983        self.state.intents_sent += 1;
9984        Ok(())
9985    }
9986
9987    pub async fn stop(&mut self) -> anyhow::Result<()> {
9988        self.seq += 1;
9989        self.session
9990            .submit_intent(Intent::Stop {
9991                entity_id: self.state.entity_id,
9992                seq: self.seq,
9993            })
9994            .await?;
9995        self.state.intents_sent += 1;
9996        Ok(())
9997    }
9998
9999    pub fn disconnect(&self) {
10000        self.session.disconnect();
10001    }
10002}
10003
10004fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
10005    let dx = ax - bx;
10006    let dy = ay - by;
10007    (dx * dx + dy * dy).sqrt()
10008}
10009
10010#[cfg(test)]
10011mod tests {
10012    use std::collections::BTreeMap;
10013
10014    use super::*;
10015    use flatland_protocol::{
10016        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
10017    };
10018
10019    fn sample_state() -> GameState {
10020        let mut state = GameState {
10021            session_id: 1,
10022            entity_id: 1,
10023            character_id: None,
10024            tick: 0,
10025            chunk_rev: 0,
10026            content_rev: 0,
10027            publish_rev: 0,
10028            entities: vec![EntityState {
10029                id: 1,
10030                label: "You".into(),
10031                transform: Transform {
10032                    position: WorldCoord::surface(128.0, 128.0),
10033                    yaw: 0.0,
10034                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10035                },
10036                vitals: None,
10037                attributes: None,
10038                skills: None,
10039                inside_building: None,
10040                tile_id: None,
10041                paperdoll_ref: None,
10042                presentation_state: None,
10043                sprite_mode: None,
10044                progression_xp: None,
10045            }],
10046            player: None,
10047            resource_nodes: vec![ResourceNodeView {
10048                id: "oak-1".into(),
10049                label: "Oak".into(),
10050                x: 130.0,
10051                y: 128.0,
10052                z: 0.0,
10053                item_template: "oak_log".into(),
10054                state: ResourceNodeState::Available,
10055                blocking: true,
10056                blocking_radius_m: 0.8,
10057                tile_id: None,
10058                paperdoll_ref: None,
10059                yaw: 0.0,
10060                pitch: 0.0,
10061                roll: 0.0,
10062                draw_scale: 1.0,
10063                sprite_mode: None,
10064                presentation_state: None,
10065            }],
10066            ground_drops: vec![],
10067            placed_containers: vec![],
10068            buildings: vec![BuildingView {
10069                id: "broker-hut".into(),
10070                label: "Broker".into(),
10071                x: 148.0,
10072                y: 118.0,
10073                width_m: 8.0,
10074                depth_m: 6.0,
10075                interior_blueprint: Some("broker_hut".into()),
10076                tags: vec![],
10077            }],
10078            doors: vec![flatland_protocol::DoorView {
10079                id: "door-1".into(),
10080                building_id: "broker-hut".into(),
10081                x: 148.0,
10082                y: 118.0,
10083                open: false,
10084                portal: Some("front".into()),
10085            }],
10086            interior_map: None,
10087            npcs: vec![],
10088            blueprints: vec![],
10089            world_width_m: 256.0,
10090            world_height_m: 256.0,
10091            terrain_zones: Vec::new(),
10092            z_platforms: Vec::new(),
10093            z_transitions: Vec::new(),
10094            world_clock: flatland_protocol::WorldClock::default(),
10095            inventory: std::collections::HashMap::new(),
10096            inventory_hints: std::collections::HashMap::new(),
10097            logs: VecDeque::new(),
10098            intents_sent: 0,
10099            ticks_received: 0,
10100            connected: true,
10101            disconnect_reason: None,
10102            show_stats: false,
10103            show_equip_menu: false,
10104            equip_menu_index: 0,
10105            show_craft_menu: false,
10106            craft_menu_index: 0,
10107            craft_batch_quantity: 1,
10108            show_shop_menu: false,
10109            shop_catalog: None,
10110            bank_panel: None,
10111            bank_menu_index: 0,
10112            bank_ui_mode: BankUiMode::Menu,
10113            storage_panel: None,
10114            storage_menu_index: 0,
10115            storage_ui_mode: StorageUiMode::Menu,
10116            shop_tab: ShopTab::default(),
10117            shop_menu_index: 0,
10118            shop_quantity: 1,
10119            shop_trade_log: VecDeque::new(),
10120            show_npc_verb_menu: false,
10121            npc_verb_target: None,
10122            npc_verb_index: 0,
10123            show_npc_chat: false,
10124            npc_chat: None,
10125            show_inventory_menu: false,
10126            inventory_menu_index: 0,
10127            inventory_tab: InventoryTab::OnPerson,
10128            inventory_filter: String::new(),
10129            inventory_filter_focused: false,
10130            show_move_picker: false,
10131            show_rename_prompt: false,
10132            show_worker_rename: false,
10133            rename_buffer: String::new(),
10134            move_picker_index: 0,
10135            move_picker: None,
10136            show_grant_picker: false,
10137            grant_picker_index: 0,
10138            grant_picker: None,
10139            show_destroy_picker: false,
10140            destroy_confirm_pending: false,
10141            destroy_picker: None,
10142            combat_target: None,
10143            combat_target_label: None,
10144            combat_fx: Vec::new(),
10145            in_combat: false,
10146            auto_attack: true,
10147            combat_has_los: false,
10148            attack_cd_ticks: 0,
10149            gcd_ticks: 0,
10150            weapon_ability_id: "unarmed".into(),
10151            mainhand_template_id: None,
10152            mainhand_label: None,
10153            offhand_template_id: None,
10154            offhand_label: None,
10155            mainhand_hand_slots: 1,
10156            defense: None,
10157            worn: BTreeMap::new(),
10158            carry_mass: 0.0,
10159            carry_mass_max: 0.0,
10160            encumbrance: flatland_protocol::EncumbranceState::Light,
10161            inventory_stacks: Vec::new(),
10162            keychain_stacks: Vec::new(),
10163            combat_target_detail: None,
10164            statuses: Vec::new(),
10165            cast_progress: None,
10166            ability_cooldowns: Vec::new(),
10167            blocking_active: false,
10168            max_target_slots: 1,
10169            combat_slots: Vec::new(),
10170            rotation_presets: Vec::new(),
10171            known_abilities: Vec::new(),
10172            hotbar: vec![None; 9],
10173            max_abilities_per_rotation: 0,
10174            show_loadout_menu: false,
10175            show_keychain_menu: false,
10176            keychain_menu_index: 0,
10177            show_rotation_editor: false,
10178            loadout_menu_index: 0,
10179            loadout_hotbar_slot: 1,
10180            loadout_ability_index: 0,
10181            loadout_focus_presets: false,
10182            rotation_editor: RotationEditorState::default(),
10183            harvest_in_progress: false,
10184            harvest_started_at: None,
10185            pending_craft_ack: None,
10186            pending_worker_job_ack: None,
10187            quest_log: Vec::new(),
10188            interactables: Vec::new(),
10189            ledger: None,
10190            career: None,
10191            character_sheet_tab: CharacterSheetTab::Character,
10192            ledger_period: LedgerPeriod::Day,
10193            show_quest_offer: false,
10194            pending_quest_offer: None,
10195            show_quest_menu: false,
10196            quest_menu_index: 0,
10197            quest_withdraw_confirm: false,
10198            hired_workers: Vec::new(),
10199            show_workers_menu: false,
10200            workers_menu_index: 0,
10201            workers_menu_compact: false,
10202            worker_step_display: BTreeMap::new(),
10203            worker_error_display: BTreeMap::new(),
10204            show_worker_give_picker: false,
10205            worker_give_picker_index: 0,
10206            worker_give_picker: None,
10207            show_worker_give_target_picker: false,
10208            worker_give_target_picker_index: 0,
10209            worker_give_target_picker: None,
10210            show_worker_take_picker: false,
10211            worker_take_picker_index: 0,
10212            worker_take_picker: None,
10213            show_worker_teach_picker: false,
10214            worker_teach_picker_index: 0,
10215            worker_teach_picker: None,
10216            worker_route_editor: None,
10217            progression_curve: None,
10218        };
10219        state.player = state.entities.first().cloned();
10220        state
10221    }
10222
10223    #[test]
10224    fn probe_use_world_npc_beats_nearby_loot() {
10225        let mut state = sample_state();
10226        state.npcs.push(flatland_protocol::NpcView {
10227            id: "ada".into(),
10228            label: "Ada".into(),
10229            role: "broker".into(),
10230            x: 129.0,
10231            y: 128.0,
10232            building_id: None,
10233            entity_id: None,
10234            life_state: None,
10235            hp_pct: None,
10236            can_trade: true,
10237            tile_id: None,
10238            behavior_state: None,
10239            presentation_state: None,
10240            sprite_mode: None,
10241            paperdoll_ref: None,
10242        });
10243        state.ground_drops.push(flatland_protocol::GroundDropView {
10244            id: "d1".into(),
10245            template_id: "lumber".into(),
10246            quantity: 1,
10247            x: 128.5,
10248            y: 128.0,
10249            z: 0.0,
10250            tile_id: None,
10251            paperdoll_ref: None,
10252            yaw: 0.0,
10253            pitch: 0.0,
10254            roll: 0.0,
10255            draw_scale: 1.0,
10256        });
10257        let probe = state.probe_use_world();
10258        let primary = probe.primary.expect("primary");
10259        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
10260        assert_eq!(primary.id, "ada");
10261    }
10262
10263    #[test]
10264    fn probe_use_world_harvest_when_in_range() {
10265        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
10266        let probe = state.probe_use_world();
10267        assert!(
10268            probe.primary.is_none(),
10269            "oak is 2m away, out of harvest range"
10270        );
10271        assert!(probe
10272            .candidates
10273            .iter()
10274            .any(|c| c.kind == crate::UseWorldKind::Harvest));
10275
10276        let mut state = sample_state();
10277        state.resource_nodes[0].x = 129.0;
10278        let probe = state.probe_use_world();
10279        let primary = probe.primary.expect("primary");
10280        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
10281    }
10282
10283    #[test]
10284    fn probe_use_world_door_uses_building_label() {
10285        let mut state = sample_state();
10286        state.doors[0].x = 129.0;
10287        state.doors[0].y = 128.0;
10288        let probe = state.probe_use_world();
10289        let primary = probe.primary.expect("primary");
10290        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
10291        assert_eq!(primary.label, "Broker");
10292        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
10293    }
10294
10295    #[test]
10296    fn empty_entity_tick_preserves_welcome_snapshot() {
10297        let mut state = sample_state();
10298        state.inventory.insert("carrot".into(), 3);
10299        let delta = TickDelta {
10300            tick: 1,
10301            entities: vec![],
10302            resource_nodes: vec![],
10303            ground_drops: vec![],
10304            placed_containers: vec![],
10305            buildings: vec![],
10306            doors: vec![],
10307            interior_map: None,
10308            npcs: vec![],
10309            inventory: vec![],
10310            blueprints: vec![],
10311            world_clock: flatland_protocol::WorldClock::default(),
10312            combat: None,
10313            quest_log: vec![],
10314            hired_workers: Vec::new(),
10315            interactables: vec![],
10316            ledger: None,
10317            career: None,
10318            combat_fx: Vec::new(),
10319        };
10320
10321        state.apply_tick_fields(&delta, 1);
10322
10323        assert_eq!(state.entities.len(), 1);
10324        assert!(state.player.is_some());
10325        assert_eq!(state.inventory.get("carrot"), Some(&3));
10326        assert_eq!(state.resource_nodes.len(), 1);
10327    }
10328
10329    #[test]
10330    fn tick_preserves_world_layers_when_delta_omits_them() {
10331        let mut state = sample_state();
10332        let delta = TickDelta {
10333            tick: 1,
10334            entities: state.entities.clone(),
10335            resource_nodes: vec![],
10336            ground_drops: vec![],
10337            placed_containers: vec![],
10338            buildings: vec![],
10339            doors: vec![],
10340            interior_map: None,
10341            npcs: vec![],
10342            inventory: vec![],
10343            blueprints: vec![],
10344            world_clock: flatland_protocol::WorldClock::default(),
10345            combat: None,
10346            quest_log: vec![],
10347            hired_workers: Vec::new(),
10348            interactables: vec![],
10349            ledger: None,
10350            career: None,
10351            combat_fx: Vec::new(),
10352        };
10353
10354        state.apply_tick_fields(&delta, 1);
10355
10356        assert_eq!(state.resource_nodes.len(), 1);
10357        assert_eq!(state.buildings.len(), 1);
10358        assert_eq!(state.doors.len(), 1);
10359    }
10360
10361    #[test]
10362    fn tick_updates_resource_nodes_when_server_sends_them() {
10363        let mut state = sample_state();
10364        let delta = TickDelta {
10365            tick: 1,
10366            entities: state.entities.clone(),
10367            resource_nodes: vec![ResourceNodeView {
10368                id: "oak-1".into(),
10369                label: "Oak".into(),
10370                x: 130.0,
10371                y: 128.0,
10372                z: 0.0,
10373                item_template: "oak_log".into(),
10374                state: ResourceNodeState::Cooldown,
10375                blocking: true,
10376                blocking_radius_m: 0.8,
10377                tile_id: None,
10378                paperdoll_ref: None,
10379                yaw: 0.0,
10380                pitch: 0.0,
10381                roll: 0.0,
10382                draw_scale: 1.0,
10383                sprite_mode: None,
10384                presentation_state: None,
10385            }],
10386            buildings: vec![],
10387            doors: vec![],
10388            interior_map: None,
10389            npcs: vec![],
10390            inventory: vec![],
10391            blueprints: vec![],
10392            world_clock: flatland_protocol::WorldClock::default(),
10393            ground_drops: vec![],
10394            placed_containers: vec![],
10395            combat: None,
10396            quest_log: vec![],
10397            hired_workers: Vec::new(),
10398            interactables: vec![],
10399            ledger: None,
10400            career: None,
10401            combat_fx: Vec::new(),
10402        };
10403
10404        state.apply_tick_fields(&delta, 1);
10405
10406        assert!(matches!(
10407            state.resource_nodes[0].state,
10408            ResourceNodeState::Cooldown
10409        ));
10410    }
10411
10412    #[test]
10413    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
10414        let mut state = GameState {
10415            session_id: 1,
10416            entity_id: 1,
10417            character_id: None,
10418            tick: 0,
10419            chunk_rev: 0,
10420            content_rev: 0,
10421            publish_rev: 0,
10422            entities: vec![EntityState {
10423                id: 1,
10424                label: "You".into(),
10425                transform: Transform {
10426                    position: WorldCoord::surface(4.5, 2.0),
10427                    yaw: 0.0,
10428                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10429                },
10430                vitals: None,
10431                attributes: None,
10432                skills: None,
10433                inside_building: Some("broker_hut".into()),
10434                tile_id: None,
10435                paperdoll_ref: None,
10436                presentation_state: None,
10437                sprite_mode: None,
10438                progression_xp: None,
10439            }],
10440            player: None,
10441            resource_nodes: vec![],
10442            ground_drops: vec![],
10443            placed_containers: vec![],
10444            buildings: vec![BuildingView {
10445                id: "broker_hut".into(),
10446                label: "Broker".into(),
10447                x: 158.0,
10448                y: 124.0,
10449                width_m: 8.0,
10450                depth_m: 6.0,
10451                interior_blueprint: Some("broker_hut".into()),
10452                tags: vec![],
10453            }],
10454            doors: vec![flatland_protocol::DoorView {
10455                id: "broker_hut_exit".into(),
10456                building_id: "broker_hut".into(),
10457                x: 4.3,
10458                y: 0.9,
10459                open: true,
10460                portal: Some("front".into()),
10461            }],
10462            interior_map: None,
10463            npcs: vec![flatland_protocol::NpcView {
10464                id: "ada_broker".into(),
10465                label: "Ada".into(),
10466                x: 4.5,
10467                y: 2.0,
10468                building_id: Some("broker_hut".into()),
10469                role: "broker".into(),
10470                entity_id: None,
10471                life_state: None,
10472                hp_pct: None,
10473                can_trade: true,
10474                tile_id: None,
10475                behavior_state: None,
10476                presentation_state: None,
10477                sprite_mode: None,
10478                paperdoll_ref: None,
10479            }],
10480            blueprints: vec![],
10481            world_width_m: 256.0,
10482            world_height_m: 256.0,
10483            terrain_zones: Vec::new(),
10484            z_platforms: Vec::new(),
10485            z_transitions: Vec::new(),
10486            world_clock: flatland_protocol::WorldClock::default(),
10487            inventory: std::collections::HashMap::new(),
10488            inventory_hints: std::collections::HashMap::new(),
10489            logs: VecDeque::new(),
10490            intents_sent: 0,
10491            ticks_received: 0,
10492            connected: true,
10493            disconnect_reason: None,
10494            show_stats: false,
10495            show_equip_menu: false,
10496            equip_menu_index: 0,
10497            show_craft_menu: false,
10498            craft_menu_index: 0,
10499            craft_batch_quantity: 1,
10500            show_shop_menu: false,
10501            shop_catalog: None,
10502            bank_panel: None,
10503            bank_menu_index: 0,
10504            bank_ui_mode: BankUiMode::Menu,
10505            storage_panel: None,
10506            storage_menu_index: 0,
10507            storage_ui_mode: StorageUiMode::Menu,
10508            shop_tab: ShopTab::default(),
10509            shop_menu_index: 0,
10510            shop_quantity: 1,
10511            shop_trade_log: VecDeque::new(),
10512            show_npc_verb_menu: false,
10513            npc_verb_target: None,
10514            npc_verb_index: 0,
10515            show_npc_chat: false,
10516            npc_chat: None,
10517            show_inventory_menu: false,
10518            inventory_menu_index: 0,
10519            inventory_tab: InventoryTab::OnPerson,
10520            inventory_filter: String::new(),
10521            inventory_filter_focused: false,
10522            show_move_picker: false,
10523            show_rename_prompt: false,
10524            show_worker_rename: false,
10525            rename_buffer: String::new(),
10526            move_picker_index: 0,
10527            move_picker: None,
10528            show_grant_picker: false,
10529            grant_picker_index: 0,
10530            grant_picker: None,
10531            show_destroy_picker: false,
10532            destroy_confirm_pending: false,
10533            destroy_picker: None,
10534            combat_target: None,
10535            combat_target_label: None,
10536            combat_fx: Vec::new(),
10537            in_combat: false,
10538            auto_attack: true,
10539            combat_has_los: false,
10540            attack_cd_ticks: 0,
10541            gcd_ticks: 0,
10542            weapon_ability_id: "unarmed".into(),
10543            mainhand_template_id: None,
10544            mainhand_label: None,
10545            offhand_template_id: None,
10546            offhand_label: None,
10547            mainhand_hand_slots: 1,
10548            defense: None,
10549            worn: BTreeMap::new(),
10550            carry_mass: 0.0,
10551            carry_mass_max: 0.0,
10552            encumbrance: flatland_protocol::EncumbranceState::Light,
10553            inventory_stacks: Vec::new(),
10554            keychain_stacks: Vec::new(),
10555            combat_target_detail: None,
10556            statuses: Vec::new(),
10557            cast_progress: None,
10558            ability_cooldowns: Vec::new(),
10559            blocking_active: false,
10560            max_target_slots: 1,
10561            combat_slots: Vec::new(),
10562            rotation_presets: Vec::new(),
10563            known_abilities: Vec::new(),
10564            hotbar: vec![None; 9],
10565            max_abilities_per_rotation: 0,
10566            show_loadout_menu: false,
10567            show_keychain_menu: false,
10568            keychain_menu_index: 0,
10569            show_rotation_editor: false,
10570            loadout_menu_index: 0,
10571            loadout_hotbar_slot: 1,
10572            loadout_ability_index: 0,
10573            loadout_focus_presets: false,
10574            rotation_editor: RotationEditorState::default(),
10575            harvest_in_progress: false,
10576            harvest_started_at: None,
10577            pending_craft_ack: None,
10578            pending_worker_job_ack: None,
10579            quest_log: Vec::new(),
10580            interactables: Vec::new(),
10581            ledger: None,
10582            career: None,
10583            character_sheet_tab: CharacterSheetTab::Character,
10584            ledger_period: LedgerPeriod::Day,
10585            show_quest_offer: false,
10586            pending_quest_offer: None,
10587            show_quest_menu: false,
10588            quest_menu_index: 0,
10589            quest_withdraw_confirm: false,
10590            hired_workers: Vec::new(),
10591            show_workers_menu: false,
10592            workers_menu_index: 0,
10593            workers_menu_compact: false,
10594            worker_step_display: BTreeMap::new(),
10595            worker_error_display: BTreeMap::new(),
10596            show_worker_give_picker: false,
10597            worker_give_picker_index: 0,
10598            worker_give_picker: None,
10599            show_worker_give_target_picker: false,
10600            worker_give_target_picker_index: 0,
10601            worker_give_target_picker: None,
10602            show_worker_take_picker: false,
10603            worker_take_picker_index: 0,
10604            worker_take_picker: None,
10605            show_worker_teach_picker: false,
10606            worker_teach_picker_index: 0,
10607            worker_teach_picker: None,
10608            worker_route_editor: None,
10609            progression_curve: None,
10610        };
10611        state.player = state.entities.first().cloned();
10612        assert_eq!(
10613            state.nearest_interact_target().as_deref(),
10614            Some("ada_broker")
10615        );
10616    }
10617
10618    #[test]
10619    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
10620        let mut state = sample_state();
10621        // Player is at (128, 128) per sample_state(). One chest just inside
10622        // CONTAINER_RANGE_M, one clearly beyond it.
10623        state.placed_containers = vec![
10624            flatland_protocol::PlacedContainerView {
10625                id: "near".into(),
10626                template_id: "wooden_chest_small".into(),
10627                display_name: "Wooden Chest".into(),
10628                x: 130.0,
10629                y: 128.0,
10630                z: 0.0,
10631                locked: true,
10632                accessible: true,
10633                owner_character_id: None,
10634                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
10635                lock_id: None,
10636                capacity_volume: None,
10637                item_instance_id: Some(uuid::Uuid::from_u128(1)),
10638                tile_id: None,
10639                paperdoll_ref: None,
10640                worker_lodging_capacity: None,
10641            },
10642            flatland_protocol::PlacedContainerView {
10643                id: "far".into(),
10644                template_id: "wooden_chest_small".into(),
10645                display_name: "Distant Chest".into(),
10646                x: 128.0 + CONTAINER_RANGE_M + 5.0,
10647                y: 128.0,
10648                z: 0.0,
10649                locked: false,
10650                accessible: true,
10651                owner_character_id: None,
10652                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
10653                lock_id: None,
10654                capacity_volume: None,
10655                item_instance_id: Some(uuid::Uuid::from_u128(2)),
10656                tile_id: None,
10657                paperdoll_ref: None,
10658                worker_lodging_capacity: None,
10659            },
10660        ];
10661
10662        let nearby = state.nearby_containers();
10663        assert_eq!(
10664            nearby.len(),
10665            1,
10666            "far chest must not appear once out of range"
10667        );
10668        assert_eq!(nearby[0].view.id, "near");
10669        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
10670        assert!(nearby[0].rows[0].is_chest_shell);
10671
10672        // The same chest, but locked and inaccessible (no key held), must hide
10673        // contents but still show the selectable chest shell row.
10674        state.placed_containers[0].accessible = false;
10675        let nearby = state.nearby_containers();
10676        assert_eq!(nearby.len(), 1);
10677        assert_eq!(nearby[0].rows.len(), 1);
10678        assert!(nearby[0].rows[0].is_chest_shell);
10679    }
10680
10681    #[test]
10682    fn chest_pickup_destinations_offer_person_and_worn_bag() {
10683        let mut state = sample_state();
10684        let back_id = uuid::Uuid::from_u128(42);
10685        state.worn.insert(
10686            BodySlot::Back,
10687            flatland_protocol::ItemStack {
10688                template_id: "travel_backpack".into(),
10689                quantity: 1,
10690                item_instance_id: Some(back_id),
10691                props: Default::default(),
10692                status_bindings: Vec::new(),
10693                contents: Vec::new(),
10694                display_name: Some("Travel Backpack".into()),
10695                category: Some("container".into()),
10696                base_mass: Some(2.5),
10697                base_volume: Some(12.0),
10698                capacity_volume: Some(80.0),
10699                stackable: Some(false),
10700                world_placeable: Some(false),
10701                worker_lodging_capacity: None,
10702                equip_slot: None,
10703                armor_physical: None,
10704                resists: vec![],
10705                hand_slots: None,
10706            },
10707        );
10708        let opts = state.chest_pickup_destinations("chest-1");
10709        assert!(opts.iter().any(|o| matches!(
10710            &o.kind,
10711            MoveOptionKind::PickupPlaced {
10712                nest_parent_instance_id: None,
10713                ..
10714            }
10715        )));
10716        assert!(opts.iter().any(|o| matches!(
10717            &o.kind,
10718            MoveOptionKind::PickupPlaced {
10719                nest_parent_instance_id: Some(id),
10720                ..
10721            } if *id == back_id
10722        )));
10723        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
10724    }
10725
10726    #[test]
10727    fn placed_container_public_label_hides_owner_custom_name() {
10728        let owner = uuid::Uuid::from_u128(99);
10729        let mut state = sample_state();
10730        state.character_id = Some(uuid::Uuid::from_u128(1));
10731        state.inventory_hints.insert(
10732            "wooden_chest_medium".into(),
10733            InventoryHint {
10734                display_name: "Medium Wooden Chest".into(),
10735                category: "container".into(),
10736                base_mass: None,
10737                base_volume: None,
10738                capacity_volume: None,
10739                stackable: false,
10740            },
10741        );
10742        let chest = flatland_protocol::PlacedContainerView {
10743            id: "c1".into(),
10744            template_id: "wooden_chest_medium".into(),
10745            display_name: "Barry's Loot #a3f2".into(),
10746            x: 128.0,
10747            y: 128.0,
10748            z: 0.0,
10749            locked: false,
10750            accessible: true,
10751            owner_character_id: Some(owner),
10752            contents: vec![],
10753            lock_id: None,
10754            capacity_volume: None,
10755            item_instance_id: None,
10756            tile_id: None,
10757            paperdoll_ref: None,
10758            worker_lodging_capacity: None,
10759        };
10760        assert_eq!(
10761            state.placed_container_public_label(&chest),
10762            "Medium Wooden Chest"
10763        );
10764        state.character_id = Some(owner);
10765        assert_eq!(
10766            state.placed_container_public_label(&chest),
10767            "Barry's Loot #a3f2"
10768        );
10769    }
10770
10771    #[test]
10772    fn location_context_lists_nearby_resource_node() {
10773        let mut state = sample_state();
10774        state.player = state.entities.first().cloned();
10775        state.resource_nodes[0].x = 128.2;
10776        state.resource_nodes[0].y = 128.0;
10777        let lines = state.location_context_lines();
10778        assert!(
10779            lines
10780                .iter()
10781                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
10782            "expected resource node in context: {:?}",
10783            lines
10784        );
10785    }
10786
10787    #[test]
10788    fn quest_board_usable_within_board_radius() {
10789        let mut state = sample_state();
10790        state.player = state.entities.first().cloned();
10791        state.interactables = vec![flatland_protocol::InteractableView {
10792            id: "board-1".into(),
10793            kind: "quest_board".into(),
10794            label: "Town Quest Board".into(),
10795            x: 130.5,
10796            y: 128.0,
10797            z: 0.0,
10798            board_id: Some("starter_town_board".into()),
10799        }];
10800        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
10801        assert_eq!(
10802            state.nearest_interact_target().as_deref(),
10803            Some("board-1"),
10804            "quest board should be selectable at ~2.5m"
10805        );
10806        let lines = state.location_context_lines();
10807        assert!(
10808            lines
10809                .iter()
10810                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
10811            "HUD should advertise f when board is in range: {:?}",
10812            lines
10813        );
10814    }
10815
10816    #[test]
10817    fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
10818        let mut state = sample_state();
10819        state.worn.insert(
10820            BodySlot::Back,
10821            flatland_protocol::ItemStack {
10822                template_id: "travel_backpack".into(),
10823                quantity: 1,
10824                item_instance_id: Some(uuid::Uuid::from_u128(3)),
10825                props: Default::default(),
10826                status_bindings: Vec::new(),
10827                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
10828                display_name: None,
10829                category: None,
10830                base_mass: None,
10831                base_volume: None,
10832                capacity_volume: None,
10833                stackable: None,
10834                world_placeable: None,
10835                worker_lodging_capacity: None,
10836                equip_slot: None,
10837                armor_physical: None,
10838                resists: vec![],
10839                hand_slots: None,
10840            },
10841        );
10842        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
10843        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
10844            id: "chest-1".into(),
10845            template_id: "wooden_chest_small".into(),
10846            display_name: "Wooden Chest".into(),
10847            x: 129.0,
10848            y: 128.0,
10849            z: 0.0,
10850            locked: false,
10851            accessible: true,
10852            owner_character_id: None,
10853            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
10854            lock_id: None,
10855            capacity_volume: None,
10856            item_instance_id: Some(uuid::Uuid::from_u128(4)),
10857            tile_id: None,
10858            paperdoll_ref: None,
10859            worker_lodging_capacity: None,
10860        }];
10861
10862        state.inventory_tab = InventoryTab::OnPerson;
10863        let rows = state.inventory_selectable_rows();
10864        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
10865        assert_eq!(
10866            sections,
10867            vec![
10868                InventorySection::Worn,   // backpack shell
10869                InventorySection::Worn,   // iron_ore nested in backpack
10870                InventorySection::Person, // lumber
10871            ]
10872        );
10873        assert_eq!(rows[0].stack.template_id, "travel_backpack");
10874        assert!(rows[0].is_equip_shell);
10875        assert_eq!(rows[1].stack.template_id, "iron_ore");
10876        assert_eq!(rows[1].depth, 1);
10877        assert_eq!(rows[2].stack.template_id, "lumber");
10878
10879        let lines = state.inventory_browser_lines();
10880        assert!(lines.iter().any(|l| matches!(
10881            l,
10882            InventoryBrowserLine::Section(s) if s.contains("Worn")
10883        )));
10884        assert!(lines.iter().any(|l| matches!(
10885            l,
10886            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
10887                || text.contains("backpack")
10888        )));
10889        assert!(!lines.iter().any(|l| matches!(
10890            l,
10891            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
10892        )));
10893
10894        state.inventory_tab = InventoryTab::Nearby;
10895        let nearby_rows = state.inventory_selectable_rows();
10896        assert_eq!(nearby_rows.len(), 2);
10897        assert!(nearby_rows[0].is_chest_shell);
10898        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
10899        let nearby_lines = state.inventory_browser_lines();
10900        assert!(nearby_lines.iter().any(|l| matches!(
10901            l,
10902            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
10903        )));
10904    }
10905
10906    #[test]
10907    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
10908        let mut state = sample_state();
10909        let back_id = uuid::Uuid::from_u128(5);
10910        state.worn.insert(
10911            BodySlot::Back,
10912            flatland_protocol::ItemStack {
10913                template_id: "travel_backpack".into(),
10914                quantity: 1,
10915                item_instance_id: Some(back_id),
10916                props: Default::default(),
10917                status_bindings: Vec::new(),
10918                contents: Vec::new(),
10919                display_name: None,
10920                category: Some("container".into()),
10921                base_mass: None,
10922                base_volume: None,
10923                capacity_volume: Some(80.0),
10924                stackable: None,
10925                world_placeable: None,
10926                worker_lodging_capacity: None,
10927                equip_slot: None,
10928                armor_physical: None,
10929                resists: vec![],
10930                hand_slots: None,
10931            },
10932        );
10933        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
10934            id: "chest-1".into(),
10935            template_id: "wooden_chest_small".into(),
10936            display_name: "Wooden Chest".into(),
10937            x: 129.0,
10938            y: 128.0,
10939            z: 0.0,
10940            locked: false,
10941            accessible: true,
10942            owner_character_id: None,
10943            contents: Vec::new(),
10944            lock_id: None,
10945            capacity_volume: None,
10946            item_instance_id: Some(uuid::Uuid::from_u128(6)),
10947            tile_id: None,
10948            paperdoll_ref: None,
10949            worker_lodging_capacity: None,
10950        }];
10951
10952        // Item currently sitting loose on the person (Root): backpack + nearby
10953        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
10954        let opts = state.move_destinations_for(
10955            &flatland_protocol::InventoryLocation::Root,
10956            None,
10957            None,
10958            "lumber",
10959        );
10960        assert!(!opts.iter().any(|o| matches!(
10961            &o.kind,
10962            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
10963        )));
10964        assert!(opts.iter().any(|o| matches!(
10965            &o.kind,
10966            MoveOptionKind::Move { location, parent_instance_id, .. }
10967                if *location == flatland_protocol::InventoryLocation::Worn {
10968                    slot: BodySlot::Back,
10969                } && *parent_instance_id == Some(back_id)
10970        )));
10971        assert!(opts.iter().any(|o| matches!(
10972            &o.kind,
10973            MoveOptionKind::Move { location, .. }
10974                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
10975        )));
10976        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
10977        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
10978
10979        // Item currently inside the worn backpack: the backpack itself must be
10980        // excluded from its own destination list (can't move an item into the
10981        // container it's already in).
10982        let from_backpack = flatland_protocol::InventoryLocation::Worn {
10983            slot: BodySlot::Back,
10984        };
10985        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
10986        assert!(!opts.iter().any(|o| matches!(
10987            &o.kind,
10988            MoveOptionKind::Move { location, parent_instance_id, .. }
10989                if *location == from_backpack && *parent_instance_id == Some(back_id)
10990        )));
10991        assert!(opts.iter().any(|o| matches!(
10992            &o.kind,
10993            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
10994        )));
10995    }
10996
10997    #[test]
10998    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
10999        let mut state = sample_state();
11000        // Insert out of display order — BTreeMap iteration must still yield the
11001        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
11002        state.worn.insert(
11003            BodySlot::Waist,
11004            flatland_protocol::ItemStack {
11005                template_id: "simple_belt".into(),
11006                quantity: 1,
11007                item_instance_id: Some(uuid::Uuid::from_u128(10)),
11008                props: Default::default(),
11009                status_bindings: Vec::new(),
11010                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
11011                display_name: None,
11012                category: Some("container".into()),
11013                base_mass: None,
11014                base_volume: None,
11015                capacity_volume: None,
11016                stackable: None,
11017                world_placeable: None,
11018                worker_lodging_capacity: None,
11019                equip_slot: None,
11020                armor_physical: None,
11021                resists: vec![],
11022                hand_slots: None,
11023            },
11024        );
11025        state.worn.insert(
11026            BodySlot::Head,
11027            flatland_protocol::ItemStack {
11028                template_id: "cloth_cap".into(),
11029                quantity: 1,
11030                item_instance_id: Some(uuid::Uuid::from_u128(11)),
11031                props: Default::default(),
11032                status_bindings: Vec::new(),
11033                contents: Vec::new(),
11034                display_name: None,
11035                category: Some("armor".into()),
11036                base_mass: None,
11037                base_volume: None,
11038                capacity_volume: None,
11039                stackable: None,
11040                world_placeable: None,
11041                worker_lodging_capacity: None,
11042                equip_slot: None,
11043                armor_physical: None,
11044                resists: vec![],
11045                hand_slots: None,
11046            },
11047        );
11048        state.worn.insert(
11049            BodySlot::Back,
11050            flatland_protocol::ItemStack {
11051                template_id: "travel_backpack".into(),
11052                quantity: 1,
11053                item_instance_id: Some(uuid::Uuid::from_u128(12)),
11054                props: Default::default(),
11055                status_bindings: Vec::new(),
11056                contents: Vec::new(),
11057                display_name: None,
11058                category: Some("container".into()),
11059                base_mass: None,
11060                base_volume: None,
11061                capacity_volume: None,
11062                stackable: None,
11063                world_placeable: None,
11064                worker_lodging_capacity: None,
11065                equip_slot: None,
11066                armor_physical: None,
11067                resists: vec![],
11068                hand_slots: None,
11069            },
11070        );
11071
11072        let rows = state.worn_rows();
11073        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
11074        assert_eq!(rows.len(), 4);
11075        assert_eq!(rows[0].stack.template_id, "cloth_cap");
11076        assert!(rows[0].is_equip_shell);
11077        assert_eq!(rows[1].stack.template_id, "travel_backpack");
11078        assert!(rows[1].is_equip_shell);
11079        assert_eq!(rows[2].stack.template_id, "simple_belt");
11080        assert!(rows[2].is_equip_shell);
11081        assert_eq!(rows[3].stack.template_id, "leather_pouch");
11082        assert_eq!(rows[3].depth, 1);
11083        assert!(!rows[3].is_equip_shell);
11084    }
11085
11086    #[test]
11087    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
11088        let mut state = sample_state();
11089        state.worn.insert(
11090            BodySlot::Waist,
11091            flatland_protocol::ItemStack {
11092                template_id: "simple_belt".into(),
11093                quantity: 1,
11094                item_instance_id: Some(uuid::Uuid::from_u128(20)),
11095                props: Default::default(),
11096                status_bindings: Vec::new(),
11097                contents: Vec::new(),
11098                display_name: Some("Simple Belt".into()),
11099                category: Some("container".into()),
11100                base_mass: None,
11101                base_volume: None,
11102                capacity_volume: None,
11103                stackable: None,
11104                world_placeable: None,
11105                worker_lodging_capacity: None,
11106                equip_slot: None,
11107                armor_physical: None,
11108                resists: vec![],
11109                hand_slots: None,
11110            },
11111        );
11112        state.worn.insert(
11113            BodySlot::Head,
11114            flatland_protocol::ItemStack {
11115                template_id: "cloth_cap".into(),
11116                quantity: 1,
11117                item_instance_id: Some(uuid::Uuid::from_u128(21)),
11118                props: Default::default(),
11119                status_bindings: Vec::new(),
11120                contents: Vec::new(),
11121                display_name: Some("Cloth Cap".into()),
11122                category: Some("armor".into()),
11123                base_mass: None,
11124                base_volume: None,
11125                capacity_volume: None,
11126                stackable: None,
11127                world_placeable: None,
11128                worker_lodging_capacity: None,
11129                equip_slot: None,
11130                armor_physical: None,
11131                resists: vec![],
11132                hand_slots: None,
11133            },
11134        );
11135
11136        let opts = state.move_destinations_for(
11137            &flatland_protocol::InventoryLocation::Root,
11138            None,
11139            None,
11140            "leather_pouch",
11141        );
11142        assert!(
11143            opts.iter().any(|o| matches!(
11144                &o.kind,
11145                MoveOptionKind::Move { location, .. }
11146                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11147            )),
11148            "belt loop must be offered when moving a pouch"
11149        );
11150        assert!(
11151            !opts.iter().any(|o| matches!(
11152                &o.kind,
11153                MoveOptionKind::Move { location, .. }
11154                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
11155            )),
11156            "armor slots can't hold other items and must not appear as move destinations"
11157        );
11158        let belt_opt = opts
11159            .iter()
11160            .find(|o| matches!(
11161                &o.kind,
11162                MoveOptionKind::Move { location, .. }
11163                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11164            ))
11165            .unwrap();
11166        assert!(belt_opt.label.contains("belt loop"));
11167
11168        let opts = state.move_destinations_for(
11169            &flatland_protocol::InventoryLocation::Root,
11170            None,
11171            None,
11172            "lumber",
11173        );
11174        assert!(
11175            !opts.iter().any(|o| o.label.contains("belt loop")),
11176            "loose materials must not target the belt shell — only nested pouches"
11177        );
11178    }
11179
11180    #[test]
11181    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
11182        let mut state = sample_state();
11183        let belt_id = uuid::Uuid::from_u128(30);
11184        let pouch_id = uuid::Uuid::from_u128(31);
11185        state.worn.insert(
11186            BodySlot::Waist,
11187            flatland_protocol::ItemStack {
11188                template_id: "simple_belt".into(),
11189                quantity: 1,
11190                item_instance_id: Some(belt_id),
11191                props: Default::default(),
11192                status_bindings: Vec::new(),
11193                world_placeable: None,
11194                worker_lodging_capacity: None,
11195                equip_slot: None,
11196                armor_physical: None,
11197                resists: vec![],
11198                hand_slots: None,
11199                contents: vec![flatland_protocol::ItemStack {
11200                    template_id: "dimensional_pouch".into(),
11201                    quantity: 1,
11202                    item_instance_id: Some(pouch_id),
11203                    props: Default::default(),
11204                    status_bindings: Vec::new(),
11205                    contents: Vec::new(),
11206                    display_name: Some("Dimensional Pouch".into()),
11207                    category: Some("container".into()),
11208                    base_mass: None,
11209                    base_volume: None,
11210                    capacity_volume: Some(200.0),
11211                    stackable: None,
11212                    world_placeable: None,
11213                    worker_lodging_capacity: None,
11214                    equip_slot: None,
11215                    armor_physical: None,
11216                    resists: vec![],
11217                    hand_slots: None,
11218                }],
11219                display_name: Some("Simple Belt".into()),
11220                category: Some("container".into()),
11221                base_mass: None,
11222                base_volume: None,
11223                capacity_volume: None,
11224                stackable: None,
11225            },
11226        );
11227
11228        let opts = state.move_destinations_for(
11229            &flatland_protocol::InventoryLocation::Root,
11230            None,
11231            None,
11232            "iron_ore",
11233        );
11234        assert!(
11235            opts.iter().any(|o| matches!(
11236                &o.kind,
11237                MoveOptionKind::Move {
11238                    location,
11239                    parent_instance_id,
11240                    ..
11241                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11242                    && *parent_instance_id == Some(pouch_id)
11243            )),
11244            "dimensional pouch clipped on belt must accept loose items"
11245        );
11246        assert!(
11247            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
11248            "destination label should name the pouch"
11249        );
11250    }
11251
11252    #[test]
11253    fn container_volume_label_on_placed_chest_shell() {
11254        let mut state = sample_state();
11255        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11256            id: "chest-1".into(),
11257            template_id: "wooden_chest_small".into(),
11258            display_name: "Camp Chest".into(),
11259            worker_lodging_capacity: None,
11260            x: 129.0,
11261            y: 128.0,
11262            z: 0.0,
11263            locked: false,
11264            accessible: true,
11265            owner_character_id: None,
11266            contents: vec![flatland_protocol::ItemStack {
11267                template_id: "iron_ore".into(),
11268                quantity: 2,
11269                item_instance_id: None,
11270                props: Default::default(),
11271                status_bindings: Vec::new(),
11272                contents: Vec::new(),
11273                display_name: None,
11274                category: None,
11275                base_mass: None,
11276                base_volume: Some(2.0),
11277                capacity_volume: None,
11278                stackable: None,
11279                world_placeable: None,
11280                worker_lodging_capacity: None,
11281                equip_slot: None,
11282                armor_physical: None,
11283                resists: vec![],
11284                hand_slots: None,
11285            }],
11286            lock_id: None,
11287            capacity_volume: Some(60.0),
11288            item_instance_id: Some(uuid::Uuid::from_u128(4)),
11289            tile_id: None,
11290            paperdoll_ref: None,
11291        }];
11292        let nearby = state.nearby_containers();
11293        let label = state.container_volume_label(&nearby[0].rows[0]);
11294        assert!(
11295            label.contains("vol 4/60"),
11296            "expected used/cap in label, got {label}"
11297        );
11298        assert!(
11299            label.contains("56 free"),
11300            "expected free space, got {label}"
11301        );
11302    }
11303
11304    #[test]
11305    fn key_pair_chest_label_from_placed_lock_id() {
11306        let mut state = sample_state();
11307        let owner = uuid::Uuid::from_u128(77);
11308        state.character_id = Some(owner);
11309        let lock = uuid::Uuid::from_u128(99).to_string();
11310        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11311            id: "chest-1".into(),
11312            template_id: "wooden_chest_small".into(),
11313            display_name: "Barry's Loot #a3f2".into(),
11314            x: 129.0,
11315            y: 128.0,
11316            z: 0.0,
11317            locked: true,
11318            accessible: true,
11319            owner_character_id: Some(owner),
11320            contents: Vec::new(),
11321            lock_id: Some(lock.clone()),
11322            capacity_volume: None,
11323            item_instance_id: Some(uuid::Uuid::from_u128(4)),
11324            tile_id: None,
11325            paperdoll_ref: None,
11326            worker_lodging_capacity: None,
11327        }];
11328        let key_id = uuid::Uuid::from_u128(5);
11329        let key = flatland_protocol::ItemStack {
11330            template_id: KEY_TEMPLATE.into(),
11331            quantity: 1,
11332            item_instance_id: Some(key_id),
11333            props: BTreeMap::from([
11334                (PROP_OPENS_LOCK_ID.into(), lock),
11335                (
11336                    PROP_OPENS_CONTAINER_NAME.into(),
11337                    "Barry's Loot #a3f2".into(),
11338                ),
11339            ]),
11340            status_bindings: Vec::new(),
11341            contents: Vec::new(),
11342            display_name: Some("Container Key".into()),
11343            category: Some("key".into()),
11344            base_mass: None,
11345            base_volume: None,
11346            capacity_volume: None,
11347            stackable: None,
11348            world_placeable: None,
11349            worker_lodging_capacity: None,
11350            equip_slot: None,
11351            armor_physical: None,
11352            resists: vec![],
11353            hand_slots: None,
11354        };
11355        state.inventory_stacks = vec![key.clone()];
11356        assert_eq!(
11357            state.key_pair_chest_label(&key).as_deref(),
11358            Some("Barry's Loot #a3f2")
11359        );
11360        assert!(state.key_drop_blocked(&key));
11361    }
11362
11363    #[test]
11364    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
11365        let mut state = sample_state();
11366        let lock = uuid::Uuid::from_u128(101).to_string();
11367        let key = flatland_protocol::ItemStack {
11368            template_id: KEY_TEMPLATE.into(),
11369            quantity: 1,
11370            item_instance_id: Some(uuid::Uuid::from_u128(7)),
11371            props: BTreeMap::from([
11372                (PROP_OPENS_LOCK_ID.into(), lock),
11373                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
11374            ]),
11375            status_bindings: Vec::new(),
11376            contents: Vec::new(),
11377            display_name: None,
11378            category: Some("key".into()),
11379            base_mass: None,
11380            base_volume: None,
11381            capacity_volume: None,
11382            stackable: None,
11383            world_placeable: None,
11384            worker_lodging_capacity: None,
11385            equip_slot: None,
11386            armor_physical: None,
11387            resists: vec![],
11388            hand_slots: None,
11389        };
11390        state.placed_containers.clear();
11391        assert_eq!(
11392            state.key_pair_chest_label(&key).as_deref(),
11393            Some("Camp Stash")
11394        );
11395    }
11396
11397    #[test]
11398    fn key_drop_allowed_when_paired_chest_unlocked() {
11399        let mut state = sample_state();
11400        let lock = uuid::Uuid::from_u128(100).to_string();
11401        let key_id = uuid::Uuid::from_u128(6);
11402        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11403            id: "chest-1".into(),
11404            template_id: "wooden_chest_small".into(),
11405            display_name: "Camp Chest".into(),
11406            x: 129.0,
11407            y: 128.0,
11408            z: 0.0,
11409            locked: false,
11410            accessible: true,
11411            owner_character_id: None,
11412            contents: Vec::new(),
11413            lock_id: Some(lock.clone()),
11414            capacity_volume: None,
11415            item_instance_id: None,
11416            tile_id: None,
11417            paperdoll_ref: None,
11418            worker_lodging_capacity: None,
11419        }];
11420        let key = flatland_protocol::ItemStack {
11421            template_id: KEY_TEMPLATE.into(),
11422            quantity: 1,
11423            item_instance_id: Some(key_id),
11424            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
11425            status_bindings: Vec::new(),
11426            contents: Vec::new(),
11427            display_name: None,
11428            category: Some("key".into()),
11429            base_mass: None,
11430            base_volume: None,
11431            capacity_volume: None,
11432            stackable: None,
11433            world_placeable: None,
11434            worker_lodging_capacity: None,
11435                equip_slot: None,
11436                armor_physical: None,
11437                resists: vec![],
11438                hand_slots: None,
11439        };
11440        state.inventory_stacks = vec![key.clone()];
11441        assert!(!state.key_drop_blocked(&key));
11442        let opts = state.move_destinations_for(
11443            &flatland_protocol::InventoryLocation::Root,
11444            None,
11445            Some(key_id),
11446            KEY_TEMPLATE,
11447        );
11448        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
11449    }
11450
11451    #[test]
11452    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
11453        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
11454
11455        let mut state = sample_state();
11456        let curve = ProgressionCurve::default();
11457        let bootstrap = ProgressionXp::bootstrap_new(
11458            curve.baseline_display,
11459            curve.xp_base,
11460            curve.xp_growth,
11461        );
11462        let mut fresh = bootstrap.clone();
11463        fresh.strength += 0.08;
11464        if let Some(player) = state.player.as_mut() {
11465            player.progression_xp = Some(bootstrap);
11466        }
11467
11468        let combat = CombatHud {
11469            progression_xp: Some(fresh.clone()),
11470            progression_baseline: curve.baseline_display,
11471            progression_xp_base: curve.xp_base,
11472            progression_xp_growth: curve.xp_growth,
11473            attributes: state.player.as_ref().and_then(|p| p.attributes),
11474            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
11475            ..CombatHud::default()
11476        };
11477        state.apply_combat_hud(&combat);
11478
11479        let xp = state
11480            .player
11481            .as_ref()
11482            .and_then(|p| p.progression_xp.as_ref())
11483            .expect("xp");
11484        assert!((xp.strength - fresh.strength).abs() < 0.001);
11485        assert!(state.progression_curve.is_some());
11486    }
11487
11488    #[test]
11489    fn combat_hud_syncs_known_abilities_and_hotbar() {
11490        use flatland_protocol::CombatHud;
11491
11492        let mut state = sample_state();
11493        let combat = CombatHud {
11494            known_abilities: vec!["unarmed".into(), "fireball".into()],
11495            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
11496            max_abilities_per_rotation: 4,
11497            ability_id: "short_sword_slash".into(),
11498            ..CombatHud::default()
11499        };
11500        state.apply_combat_hud(&combat);
11501
11502        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
11503        assert_eq!(state.hotbar_ability(1), Some("fireball"));
11504        assert_eq!(state.hotbar_ability(2), None);
11505        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
11506        assert_eq!(state.max_abilities_per_rotation, 4);
11507        let choices = state.loadout_ability_choices();
11508        assert!(choices.iter().any(|a| a == "short_sword_slash"));
11509        assert!(choices.iter().any(|a| a == "fireball"));
11510    }
11511
11512    #[test]
11513    fn loose_consumable_move_picker_offers_use_and_storage() {
11514        let mut state = sample_state();
11515        let inst = uuid::Uuid::from_u128(77);
11516        state.inventory_stacks = vec![flatland_protocol::ItemStack {
11517            template_id: "carrot".into(),
11518            quantity: 2,
11519            item_instance_id: Some(inst),
11520            props: Default::default(),
11521            status_bindings: Vec::new(),
11522            contents: Vec::new(),
11523            display_name: Some("Wild Carrot".into()),
11524            category: Some("consumable".into()),
11525            base_mass: None,
11526            base_volume: None,
11527            capacity_volume: None,
11528            stackable: Some(true),
11529            world_placeable: None,
11530            worker_lodging_capacity: None,
11531                equip_slot: None,
11532                armor_physical: None,
11533                resists: vec![],
11534                hand_slots: None,
11535        }];
11536        state.inventory_hints.insert(
11537            "carrot".into(),
11538            InventoryHint {
11539                display_name: "Wild Carrot".into(),
11540                category: "consumable".into(),
11541                base_mass: Some(0.15),
11542                base_volume: Some(0.3),
11543                capacity_volume: None,
11544                stackable: true,
11545            },
11546        );
11547        state.show_inventory_menu = true;
11548        state.inventory_menu_index = 0;
11549
11550        let row = state.inventory_selected_row().expect("carrot row");
11551        let mut options = state.move_destinations_for(
11552            &row.from,
11553            row.from_parent_instance_id,
11554            row.stack.item_instance_id,
11555            &row.stack.template_id,
11556        );
11557        if row.from == flatland_protocol::InventoryLocation::Root
11558            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
11559        {
11560            options.insert(
11561                0,
11562                MoveOption {
11563                    label: "Use (eat / drink)".into(),
11564                    kind: MoveOptionKind::Use,
11565                },
11566            );
11567        }
11568
11569        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
11570        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
11571        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
11572    }
11573
11574    #[test]
11575    fn inventory_category_group_order_is_stable() {
11576        assert_eq!(inventory_category_group("weapon").0, "Weapons");
11577        assert_eq!(inventory_category_group("armor").0, "Armor");
11578        assert_eq!(inventory_category_group("consumable").0, "Consumables");
11579        assert_eq!(inventory_category_group("resource").0, "Resources");
11580        assert_eq!(inventory_category_group("container").0, "Containers");
11581        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
11582        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
11583    }
11584
11585    #[test]
11586    fn page_list_index_clamps_without_wrap() {
11587        assert_eq!(page_list_index(0, -1, 25), 0);
11588        assert_eq!(page_list_index(0, 1, 25), 10);
11589        assert_eq!(page_list_index(12, 1, 25), 22);
11590        assert_eq!(page_list_index(22, 1, 25), 24);
11591        assert_eq!(page_list_index(5, 1, 0), 0);
11592        assert_eq!(page_list_index(3, -1, 8), 0);
11593    }
11594
11595    #[test]
11596    fn inventory_filter_hides_non_matching_person_items() {
11597        let mut state = sample_state();
11598        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
11599        sword.display_name = Some("Iron Sword".into());
11600        sword.category = Some("weapon".into());
11601        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
11602        herb.display_name = Some("Wild Herb".into());
11603        herb.category = Some("consumable".into());
11604        state.inventory_stacks = vec![sword, herb];
11605        state.inventory_tab = InventoryTab::OnPerson;
11606        state.inventory_filter = "sword".into();
11607
11608        let rows = state.inventory_selectable_rows();
11609        assert_eq!(rows.len(), 1);
11610        assert_eq!(rows[0].stack.template_id, "iron_sword");
11611
11612        let lines = state.inventory_browser_lines();
11613        assert!(lines.iter().any(|l| matches!(
11614            l,
11615            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
11616        )));
11617        assert!(!lines.iter().any(|l| matches!(
11618            l,
11619            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
11620        )));
11621    }
11622
11623    #[test]
11624    fn inventory_person_rows_group_by_category() {
11625        let mut state = sample_state();
11626        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
11627        sword.category = Some("weapon".into());
11628        sword.display_name = Some("Iron Sword".into());
11629        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
11630        ore.category = Some("resource".into());
11631        ore.display_name = Some("Iron Ore".into());
11632        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
11633        potion.category = Some("consumable".into());
11634        potion.display_name = Some("Health Potion".into());
11635        state.inventory_stacks = vec![ore, potion, sword];
11636        state.inventory_tab = InventoryTab::OnPerson;
11637
11638        let lines = state.inventory_browser_lines();
11639        let labels: Vec<&str> = lines
11640            .iter()
11641            .filter_map(|l| match l {
11642                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
11643                _ => None,
11644            })
11645            .collect();
11646        assert!(
11647            labels.iter().any(|s| s.contains("Weapons")),
11648            "expected Weapons group: {labels:?}"
11649        );
11650        assert!(labels.iter().any(|s| s.contains("Consumables")));
11651        assert!(labels.iter().any(|s| s.contains("Resources")));
11652
11653        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
11654        let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
11655        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
11656        assert!(weapon_pos < consumable_pos);
11657        assert!(consumable_pos < resource_pos);
11658    }
11659
11660    #[test]
11661    fn inventory_tab_cycle_resets_selection() {
11662        let mut state = sample_state();
11663        state.inventory_tab = InventoryTab::OnPerson;
11664        state.inventory_menu_index = 3;
11665        state.inventory_tab = state.inventory_tab.cycle(true);
11666        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
11667        // Client method resets index; enum cycle alone does not — verify cycle labels.
11668        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
11669        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
11670        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
11671        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
11672    }
11673
11674    #[test]
11675    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
11676        assert_eq!(parse_bank_copper_amount(""), Some(0));
11677        assert_eq!(parse_bank_copper_amount("  "), Some(0));
11678        assert_eq!(parse_bank_copper_amount("0"), Some(0));
11679        assert_eq!(parse_bank_copper_amount("250"), Some(250));
11680        assert_eq!(parse_bank_copper_amount("nope"), None);
11681    }
11682
11683    #[test]
11684    fn parse_storage_quantity_blank_and_zero_mean_all() {
11685        assert_eq!(parse_storage_quantity(""), Some(None));
11686        assert_eq!(parse_storage_quantity("  "), Some(None));
11687        assert_eq!(parse_storage_quantity("0"), Some(None));
11688        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
11689        assert_eq!(parse_storage_quantity("nope"), None);
11690    }
11691}