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