Skip to main content

flatland_client_lib/
game.rs

1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5    AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatCueKind,
6    CombatFxHitOutcome, CombatFxKind, CombatHud, CombatSlotHud, CombatTargetHud, DoorView,
7    EntityId, EntityState, Intent, InteriorMapView, ItemCatalogEntryView, LifeState, NpcView,
8    RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick, ZPlatformView,
9    ZTransitionView,
10};
11
12use crate::session::{PlayConnection, SessionEvent};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum CharacterSheetTab {
16    #[default]
17    Character,
18    Ledger,
19    Career,
20}
21
22impl CharacterSheetTab {
23    pub fn cycle(self) -> Self {
24        match self {
25            Self::Character => Self::Ledger,
26            Self::Ledger => Self::Career,
27            Self::Career => Self::Character,
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LedgerPeriod {
34    #[default]
35    Day,
36    Week,
37    Month,
38    Lifetime,
39}
40
41impl LedgerPeriod {
42    pub fn label(self) -> &'static str {
43        match self {
44            Self::Day => "Day",
45            Self::Week => "Week",
46            Self::Month => "Month",
47            Self::Lifetime => "All",
48        }
49    }
50
51    pub fn cycle(self) -> Self {
52        match self {
53            Self::Day => Self::Week,
54            Self::Week => Self::Month,
55            Self::Month => Self::Lifetime,
56            Self::Lifetime => Self::Day,
57        }
58    }
59
60    pub fn from_digit(c: char) -> Option<Self> {
61        match c {
62            '1' => Some(Self::Day),
63            '2' => Some(Self::Week),
64            '3' => Some(Self::Month),
65            '4' => Some(Self::Lifetime),
66            _ => None,
67        }
68    }
69}
70
71/// Matches `flatland_sim::containers` prop keys (client does not depend on sim).
72const KEY_TEMPLATE: &str = "container_key";
73const PROPERTY_DEED_TEMPLATE: &str = "property_deed";
74const PROP_LOCK_ID: &str = "lock_id";
75const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
76const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
77const PROP_CUSTOM_NAME: &str = "custom_name";
78const PROP_LOCKED: &str = "locked";
79
80/// Local claim-mode footprint editor (plan 40) — SW corner + size in meters.
81#[derive(Debug, Clone, PartialEq)]
82pub struct ClaimModeState {
83    pub zone_id: String,
84    pub width_m: u32,
85    pub height_m: u32,
86    pub anchor_x: f32,
87    pub anchor_y: f32,
88}
89
90/// Local relocate ghost for a placed chest/lodging (1×1 cell cursor).
91#[derive(Debug, Clone, PartialEq)]
92pub struct RelocateModeState {
93    pub container_id: String,
94    pub label: String,
95    pub cursor_x: f32,
96    pub cursor_y: f32,
97}
98
99fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
100    stack
101        .props
102        .get(PROP_LOCKED)
103        .is_some_and(|v| v == "true" || v == "1")
104}
105
106const MAX_LOG_LINES: usize = 200;
107const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
108const INTERACTION_RADIUS_M: f32 = 1.5;
109const DOOR_INTERACTION_RADIUS_M: f32 = 2.0;
110const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
111const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
112/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
113const CRAFT_STAMINA_COST: f32 = 3.0;
114/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
115const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
116/// Keep the last worker route error visible in the HUD after the server clears it.
117const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
118/// Keep a defender's HP ring visible after a hit when combat has already ended.
119const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
120/// Prevent repeated hire-key presses while the new worker roster is in flight.
121const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
122
123/// Catalog hints synced from server `ItemStack` wire rows.
124#[derive(Debug, Clone, Default)]
125pub struct InventoryHint {
126    pub display_name: String,
127    pub category: String,
128    pub base_mass: Option<f32>,
129    pub base_volume: Option<f32>,
130    pub capacity_volume: Option<f32>,
131    pub stackable: bool,
132    /// Market-hall list eligibility (from catalog enrichment).
133    pub listable: bool,
134    /// NPC trade base for dump-queue payout hints.
135    pub base_value_copper: Option<u32>,
136}
137
138/// One row in the loadout hotbar picker (ability or inventory consumable).
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct LoadoutHotbarChoice {
141    /// Value sent via [`Intent::SetHotbarSlot`] (`ability_id` or `item:<template>`).
142    pub binding: String,
143    /// Display label (ability id, or "Carrot ×3").
144    pub label: String,
145    /// Optional meta badge (`weapon`, `use`, …).
146    pub meta: Option<String>,
147}
148
149/// Rotation editor overlay mode (`plans/26` §C2.5).
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum RotationEditorMode {
152    #[default]
153    List,
154    EditSequence,
155    PickAbility,
156    EditLabel,
157}
158
159/// Local rotation editor UI state (not persisted).
160#[derive(Debug, Clone, Default)]
161pub struct RotationEditorState {
162    pub mode: RotationEditorMode,
163    pub list_index: usize,
164    pub ability_index: usize,
165    pub picker_index: usize,
166    pub draft: Option<RotationPreset>,
167    pub label_buffer: String,
168}
169
170impl RotationEditorState {
171    pub fn reset(&mut self) {
172        *self = Self::default();
173    }
174}
175
176/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
177/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
178/// client only ever shows chests the server will actually let you use — this is
179/// what makes a chest disappear from the menu as soon as you walk away.
180pub const CONTAINER_RANGE_M: f32 = 3.0;
181
182/// Broad section of the inventory browser a row belongs to (drives the grouped
183/// "Worn" / "On you" / "Nearby chest" headers in the UI).
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum InventorySection {
186    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
187    Worn,
188    /// Loose on your person — not worn, not inside a placed chest.
189    Person,
190    /// Inside a placed chest within reach.
191    Nearby,
192}
193
194/// Top-level inventory browser tab (`b` menu).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum InventoryTab {
197    #[default]
198    OnPerson,
199    Nearby,
200}
201
202impl InventoryTab {
203    pub fn label(self) -> &'static str {
204        match self {
205            Self::OnPerson => "On person",
206            Self::Nearby => "Nearby storage",
207        }
208    }
209
210    pub fn cycle(self, forward: bool) -> Self {
211        match (self, forward) {
212            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
213            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
214        }
215    }
216}
217
218/// Rows jumped by PageUp / PageDown in list UIs.
219pub const LIST_PAGE_SIZE: usize = 10;
220
221/// Case-insensitive substring match for list filters (name / template / label).
222pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
223    if filter.is_empty() {
224        return true;
225    }
226    haystack
227        .to_ascii_lowercase()
228        .contains(&filter.to_ascii_lowercase())
229}
230
231/// True for characters that belong in `/` list search fields.
232/// Rejects control chars and macOS private-use arrow glyphs (`U+F700`…).
233pub fn is_list_filter_char(ch: char) -> bool {
234    match ch {
235        ' '..='~' => true,
236        c if c.is_alphanumeric() => true,
237        _ => false,
238    }
239}
240
241/// Clamp a list selection after a page jump (`pages` is typically ±1).
242pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
243    if len == 0 {
244        return 0;
245    }
246    let page = LIST_PAGE_SIZE as i32;
247    let next = index as i32 + pages * page;
248    next.clamp(0, (len as i32) - 1) as usize
249}
250
251/// Advance `index` by `delta` among indices where `pred` is true (wraps).
252pub fn step_filtered_index(
253    index: usize,
254    delta: i32,
255    len: usize,
256    pred: impl Fn(usize) -> bool,
257) -> usize {
258    if len == 0 {
259        return 0;
260    }
261    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
262    if matching.is_empty() {
263        return index.min(len - 1);
264    }
265    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
266    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
267    matching[next]
268}
269
270/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
271pub fn page_filtered_index(
272    index: usize,
273    pages: i32,
274    len: usize,
275    pred: impl Fn(usize) -> bool,
276) -> usize {
277    if len == 0 {
278        return 0;
279    }
280    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
281    if matching.is_empty() {
282        return index.min(len - 1);
283    }
284    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
285    let next = page_list_index(pos, pages, matching.len());
286    matching[next]
287}
288
289/// Category group for On-person loose items (stable order).
290pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
291    match category {
292        "weapon" | "ammo" => ("Weapons", 0),
293        "armor" | "shield" | "offhand" => ("Armor", 1),
294        "consumable" | "liquid" | "bulk" => ("Consumables", 2),
295        "resource" | "harvest_node" | "seed" => ("Resources", 3),
296        "container" | "lodging" => ("Containers", 4),
297        "currency" | "key" => ("Currency & keys", 5),
298        "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
299        _ => ("Other", 7),
300    }
301}
302
303/// Whether a template category is listable when the wire/hint omits an explicit flag.
304pub fn category_default_listable(category: &str) -> bool {
305    !matches!(
306        category,
307        "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
308    )
309}
310
311fn vessel_holds_category(stack: &flatland_protocol::ItemStack, category: Option<&str>) -> bool {
312    let cat = category.unwrap_or("");
313    if let Some(holds) = stack.props.get("serving_holds") {
314        return holds.split(',').any(|p| {
315            let p = p.trim();
316            p == cat
317                || (cat == "liquid" && p == "liquid")
318                || (cat == "bulk" && p == "bulk")
319                || (matches!(cat, "consumable") && p == "food")
320        });
321    }
322    // Props incomplete (older sync) — fall back to vessel flags.
323    match cat {
324        "bulk" => stack.props.get("bulk_vessel").is_some_and(|v| v == "1"),
325        "liquid" => stack.props.get("liquid_vessel").is_some_and(|v| v == "1"),
326        _ => false,
327    }
328}
329
330fn serving_capacity_of(stack: &flatland_protocol::ItemStack) -> u32 {
331    stack
332        .props
333        .get("serving_capacity")
334        .and_then(|s| s.parse().ok())
335        .unwrap_or(0)
336}
337
338fn payload_units_in_vessel(stack: &flatland_protocol::ItemStack) -> u32 {
339    stack.contents.iter().map(|c| c.quantity).sum()
340}
341
342fn is_serving_vessel_stack(stack: &flatland_protocol::ItemStack) -> bool {
343    stack.props.get("serving").is_some_and(|v| v == "1")
344        || stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
345        || stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
346        || stack.props.contains_key("serving_holds")
347        || stack.props.contains_key("serving_capacity")
348}
349
350fn vessel_free_room_for_payload(
351    stack: &flatland_protocol::ItemStack,
352    payload_id: &str,
353    payload_category: Option<&str>,
354) -> u32 {
355    if !is_serving_vessel_stack(stack) || !vessel_holds_category(stack, payload_category) {
356        return 0;
357    }
358    let primary = stack.contents.iter().find(|c| c.quantity > 0);
359    let compatible = primary.is_none_or(|c| c.template_id == payload_id);
360    if !compatible {
361        return 0;
362    }
363    let cap = serving_capacity_of(stack);
364    let used = payload_units_in_vessel(stack);
365    let per_shell = cap.saturating_sub(used);
366    if per_shell == 0 {
367        return 0;
368    }
369    // Empty stacked shells each contribute full capacity.
370    let shells = if stack.contents.is_empty() {
371        stack.quantity.max(1)
372    } else {
373        1
374    };
375    per_shell.saturating_mul(shells)
376}
377
378fn drain_payload_from_stacks(
379    stacks: &mut [flatland_protocol::ItemStack],
380    template_id: &str,
381    remaining: &mut u32,
382) {
383    if *remaining == 0 {
384        return;
385    }
386    for stack in stacks.iter_mut() {
387        if *remaining == 0 {
388            return;
389        }
390        if stack.template_id == template_id && stack.quantity > 0 {
391            let take = (*remaining).min(stack.quantity);
392            stack.quantity -= take;
393            *remaining -= take;
394        }
395        drain_payload_from_stacks(&mut stack.contents, template_id, remaining);
396        // Drop emptied nested payload shells so vessel room frees.
397        stack.contents.retain(|c| c.quantity > 0);
398    }
399}
400
401fn vessel_room_for_payload_in_stacks(
402    stacks: &[flatland_protocol::ItemStack],
403    payload_id: &str,
404    payload_category: Option<&str>,
405) -> u32 {
406    let mut room = 0u32;
407    for stack in stacks {
408        room = room.saturating_add(vessel_free_room_for_payload(
409            stack,
410            payload_id,
411            payload_category,
412        ));
413        room = room.saturating_add(vessel_room_for_payload_in_stacks(
414            &stack.contents,
415            payload_id,
416            payload_category,
417        ));
418    }
419    room
420}
421
422/// One serving vessel shown in the craft detail panel.
423#[derive(Debug, Clone)]
424pub struct CraftVesselLine {
425    pub label: String,
426    pub holds: String,
427    pub capacity: u32,
428    pub used: u32,
429    pub free: u32,
430    pub quantity: u32,
431    pub accepts_output: bool,
432    pub location: &'static str,
433}
434
435/// Full vessel transparency for a selected craft recipe.
436#[derive(Debug, Clone)]
437pub struct CraftVesselStatus {
438    pub needs_vessel: bool,
439    pub output_label: String,
440    pub need_units: u32,
441    pub free_after_inputs: u32,
442    pub ok: bool,
443    pub vessels: Vec<CraftVesselLine>,
444}
445
446/// Craft menu primary tabs — mutually exclusive list scopes.
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum CraftTab {
449    Ready,
450    Favorites,
451    Recent,
452    Tier(u32),
453}
454
455impl CraftTab {
456    pub fn label(self) -> String {
457        match self {
458            Self::Ready => "Ready".into(),
459            Self::Favorites => "★".into(),
460            Self::Recent => "Recent".into(),
461            Self::Tier(n) => format!("T{n}"),
462        }
463    }
464}
465
466/// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
467pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
468    if base_value == 0 {
469        return None;
470    }
471    let unit = ((base_value as f32) * 0.5).floor() as u32;
472    if unit == 0 {
473        return None;
474    }
475    Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
476}
477
478/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
479fn parse_bank_copper_amount(input: &str) -> Option<u64> {
480    let s = input.trim();
481    if s.is_empty() {
482        return Some(0);
483    }
484    s.parse::<u64>().ok()
485}
486
487/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
488fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
489    let s = input.trim();
490    if s.is_empty() || s == "0" {
491        return Some(None);
492    }
493    let n = s.parse::<u32>().ok()?;
494    if n == 0 {
495        return Some(None);
496    }
497    Some(Some(n))
498}
499
500fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
501    let name = stack
502        .display_name
503        .as_deref()
504        .unwrap_or(stack.template_id.as_str());
505    if stack.quantity > 1 {
506        format!("{name} ×{}", stack.quantity)
507    } else {
508        name.to_string()
509    }
510}
511
512/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
513/// browser section headers and the move-destination picker.
514pub fn body_slot_label(slot: BodySlot) -> &'static str {
515    match slot {
516        BodySlot::Head => "Head",
517        BodySlot::Chest => "Chest",
518        BodySlot::Forearms => "Forearms",
519        BodySlot::Legs => "Legs",
520        BodySlot::Feet => "Feet",
521        BodySlot::Cloak => "Cloak",
522        BodySlot::Back => "Back",
523        BodySlot::Waist => "Waist",
524        BodySlot::Earrings => "Earrings",
525        BodySlot::Necklace => "Necklace",
526        BodySlot::Eyeglasses => "Eyeglasses",
527        BodySlot::RingLeft1 => "Ring L1",
528        BodySlot::RingLeft2 => "Ring L2",
529        BodySlot::RingRight1 => "Ring R1",
530        BodySlot::RingRight2 => "Ring R2",
531    }
532}
533
534fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
535    let cat = stack.category.as_deref().unwrap_or("");
536    match mode {
537        "while_equipped" => {
538            stack.equip_slot.is_some()
539                || cat == "weapon"
540                || cat == "shield"
541                || cat == "offhand"
542                || cat == "armor"
543        }
544        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
545    }
546}
547
548fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
549    if grant_tags.is_empty() {
550        return true;
551    }
552    let target_tags: Vec<&str> = stack
553        .props
554        .get("allowed_enchant_tags")
555        .map(|s| {
556            s.split(',')
557                .map(str::trim)
558                .filter(|t| !t.is_empty())
559                .collect()
560        })
561        .unwrap_or_default();
562    if target_tags.is_empty() {
563        return true;
564    }
565    grant_tags.iter().any(|t| target_tags.contains(t))
566}
567
568/// Default sim tick rate when the client has no settings (matches server-settings).
569pub const DEFAULT_TICK_HZ: u32 = 30;
570
571/// Human-readable remaining time for an item status binding.
572pub fn format_binding_ttl(
573    binding: &flatland_protocol::ItemStatusBinding,
574    tick: u64,
575    tick_hz: u32,
576) -> String {
577    let Some(expires) = binding.expires_at_tick else {
578        return "permanent".into();
579    };
580    let hz = tick_hz.max(1) as f32;
581    let remaining = expires.saturating_sub(tick) as f32 / hz;
582    if remaining <= 0.0 {
583        return "expired".into();
584    }
585    if remaining >= 120.0 {
586        format!("{:.0}m left", remaining / 60.0)
587    } else if remaining >= 10.0 {
588        format!("{remaining:.0}s left")
589    } else {
590        format!("{remaining:.1}s left")
591    }
592}
593
594pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
595    match mode {
596        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
597        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
598    }
599}
600
601/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
602pub fn format_status_bindings_suffix(
603    bindings: &[flatland_protocol::ItemStatusBinding],
604    tick: u64,
605    tick_hz: u32,
606) -> String {
607    if bindings.is_empty() {
608        return String::new();
609    }
610    let parts: Vec<String> = bindings
611        .iter()
612        .map(|b| {
613            format!(
614                "{} ({}, {})",
615                b.effect_id,
616                format_binding_mode(b.mode),
617                format_binding_ttl(b, tick, tick_hz)
618            )
619        })
620        .collect();
621    format!(" · {}", parts.join("; "))
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625pub enum EquipPaperdollRow {
626    Body { slot: BodySlot, filled: bool },
627    Mainhand { filled: bool },
628    Offhand { filled: bool, locked: bool },
629}
630
631pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
632    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
633        .iter()
634        .map(|slot| EquipPaperdollRow::Body {
635            slot: *slot,
636            filled: state.worn.contains_key(slot),
637        })
638        .collect();
639    let two_hand = state.mainhand_hand_slots >= 2;
640    rows.push(EquipPaperdollRow::Mainhand {
641        filled: state.mainhand_template_id.is_some(),
642    });
643    rows.push(EquipPaperdollRow::Offhand {
644        filled: state.offhand_template_id.is_some(),
645        locked: two_hand,
646    });
647    rows
648}
649
650fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
651    for stack in &state.inventory_stacks {
652        let matches = stack
653            .equip_slot
654            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
655            .unwrap_or(false)
656            || guess_body_slot(&stack.template_id) == Some(slot);
657        if matches {
658            return stack.item_instance_id;
659        }
660    }
661    None
662}
663
664fn is_client_ring(slot: BodySlot) -> bool {
665    matches!(
666        slot,
667        BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
668    )
669}
670
671fn first_inventory_weapon(state: &GameState) -> Option<String> {
672    for stack in &state.inventory_stacks {
673        if stack.category.as_deref() == Some("weapon") {
674            return Some(stack.template_id.clone());
675        }
676    }
677    None
678}
679
680fn first_inventory_offhand(state: &GameState) -> Option<String> {
681    for stack in &state.inventory_stacks {
682        let cat = stack.category.as_deref().unwrap_or("");
683        if matches!(cat, "shield" | "offhand") {
684            return Some(stack.template_id.clone());
685        }
686    }
687    None
688}
689
690/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
691/// on the stack when present; otherwise guesses from `template_id`.
692fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
693    if template_id.contains("backpack") {
694        Some(BodySlot::Back)
695    } else if template_id.contains("belt") {
696        Some(BodySlot::Waist)
697    } else if template_id.contains("cloak") || template_id.contains("cape") {
698        Some(BodySlot::Cloak)
699    } else if template_id.contains("cap")
700        || template_id.contains("hat")
701        || template_id.contains("helm")
702    {
703        Some(BodySlot::Head)
704    } else if template_id.contains("shirt")
705        || template_id.contains("robe")
706        || template_id.contains("vest")
707        || template_id.contains("chest")
708        || template_id.contains("jerkin")
709    {
710        Some(BodySlot::Chest)
711    } else if template_id.contains("sleeves")
712        || template_id.contains("gloves")
713        || template_id.contains("gauntlets")
714    {
715        Some(BodySlot::Forearms)
716    } else if template_id.contains("pants") || template_id.contains("leggings") {
717        Some(BodySlot::Legs)
718    } else if template_id.contains("boots") || template_id.contains("shoes") {
719        Some(BodySlot::Feet)
720    } else if template_id.contains("earring") {
721        Some(BodySlot::Earrings)
722    } else if template_id.contains("necklace") || template_id.contains("amulet") {
723        Some(BodySlot::Necklace)
724    } else if template_id.contains("glass")
725        || template_id.contains("spectacles")
726        || template_id.contains("goggles")
727    {
728        Some(BodySlot::Eyeglasses)
729    } else if template_id.contains("ring") {
730        Some(BodySlot::RingLeft1)
731    } else {
732        None
733    }
734}
735
736/// One row in the inventory browser tree.
737#[derive(Debug, Clone)]
738pub struct InventoryRow {
739    pub depth: usize,
740    pub stack: flatland_protocol::ItemStack,
741    /// `MoveItem` source location for this stack.
742    pub from: flatland_protocol::InventoryLocation,
743    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
744    pub from_parent_instance_id: Option<uuid::Uuid>,
745    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
746    pub is_equip_shell: bool,
747    /// Placed world chest shell — lock/unlock via Enter or `l`.
748    pub is_chest_shell: bool,
749    pub section: InventorySection,
750}
751
752/// Formatted inventory row text shared by TUI and gfx browsers.
753#[derive(Debug, Clone)]
754pub struct InventoryRowView {
755    pub depth: usize,
756    /// Dense single-line label (legacy / TUI).
757    pub text: String,
758    /// Primary label for redesigned gfx rows (name, qty, slot).
759    pub title: String,
760    pub mass_kg: Option<f32>,
761    pub volume: Option<(f32, f32)>,
762    /// Hover hint when multiple rows share the same visible identity (template + label + mods).
763    pub instance_tooltip: Option<String>,
764}
765
766/// One line in the sectioned inventory browser (headers are non-selectable).
767#[derive(Debug, Clone)]
768pub enum InventoryBrowserLine {
769    Section(String),
770    SlotLabel(String),
771    Hint(String),
772    Blank,
773    Item {
774        selectable_index: usize,
775        selected: bool,
776        depth: usize,
777        text: String,
778        title: String,
779        mass_kg: Option<f32>,
780        volume: Option<(f32, f32)>,
781        instance_tooltip: Option<String>,
782    },
783}
784
785/// Bank teller panel focus (action list vs amount / transfer prompts).
786#[derive(Debug, Clone, PartialEq, Eq, Default)]
787pub enum BankUiMode {
788    #[default]
789    Menu,
790    DepositAmount {
791        input: String,
792    },
793    WithdrawAmount {
794        input: String,
795    },
796    TransferName {
797        input: String,
798    },
799    TransferAmount {
800        to_name: String,
801        input: String,
802    },
803}
804
805/// Town storage manager focus (action list vs item pickers vs quantity).
806#[derive(Debug, Clone, PartialEq, Eq, Default)]
807pub enum StorageUiMode {
808    #[default]
809    Menu,
810    /// Pick a loose on-person stack to store.
811    StorePick { index: usize },
812    /// Quantity for a chosen store stack (blank/0 = all).
813    StoreAmount {
814        pick_index: usize,
815        item_instance_id: uuid::Uuid,
816        label: String,
817        max_qty: u32,
818        input: String,
819    },
820    /// Pick a vault stack to take.
821    TakePick { index: usize },
822    /// Quantity for a chosen take stack (blank/0 = all).
823    TakeAmount {
824        pick_index: usize,
825        item_instance_id: uuid::Uuid,
826        label: String,
827        max_qty: u32,
828        input: String,
829    },
830    /// Pick a vault stack to ship to `dest_building_id`.
831    ShipPick {
832        dest_building_id: String,
833        dest_label: String,
834        index: usize,
835    },
836    /// Quantity for a chosen ship stack (blank/0 = all).
837    ShipAmount {
838        dest_building_id: String,
839        dest_label: String,
840        pick_index: usize,
841        item_instance_id: uuid::Uuid,
842        label: String,
843        max_qty: u32,
844        input: String,
845    },
846}
847
848/// Where market list goods are taken from (`GoodsLocation` on submit).
849#[derive(Debug, Clone, PartialEq, Eq)]
850pub enum MarketListSourceKind {
851    Person,
852    TownStorage { building_id: String },
853}
854
855/// Market hall clerk focus (browse vs list wizard).
856#[derive(Debug, Clone, PartialEq, Eq, Default)]
857pub enum MarketUiMode {
858    #[default]
859    Browse,
860    /// Pick on-person vs an eligible town vault.
861    ListSource { index: usize },
862    /// Pick a stack from the chosen source.
863    ListPick {
864        source: MarketListSourceKind,
865        index: usize,
866    },
867    /// Quantity to list (blank/0 = all).
868    ListAmount {
869        source: MarketListSourceKind,
870        pick_index: usize,
871        item_instance_id: uuid::Uuid,
872        template_id: String,
873        label: String,
874        max_qty: u32,
875        input: String,
876    },
877    /// Choose Fixed copper vs NPC-price dump queue (`plans/53`).
878    ListPricingMode {
879        source: MarketListSourceKind,
880        pick_index: usize,
881        item_instance_id: uuid::Uuid,
882        template_id: String,
883        label: String,
884        quantity: Option<u32>,
885        max_qty: u32,
886        /// 0 = NPC price, 1 = Fixed.
887        index: usize,
888    },
889    /// Unit price in copper.
890    ListPrice {
891        source: MarketListSourceKind,
892        pick_index: usize,
893        item_instance_id: uuid::Uuid,
894        template_id: String,
895        label: String,
896        /// Resolved quantity to list (`None` = all / omit on wire).
897        quantity: Option<u32>,
898        max_qty: u32,
899        input: String,
900    },
901}
902
903/// One selectable stack in a storage store/take/ship picker.
904#[derive(Debug, Clone)]
905pub struct StoragePickOption {
906    pub item_instance_id: uuid::Uuid,
907    pub template_id: String,
908    pub label: String,
909    pub quantity: u32,
910    /// Catalog category when known (market list filters).
911    pub category: String,
912}
913
914/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
915/// the browser (empty when locked without the matching key).
916#[derive(Debug, Clone)]
917pub struct NearbyContainer {
918    pub view: flatland_protocol::PlacedContainerView,
919    pub distance_m: f32,
920    pub rows: Vec<InventoryRow>,
921}
922
923/// One key row in the keychain overlay (carried vs stowed).
924#[derive(Debug, Clone)]
925pub struct KeychainEntry {
926    pub stack: flatland_protocol::ItemStack,
927    pub stowed: bool,
928}
929
930/// A destination the currently-picked item could be moved to.
931#[derive(Debug, Clone)]
932pub struct MoveOption {
933    pub label: String,
934    pub kind: MoveOptionKind,
935}
936
937#[derive(Debug, Clone, PartialEq)]
938pub enum MoveOptionKind {
939    Move {
940        location: flatland_protocol::InventoryLocation,
941        parent_instance_id: Option<uuid::Uuid>,
942    },
943    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
944    PickupPlaced {
945        container_id: String,
946        nest_location: flatland_protocol::InventoryLocation,
947        nest_parent_instance_id: Option<uuid::Uuid>,
948    },
949    /// Enter map relocate mode for a placed chest (no pickup).
950    RelocatePlaced {
951        container_id: String,
952    },
953    /// Eat/drink a loose consumable (one unit per use).
954    Use,
955    /// Open grant-target picker for `grants_item_status` consumables.
956    GrantApply,
957    Drop,
958    /// Sell the plot tied to a property deed back to the crown.
959    SellPlotToCrown {
960        plot_id: uuid::Uuid,
961    },
962    Cancel,
963}
964
965/// One navigable row in the deed farm-access panel.
966#[derive(Debug, Clone, PartialEq)]
967pub enum FarmAccessRow {
968    PublicToggle,
969    PublicDiscount,
970    AllowRemove {
971        character_id: uuid::Uuid,
972        label: String,
973        tax_discount_bps: u32,
974    },
975    NearbyAdd {
976        name: String,
977    },
978}
979
980/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
981#[derive(Debug, Clone)]
982pub struct GrantTargetPicker {
983    pub grant_instance_id: uuid::Uuid,
984    pub grant_label: String,
985    pub effect_id: String,
986    pub mode: String,
987    pub options: Vec<GrantTargetOption>,
988    pub filter: String,
989    pub filter_focused: bool,
990}
991
992#[derive(Debug, Clone)]
993pub struct GrantTargetOption {
994    pub label: String,
995    pub target_instance_id: uuid::Uuid,
996}
997
998/// Active "move to…" destination picker state for the selected inventory item.
999#[derive(Debug, Clone)]
1000pub struct MovePicker {
1001    pub item_instance_id: uuid::Uuid,
1002    pub from: flatland_protocol::InventoryLocation,
1003    pub item_label: String,
1004    pub template_id: String,
1005    pub stack_quantity: u32,
1006    pub quantity: u32,
1007    pub options: Vec<MoveOption>,
1008    pub filter: String,
1009    pub filter_focused: bool,
1010}
1011
1012/// Active permanent-delete picker for the selected inventory item.
1013#[derive(Debug, Clone)]
1014pub struct DestroyPicker {
1015    pub item_instance_id: uuid::Uuid,
1016    pub from: flatland_protocol::InventoryLocation,
1017    pub item_label: String,
1018    pub stack_quantity: u32,
1019    pub quantity: u32,
1020}
1021
1022/// One giveable stack in the workers-menu give picker.
1023#[derive(Debug, Clone)]
1024pub struct WorkerGiveOption {
1025    pub item_instance_id: uuid::Uuid,
1026    pub label: String,
1027    pub quantity: u32,
1028    pub template_id: String,
1029}
1030
1031/// Give an on-person inventory stack to the selected hired worker.
1032#[derive(Debug, Clone)]
1033pub struct WorkerGivePicker {
1034    pub worker_instance_id: String,
1035    pub worker_label: String,
1036    pub options: Vec<WorkerGiveOption>,
1037}
1038
1039/// Nearby hired worker choice when giving a selected inventory stack (`g`).
1040#[derive(Debug, Clone)]
1041pub struct WorkerGiveTargetOption {
1042    pub instance_id: String,
1043    pub label: String,
1044    pub distance_m: f32,
1045}
1046
1047/// Pick which nearby worker receives the selected inventory item.
1048#[derive(Debug, Clone)]
1049pub struct WorkerGiveTargetPicker {
1050    pub item_instance_id: uuid::Uuid,
1051    pub item_label: String,
1052    pub quantity: Option<u32>,
1053    pub options: Vec<WorkerGiveTargetOption>,
1054}
1055
1056/// Take an item from a hired worker back into the employer's inventory.
1057#[derive(Debug, Clone)]
1058pub struct WorkerTakePicker {
1059    pub worker_instance_id: String,
1060    pub worker_label: String,
1061    pub options: Vec<WorkerGiveOption>,
1062    /// How many of the selected stack to take (1..=stack quantity).
1063    pub quantity: u32,
1064}
1065
1066/// Max distance (m) to hand an item to a hired worker.
1067pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1068
1069/// One teachable blueprint in the workers-menu teach picker.
1070#[derive(Debug, Clone)]
1071pub struct WorkerTeachOption {
1072    pub blueprint_id: String,
1073    pub label: String,
1074    pub cost_copper: u64,
1075    pub min_level: u32,
1076    pub worker_level: u32,
1077    pub can_afford: bool,
1078    pub level_ok: bool,
1079}
1080
1081/// Teach a known blueprint to the selected hired worker.
1082#[derive(Debug, Clone)]
1083pub struct WorkerTeachPicker {
1084    pub worker_instance_id: String,
1085    pub worker_label: String,
1086    pub worker_level: u32,
1087    pub options: Vec<WorkerTeachOption>,
1088}
1089
1090/// Selected worker awaiting confirmation before permanent dismissal.
1091#[derive(Debug, Clone)]
1092pub struct WorkerDismissConfirmation {
1093    pub worker_instance_id: String,
1094    pub worker_label: String,
1095}
1096
1097/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
1098/// do not thrash the UI.
1099#[derive(Debug, Clone, Default)]
1100pub struct StickyWorkerStep {
1101    shown: String,
1102    pending: String,
1103    pending_since: Option<Instant>,
1104}
1105
1106impl StickyWorkerStep {
1107    fn from_label(label: String) -> Self {
1108        Self {
1109            shown: label.clone(),
1110            pending: label,
1111            pending_since: Some(Instant::now()),
1112        }
1113    }
1114
1115    fn observe(&mut self, label: &str, now: Instant) {
1116        let pending_since = self.pending_since.unwrap_or(now);
1117        if label == self.pending {
1118            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1119                self.shown = self.pending.clone();
1120            }
1121            return;
1122        }
1123        self.pending = label.to_string();
1124        self.pending_since = Some(now);
1125        // Empty → first value, or first observation: show immediately.
1126        if self.shown.is_empty() {
1127            self.shown = self.pending.clone();
1128        }
1129    }
1130}
1131
1132/// Last non-transient worker error — held so route flicker stays readable.
1133/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
1134#[derive(Debug, Clone, Default)]
1135pub struct StickyWorkerError {
1136    message: String,
1137    last_seen: Option<Instant>,
1138}
1139
1140impl StickyWorkerError {
1141    fn observe(&mut self, err: Option<&str>, now: Instant) {
1142        if let Some(e) = err {
1143            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1144                self.message = e.to_string();
1145                self.last_seen = Some(now);
1146            }
1147            return;
1148        }
1149        if let Some(seen) = self.last_seen {
1150            if now.duration_since(seen) > WORKER_ERROR_HOLD {
1151                self.message.clear();
1152                self.last_seen = None;
1153            }
1154        }
1155    }
1156
1157    pub fn shown(&self, now: Instant) -> Option<&str> {
1158        if self.message.is_empty() {
1159            return None;
1160        }
1161        let seen = self.last_seen?;
1162        if now.duration_since(seen) > WORKER_ERROR_HOLD {
1163            return None;
1164        }
1165        Some(self.message.as_str())
1166    }
1167}
1168
1169/// First non-transient worker issue for the status bar (strike, route error, etc.).
1170/// Routine recovery noise (path failures → lodging) stays in the game log only.
1171pub fn worker_attention_line(state: &GameState) -> Option<String> {
1172    use flatland_protocol::WorkerStateView;
1173    let now = Instant::now();
1174    for w in &state.hired_workers {
1175        if matches!(w.state, WorkerStateView::Strike) {
1176            return Some(format!(
1177                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1178                w.label
1179            ));
1180        }
1181        let sticky = state
1182            .worker_error_display
1183            .get(&w.instance_id)
1184            .and_then(|s| s.shown(now))
1185            .filter(|e| !worker_error_is_hud_noise(e));
1186        let live = w
1187            .last_error
1188            .as_deref()
1189            .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1190        if let Some(err) = sticky.or(live) {
1191            if let Some(hint) = w
1192                .issue_hint
1193                .as_deref()
1194                .filter(|h| !h.is_empty())
1195                .or_else(|| worker_issue_fix_hint(err))
1196            {
1197                return Some(format!("Worker {}: {err} — {hint}", w.label));
1198            }
1199            return Some(format!("Worker {}: {err}", w.label));
1200        }
1201        // Plan issue with hint but cleared last_error (sticky already shown above).
1202        if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1203            return Some(format!("Worker {}: {hint}", w.label));
1204        }
1205    }
1206    None
1207}
1208
1209/// Fallback fix copy when the server did not send `issue_hint`.
1210pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1211    let e = err.to_ascii_lowercase();
1212    if e.contains("missing")
1213        || e.contains("container not found")
1214        || e.contains("lodging container not found")
1215    {
1216        return Some("edit route (e): replace the missing chest/bed");
1217    }
1218    if e.contains("stranded at interior") || e.contains("interior map coords") {
1219        return Some("recovered — continuing route");
1220    }
1221    if e.contains("stuck inside")
1222        || e.contains("sent outside")
1223        || e.contains("sent to door")
1224        || e.contains("left building")
1225    {
1226        return Some("auto-exit for outdoor work — restart after update if it still loops");
1227    }
1228    if e.contains("collapsed") || e.contains("need food") {
1229        return Some("stock lodging bed with food and drink");
1230    }
1231    if e.contains("overburdened") {
1232        return Some("add a deposit/sell stop, or empty their pack");
1233    }
1234    if e.contains("storage full") {
1235        return Some("empty or upgrade the destination chest, or reassign the deposit");
1236    }
1237    if e.contains("need a hoe") || e.contains("need a dibber") {
1238        return Some("give them the tool or withdraw it on the route");
1239    }
1240    None
1241}
1242
1243/// True when a worker `last_error` is transient noise (soft-skip chatter).
1244pub fn worker_error_is_transient(err: &str) -> bool {
1245    let e = err.to_ascii_lowercase();
1246    e.contains("continuing route") || e.starts_with("nothing to withdraw")
1247}
1248
1249/// True for routine worker recoveries that should not paint the status bar.
1250/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1251pub fn worker_error_is_hud_noise(err: &str) -> bool {
1252    let e = err.to_ascii_lowercase();
1253    // Durable complete-or-idle park must stay visible on HUD and attention lines.
1254    if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1255        return false;
1256    }
1257    e.contains("returned to lodging after path")
1258        || e.contains("path failure")
1259        || e.contains("no path to")
1260        || e.contains("pathfinding")
1261        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1262        || e.contains("repathing")
1263        || e.contains("nudged clear")
1264        // Auto space rescue — informative in the log, not a red alert.
1265        || e.contains("auto-recovery")
1266        || e.contains("stranded at interior map coords")
1267}
1268
1269/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1270#[derive(Debug, Clone)]
1271pub struct PendingWorkerJobAck {
1272    pub seq: u32,
1273    pub worker_instance_id: String,
1274    pub worker_label: String,
1275    pub idle: bool,
1276    pub stop_count: usize,
1277    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1278    pub prev_mode: flatland_protocol::WorkerModeView,
1279    pub prev_step_label: String,
1280    pub prev_last_error: Option<String>,
1281}
1282
1283fn push_inventory_rows(
1284    rows: &mut Vec<InventoryRow>,
1285    depth: usize,
1286    stack: &flatland_protocol::ItemStack,
1287    from: &flatland_protocol::InventoryLocation,
1288    from_parent_instance_id: Option<uuid::Uuid>,
1289    section: InventorySection,
1290) {
1291    push_inventory_rows_filtered(
1292        rows,
1293        depth,
1294        stack,
1295        from,
1296        from_parent_instance_id,
1297        section,
1298        "",
1299    );
1300}
1301
1302fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1303    if filter.is_empty() {
1304        return true;
1305    }
1306    let f = filter.to_ascii_lowercase();
1307    let name = stack
1308        .display_name
1309        .as_deref()
1310        .unwrap_or("")
1311        .to_ascii_lowercase();
1312    let tid = stack.template_id.to_ascii_lowercase();
1313    name.contains(&f)
1314        || tid.contains(&f)
1315        || stack
1316            .contents
1317            .iter()
1318            .any(|c| stack_matches_filter(c, filter))
1319}
1320
1321fn push_inventory_rows_filtered(
1322    rows: &mut Vec<InventoryRow>,
1323    depth: usize,
1324    stack: &flatland_protocol::ItemStack,
1325    from: &flatland_protocol::InventoryLocation,
1326    from_parent_instance_id: Option<uuid::Uuid>,
1327    section: InventorySection,
1328    filter: &str,
1329) {
1330    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1331        return;
1332    }
1333    let self_hit = filter.is_empty() || {
1334        let f = filter.to_ascii_lowercase();
1335        let name = stack
1336            .display_name
1337            .as_deref()
1338            .unwrap_or("")
1339            .to_ascii_lowercase();
1340        let tid = stack.template_id.to_ascii_lowercase();
1341        name.contains(&f) || tid.contains(&f)
1342    };
1343    rows.push(InventoryRow {
1344        depth,
1345        stack: stack.clone(),
1346        from: from.clone(),
1347        from_parent_instance_id,
1348        is_equip_shell: false,
1349        is_chest_shell: false,
1350        section,
1351    });
1352    for child in &stack.contents {
1353        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1354            push_inventory_rows_filtered(
1355                rows,
1356                depth + 1,
1357                child,
1358                from,
1359                stack.item_instance_id,
1360                section,
1361                if self_hit { "" } else { filter },
1362            );
1363        }
1364    }
1365}
1366
1367#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1368pub enum ShopTab {
1369    #[default]
1370    Buy,
1371    Sell,
1372}
1373
1374#[derive(Debug, Clone)]
1375pub struct NpcChatState {
1376    pub npc_id: String,
1377    pub npc_label: String,
1378    pub lines: Vec<String>,
1379    pub input: String,
1380    pub pending: bool,
1381    pub talk_depth: flatland_protocol::NpcTalkDepth,
1382    pub trade_allowed: bool,
1383    pub banner: Option<String>,
1384    pub suggested_topics: Vec<String>,
1385}
1386
1387impl Default for NpcChatState {
1388    fn default() -> Self {
1389        Self {
1390            npc_id: String::new(),
1391            npc_label: String::new(),
1392            lines: Vec::new(),
1393            input: String::new(),
1394            pending: false,
1395            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1396            trade_allowed: true,
1397            banner: None,
1398            suggested_topics: Vec::new(),
1399        }
1400    }
1401}
1402
1403/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1404pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1405    npc.entity_id
1406        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1407        .map(|e| (e.transform.position.x, e.transform.position.y))
1408        .unwrap_or((npc.x, npc.y))
1409}
1410
1411#[derive(Debug, Clone)]
1412pub struct GameState {
1413    pub session_id: SessionId,
1414    pub entity_id: EntityId,
1415    /// Logged-in character — used to show owner-only container labels.
1416    pub character_id: Option<uuid::Uuid>,
1417    pub tick: Tick,
1418    pub chunk_rev: u64,
1419    pub content_rev: u64,
1420    pub publish_rev: u64,
1421    pub entities: Vec<EntityState>,
1422    pub player: Option<EntityState>,
1423    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1424    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1425    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1426    pub buildings: Vec<BuildingView>,
1427    pub doors: Vec<DoorView>,
1428    pub interior_map: Option<InteriorMapView>,
1429    pub npcs: Vec<NpcView>,
1430    pub blueprints: Vec<BlueprintView>,
1431    /// Wall/roof packs for the B plot-build menu.
1432    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1433    /// Outdoor play AABB origin (meters).
1434    pub world_x0: f32,
1435    pub world_y0: f32,
1436    pub world_width_m: f32,
1437    pub world_height_m: f32,
1438    pub terrain_zones: Vec<TerrainZoneView>,
1439    pub z_platforms: Vec<ZPlatformView>,
1440    pub z_transitions: Vec<ZTransitionView>,
1441    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1442    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1443    #[doc(hidden)]
1444    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1445    pub world_clock: flatland_protocol::WorldClock,
1446    pub inventory: std::collections::HashMap<String, u32>,
1447    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1448    /// Server item catalog (labels/categories). Survives inventory resync.
1449    pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1450    pub logs: VecDeque<String>,
1451    pub intents_sent: u64,
1452    pub ticks_received: u64,
1453    pub connected: bool,
1454    pub disconnect_reason: Option<String>,
1455    pub show_stats: bool,
1456    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1457    pub hud_log_hidden: bool,
1458    pub show_equip_menu: bool,
1459    pub equip_menu_index: usize,
1460    pub show_craft_menu: bool,
1461    pub craft_menu_index: usize,
1462    /// How many timed crafts to queue when confirming the craft menu.
1463    pub craft_batch_quantity: u32,
1464    /// Active craft list tab (Ready default).
1465    pub craft_tab: CraftTab,
1466    /// Search within the active craft tab (`/` to focus).
1467    pub craft_filter: String,
1468    pub craft_filter_focused: bool,
1469    /// Per-character favorites / recent (loaded when character id is known).
1470    pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1471    /// B — wall/roof material picker for plot buildings.
1472    pub show_plot_build_menu: bool,
1473    /// True = navigate walls; false = navigate roofs (Tab switches).
1474    pub plot_build_focus_wall: bool,
1475    pub plot_build_wall_index: usize,
1476    pub plot_build_roof_index: usize,
1477    pub show_shop_menu: bool,
1478    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1479    pub bank_panel: Option<flatland_protocol::BankPanel>,
1480    pub bank_menu_index: usize,
1481    pub bank_ui_mode: BankUiMode,
1482    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1483    pub market_panel: Option<flatland_protocol::MarketPanel>,
1484    /// Selected row among filtered market browse listings.
1485    pub market_menu_index: usize,
1486    /// Browse / list-pick text filter (`/` to focus).
1487    pub market_filter: String,
1488    pub market_filter_focused: bool,
1489    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1490    pub market_category_filter: Option<&'static str>,
1491    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1492    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1493    pub market_ui_mode: MarketUiMode,
1494    pub storage_menu_index: usize,
1495    pub storage_ui_mode: StorageUiMode,
1496    pub shop_tab: ShopTab,
1497    pub shop_menu_index: usize,
1498    pub shop_quantity: u32,
1499    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1500    pub shop_trade_log: VecDeque<String>,
1501    pub show_npc_verb_menu: bool,
1502    pub npc_verb_target: Option<String>,
1503    pub npc_verb_index: usize,
1504    /// Last refuse/system line shown while the Talk/Trade verb dock is open.
1505    pub npc_verb_notice: Option<String>,
1506    /// Nearby player Speak / Whisper / Trade dock.
1507    pub player_verbs: crate::social::PlayerVerbState,
1508    pub social_chat: crate::social::SocialChatState,
1509    pub trade_ui: crate::social::TradeUiState,
1510    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1511    pub show_npc_chat: bool,
1512    pub npc_chat: Option<NpcChatState>,
1513    pub show_inventory_menu: bool,
1514    pub inventory_menu_index: usize,
1515    pub inventory_tab: InventoryTab,
1516    pub inventory_filter: String,
1517    pub inventory_filter_focused: bool,
1518    pub show_move_picker: bool,
1519    pub move_picker_index: usize,
1520    pub move_picker: Option<MovePicker>,
1521    pub show_grant_picker: bool,
1522    pub grant_picker_index: usize,
1523    pub grant_picker: Option<GrantTargetPicker>,
1524    pub show_destroy_picker: bool,
1525    pub destroy_confirm_pending: bool,
1526    pub destroy_picker: Option<DestroyPicker>,
1527    /// Rename prompt for a selected container (`n` in inventory).
1528    pub show_rename_prompt: bool,
1529    /// When set, the rename prompt targets a property plot (deed or on-plot).
1530    pub rename_plot_id: Option<uuid::Uuid>,
1531    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1532    pub highlighted_plot_id: Option<uuid::Uuid>,
1533    /// Rename prompt for a hired worker (`n` in workers menu).
1534    pub show_worker_rename: bool,
1535    pub rename_buffer: String,
1536    /// Slot-1 combat target (mirrors server after SetTarget).
1537    pub combat_target: Option<EntityId>,
1538    pub combat_target_label: Option<String>,
1539    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1540    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1541    pub ground_target: Option<(f32, f32, f32)>,
1542    /// Active combat footprints from the server (`plans/39`).
1543    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1544    /// Lingering ground hazards (boss puddles) from the server.
1545    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1546    /// Authored crown property zones (claimable land) — plan 40.
1547    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1548    /// Tax overlays for claim cost premium preview.
1549    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1550    /// Growth / fertility overlays (farming, respawn).
1551    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1552    /// Climate / biome overlays.
1553    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1554    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1555    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1556    /// Claimed property plots near the observer.
1557    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1558    /// Server knobs for client-side claim quotes.
1559    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1560    /// Local claim-footprint editor (not server state).
1561    pub claim_mode: Option<ClaimModeState>,
1562    /// Local relocate ghost for a placed chest/lodging (not server state).
1563    pub relocate_mode: Option<RelocateModeState>,
1564    /// Second `f` within a short window confirms selling this plot back to the crown.
1565    pub sell_plot_confirm: Option<uuid::Uuid>,
1566    /// When `sell_plot_confirm` was armed (for double-tap window).
1567    pub sell_plot_armed_at: Option<Instant>,
1568    /// Choose seed type when planting on owned tilled soil (`f`).
1569    pub show_plant_menu: bool,
1570    pub plant_menu_index: usize,
1571    /// Deed-holder farm access panel (public toggle + allow-list).
1572    pub show_farm_access: bool,
1573    /// Draft name for adding a tenant from the farm-access panel.
1574    pub farm_access_name_draft: String,
1575    /// Public / grant discount draft (bps) while editing farm access.
1576    pub farm_access_discount_bps: u32,
1577    /// Selected row in the farm-access panel (0 = public toggle).
1578    pub farm_access_index: usize,
1579    pub plant_quantity: u32,
1580    pub in_combat: bool,
1581    pub auto_attack: bool,
1582    pub combat_has_los: bool,
1583    pub attack_cd_ticks: u64,
1584    pub gcd_ticks: u64,
1585    pub weapon_ability_id: String,
1586    pub mainhand_template_id: Option<String>,
1587    pub mainhand_label: Option<String>,
1588    pub mainhand_instance_id: Option<uuid::Uuid>,
1589    pub offhand_template_id: Option<String>,
1590    pub offhand_label: Option<String>,
1591    pub offhand_instance_id: Option<uuid::Uuid>,
1592    pub mainhand_hand_slots: u8,
1593    pub defense: Option<flatland_protocol::DefenseHud>,
1594    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1595    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1596    pub carry_mass: f32,
1597    pub carry_mass_max: f32,
1598    pub encumbrance: flatland_protocol::EncumbranceState,
1599    /// Walk m/s after encumbrance / DEX (from combat HUD). `0` until first HUD.
1600    pub move_speed_mps: f32,
1601    /// Encumbrance+status walk multiplier (`1.0` unloaded). `0` until first HUD.
1602    pub move_speed_mult: f32,
1603    /// Full nested inventory stacks from the server (root only; worn are separate).
1604    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1605    /// Keys stowed on the virtual keychain (zero carry mass).
1606    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1607    /// Whisper stones in the pouch (zero carry mass).
1608    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1609    /// Active status effects on the local player (buff/debuff strip).
1610    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1611    pub combat_target_detail: Option<CombatTargetHud>,
1612    pub cast_progress: Option<CastProgressHud>,
1613    /// Till / plant / other non-combat timed channels on the local player.
1614    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1615    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1616    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1617    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1618    pub blocking_active: bool,
1619    pub max_target_slots: u8,
1620    pub combat_slots: Vec<CombatSlotHud>,
1621    pub rotation_presets: Vec<RotationPreset>,
1622    /// Learned abilities from combat HUD (usable casts).
1623    pub known_abilities: Vec<String>,
1624    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1625    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1626    /// Per-ability mastery from combat HUD (tier / XP).
1627    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1628    /// Server-persisted hotbar bindings (index 0 = key 1).
1629    pub hotbar: Vec<Option<String>>,
1630    /// Max abilities allowed in one rotation (mind score).
1631    pub max_abilities_per_rotation: u8,
1632    pub show_loadout_menu: bool,
1633    pub show_keychain_menu: bool,
1634    pub keychain_menu_index: usize,
1635    pub show_rotation_editor: bool,
1636    /// Selected rotation preset in the loadout menu.
1637    pub loadout_menu_index: usize,
1638    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1639    pub loadout_hotbar_slot: u8,
1640    /// Selected known-ability row in the loadout menu.
1641    pub loadout_ability_index: usize,
1642    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1643    pub loadout_focus_presets: bool,
1644    pub rotation_editor: RotationEditorState,
1645    /// True after a harvest intent is accepted until result/reject/disconnect.
1646    pub harvest_in_progress: bool,
1647    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1648    pub harvest_started_at: Option<Instant>,
1649    /// Craft log deferred until the server acks the craft intent.
1650    pub pending_craft_ack: Option<(u32, String, u32)>,
1651    /// Blueprint kept on the Ready tab while its craft channel runs (inputs
1652    /// are spent at begin, so `can_craft` goes false before progress finishes).
1653    pub craft_channel_blueprint_id: Option<String>,
1654    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1655    pub interactables: Vec<flatland_protocol::InteractableView>,
1656    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1657    pub career: Option<flatland_protocol::PlayerCareerView>,
1658    pub character_sheet_tab: CharacterSheetTab,
1659    pub ledger_period: LedgerPeriod,
1660    pub show_quest_offer: bool,
1661    pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1662    pub quest_offer_index: usize,
1663    pub show_quest_menu: bool,
1664    pub quest_menu_index: usize,
1665    pub quest_withdraw_confirm: bool,
1666    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1667    pub show_workers_menu: bool,
1668    pub workers_menu_index: usize,
1669    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1670    /// Workers panel: one-line rows instead of full cards (more on screen).
1671    pub workers_menu_compact: bool,
1672    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1673    /// Public so out-of-crate tests can construct [`GameState`].
1674    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1675    /// Held worker route errors (readable when server clears `last_error` each tick).
1676    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1677    /// Wall-clock expiry for defender HP rings after their last observed hit.
1678    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1679    /// Set after a hire request until its worker appears in a roster update.
1680    pub pending_worker_hire_since: Option<Instant>,
1681    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1682    pub show_worker_give_picker: bool,
1683    pub worker_give_picker_index: usize,
1684    pub worker_give_picker: Option<WorkerGivePicker>,
1685    /// Inventory `g` — pick which nearby worker receives the selected stack.
1686    pub show_worker_give_target_picker: bool,
1687    pub worker_give_target_picker_index: usize,
1688    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1689    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1690    pub show_worker_take_picker: bool,
1691    pub worker_take_picker_index: usize,
1692    pub worker_take_picker: Option<WorkerTakePicker>,
1693    /// Teach-blueprint sheet opened from the workers menu (`t`).
1694    pub show_worker_teach_picker: bool,
1695    pub worker_teach_picker_index: usize,
1696    pub worker_teach_picker: Option<WorkerTeachPicker>,
1697    /// Active harvest-route editor (`h` → `e` on a worker).
1698    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1699    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1700    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1701    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1702    pub attending_worker_instance_id: Option<String>,
1703    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1704    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1705}
1706
1707#[derive(Debug, Clone, PartialEq, Eq)]
1708pub enum NpcVerbAction {
1709    Talk,
1710    Trade,
1711    Bank,
1712    Storage,
1713    Market,
1714    QuestTalk { quest_id: String },
1715    QuestGive { quest_id: String },
1716}
1717
1718#[derive(Debug, Clone, PartialEq, Eq)]
1719pub struct NpcVerbChoice {
1720    pub label: String,
1721    pub action: NpcVerbAction,
1722}
1723
1724impl std::fmt::Display for NpcVerbChoice {
1725    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1726        f.write_str(&self.label)
1727    }
1728}
1729
1730impl GameState {
1731    pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1732        self.pending_quest_offers.get(self.quest_offer_index)
1733    }
1734
1735    pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1736        if self
1737            .pending_quest_offers
1738            .iter()
1739            .any(|existing| existing.quest_id == offer.quest_id)
1740        {
1741            self.show_quest_offer = true;
1742            return;
1743        }
1744        self.pending_quest_offers.push(offer);
1745        self.show_quest_offer = true;
1746    }
1747
1748    pub fn remove_quest_offer(&mut self, quest_id: &str) {
1749        self.pending_quest_offers
1750            .retain(|offer| offer.quest_id != quest_id);
1751        if self.pending_quest_offers.is_empty() {
1752            self.show_quest_offer = false;
1753            self.quest_offer_index = 0;
1754            return;
1755        }
1756        self.quest_offer_index = self
1757            .quest_offer_index
1758            .min(self.pending_quest_offers.len() - 1);
1759        self.show_quest_offer = true;
1760    }
1761
1762    pub fn clear_quest_offers(&mut self) {
1763        self.pending_quest_offers.clear();
1764        self.quest_offer_index = 0;
1765        self.show_quest_offer = false;
1766    }
1767
1768    pub fn move_quest_offer_selection(&mut self, delta: i32) {
1769        let n = self.pending_quest_offers.len();
1770        if n == 0 {
1771            self.quest_offer_index = 0;
1772            return;
1773        }
1774        let idx = self.quest_offer_index as i32;
1775        self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1776    }
1777
1778    pub fn push_log(&mut self, line: impl Into<String>) {
1779        self.logs.push_back(line.into());
1780        while self.logs.len() > MAX_LOG_LINES {
1781            self.logs.pop_front();
1782        }
1783    }
1784
1785    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1786        self.shop_trade_log.push_back(line.into());
1787        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1788            self.shop_trade_log.pop_front();
1789        }
1790    }
1791
1792    pub fn clear_shop_trade_log(&mut self) {
1793        self.shop_trade_log.clear();
1794    }
1795
1796    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1797        if !self.show_shop_menu {
1798            return;
1799        }
1800        let msg = notice.message.trim();
1801        if msg.is_empty() {
1802            return;
1803        }
1804        if notice.coins_delta != 0
1805            || msg.starts_with("Bought ")
1806            || msg.starts_with("Sold ")
1807            || msg.contains("taught you how to craft")
1808            || msg.starts_with("need ")
1809        {
1810            self.push_shop_trade_log(msg);
1811        }
1812    }
1813
1814    pub fn is_alive(&self) -> bool {
1815        self.player
1816            .as_ref()
1817            .and_then(|p| p.vitals)
1818            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1819            .unwrap_or(true)
1820    }
1821
1822    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1823        self.social_chat.push_cue(cue);
1824    }
1825
1826    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1827    fn sync_gameplay_audio(&mut self) {
1828        use crate::social::AudioCue;
1829        use flatland_protocol::PrimaryAttributes;
1830
1831        let alive = self.is_alive();
1832        let casting = self.cast_progress.is_some();
1833        let telegraph = self.focus_attack_telegraph_active();
1834        let in_aoe = self.player_inside_spatial_telegraph();
1835        let quest_sig = self.quest_audio_signature();
1836        let entity_id = self.entity_id;
1837        let char_level = self
1838            .player
1839            .as_ref()
1840            .and_then(|p| p.attributes)
1841            .map(|a| {
1842                PrimaryAttributes::display(a.strength)
1843                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1844                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1845                    .saturating_add(PrimaryAttributes::display(a.stamina))
1846                    .saturating_add(PrimaryAttributes::display(a.vitality))
1847                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1848                    .saturating_add(PrimaryAttributes::display(a.charisma))
1849            })
1850            .unwrap_or(0);
1851
1852        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1853        let mut hit_cues = Vec::new();
1854        {
1855            let seen = &self.social_chat.audio_seen_fx_ids;
1856            for fx in &self.combat_fx {
1857                if seen.contains(&fx.id) {
1858                    continue;
1859                }
1860                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1861                    continue;
1862                };
1863                if hit.outcome == CombatFxHitOutcome::Blocked {
1864                    hit_cues.push(AudioCue::CombatBlock);
1865                } else {
1866                    let heavy = matches!(
1867                        fx.kind,
1868                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1869                    );
1870                    hit_cues.push(if heavy {
1871                        AudioCue::CombatHitHeavy
1872                    } else {
1873                        AudioCue::CombatHitLight
1874                    });
1875                }
1876            }
1877        }
1878
1879        let audio = &mut self.social_chat;
1880        if !audio.audio_bootstrapped {
1881            audio.audio_was_alive = alive;
1882            audio.audio_was_casting = casting;
1883            audio.audio_had_target_telegraph = telegraph;
1884            audio.audio_was_in_aoe = in_aoe;
1885            audio.audio_quest_sig = quest_sig;
1886            audio.audio_char_level = char_level;
1887            audio.audio_seen_fx_ids = fx_ids;
1888            audio.audio_bootstrapped = true;
1889            return;
1890        }
1891
1892        if telegraph && !audio.audio_had_target_telegraph {
1893            audio.push_cue(AudioCue::CombatTelegraphStart);
1894        } else if !telegraph && audio.audio_had_target_telegraph {
1895            audio.push_cue(AudioCue::CombatTelegraphImpact);
1896        }
1897        audio.audio_had_target_telegraph = telegraph;
1898
1899        if in_aoe && !audio.audio_was_in_aoe {
1900            audio.push_cue(AudioCue::CombatAoeWarn);
1901        }
1902        audio.audio_was_in_aoe = in_aoe;
1903
1904        if casting && !audio.audio_was_casting {
1905            audio.push_cue(AudioCue::AbilityCastSelf);
1906        }
1907        audio.audio_was_casting = casting;
1908
1909        if !alive && audio.audio_was_alive {
1910            audio.push_cue(AudioCue::PlayerDeath);
1911        }
1912        audio.audio_was_alive = alive;
1913
1914        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1915            audio.push_cue(AudioCue::QuestUpdate);
1916        }
1917        audio.audio_quest_sig = quest_sig;
1918
1919        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1920            audio.push_cue(AudioCue::LevelUp);
1921        }
1922        audio.audio_char_level = char_level;
1923
1924        for cue in hit_cues {
1925            audio.push_cue(cue);
1926        }
1927        audio.audio_seen_fx_ids = fx_ids;
1928    }
1929
1930    fn focus_attack_telegraph_active(&self) -> bool {
1931        let Some(tid) = self.combat_target else {
1932            return false;
1933        };
1934        self.entities
1935            .iter()
1936            .find(|e| e.id == tid)
1937            .map(|e| {
1938                e.combat_cues.iter().any(|c| {
1939                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1940                })
1941            })
1942            .unwrap_or(false)
1943    }
1944
1945    fn player_inside_spatial_telegraph(&self) -> bool {
1946        let (px, py) = self.player_position();
1947        for e in &self.entities {
1948            for cue in &e.combat_cues {
1949                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1950                    || cue.until_tick <= self.tick
1951                {
1952                    continue;
1953                }
1954                let Some(kind) = cue.telegraph_kind else {
1955                    continue;
1956                };
1957                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1958                    (Some(x), Some(y)) => (x, y),
1959                    _ => continue,
1960                };
1961                match kind {
1962                    CombatFxKind::Sphere => {
1963                        let r = cue.radius_m.unwrap_or(1.0);
1964                        let dx = px - ox;
1965                        let dy = py - oy;
1966                        if dx * dx + dy * dy <= r * r {
1967                            return true;
1968                        }
1969                    }
1970                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1971                        let reach = cue.reach_m.unwrap_or(2.0);
1972                        let yaw = cue.yaw.unwrap_or(0.0);
1973                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1974                        let dx = px - ox;
1975                        let dy = py - oy;
1976                        let dist = (dx * dx + dy * dy).sqrt();
1977                        if dist > reach || dist < 0.05 {
1978                            continue;
1979                        }
1980                        let ang = dx.atan2(dy);
1981                        let mut delta = ang - yaw;
1982                        while delta > std::f32::consts::PI {
1983                            delta -= std::f32::consts::TAU;
1984                        }
1985                        while delta < -std::f32::consts::PI {
1986                            delta += std::f32::consts::TAU;
1987                        }
1988                        if delta.abs() <= arc * 0.5 {
1989                            return true;
1990                        }
1991                    }
1992                    _ => {}
1993                }
1994            }
1995        }
1996        false
1997    }
1998
1999    fn quest_audio_signature(&self) -> u64 {
2000        use std::collections::hash_map::DefaultHasher;
2001        use std::hash::{Hash, Hasher};
2002        let mut h = DefaultHasher::new();
2003        for q in &self.quest_log {
2004            q.quest_id.hash(&mut h);
2005            format!("{:?}", q.status).hash(&mut h);
2006            q.current_step_id.hash(&mut h);
2007            for o in &q.objectives {
2008                o.done.hash(&mut h);
2009                o.current.hash(&mut h);
2010            }
2011        }
2012        h.finish()
2013    }
2014
2015    /// Verb menu entries for the current `npc_verb_target`.
2016    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2017        let Some(ref id) = self.npc_verb_target else {
2018            return vec![];
2019        };
2020        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2021            return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2022        };
2023        let role = npc.role.as_str();
2024        let rest = if Self::npc_role_is_bank(role) {
2025            vec![
2026                NpcVerbChoice {
2027                    label: "Bank".into(),
2028                    action: NpcVerbAction::Bank,
2029                },
2030                Self::talk_choice(),
2031            ]
2032        } else if Self::npc_role_is_storage(role) {
2033            vec![
2034                NpcVerbChoice {
2035                    label: "Storage".into(),
2036                    action: NpcVerbAction::Storage,
2037                },
2038                Self::talk_choice(),
2039            ]
2040        } else if Self::npc_role_is_market(role) {
2041            vec![
2042                NpcVerbChoice {
2043                    label: "Market".into(),
2044                    action: NpcVerbAction::Market,
2045                },
2046                Self::talk_choice(),
2047            ]
2048        } else if npc.can_trade || Self::npc_role_can_trade(role) {
2049            vec![
2050                Self::talk_choice(),
2051                NpcVerbChoice {
2052                    label: "Trade".into(),
2053                    action: NpcVerbAction::Trade,
2054                },
2055            ]
2056        } else {
2057            vec![Self::talk_choice()]
2058        };
2059        self.with_quest_verbs(id, rest)
2060    }
2061
2062    fn talk_choice() -> NpcVerbChoice {
2063        NpcVerbChoice {
2064            label: "Talk".into(),
2065            action: NpcVerbAction::Talk,
2066        }
2067    }
2068
2069    fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2070        let mut opts = self.quest_verb_choices(npc_id);
2071        opts.extend(rest);
2072        opts
2073    }
2074
2075    fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2076        if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2077            if !npc.quest_verbs.is_empty() {
2078                return npc
2079                    .quest_verbs
2080                    .iter()
2081                    .map(|v| {
2082                        let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2083                            NpcVerbAction::QuestGive {
2084                                quest_id: v.quest_id.clone(),
2085                            }
2086                        } else {
2087                            NpcVerbAction::QuestTalk {
2088                                quest_id: v.quest_id.clone(),
2089                            }
2090                        };
2091                        NpcVerbChoice {
2092                            label: v.label.clone(),
2093                            action,
2094                        }
2095                    })
2096                    .collect();
2097            }
2098        }
2099        let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2100        let mut opts = Vec::new();
2101        for q in &self.quest_log {
2102            if q.status != flatland_protocol::QuestStatusView::Active {
2103                continue;
2104            }
2105            let title = if q.title.trim().is_empty() {
2106                "Quest".to_string()
2107            } else {
2108                q.title.clone()
2109            };
2110            for o in &q.objectives {
2111                if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2112                    continue;
2113                }
2114                if o.kind == "give_item" {
2115                    opts.push(NpcVerbChoice {
2116                        label: format!("Turn in: {title}"),
2117                        action: NpcVerbAction::QuestGive {
2118                            quest_id: q.quest_id.clone(),
2119                        },
2120                    });
2121                } else if o.kind == "talk_npc" {
2122                    opts.push(NpcVerbChoice {
2123                        label: title.clone(),
2124                        action: NpcVerbAction::QuestTalk {
2125                            quest_id: q.quest_id.clone(),
2126                        },
2127                    });
2128                }
2129            }
2130        }
2131        opts
2132    }
2133
2134    fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2135        self.npcs
2136            .iter()
2137            .find(|n| n.id == npc_id)
2138            .and_then(|n| n.paperdoll_ref.clone())
2139            .unwrap_or_else(|| npc_id.to_string())
2140    }
2141
2142    fn count_inventory_template(&self, template: &str) -> u32 {
2143        self.inventory_stacks
2144            .iter()
2145            .filter(|s| s.template_id == template)
2146            .map(|s| s.quantity)
2147            .sum()
2148    }
2149
2150    fn npc_role_can_trade(role: &str) -> bool {
2151        matches!(role, "broker" | "cook" | "farmer" | "merchant")
2152    }
2153
2154    fn npc_role_is_bank(role: &str) -> bool {
2155        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2156    }
2157
2158    fn npc_role_is_storage(role: &str) -> bool {
2159        role.eq_ignore_ascii_case("storage_manager")
2160    }
2161
2162    fn npc_role_is_market(role: &str) -> bool {
2163        role.eq_ignore_ascii_case("market_clerk")
2164    }
2165
2166    pub fn bank_menu_options(&self) -> Vec<&'static str> {
2167        vec![
2168            "Deposit…",
2169            "Withdraw…",
2170            "Deposit all",
2171            "Withdraw all",
2172            "Transfer…",
2173        ]
2174    }
2175
2176    pub fn storage_menu_options(&self) -> Vec<String> {
2177        let mut opts = vec!["Store…".into(), "Take…".into()];
2178        if let Some(panel) = &self.storage_panel {
2179            for dest in &panel.ship_destinations {
2180                opts.push(format!(
2181                    "Ship → {} ({} cp / {} ticks)",
2182                    dest.label, dest.fee_copper, dest.travel_ticks
2183                ));
2184            }
2185        }
2186        opts
2187    }
2188
2189    /// Loose on-person stacks eligible for town-storage store.
2190    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
2191    /// already outside root inventory.
2192    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2193        let equipped = self.hand_equipped_instance_ids();
2194        self.person_rows()
2195            .into_iter()
2196            .filter(|r| r.depth == 0)
2197            .filter_map(|r| {
2198                let id = r.stack.item_instance_id?;
2199                if equipped.contains(&id) {
2200                    return None;
2201                }
2202                Some(StoragePickOption {
2203                    item_instance_id: id,
2204                    template_id: r.stack.template_id.clone(),
2205                    label: storage_stack_label(&r.stack),
2206                    quantity: r.stack.quantity,
2207                    category: r.stack.category.clone().unwrap_or_default(),
2208                })
2209            })
2210            .collect()
2211    }
2212
2213    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
2214    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2215        let mut ids = std::collections::HashSet::new();
2216        if let Some(id) = self.mainhand_instance_id {
2217            ids.insert(id);
2218        } else if let Some(tid) = &self.mainhand_template_id {
2219            if let Some(id) = self
2220                .inventory_stacks
2221                .iter()
2222                .find(|s| &s.template_id == tid)
2223                .and_then(|s| s.item_instance_id)
2224            {
2225                ids.insert(id);
2226            }
2227        }
2228        if let Some(id) = self.offhand_instance_id {
2229            ids.insert(id);
2230        } else if let Some(tid) = &self.offhand_template_id {
2231            if let Some(id) = self
2232                .inventory_stacks
2233                .iter()
2234                .find(|s| {
2235                    &s.template_id == tid
2236                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2237                })
2238                .and_then(|s| s.item_instance_id)
2239            {
2240                ids.insert(id);
2241            }
2242        }
2243        ids
2244    }
2245
2246    /// Vault stacks eligible for take / ship.
2247    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2248        let Some(panel) = &self.storage_panel else {
2249            return Vec::new();
2250        };
2251        panel
2252            .contents
2253            .iter()
2254            .filter_map(|s| {
2255                let id = s.item_instance_id?;
2256                Some(StoragePickOption {
2257                    item_instance_id: id,
2258                    template_id: s.template_id.clone(),
2259                    label: storage_stack_label(s),
2260                    quantity: s.quantity,
2261                    category: s.category.clone().unwrap_or_default(),
2262                })
2263            })
2264            .collect()
2265    }
2266
2267    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
2268    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2269        let mut opts = Vec::new();
2270        if !self
2271            .market_list_item_options(&MarketListSourceKind::Person)
2272            .is_empty()
2273        {
2274            opts.push((MarketListSourceKind::Person, "On person".into()));
2275        }
2276        if let Some(panel) = &self.market_panel {
2277            for vault in &panel.list_vaults {
2278                let source = MarketListSourceKind::TownStorage {
2279                    building_id: vault.building_id.clone(),
2280                };
2281                if self.market_list_item_options(&source).is_empty() {
2282                    continue;
2283                }
2284                let label = if vault.building_label.is_empty() {
2285                    format!("Town storage ({})", vault.building_id)
2286                } else {
2287                    format!("Town storage — {}", vault.building_label)
2288                };
2289                opts.push((source, label));
2290            }
2291        }
2292        opts
2293    }
2294
2295    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
2296    pub fn market_list_item_options(
2297        &self,
2298        source: &MarketListSourceKind,
2299    ) -> Vec<StoragePickOption> {
2300        let filter = self.market_filter.as_str();
2301        let cat_filter = self.market_category_filter;
2302        let mut opts: Vec<StoragePickOption> = match source {
2303            MarketListSourceKind::Person => {
2304                let equipped = self.hand_equipped_instance_ids();
2305                self.person_rows()
2306                    .into_iter()
2307                    .filter(|r| r.depth == 0)
2308                    .filter(|r| self.stack_is_market_listable(&r.stack))
2309                    .filter_map(|r| {
2310                        let id = r.stack.item_instance_id?;
2311                        if equipped.contains(&id) {
2312                            return None;
2313                        }
2314                        Some(StoragePickOption {
2315                            item_instance_id: id,
2316                            template_id: r.stack.template_id.clone(),
2317                            label: storage_stack_label(&r.stack),
2318                            quantity: r.stack.quantity,
2319                            category: r
2320                                .stack
2321                                .category
2322                                .clone()
2323                                .or_else(|| {
2324                                    self.inventory_item_category(&r.stack.template_id)
2325                                        .map(str::to_string)
2326                                })
2327                                .unwrap_or_default(),
2328                        })
2329                    })
2330                    .collect()
2331            }
2332            MarketListSourceKind::TownStorage { building_id } => {
2333                let Some(panel) = &self.market_panel else {
2334                    return Vec::new();
2335                };
2336                let Some(vault) = panel
2337                    .list_vaults
2338                    .iter()
2339                    .find(|v| &v.building_id == building_id)
2340                else {
2341                    return Vec::new();
2342                };
2343                vault
2344                    .contents
2345                    .iter()
2346                    .filter(|s| self.stack_is_market_listable(s))
2347                    .filter_map(|s| {
2348                        let id = s.item_instance_id?;
2349                        Some(StoragePickOption {
2350                            item_instance_id: id,
2351                            template_id: s.template_id.clone(),
2352                            label: storage_stack_label(s),
2353                            quantity: s.quantity,
2354                            category: s
2355                                .category
2356                                .clone()
2357                                .or_else(|| {
2358                                    self.inventory_item_category(&s.template_id)
2359                                        .map(str::to_string)
2360                                })
2361                                .unwrap_or_default(),
2362                        })
2363                    })
2364                    .collect()
2365            }
2366        };
2367        opts.retain(|o| {
2368            if !list_label_matches(&o.label, filter) {
2369                return false;
2370            }
2371            if let Some(group) = cat_filter {
2372                inventory_category_group(&o.category).0 == group
2373            } else {
2374                true
2375            }
2376        });
2377        opts
2378    }
2379
2380    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2381    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2382        if let Some(hint) = self.inventory_hints.get(template_id) {
2383            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2384                return Some(v);
2385            }
2386        }
2387        if let Some(v) = self
2388            .inventory_stacks
2389            .iter()
2390            .find(|s| s.template_id == template_id)
2391            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2392        {
2393            return Some(v);
2394        }
2395        self.market_panel.as_ref().and_then(|panel| {
2396            panel.list_vaults.iter().find_map(|vault| {
2397                vault.contents.iter().find_map(|stack| {
2398                    (stack.template_id == template_id)
2399                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2400                        .flatten()
2401                })
2402            })
2403        })
2404    }
2405
2406    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2407    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2408        let base = self.item_base_value_copper_hint(template_id)?;
2409        npc_market_dump_unit_estimate_copper(base)
2410    }
2411
2412    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2413        if crate::currency::is_currency(&stack.template_id) {
2414            return false;
2415        }
2416        if let Some(flag) = stack.listable {
2417            return flag;
2418        }
2419        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2420            return hint.listable;
2421        }
2422        let cat = stack
2423            .category
2424            .as_deref()
2425            .or_else(|| self.inventory_item_category(&stack.template_id))
2426            .unwrap_or("");
2427        category_default_listable(cat)
2428    }
2429
2430    /// Category group labels present in the current browse book or list-pick source.
2431    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2432        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2433        match &self.market_ui_mode {
2434            MarketUiMode::ListPick { source, .. } => {
2435                let raw: Vec<_> = match source {
2436                    MarketListSourceKind::Person => self
2437                        .person_rows()
2438                        .into_iter()
2439                        .filter(|r| r.depth == 0)
2440                        .filter(|r| self.stack_is_market_listable(&r.stack))
2441                        .filter(|r| {
2442                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2443                        })
2444                        .map(|r| {
2445                            r.stack
2446                                .category
2447                                .clone()
2448                                .or_else(|| {
2449                                    self.inventory_item_category(&r.stack.template_id)
2450                                        .map(str::to_string)
2451                                })
2452                                .unwrap_or_default()
2453                        })
2454                        .collect(),
2455                    MarketListSourceKind::TownStorage { building_id } => self
2456                        .market_panel
2457                        .as_ref()
2458                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2459                        .map(|vault| {
2460                            vault
2461                                .contents
2462                                .iter()
2463                                .filter(|s| self.stack_is_market_listable(s))
2464                                .filter(|s| {
2465                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2466                                })
2467                                .map(|s| {
2468                                    s.category
2469                                        .clone()
2470                                        .or_else(|| {
2471                                            self.inventory_item_category(&s.template_id)
2472                                                .map(str::to_string)
2473                                        })
2474                                        .unwrap_or_default()
2475                                })
2476                                .collect::<Vec<_>>()
2477                        })
2478                        .unwrap_or_default(),
2479                };
2480                for category in raw {
2481                    let (label, ord) = inventory_category_group(&category);
2482                    seen.insert(ord, label);
2483                }
2484            }
2485            _ => {
2486                if let Some(panel) = &self.market_panel {
2487                    for listing in &panel.listings {
2488                        if !list_label_matches(&listing.display_name, &self.market_filter)
2489                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2490                        {
2491                            continue;
2492                        }
2493                        let (label, ord) = inventory_category_group(&listing.category);
2494                        seen.insert(ord, label);
2495                    }
2496                }
2497            }
2498        }
2499        seen.into_values().collect()
2500    }
2501
2502    /// Indices into `market_panel.listings` after category + text filter.
2503    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2504        let Some(panel) = &self.market_panel else {
2505            return Vec::new();
2506        };
2507        let filter = self.market_filter.as_str();
2508        let cat_filter = self.market_category_filter;
2509        panel
2510            .listings
2511            .iter()
2512            .enumerate()
2513            .filter(|(_, listing)| {
2514                if !list_label_matches(&listing.display_name, filter)
2515                    && !list_label_matches(&listing.seller_label, filter)
2516                    && !list_label_matches(&listing.template_id, filter)
2517                {
2518                    return false;
2519                }
2520                if let Some(group) = cat_filter {
2521                    inventory_category_group(&listing.category).0 == group
2522                } else {
2523                    true
2524                }
2525            })
2526            .map(|(i, _)| i)
2527            .collect()
2528    }
2529
2530    pub fn clear_harvest_state(&mut self) {
2531        self.harvest_in_progress = false;
2532        self.harvest_started_at = None;
2533    }
2534
2535    fn harvest_state_stale(&self) -> bool {
2536        match self.harvest_started_at {
2537            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2538            None => self.harvest_in_progress,
2539        }
2540    }
2541
2542    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2543        self.player.as_ref().and_then(|p| p.vitals)
2544    }
2545
2546    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2547        let materials_ok = blueprint.inputs.iter().all(|input| {
2548            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2549        });
2550        let tools_ok = blueprint
2551            .required_tools
2552            .iter()
2553            .all(|tool| self.player_has_craft_tool(&tool.item));
2554        let station_ok = match blueprint.station.as_deref() {
2555            None | Some("hand") => true,
2556            Some(tag) => self.player_at_station_tag(tag),
2557        };
2558        materials_ok
2559            && tools_ok
2560            && station_ok
2561            && self.craft_has_vessel_room_for_output(blueprint)
2562    }
2563
2564    /// Inventory/worn **or** a nearby placed furniture tool in the same space.
2565    pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2566        if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2567            return true;
2568        }
2569        let Some(player) = self.player.as_ref() else {
2570            return false;
2571        };
2572        let px = player.transform.position.x;
2573        let py = player.transform.position.y;
2574        // Match sim `CONTAINER_INTERACTION_RADIUS_M`.
2575        const RANGE: f32 = 3.0;
2576        self.placed_containers.iter().any(|c| {
2577            if c.template_id != tool_template {
2578                return false;
2579            }
2580            if !self.placed_container_in_current_space(c) {
2581                return false;
2582            }
2583            let dx = c.x - px;
2584            let dy = c.y - py;
2585            dx * dx + dy * dy <= RANGE * RANGE
2586        })
2587    }
2588
2589    /// Bulk/liquid craft outputs need an empty (or matching) vessel after inputs drain.
2590    fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2591        matches!(
2592            self.inventory_item_category(&blueprint.output),
2593            Some("bulk") | Some("liquid")
2594        ) || matches!(
2595            blueprint.output.as_str(),
2596            "dirt" | "mud" | "sand" | "water" | "milk"
2597        )
2598    }
2599
2600    fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2601        self.inventory_item_category(&blueprint.output).or_else(|| {
2602            match blueprint.output.as_str() {
2603                "dirt" | "mud" | "sand" => Some("bulk"),
2604                "water" | "milk" => Some("liquid"),
2605                _ => None,
2606            }
2607        })
2608    }
2609
2610    fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2611        if !self.craft_output_needs_vessel(blueprint) {
2612            return true;
2613        }
2614        let need = blueprint.output_qty.max(1);
2615        self.vessel_room_after_craft_inputs(blueprint) >= need
2616    }
2617
2618    /// Free capacity in vessels that can hold `blueprint.output` after consuming inputs.
2619    fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2620        let mut stacks = self.inventory_stacks.clone();
2621        for worn in self.worn.values() {
2622            stacks.push(worn.clone());
2623        }
2624        for input in &blueprint.inputs {
2625            let mut left = input.quantity;
2626            drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2627            if left > 0 {
2628                return 0;
2629            }
2630        }
2631        vessel_room_for_payload_in_stacks(
2632            &stacks,
2633            &blueprint.output,
2634            self.craft_output_category(blueprint),
2635        )
2636    }
2637
2638    /// Vessel inventory transparency for the craft detail panel.
2639    pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2640        let output_label = self.blueprint_output_label(blueprint);
2641        let needs_vessel = self.craft_output_needs_vessel(blueprint);
2642        let need_units = if needs_vessel {
2643            blueprint.output_qty.max(1)
2644        } else {
2645            0
2646        };
2647        let free_after_inputs = if needs_vessel {
2648            self.vessel_room_after_craft_inputs(blueprint)
2649        } else {
2650            0
2651        };
2652        let payload_cat = self.craft_output_category(blueprint);
2653        let mut vessels = Vec::new();
2654        Self::collect_craft_vessel_lines(
2655            &self.inventory_stacks,
2656            "pack",
2657            &blueprint.output,
2658            payload_cat,
2659            &mut vessels,
2660        );
2661        for worn in self.worn.values() {
2662            Self::collect_craft_vessel_lines(
2663                std::slice::from_ref(worn),
2664                "worn",
2665                &blueprint.output,
2666                payload_cat,
2667                &mut vessels,
2668            );
2669        }
2670        CraftVesselStatus {
2671            needs_vessel,
2672            output_label,
2673            need_units,
2674            free_after_inputs,
2675            ok: !needs_vessel || free_after_inputs >= need_units,
2676            vessels,
2677        }
2678    }
2679
2680    fn collect_craft_vessel_lines(
2681        stacks: &[flatland_protocol::ItemStack],
2682        location: &'static str,
2683        payload_id: &str,
2684        payload_category: Option<&str>,
2685        out: &mut Vec<CraftVesselLine>,
2686    ) {
2687        for stack in stacks {
2688            if is_serving_vessel_stack(stack) {
2689                let cap = serving_capacity_of(stack);
2690                let used = payload_units_in_vessel(stack);
2691                let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2692                let holds = stack
2693                    .props
2694                    .get("serving_holds")
2695                    .cloned()
2696                    .unwrap_or_else(|| {
2697                        if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2698                            && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2699                        {
2700                            "liquid,bulk".into()
2701                        } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2702                            "bulk".into()
2703                        } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2704                            "liquid".into()
2705                        } else {
2706                            "?".into()
2707                        }
2708                    });
2709                let label = stack
2710                    .display_name
2711                    .clone()
2712                    .unwrap_or_else(|| stack.template_id.clone());
2713                out.push(CraftVesselLine {
2714                    label,
2715                    holds,
2716                    capacity: cap,
2717                    used,
2718                    free,
2719                    quantity: stack.quantity.max(1),
2720                    accepts_output: free > 0,
2721                    location,
2722                });
2723            }
2724            Self::collect_craft_vessel_lines(
2725                &stack.contents,
2726                location,
2727                payload_id,
2728                payload_category,
2729                out,
2730            );
2731        }
2732    }
2733
2734    fn craft_prefs_key(&self) -> String {
2735        if let Some(cid) = self.character_id {
2736            cid.to_string()
2737        } else if self.entity_id != 0 {
2738            format!("entity:{}", self.entity_id)
2739        } else {
2740            String::new()
2741        }
2742    }
2743
2744    pub fn reload_craft_prefs(&mut self) {
2745        let key = self.craft_prefs_key();
2746        self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2747    }
2748
2749    fn persist_craft_prefs(&self) {
2750        crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2751    }
2752
2753    /// Distinct craft tiers present in known blueprints (sorted ascending).
2754    pub fn craft_known_tiers(&self) -> Vec<u32> {
2755        let mut tiers: Vec<u32> = self
2756            .blueprints
2757            .iter()
2758            .map(|bp| bp.craft_tier.max(1))
2759            .collect::<std::collections::BTreeSet<_>>()
2760            .into_iter()
2761            .collect();
2762        tiers.sort_unstable();
2763        tiers
2764    }
2765
2766    /// Tab strip order: Ready, ★, Recent, then T1..Tn that have known recipes.
2767    pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2768        let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2769        for t in self.craft_known_tiers() {
2770            tabs.push(CraftTab::Tier(t));
2771        }
2772        tabs
2773    }
2774
2775    pub fn craft_set_tab(&mut self, tab: CraftTab) {
2776        self.craft_tab = tab;
2777        self.craft_menu_index = 0;
2778        self.clamp_craft_menu_index();
2779        self.clamp_craft_batch_quantity();
2780    }
2781
2782    pub fn craft_cycle_tab(&mut self, delta: i32) {
2783        let tabs = self.craft_tab_strip();
2784        if tabs.is_empty() {
2785            return;
2786        }
2787        let cur = tabs
2788            .iter()
2789            .position(|t| *t == self.craft_tab)
2790            .unwrap_or(0) as i32;
2791        let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2792        self.craft_set_tab(tabs[next]);
2793    }
2794
2795    pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2796        let f = self.craft_filter.trim();
2797        if f.is_empty() {
2798            return true;
2799        }
2800        if list_label_matches(&bp.label, f)
2801            || list_label_matches(&bp.output, f)
2802            || list_label_matches(&bp.output_display_name, f)
2803            || bp
2804                .category
2805                .as_deref()
2806                .is_some_and(|c| list_label_matches(c, f))
2807            || bp
2808                .station
2809                .as_deref()
2810                .is_some_and(|s| list_label_matches(s, f))
2811        {
2812            return true;
2813        }
2814        bp.inputs.iter().any(|i| {
2815            list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2816        }) || bp.required_tools.iter().any(|t| {
2817            list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f)
2818        })
2819    }
2820
2821    /// True while this blueprint's craft channel is running (Ready-tab pin).
2822    pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2823        self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2824            && self.active_craft_channel().is_some()
2825    }
2826
2827    /// Blueprint indices for the active tab + search, ready-first then A–Z.
2828    pub fn craft_filtered_indices(&self) -> Vec<usize> {
2829        let mut idxs: Vec<usize> = (0..self.blueprints.len())
2830            .filter(|&i| {
2831                let bp = &self.blueprints[i];
2832                if !self.craft_matches_search(bp) {
2833                    return false;
2834                }
2835                match self.craft_tab {
2836                    CraftTab::Ready => {
2837                        self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2838                    }
2839                    CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2840                    CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2841                    CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2842                }
2843            })
2844            .collect();
2845        match self.craft_tab {
2846            CraftTab::Recent => {
2847                idxs.sort_by_key(|&i| {
2848                    self.craft_prefs
2849                        .recent
2850                        .iter()
2851                        .position(|id| id == &self.blueprints[i].id)
2852                        .unwrap_or(usize::MAX)
2853                });
2854            }
2855            _ => {
2856                idxs.sort_by(|&a, &b| {
2857                    let ba = &self.blueprints[a];
2858                    let bb = &self.blueprints[b];
2859                    let ia = self.craft_blueprint_in_channel(&ba.id);
2860                    let ib = self.craft_blueprint_in_channel(&bb.id);
2861                    // In-progress first, then craftable, then A–Z.
2862                    ib.cmp(&ia)
2863                        .then_with(|| {
2864                            let ra = self.can_craft_blueprint(ba);
2865                            let rb = self.can_craft_blueprint(bb);
2866                            rb.cmp(&ra)
2867                        })
2868                        .then_with(|| ba.label.to_ascii_lowercase().cmp(&bb.label.to_ascii_lowercase()))
2869                });
2870            }
2871        }
2872        idxs
2873    }
2874
2875    pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2876        let idxs = self.craft_filtered_indices();
2877        idxs.get(self.craft_menu_index)
2878            .and_then(|&i| self.blueprints.get(i))
2879    }
2880
2881    pub fn clamp_craft_menu_index(&mut self) {
2882        let n = self.craft_filtered_indices().len();
2883        if n == 0 {
2884            self.craft_menu_index = 0;
2885        } else {
2886            self.craft_menu_index = self.craft_menu_index.min(n - 1);
2887        }
2888    }
2889
2890    pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2891        self.craft_prefs.is_favorite(blueprint_id)
2892    }
2893
2894    pub fn craft_toggle_favorite_selected(&mut self) {
2895        let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2896            return;
2897        };
2898        self.craft_prefs.toggle_favorite(&id);
2899        self.persist_craft_prefs();
2900        if matches!(self.craft_tab, CraftTab::Favorites) {
2901            self.clamp_craft_menu_index();
2902        }
2903    }
2904
2905    pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2906        self.craft_prefs.record_crafted(blueprint_id);
2907        self.persist_craft_prefs();
2908    }
2909
2910    pub fn focus_craft_filter(&mut self) {
2911        self.craft_filter_focused = true;
2912    }
2913
2914    pub fn append_craft_filter_char(&mut self, ch: char) {
2915        if !self.craft_filter_focused {
2916            return;
2917        }
2918        if is_list_filter_char(ch) {
2919            self.craft_filter.push(ch);
2920            self.craft_menu_index = 0;
2921            self.clamp_craft_menu_index();
2922        }
2923    }
2924
2925    pub fn craft_filter_backspace(&mut self) {
2926        if !self.craft_filter_focused {
2927            return;
2928        }
2929        self.craft_filter.pop();
2930        self.craft_menu_index = 0;
2931        self.clamp_craft_menu_index();
2932    }
2933
2934    /// Esc while filter focused: clear then blur. Returns true if handled.
2935    pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2936        if self.craft_filter_focused {
2937            if !self.craft_filter.is_empty() {
2938                self.craft_filter.clear();
2939                self.craft_menu_index = 0;
2940                self.clamp_craft_menu_index();
2941            } else {
2942                self.craft_filter_focused = false;
2943            }
2944            return true;
2945        }
2946        if !self.craft_filter.is_empty() {
2947            self.craft_filter.clear();
2948            self.craft_menu_index = 0;
2949            self.clamp_craft_menu_index();
2950            return true;
2951        }
2952        false
2953    }
2954
2955    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2956        if !self.can_craft_blueprint(blueprint) {
2957            return 0;
2958        }
2959        let mut limit = u32::MAX;
2960        for input in &blueprint.inputs {
2961            if input.quantity == 0 {
2962                continue;
2963            }
2964            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2965            limit = limit.min(have / input.quantity);
2966        }
2967        for tool in &blueprint.required_tools {
2968            if tool.consumed {
2969                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2970                limit = limit.min(have);
2971            }
2972        }
2973        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2974        if CRAFT_STAMINA_COST > 0.0 {
2975            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2976        }
2977        if self.craft_output_needs_vessel(blueprint) {
2978            let need = blueprint.output_qty.max(1);
2979            let room = self.vessel_room_after_craft_inputs(blueprint);
2980            if need > 0 {
2981                limit = limit.min(room / need);
2982            }
2983        }
2984        limit
2985    }
2986
2987    pub fn clamp_craft_batch_quantity(&mut self) {
2988        let Some(bp) = self.craft_selected_blueprint().cloned() else {
2989            self.craft_batch_quantity = 1;
2990            return;
2991        };
2992        let max = self.max_craft_batches(&bp).max(1);
2993        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2994    }
2995
2996    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2997        let Some(bp) = self.craft_selected_blueprint().cloned() else {
2998            return;
2999        };
3000        let max = self.max_craft_batches(&bp).max(1);
3001        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3002        self.craft_batch_quantity = next as u32;
3003    }
3004
3005    pub fn craft_batch_set_max(&mut self) {
3006        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3007            return;
3008        };
3009        let max = self.max_craft_batches(&bp);
3010        self.craft_batch_quantity = if max == 0 { 1 } else { max };
3011    }
3012
3013    pub fn craft_batch_set_min(&mut self) {
3014        self.craft_batch_quantity = 1;
3015    }
3016
3017    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3018        let preserve_ui = self.show_shop_menu;
3019        let tab = self.shop_tab;
3020        let index = self.shop_menu_index;
3021        let qty = self.shop_quantity;
3022
3023        self.show_shop_menu = true;
3024        self.bank_panel = None;
3025        self.show_craft_menu = false;
3026        self.show_inventory_menu = false;
3027        self.show_stats = false;
3028        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3029            self.npc_verb_target = Some(catalog.npc_id.clone());
3030        }
3031        self.shop_catalog = Some(catalog);
3032
3033        if preserve_ui {
3034            self.shop_tab = tab;
3035            self.shop_menu_index = index;
3036            self.shop_quantity = qty;
3037        } else {
3038            self.shop_tab = ShopTab::Buy;
3039            self.shop_menu_index = 0;
3040            self.shop_quantity = 1;
3041            self.clear_shop_trade_log();
3042        }
3043        self.show_npc_verb_menu = false;
3044        self.clamp_shop_selection();
3045    }
3046
3047    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3048        let same_teller = self
3049            .bank_panel
3050            .as_ref()
3051            .is_some_and(|p| p.npc_id == panel.npc_id);
3052        self.bank_panel = Some(panel);
3053        self.storage_panel = None;
3054        self.market_panel = None;
3055        self.shop_catalog = None;
3056        self.show_shop_menu = false;
3057        self.show_craft_menu = false;
3058        self.show_inventory_menu = false;
3059        self.show_stats = false;
3060        self.show_npc_verb_menu = false;
3061        self.show_npc_chat = false;
3062        self.npc_chat = None;
3063        if !same_teller {
3064            self.bank_menu_index = 0;
3065            self.bank_ui_mode = BankUiMode::Menu;
3066        }
3067        if let Some(panel) = &self.bank_panel {
3068            if self.npc_verb_target.is_none() {
3069                self.npc_verb_target = Some(panel.npc_id.clone());
3070            }
3071        }
3072    }
3073
3074    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3075        let same_manager = self
3076            .storage_panel
3077            .as_ref()
3078            .is_some_and(|p| p.npc_id == panel.npc_id);
3079        self.storage_panel = Some(panel);
3080        self.bank_panel = None;
3081        self.market_panel = None;
3082        self.bank_ui_mode = BankUiMode::Menu;
3083        self.shop_catalog = None;
3084        self.show_shop_menu = false;
3085        self.show_craft_menu = false;
3086        self.show_inventory_menu = false;
3087        self.show_stats = false;
3088        self.show_npc_verb_menu = false;
3089        self.show_npc_chat = false;
3090        self.npc_chat = None;
3091        if !same_manager {
3092            self.storage_menu_index = 0;
3093            self.storage_ui_mode = StorageUiMode::Menu;
3094        } else {
3095            self.clamp_storage_pick_index();
3096        }
3097        if let Some(panel) = &self.storage_panel {
3098            if self.npc_verb_target.is_none() {
3099                self.npc_verb_target = Some(panel.npc_id.clone());
3100            }
3101        }
3102    }
3103
3104    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3105        for vault in &panel.list_vaults {
3106            self.merge_stack_catalog_hints(&vault.contents);
3107        }
3108        self.market_panel = Some(panel);
3109        self.bank_panel = None;
3110        self.storage_panel = None;
3111        self.shop_catalog = None;
3112        self.show_shop_menu = false;
3113        self.show_craft_menu = false;
3114        self.show_inventory_menu = false;
3115        self.show_stats = false;
3116        self.show_npc_verb_menu = false;
3117        self.show_npc_chat = false;
3118        self.npc_chat = None;
3119        self.market_menu_index = 0;
3120        self.market_buy_confirm = None;
3121        self.market_ui_mode = MarketUiMode::Browse;
3122        self.market_filter.clear();
3123        self.market_filter_focused = false;
3124        self.market_category_filter = None;
3125        if let Some(panel) = &self.market_panel {
3126            if self.npc_verb_target.is_none() {
3127                self.npc_verb_target = Some(panel.npc_id.clone());
3128            }
3129        }
3130    }
3131
3132    pub fn clear_market_panel(&mut self) {
3133        self.market_panel = None;
3134        self.market_menu_index = 0;
3135        self.market_buy_confirm = None;
3136        self.market_ui_mode = MarketUiMode::Browse;
3137        self.market_filter.clear();
3138        self.market_filter_focused = false;
3139        self.market_category_filter = None;
3140    }
3141
3142    pub fn clear_bank_panel(&mut self) {
3143        self.bank_panel = None;
3144        self.bank_menu_index = 0;
3145        self.bank_ui_mode = BankUiMode::Menu;
3146    }
3147
3148    pub fn clear_storage_panel(&mut self) {
3149        self.storage_panel = None;
3150        self.storage_menu_index = 0;
3151        self.storage_ui_mode = StorageUiMode::Menu;
3152    }
3153
3154    fn clamp_storage_pick_index(&mut self) {
3155        match &self.storage_ui_mode {
3156            StorageUiMode::StorePick { index } => {
3157                let n = self.storage_store_options().len();
3158                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3159                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3160            }
3161            StorageUiMode::TakePick { index } => {
3162                let n = self.storage_vault_options().len();
3163                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3164                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3165            }
3166            StorageUiMode::ShipPick {
3167                dest_building_id,
3168                dest_label,
3169                index,
3170            } => {
3171                let n = self.storage_vault_options().len();
3172                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3173                self.storage_ui_mode = StorageUiMode::ShipPick {
3174                    dest_building_id: dest_building_id.clone(),
3175                    dest_label: dest_label.clone(),
3176                    index: next,
3177                };
3178            }
3179            StorageUiMode::Menu
3180            | StorageUiMode::StoreAmount { .. }
3181            | StorageUiMode::TakeAmount { .. }
3182            | StorageUiMode::ShipAmount { .. } => {}
3183        }
3184    }
3185
3186    pub fn shop_list_len(&self) -> usize {
3187        let Some(catalog) = &self.shop_catalog else {
3188            return 0;
3189        };
3190        match self.shop_tab {
3191            ShopTab::Buy => catalog.sells.len(),
3192            ShopTab::Sell => catalog.buys.len(),
3193        }
3194    }
3195
3196    pub fn shop_menu_move(&mut self, delta: i32) {
3197        let n = self.shop_list_len();
3198        if n == 0 {
3199            return;
3200        }
3201        let idx = self.shop_menu_index as i32;
3202        let next = (idx + delta).rem_euclid(n as i32);
3203        self.shop_menu_index = next as usize;
3204        self.clamp_shop_quantity();
3205    }
3206
3207    pub fn shop_quantity_adjust(&mut self, delta: i32) {
3208        let max = self.shop_quantity_max();
3209        if max == 0 {
3210            self.shop_quantity = 0;
3211            return;
3212        }
3213        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3214        self.shop_quantity = next as u32;
3215    }
3216
3217    pub(crate) fn clamp_shop_selection(&mut self) {
3218        let n = self.shop_list_len();
3219        if n == 0 {
3220            self.shop_menu_index = 0;
3221        } else {
3222            self.shop_menu_index = self.shop_menu_index.min(n - 1);
3223        }
3224        self.clamp_shop_quantity();
3225    }
3226
3227    fn shop_quantity_max(&self) -> u32 {
3228        let Some(catalog) = &self.shop_catalog else {
3229            return 1;
3230        };
3231        match self.shop_tab {
3232            ShopTab::Buy => {
3233                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3234                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3235                        return 1;
3236                    }
3237                }
3238                99
3239            }
3240            ShopTab::Sell => catalog
3241                .buys
3242                .get(self.shop_menu_index)
3243                .map(|l| l.quantity)
3244                .unwrap_or(0),
3245        }
3246    }
3247
3248    pub fn shop_quantity_set_max(&mut self) {
3249        self.shop_quantity = self.shop_quantity_max();
3250    }
3251
3252    pub fn shop_quantity_set_min(&mut self) {
3253        let max = self.shop_quantity_max();
3254        self.shop_quantity = if max == 0 { 0 } else { 1 };
3255    }
3256
3257    fn clamp_shop_quantity(&mut self) {
3258        let max = self.shop_quantity_max();
3259        if max == 0 {
3260            self.shop_quantity = 0;
3261        } else {
3262            self.shop_quantity = self.shop_quantity.max(1).min(max);
3263        }
3264    }
3265
3266    pub fn player_at_station_tag(&self, tag: &str) -> bool {
3267        let Some(id) = self.effective_inside_building() else {
3268            return false;
3269        };
3270        self.buildings
3271            .iter()
3272            .find(|b| b.id == id)
3273            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3274    }
3275
3276    /// Short hint for UI when a recipe cannot be started.
3277    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3278        if self.can_craft_blueprint(blueprint) {
3279            return None;
3280        }
3281        let mut missing = Vec::new();
3282        for input in &blueprint.inputs {
3283            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3284            if have < input.quantity {
3285                let name = self.blueprint_ingredient_label(input);
3286                let vessel_note = if self.inventory_item_category(&input.template_id)
3287                    == Some("liquid")
3288                    || matches!(input.template_id.as_str(), "water" | "milk")
3289                {
3290                    "; fill a bottle/waterskin"
3291                } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3292                    || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3293                {
3294                    "; scoop into a sack/bucket"
3295                } else {
3296                    ""
3297                };
3298                missing.push(format!(
3299                    "{}×{} (have {have}{vessel_note})",
3300                    input.quantity, name
3301                ));
3302            }
3303        }
3304        for tool in &blueprint.required_tools {
3305            if !self.player_has_craft_tool(&tool.item) {
3306                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3307            }
3308        }
3309        if let Some(station) = blueprint.station.as_deref() {
3310            if station != "hand" && !self.player_at_station_tag(station) {
3311                missing.push(format!("station: {station} (enter building)"));
3312            }
3313        }
3314        if self.craft_output_needs_vessel(blueprint) && !self.craft_has_vessel_room_for_output(blueprint)
3315        {
3316            let name = self
3317                .inventory_hints
3318                .get(&blueprint.output)
3319                .map(|h| h.display_name.as_str())
3320                .unwrap_or(blueprint.output.as_str());
3321            let need = blueprint.output_qty.max(1);
3322            let free = self.vessel_room_after_craft_inputs(blueprint);
3323            let accepting = self
3324                .craft_vessel_status(blueprint)
3325                .vessels
3326                .iter()
3327                .filter(|v| v.accepts_output)
3328                .count();
3329            missing.push(format!(
3330                "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3331            ));
3332        }
3333        if missing.is_empty() {
3334            None
3335        } else {
3336            Some(missing.join(", "))
3337        }
3338    }
3339
3340    /// In-progress inventory craft (n-menu), if any.
3341    pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3342        self.timed_channel
3343            .as_ref()
3344            .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3345    }
3346
3347    pub fn player_entity(&self) -> Option<&EntityState> {
3348        self.player
3349            .as_ref()
3350            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3351    }
3352
3353    /// Apply persisted HUD / workers UI prefs from `client.json`.
3354    pub fn apply_client_ui_prefs(&mut self) {
3355        let cfg = crate::client_config::ClientConfig::load();
3356        if let Some(hidden) = cfg.hud_log_hidden {
3357            self.hud_log_hidden = hidden;
3358        }
3359        if let Some(compact) = cfg.workers_menu_compact {
3360            self.workers_menu_compact = compact;
3361        }
3362    }
3363
3364    pub fn player_position(&self) -> (f32, f32) {
3365        let (x, y, _) = self.player_position_with_z();
3366        (x, y)
3367    }
3368
3369    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3370        if let Some(p) = self.player_entity() {
3371            (
3372                p.transform.position.x,
3373                p.transform.position.y,
3374                p.transform.position.z,
3375            )
3376        } else {
3377            (0.0, 0.0, 0.0)
3378        }
3379    }
3380
3381    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3382        let mut rows: Vec<(String, u32, String)> = self
3383            .inventory
3384            .iter()
3385            .filter(|(_, q)| **q > 0)
3386            .map(|(id, qty)| {
3387                let label = self
3388                    .inventory_hints
3389                    .get(id)
3390                    .map(|h| h.display_name.clone())
3391                    .unwrap_or_else(|| id.clone());
3392                (id.clone(), *qty, label)
3393            })
3394            .collect();
3395        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3396        rows
3397    }
3398
3399    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3400        self.inventory_hints
3401            .get(template_id)
3402            .map(|h| h.category.as_str())
3403            .filter(|c| !c.is_empty())
3404    }
3405
3406    pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3407        stack.props.get("serving").is_some_and(|v| v == "1")
3408            || Self::stack_is_liquid_vessel(stack)
3409    }
3410
3411    pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3412        stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3413            || stack.props.get("serving_holds").is_some_and(|v| {
3414                v.split(',').any(|p| p.trim() == "liquid")
3415            })
3416    }
3417
3418    pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3419        stack.props.get("serving_holds").is_some_and(|v| {
3420            v.split(',').any(|p| p.trim() == "food")
3421        })
3422    }
3423
3424    pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3425        stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3426            || stack.props.get("serving_holds").is_some_and(|v| {
3427                v.split(',').any(|p| p.trim() == "bulk")
3428            })
3429    }
3430
3431    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3432        stack
3433            .props
3434            .get("grants_item_status_effect")
3435            .map(|s| !s.is_empty())
3436            .unwrap_or(false)
3437    }
3438
3439    pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3440        stack
3441            .props
3442            .get("teaches_blueprint")
3443            .map(|s| !s.trim().is_empty())
3444            .unwrap_or(false)
3445    }
3446
3447    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3448        stack
3449            .props
3450            .get("grants_item_status_effect")
3451            .map(String::as_str)
3452            .filter(|s| !s.is_empty())
3453    }
3454
3455    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3456        stack
3457            .props
3458            .get("grants_item_status_mode")
3459            .map(String::as_str)
3460            .unwrap_or("on_hit")
3461    }
3462
3463    /// Candidate gear for a grant consumable (inventory + worn).
3464    pub fn grant_target_options(
3465        &self,
3466        grant: &flatland_protocol::ItemStack,
3467    ) -> Vec<GrantTargetOption> {
3468        let mode = Self::grant_mode(grant);
3469        let grant_tags: Vec<&str> = grant
3470            .props
3471            .get("grants_item_status_tags")
3472            .map(|s| {
3473                s.split(',')
3474                    .map(str::trim)
3475                    .filter(|t| !t.is_empty())
3476                    .collect()
3477            })
3478            .unwrap_or_default();
3479        let grant_id = grant.item_instance_id;
3480        let mut out = Vec::new();
3481        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3482            let Some(iid) = stack.item_instance_id else {
3483                return;
3484            };
3485            if Some(iid) == grant_id {
3486                return;
3487            }
3488            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3489                return;
3490            }
3491            if !grant_target_matches_mode(stack, mode) {
3492                return;
3493            }
3494            if !grant_tags_match(stack, &grant_tags) {
3495                return;
3496            }
3497            let name = stack
3498                .display_name
3499                .clone()
3500                .unwrap_or_else(|| stack.template_id.clone());
3501            let bindings = if stack.status_bindings.is_empty() {
3502                String::new()
3503            } else {
3504                format!(
3505                    " · {}",
3506                    stack
3507                        .status_bindings
3508                        .iter()
3509                        .map(|b| b.effect_id.as_str())
3510                        .collect::<Vec<_>>()
3511                        .join(", ")
3512                )
3513            };
3514            out.push(GrantTargetOption {
3515                label: format!("{where_label}: {name}{bindings}"),
3516                target_instance_id: iid,
3517            });
3518        };
3519        fn walk(
3520            stacks: &[flatland_protocol::ItemStack],
3521            where_label: &str,
3522            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3523        ) {
3524            for s in stacks {
3525                push(s, where_label);
3526                if !s.contents.is_empty() {
3527                    let nested = format!(
3528                        "{where_label}/{}",
3529                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3530                    );
3531                    walk(&s.contents, &nested, push);
3532                }
3533            }
3534        }
3535        walk(&self.inventory_stacks, "Bag", &mut push);
3536        for (slot, stack) in &self.worn {
3537            push(stack, body_slot_label(*slot));
3538            let nest = format!(
3539                "{}/{}",
3540                body_slot_label(*slot),
3541                stack
3542                    .display_name
3543                    .as_deref()
3544                    .unwrap_or(stack.template_id.as_str())
3545            );
3546            walk(&stack.contents, &nest, &mut push);
3547        }
3548        out
3549    }
3550
3551    pub fn item_base_mass(&self, template_id: &str) -> f32 {
3552        self.inventory_hints
3553            .get(template_id)
3554            .and_then(|h| h.base_mass)
3555            .unwrap_or(0.5)
3556    }
3557
3558    pub fn item_base_volume(&self, template_id: &str) -> f32 {
3559        self.inventory_hints
3560            .get(template_id)
3561            .and_then(|h| h.base_volume)
3562            .unwrap_or(1.0)
3563    }
3564
3565    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3566        let unit = stack
3567            .base_mass
3568            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3569        unit * stack.quantity as f32
3570    }
3571
3572    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3573        let unit = stack.base_volume.unwrap_or(1.0);
3574        unit * stack.quantity as f32
3575            + stack
3576                .contents
3577                .iter()
3578                .map(Self::stack_tree_volume)
3579                .sum::<f32>()
3580    }
3581
3582    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3583        contents.iter().map(Self::stack_tree_volume).sum()
3584    }
3585
3586    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3587        self.inventory_hints
3588            .get(template_id)
3589            .and_then(|h| h.capacity_volume)
3590            .filter(|c| *c > 0.0)
3591    }
3592
3593    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3594        stack
3595            .capacity_volume
3596            .filter(|c| *c > 0.0)
3597            .or_else(|| self.template_capacity_volume(&stack.template_id))
3598    }
3599
3600    /// Volume used / capacity / free space label for storage containers in the inventory UI.
3601    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3602        let Some((used, cap)) = self.container_volume_stats(row) else {
3603            return String::new();
3604        };
3605        let free = (cap - used).max(0.0);
3606        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
3607    }
3608
3609    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3610        if row.is_chest_shell {
3611            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3612                return None;
3613            };
3614            let chest = self
3615                .placed_containers
3616                .iter()
3617                .find(|c| c.id == *container_id)?;
3618            let cap = self
3619                .stack_capacity_volume(&row.stack)
3620                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3621            let used = if chest.accessible {
3622                Self::contents_used_volume(&chest.contents)
3623            } else {
3624                0.0
3625            };
3626            return Some((used, cap));
3627        }
3628
3629        let cap = self.stack_capacity_volume(&row.stack)?;
3630        let used = Self::contents_used_volume(&row.stack.contents);
3631        Some((used, cap))
3632    }
3633
3634    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3635        if row.is_chest_shell {
3636            return true;
3637        }
3638        if row.is_equip_shell {
3639            return self.inventory_item_category(&row.stack.template_id) == Some("container");
3640        }
3641        self.inventory_item_category(&row.stack.template_id) == Some("container")
3642            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3643    }
3644
3645    fn container_stack_for(
3646        &self,
3647        location: &flatland_protocol::InventoryLocation,
3648        parent_instance_id: Option<uuid::Uuid>,
3649    ) -> Option<flatland_protocol::ItemStack> {
3650        match location {
3651            flatland_protocol::InventoryLocation::Root => {
3652                let pid = parent_instance_id?;
3653                self.find_stack_by_instance(&self.inventory_stacks, pid)
3654            }
3655            flatland_protocol::InventoryLocation::Worn { slot } => {
3656                let worn = self.worn.get(slot)?;
3657                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3658                    Some(worn.clone())
3659                } else {
3660                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3661                }
3662            }
3663            flatland_protocol::InventoryLocation::Placed { container_id } => {
3664                let chest = self
3665                    .placed_containers
3666                    .iter()
3667                    .find(|c| c.id == *container_id)?;
3668                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3669                    Some(flatland_protocol::ItemStack {
3670                        template_id: chest.template_id.clone(),
3671                        quantity: 1,
3672                        item_instance_id: chest.item_instance_id,
3673                        props: Default::default(),
3674                        status_bindings: Vec::new(),
3675                        contents: chest.contents.clone(),
3676                        display_name: Some(chest.display_name.clone()),
3677                        category: Some("container".into()),
3678                        capacity_volume: self
3679                            .inventory_hints
3680                            .get(&chest.template_id)
3681                            .and_then(|h| h.capacity_volume),
3682                        worker_lodging_capacity: chest.worker_lodging_capacity,
3683                        ..Default::default()
3684                    })
3685                } else {
3686                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3687                }
3688            }
3689            flatland_protocol::InventoryLocation::Keychain => None,
3690            flatland_protocol::InventoryLocation::WhisperPouch => None,
3691        }
3692    }
3693
3694    fn find_stack_by_instance(
3695        &self,
3696        stacks: &[flatland_protocol::ItemStack],
3697        instance_id: uuid::Uuid,
3698    ) -> Option<flatland_protocol::ItemStack> {
3699        for stack in stacks {
3700            if stack.item_instance_id == Some(instance_id) {
3701                return Some(stack.clone());
3702            }
3703            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3704                return Some(found);
3705            }
3706        }
3707        None
3708    }
3709
3710    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
3711    pub fn max_movable_to(
3712        &self,
3713        template_id: &str,
3714        stack_qty: u32,
3715        from: &flatland_protocol::InventoryLocation,
3716        to: &flatland_protocol::InventoryLocation,
3717        parent_instance_id: Option<uuid::Uuid>,
3718    ) -> u32 {
3719        let unit_vol = self.item_base_volume(template_id);
3720        let mut limit = stack_qty;
3721
3722        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3723            let cap = parent
3724                .capacity_volume
3725                .or_else(|| {
3726                    self.inventory_hints
3727                        .get(&parent.template_id)
3728                        .and_then(|h| h.capacity_volume)
3729                })
3730                .unwrap_or(0.0);
3731            if cap > 0.0 && unit_vol > 0.0 {
3732                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3733                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3734            }
3735        }
3736
3737        let _ = from;
3738        limit.max(0).min(stack_qty)
3739    }
3740
3741    pub fn move_picker_max_at_selection(&self) -> u32 {
3742        let Some(picker) = &self.move_picker else {
3743            return 1;
3744        };
3745        let Some(opt) = picker.options.get(self.move_picker_index) else {
3746            return picker.stack_quantity;
3747        };
3748        match &opt.kind {
3749            MoveOptionKind::Cancel
3750            | MoveOptionKind::Drop
3751            | MoveOptionKind::Use
3752            | MoveOptionKind::GrantApply
3753            | MoveOptionKind::SellPlotToCrown { .. }
3754            | MoveOptionKind::PickupPlaced { .. }
3755            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3756            MoveOptionKind::Move {
3757                location,
3758                parent_instance_id,
3759            } => self.max_movable_to(
3760                &picker.template_id,
3761                picker.stack_quantity,
3762                &picker.from,
3763                location,
3764                *parent_instance_id,
3765            ),
3766        }
3767    }
3768
3769    pub fn clamp_move_picker_quantity(&mut self) {
3770        let max = self.move_picker_max_at_selection();
3771        if let Some(picker) = &mut self.move_picker {
3772            if max == 0 {
3773                picker.quantity = 1;
3774            } else {
3775                picker.quantity = picker.quantity.clamp(1, max);
3776            }
3777        }
3778    }
3779
3780    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3781        let max = self.move_picker_max_at_selection().max(1);
3782        if let Some(picker) = &mut self.move_picker {
3783            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3784            picker.quantity = next as u32;
3785        }
3786    }
3787
3788    pub fn move_picker_set_quantity_max(&mut self) {
3789        let max = self.move_picker_max_at_selection();
3790        if let Some(picker) = &mut self.move_picker {
3791            picker.quantity = if max == 0 {
3792                1
3793            } else {
3794                max.min(picker.stack_quantity)
3795            };
3796        }
3797    }
3798
3799    pub fn move_picker_set_quantity_min(&mut self) {
3800        if let Some(picker) = &mut self.move_picker {
3801            picker.quantity = 1;
3802        }
3803    }
3804
3805    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3806        if let Some(picker) = &mut self.destroy_picker {
3807            let max = picker.stack_quantity.max(1);
3808            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3809            picker.quantity = next as u32;
3810        }
3811    }
3812
3813    pub fn destroy_picker_set_quantity_max(&mut self) {
3814        if let Some(picker) = &mut self.destroy_picker {
3815            picker.quantity = picker.stack_quantity.max(1);
3816        }
3817    }
3818
3819    pub fn destroy_picker_set_quantity_min(&mut self) {
3820        if let Some(picker) = &mut self.destroy_picker {
3821            picker.quantity = 1;
3822        }
3823    }
3824
3825    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3826        let have = self.inventory.get(template_id).copied().unwrap_or(0);
3827        (have, have >= need)
3828    }
3829
3830    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
3831    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3832        let have = self
3833            .plot_build_offer
3834            .as_ref()
3835            .and_then(|o| {
3836                o.available
3837                    .iter()
3838                    .find(|s| s.template_id == template_id)
3839                    .map(|s| s.quantity)
3840            })
3841            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3842        (have, have >= need)
3843    }
3844
3845    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3846        self.building_materials
3847            .iter()
3848            .filter(|m| m.can_wall)
3849            .collect()
3850    }
3851
3852    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3853        self.building_materials
3854            .iter()
3855            .filter(|m| m.can_roof)
3856            .collect()
3857    }
3858
3859    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3860        self.plot_build_wall_options()
3861            .get(self.plot_build_wall_index)
3862            .copied()
3863    }
3864
3865    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3866        self.plot_build_roof_options()
3867            .get(self.plot_build_roof_index)
3868            .copied()
3869    }
3870
3871    /// BOM lines for the current wall/roof selection and tilled pad area.
3872    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3873        let Some(wall) = self.plot_build_selected_wall() else {
3874            return Vec::new();
3875        };
3876        let Some(roof) = self.plot_build_selected_roof() else {
3877            return Vec::new();
3878        };
3879        let area = self
3880            .plot_build_offer
3881            .as_ref()
3882            .filter(|o| o.pad_ok)
3883            .map(|o| o.pad_width_m * o.pad_depth_m)
3884            .unwrap_or(0.0);
3885        if area <= 0.0 {
3886            return Vec::new();
3887        }
3888        let mut map: std::collections::HashMap<String, (String, u32)> =
3889            std::collections::HashMap::new();
3890        for line in &wall.wall_bom {
3891            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3892            if qty == 0 {
3893                continue;
3894            }
3895            let name = if line.display_name.is_empty() {
3896                line.template_id.clone()
3897            } else {
3898                line.display_name.clone()
3899            };
3900            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3901            entry.1 = entry.1.saturating_add(qty);
3902        }
3903        for line in &roof.roof_bom {
3904            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3905            if qty == 0 {
3906                continue;
3907            }
3908            let name = if line.display_name.is_empty() {
3909                line.template_id.clone()
3910            } else {
3911                line.display_name.clone()
3912            };
3913            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3914            entry.1 = entry.1.saturating_add(qty);
3915        }
3916        let mut out: Vec<_> = map
3917            .into_iter()
3918            .map(|(id, (name, qty))| (id, name, qty))
3919            .collect();
3920        out.sort_by(|a, b| a.0.cmp(&b.0));
3921        out
3922    }
3923
3924    pub fn plot_build_duration_secs(&self) -> Option<f32> {
3925        let wall = self.plot_build_selected_wall()?;
3926        let roof = self.plot_build_selected_roof()?;
3927        let offer = self.plot_build_offer.as_ref()?;
3928        if !offer.pad_ok {
3929            return None;
3930        }
3931        let area = offer.pad_width_m * offer.pad_depth_m;
3932        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3933        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3934        Some(ticks.max(2.0) / 30.0)
3935    }
3936
3937    pub fn plot_build_can_afford(&self) -> bool {
3938        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3939            return false;
3940        }
3941        self.plot_build_bom_lines()
3942            .iter()
3943            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3944    }
3945
3946    pub fn currency_display(&self) -> String {
3947        crate::currency::currency_line(&self.inventory)
3948    }
3949
3950    /// True when standing in a shallow-water terrain zone from the segment snapshot.
3951    pub fn in_shallow_water(&self) -> bool {
3952        let (px, py) = self.player_position();
3953        self.terrain_at(px, py)
3954            .is_some_and(|k| k == TerrainKindView::ShallowWater)
3955    }
3956
3957    /// On or orthogonally next to fillable water, or next to a well-tagged building.
3958    pub fn near_liquid_fill_source(&self) -> bool {
3959        let (px, py) = self.player_position();
3960        const CELL: f32 = 1.0;
3961        let offsets = [
3962            (0.0, 0.0),
3963            (CELL, 0.0),
3964            (-CELL, 0.0),
3965            (0.0, CELL),
3966            (0.0, -CELL),
3967        ];
3968        for (dx, dy) in offsets {
3969            if matches!(
3970                self.terrain_at(px + dx, py + dy),
3971                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
3972            ) {
3973                return true;
3974            }
3975        }
3976        self.buildings.iter().any(|b| {
3977            if !b.tags.iter().any(|t| t == "well") {
3978                return false;
3979            }
3980            let hw = b.width_m * 0.5;
3981            let hd = b.depth_m * 0.5;
3982            let nx = px.clamp(b.x - hw, b.x + hw);
3983            let ny = py.clamp(b.y - hd, b.y + hd);
3984            let dx = px - nx;
3985            let dy = py - ny;
3986            dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
3987        })
3988    }
3989
3990    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3991        self.terrain_zone_at(x, y).map(|z| z.kind)
3992    }
3993
3994    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
3995    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3996        use std::cell::RefCell;
3997
3998        const CHUNK: i32 = 8;
3999        thread_local! {
4000            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4001                RefCell::new(None);
4002        }
4003
4004        let zones = &self.terrain_zones;
4005        if zones.is_empty() {
4006            return None;
4007        }
4008        if zones.len() <= 48 {
4009            return zones
4010                .iter()
4011                .enumerate()
4012                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4013                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4014                .map(|(_, z)| z);
4015        }
4016
4017        let ptr = zones.as_ptr();
4018        let len = zones.len();
4019        INDEX.with(|cell| {
4020            let mut slot = cell.borrow_mut();
4021            let stale = match slot.as_ref() {
4022                Some((p, l, _)) => *p != ptr || *l != len,
4023                None => true,
4024            };
4025            if stale {
4026                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4027                    std::collections::HashMap::new();
4028                for (zi, z) in zones.iter().enumerate() {
4029                    let x0 = z.x0.min(z.x1).floor() as i32;
4030                    let y0 = z.y0.min(z.y1).floor() as i32;
4031                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4032                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4033                    let cx0 = x0.div_euclid(CHUNK);
4034                    let cy0 = y0.div_euclid(CHUNK);
4035                    let cx1 = x1.div_euclid(CHUNK);
4036                    let cy1 = y1.div_euclid(CHUNK);
4037                    for cy in cy0..=cy1 {
4038                        for cx in cx0..=cx1 {
4039                            chunks.entry((cx, cy)).or_default().push(zi);
4040                        }
4041                    }
4042                }
4043                *slot = Some((ptr, len, chunks));
4044            }
4045            let chunks = &slot.as_ref().expect("index").2;
4046            let cx = (x.floor() as i32).div_euclid(CHUNK);
4047            let cy = (y.floor() as i32).div_euclid(CHUNK);
4048            let mut best: Option<(usize, &TerrainZoneView)> = None;
4049            if let Some(list) = chunks.get(&(cx, cy)) {
4050                for &zi in list {
4051                    let Some(z) = zones.get(zi) else { continue };
4052                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4053                        continue;
4054                    }
4055                    best = match best {
4056                        None => Some((zi, z)),
4057                        Some((bi, bz)) => {
4058                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4059                                Some((zi, z))
4060                            } else {
4061                                Some((bi, bz))
4062                            }
4063                        }
4064                    };
4065                }
4066            }
4067            best.map(|(_, z)| z)
4068        })
4069    }
4070
4071    /// Ground elevation from terrain zones (m).
4072    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4073        self.terrain_zone_at(x, y)
4074            .map(|z| z.elevation)
4075            .unwrap_or(0.0)
4076    }
4077
4078    /// Walkable z levels at a map column (terrain + platforms).
4079    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4080        const TOL: f32 = 0.35;
4081        let mut levels = vec![self.elevation_at(x, y)];
4082        for p in &self.z_platforms {
4083            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4084                levels.push(p.z);
4085            }
4086        }
4087        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4088        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4089        levels
4090    }
4091
4092    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4093        const TOL: f32 = 0.35;
4094        self.walkable_levels_at(x, y)
4095            .iter()
4096            .any(|&l| (l - z).abs() <= TOL)
4097    }
4098
4099    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4100        let mut top = self.elevation_at(x, y);
4101        for p in &self.z_platforms {
4102            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4103                top = top.max(p.z);
4104            }
4105        }
4106        top
4107    }
4108
4109    /// Authoritative interior context from the server (`inside_building` flag).
4110    pub fn effective_inside_building(&self) -> Option<String> {
4111        self.player_entity().and_then(|p| p.inside_building.clone())
4112    }
4113
4114    /// True when a placed chest shares the player's outdoor/interior space.
4115    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
4116    /// labels); they must not be drawn or treated as nearby world props.
4117    pub fn placed_container_in_current_space(
4118        &self,
4119        c: &flatland_protocol::PlacedContainerView,
4120    ) -> bool {
4121        match (
4122            self.effective_inside_building().as_deref(),
4123            c.building_id.as_deref(),
4124        ) {
4125            (None, None) => true,
4126            (Some(a), Some(b)) => a == b,
4127            _ => false,
4128        }
4129    }
4130
4131    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4132        fn walk(
4133            stacks: &[flatland_protocol::ItemStack],
4134            hints: &mut std::collections::HashMap<String, InventoryHint>,
4135        ) {
4136            for stack in stacks {
4137                if stack.display_name.is_some()
4138                    || stack.category.is_some()
4139                    || stack.base_mass.is_some()
4140                    || stack.base_volume.is_some()
4141                    || stack.base_value_copper.is_some()
4142                {
4143                    hints.insert(
4144                        stack.template_id.clone(),
4145                        InventoryHint {
4146                            display_name: stack
4147                                .display_name
4148                                .clone()
4149                                .unwrap_or_else(|| stack.template_id.clone()),
4150                            category: stack.category.clone().unwrap_or_default(),
4151                            base_mass: stack.base_mass,
4152                            base_volume: stack.base_volume,
4153                            capacity_volume: stack.capacity_volume,
4154                            stackable: stack.stackable.unwrap_or(true),
4155                            listable: stack.listable.unwrap_or_else(|| {
4156                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4157                            }),
4158                            base_value_copper: stack.base_value_copper,
4159                        },
4160                    );
4161                }
4162                walk(&stack.contents, hints);
4163            }
4164        }
4165        walk(stacks, &mut self.inventory_hints);
4166    }
4167
4168    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4169        self.inventory_stacks = stacks.to_vec();
4170        self.inventory.clear();
4171        self.inventory_hints.clear();
4172        fn walk(
4173            stacks: &[flatland_protocol::ItemStack],
4174            inventory: &mut std::collections::HashMap<String, u32>,
4175            hints: &mut std::collections::HashMap<String, InventoryHint>,
4176        ) {
4177            for stack in stacks {
4178                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4179                if stack.display_name.is_some()
4180                    || stack.category.is_some()
4181                    || stack.base_mass.is_some()
4182                    || stack.base_volume.is_some()
4183                    || stack.base_value_copper.is_some()
4184                {
4185                    hints.insert(
4186                        stack.template_id.clone(),
4187                        InventoryHint {
4188                            display_name: stack
4189                                .display_name
4190                                .clone()
4191                                .unwrap_or_else(|| stack.template_id.clone()),
4192                            category: stack.category.clone().unwrap_or_default(),
4193                            base_mass: stack.base_mass,
4194                            base_volume: stack.base_volume,
4195                            capacity_volume: stack.capacity_volume,
4196                            stackable: stack.stackable.unwrap_or(true),
4197                            listable: stack.listable.unwrap_or_else(|| {
4198                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4199                            }),
4200                            base_value_copper: stack.base_value_copper,
4201                        },
4202                    );
4203                }
4204                walk(&stack.contents, inventory, hints);
4205            }
4206        }
4207        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4208        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
4209        for item in self.worn.values() {
4210            walk(
4211                std::slice::from_ref(item),
4212                &mut self.inventory,
4213                &mut self.inventory_hints,
4214            );
4215        }
4216    }
4217
4218    fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4219        if entries.is_empty() {
4220            return;
4221        }
4222        self.item_catalog.clear();
4223        self.item_catalog.reserve(entries.len());
4224        for entry in entries {
4225            if entry.template_id.is_empty() {
4226                continue;
4227            }
4228            self.item_catalog
4229                .insert(entry.template_id.clone(), entry.clone());
4230        }
4231    }
4232
4233    /// Remove a carried stack by instance id (root or nested in a worn bag).
4234    /// Used for optimistic give-to-worker so the bag UI does not keep the item
4235    /// until the next tick — and so a later "Gave …" interaction cannot add it back.
4236    fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4237        fn take_from(
4238            stacks: &mut Vec<flatland_protocol::ItemStack>,
4239            instance_id: uuid::Uuid,
4240            qty: Option<u32>,
4241        ) -> bool {
4242            if let Some(i) = stacks
4243                .iter()
4244                .position(|s| s.item_instance_id == Some(instance_id))
4245            {
4246                let have = stacks[i].quantity;
4247                let take = qty.unwrap_or(have).min(have);
4248                if take >= have {
4249                    stacks.remove(i);
4250                } else {
4251                    stacks[i].quantity = have - take;
4252                }
4253                return true;
4254            }
4255            stacks
4256                .iter_mut()
4257                .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4258        }
4259
4260        if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4261            let stacks = self.inventory_stacks.clone();
4262            self.sync_inventory_from_stacks(&stacks);
4263            self.refresh_inventory_ui();
4264            return;
4265        }
4266        let slots: Vec<_> = self.worn.keys().copied().collect();
4267        for slot in slots {
4268            let Some(item) = self.worn.get_mut(&slot) else {
4269                continue;
4270            };
4271            if take_from(&mut item.contents, instance_id, quantity) {
4272                let stacks = self.inventory_stacks.clone();
4273                self.sync_inventory_from_stacks(&stacks);
4274                self.refresh_inventory_ui();
4275                return;
4276            }
4277        }
4278    }
4279
4280    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
4281    /// inventory UI updates before the next tick snapshot arrives.
4282    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4283        // Worker handoff notices reuse `inventory_delta` as a toast payload, not a
4284        // gain. The same tick already dropped the stack; applying it as an add put
4285        // the item back in the player bag.
4286        if notice.message.starts_with("Gave ") {
4287            if notice.coins_delta != 0 {
4288                crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4289                let stacks = self.inventory_stacks.clone();
4290                self.sync_inventory_from_stacks(&stacks);
4291            }
4292            self.record_shop_trade_notice(notice);
4293            return;
4294        }
4295        let subtract_items =
4296            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4297        for stack in &notice.inventory_delta {
4298            if stack.quantity == 0 {
4299                continue;
4300            }
4301            if subtract_items {
4302                crate::currency::drain_template_stacks(
4303                    &mut self.inventory_stacks,
4304                    &stack.template_id,
4305                    stack.quantity,
4306                );
4307                continue;
4308            }
4309            let stackable = self
4310                .inventory_hints
4311                .get(&stack.template_id)
4312                .map(|h| h.stackable)
4313                .or(stack.stackable)
4314                .unwrap_or(true);
4315            if stackable {
4316                if let Some(existing) = self
4317                    .inventory_stacks
4318                    .iter_mut()
4319                    .find(|s| s.template_id == stack.template_id)
4320                {
4321                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
4322                    if stack.display_name.is_some() {
4323                        existing.display_name = stack.display_name.clone();
4324                    }
4325                    if stack.category.is_some() {
4326                        existing.category = stack.category.clone();
4327                    }
4328                    continue;
4329                }
4330            }
4331            self.inventory_stacks.push(stack.clone());
4332        }
4333        if notice.coins_delta != 0 {
4334            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4335        }
4336        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4337            let stacks = self.inventory_stacks.clone();
4338            self.sync_inventory_from_stacks(&stacks);
4339        }
4340        self.record_shop_trade_notice(notice);
4341    }
4342
4343    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
4344    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
4345    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
4346    /// iteration alone gives a stable row order.
4347    pub fn worn_rows(&self) -> Vec<InventoryRow> {
4348        let mut rows = Vec::new();
4349        for (slot, item) in &self.worn {
4350            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4351            rows.push(InventoryRow {
4352                depth: 0,
4353                stack: item.clone(),
4354                from: from.clone(),
4355                from_parent_instance_id: None,
4356                is_equip_shell: true,
4357                is_chest_shell: false,
4358                section: InventorySection::Worn,
4359            });
4360            for child in &item.contents {
4361                push_inventory_rows(
4362                    &mut rows,
4363                    1,
4364                    child,
4365                    &from,
4366                    item.item_instance_id,
4367                    InventorySection::Worn,
4368                );
4369            }
4370        }
4371        rows
4372    }
4373
4374    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
4375    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4376        let equipped = self.hand_equipped_instance_ids();
4377        self.inventory_stacks
4378            .iter()
4379            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4380            .collect()
4381    }
4382
4383    /// Root on-person stacks that can be handed to a hired worker.
4384    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4385        let equipped = self.hand_equipped_instance_ids();
4386        self.inventory_stacks
4387            .iter()
4388            .filter_map(|stack| {
4389                let item_instance_id = stack.item_instance_id?;
4390                if equipped.contains(&item_instance_id) {
4391                    return None;
4392                }
4393                let label = stack
4394                    .display_name
4395                    .clone()
4396                    .unwrap_or_else(|| stack.template_id.clone());
4397                let label = if stack.quantity > 1 {
4398                    format!("{label} ×{}", stack.quantity)
4399                } else {
4400                    label
4401                };
4402                Some(WorkerGiveOption {
4403                    item_instance_id,
4404                    label,
4405                    quantity: stack.quantity,
4406                    template_id: stack.template_id.clone(),
4407                })
4408            })
4409            .collect()
4410    }
4411
4412    /// Employer-known blueprints the selected worker does not yet know.
4413    pub fn teachable_blueprint_options(
4414        &self,
4415        worker: &flatland_protocol::HiredWorkerView,
4416    ) -> Vec<WorkerTeachOption> {
4417        let copper = crate::currency::copper_from_counts(&self.inventory);
4418        let mut options: Vec<WorkerTeachOption> = self
4419            .blueprints
4420            .iter()
4421            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4422            .map(|bp| {
4423                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4424                let cost = bp.worker_train_copper;
4425                WorkerTeachOption {
4426                    blueprint_id: bp.id.clone(),
4427                    label: if bp.label.is_empty() {
4428                        bp.id.clone()
4429                    } else {
4430                        bp.label.clone()
4431                    },
4432                    cost_copper: cost,
4433                    min_level,
4434                    worker_level: worker.level,
4435                    can_afford: copper >= cost,
4436                    level_ok: worker.level >= min_level,
4437                }
4438            })
4439            .collect();
4440        options.sort_by(|a, b| a.label.cmp(&b.label));
4441        options
4442    }
4443
4444    /// Loose on-person inventory (not worn, not inside a placed chest).
4445    /// Root stacks are ordered by category group; nested contents stay under their parent.
4446    pub fn person_rows(&self) -> Vec<InventoryRow> {
4447        self.person_rows_filtered("")
4448    }
4449
4450    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4451        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4452        roots.sort_by(|a, b| {
4453            let ca = a
4454                .category
4455                .as_deref()
4456                .or_else(|| self.inventory_item_category(&a.template_id))
4457                .unwrap_or("");
4458            let cb = b
4459                .category
4460                .as_deref()
4461                .or_else(|| self.inventory_item_category(&b.template_id))
4462                .unwrap_or("");
4463            let ga = inventory_category_group(ca).1;
4464            let gb = inventory_category_group(cb).1;
4465            ga.cmp(&gb).then_with(|| {
4466                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4467                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4468                na.cmp(nb)
4469            })
4470        });
4471        let mut rows = Vec::new();
4472        for stack in roots {
4473            push_inventory_rows_filtered(
4474                &mut rows,
4475                0,
4476                stack,
4477                &flatland_protocol::InventoryLocation::Root,
4478                None,
4479                InventorySection::Person,
4480                filter,
4481            );
4482        }
4483        rows
4484    }
4485
4486    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4487        if filter.is_empty() {
4488            return self.worn_rows();
4489        }
4490        let mut rows = Vec::new();
4491        for (slot, item) in &self.worn {
4492            if !stack_matches_filter(item, filter) {
4493                continue;
4494            }
4495            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4496            let self_hit = {
4497                let f = filter.to_ascii_lowercase();
4498                let name = item
4499                    .display_name
4500                    .as_deref()
4501                    .unwrap_or("")
4502                    .to_ascii_lowercase();
4503                let tid = item.template_id.to_ascii_lowercase();
4504                name.contains(&f) || tid.contains(&f)
4505            };
4506            rows.push(InventoryRow {
4507                depth: 0,
4508                stack: item.clone(),
4509                from: from.clone(),
4510                from_parent_instance_id: None,
4511                is_equip_shell: true,
4512                is_chest_shell: false,
4513                section: InventorySection::Worn,
4514            });
4515            for child in &item.contents {
4516                if self_hit || stack_matches_filter(child, filter) {
4517                    push_inventory_rows_filtered(
4518                        &mut rows,
4519                        1,
4520                        child,
4521                        &from,
4522                        item.item_instance_id,
4523                        InventorySection::Worn,
4524                        if self_hit { "" } else { filter },
4525                    );
4526                }
4527            }
4528        }
4529        rows
4530    }
4531
4532    /// Items carried inside equipped containers, without showing the equipped
4533    /// container shells themselves. Equipped gear belongs in the paperdoll;
4534    /// these rows are still movable because the contents are carried by the player.
4535    pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4536        let mut rows = Vec::new();
4537        for (slot, item) in &self.worn {
4538            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4539            for child in &item.contents {
4540                push_inventory_rows_filtered(
4541                    &mut rows,
4542                    0,
4543                    child,
4544                    &from,
4545                    item.item_instance_id,
4546                    InventorySection::Person,
4547                    filter,
4548                );
4549            }
4550        }
4551        rows
4552    }
4553
4554    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
4555    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4556        let mut rows = self.worn_rows();
4557        rows.extend(self.person_rows());
4558        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4559    }
4560
4561    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
4562    /// populated when `accessible` — this is what makes a chest's contents
4563    /// disappear the moment you walk away or it's locked without your key.
4564    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4565        let (px, py) = self.player_position();
4566        let mut list: Vec<NearbyContainer> = self
4567            .placed_containers
4568            .iter()
4569            .filter(|c| self.placed_container_in_current_space(c))
4570            .filter_map(|c| {
4571                let distance_m = (c.x - px).hypot(c.y - py);
4572                if distance_m > CONTAINER_RANGE_M {
4573                    return None;
4574                }
4575                let mut rows = Vec::new();
4576                let from = flatland_protocol::InventoryLocation::Placed {
4577                    container_id: c.id.clone(),
4578                };
4579                rows.push(InventoryRow {
4580                    depth: 0,
4581                    stack: flatland_protocol::ItemStack {
4582                        template_id: c.template_id.clone(),
4583                        quantity: 1,
4584                        item_instance_id: c.item_instance_id,
4585                        props: Default::default(),
4586                        status_bindings: Vec::new(),
4587                        contents: Vec::new(),
4588                        display_name: Some(c.display_name.clone()),
4589                        category: Some("container".into()),
4590                        capacity_volume: c.capacity_volume,
4591                        worker_lodging_capacity: c.worker_lodging_capacity,
4592                        ..Default::default()
4593                    },
4594                    from: from.clone(),
4595                    from_parent_instance_id: None,
4596                    is_equip_shell: false,
4597                    is_chest_shell: true,
4598                    section: InventorySection::Nearby,
4599                });
4600                if c.accessible {
4601                    for child in &c.contents {
4602                        push_inventory_rows(
4603                            &mut rows,
4604                            1,
4605                            child,
4606                            &from,
4607                            c.item_instance_id,
4608                            InventorySection::Nearby,
4609                        );
4610                    }
4611                }
4612                Some(NearbyContainer {
4613                    view: c.clone(),
4614                    distance_m,
4615                    rows,
4616                })
4617            })
4618            .collect();
4619        list.sort_by(|a, b| {
4620            a.distance_m
4621                .partial_cmp(&b.distance_m)
4622                .unwrap_or(std::cmp::Ordering::Equal)
4623        });
4624        list
4625    }
4626
4627    /// Nearest placed chest within `max_dist`, regardless of accessibility.
4628    pub fn nearest_placed_container(
4629        &self,
4630        max_dist: f32,
4631    ) -> Option<flatland_protocol::PlacedContainerView> {
4632        let (px, py) = self.player_position();
4633        self.placed_containers
4634            .iter()
4635            .filter(|c| self.placed_container_in_current_space(c))
4636            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4637            .min_by(|a, b| {
4638                let da = (a.x - px).hypot(a.y - py);
4639                let db = (b.x - px).hypot(b.y - py);
4640                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4641            })
4642            .cloned()
4643    }
4644
4645    /// Selectable rows for the **active inventory tab** (and current filter).
4646    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
4647    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4648        let filter = self.inventory_filter.as_str();
4649        match self.inventory_tab {
4650            InventoryTab::OnPerson => {
4651                let mut rows = self.carried_worn_rows_filtered(filter);
4652                rows.extend(self.person_rows_filtered(filter));
4653                rows
4654            }
4655            InventoryTab::Nearby => {
4656                let mut rows = Vec::new();
4657                for nc in self.nearby_containers() {
4658                    if filter.is_empty() {
4659                        rows.extend(nc.rows);
4660                        continue;
4661                    }
4662                    let shell = nc.rows.first().cloned();
4663                    let contents: Vec<_> = nc
4664                        .rows
4665                        .iter()
4666                        .skip(1)
4667                        .filter(|r| stack_matches_filter(&r.stack, filter))
4668                        .cloned()
4669                        .collect();
4670                    let shell_hit = shell
4671                        .as_ref()
4672                        .map(|s| stack_matches_filter(&s.stack, filter))
4673                        .unwrap_or(false);
4674                    if shell_hit || !contents.is_empty() {
4675                        if let Some(s) = shell {
4676                            rows.push(s);
4677                        }
4678                        if shell_hit {
4679                            rows.extend(nc.rows.into_iter().skip(1));
4680                        } else {
4681                            rows.extend(contents);
4682                        }
4683                    }
4684                }
4685                rows
4686            }
4687        }
4688    }
4689
4690    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4691        self.inventory_selectable_rows()
4692            .into_iter()
4693            .nth(self.inventory_menu_index)
4694    }
4695
4696    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4697        let cat = self
4698            .inventory_item_category(&row.stack.template_id)
4699            .unwrap_or("");
4700        if cat == "key" {
4701            self.key_inventory_label(&row.stack)
4702        } else {
4703            row.stack
4704                .display_name
4705                .clone()
4706                .unwrap_or_else(|| row.stack.template_id.clone())
4707        }
4708    }
4709
4710    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
4711    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4712        let bindings =
4713            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4714        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4715            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4716            let mode = Self::grant_mode(&row.stack);
4717            format!(" [grant {effect} · {mode} — e apply]")
4718        } else {
4719            String::new()
4720        };
4721        let qty = if row.stack.quantity > 1 {
4722            format!(" ×{}", row.stack.quantity)
4723        } else {
4724            String::new()
4725        };
4726        let worn_slot = if row.is_equip_shell {
4727            match row.from {
4728                flatland_protocol::InventoryLocation::Worn { slot } => {
4729                    format!(" ({})", body_slot_label(slot))
4730                }
4731                _ => String::new(),
4732            }
4733        } else {
4734            String::new()
4735        };
4736        format!("{grant_hint}{bindings}{qty}{worn_slot}")
4737    }
4738
4739    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4740        (
4741            row.stack.template_id.clone(),
4742            self.inventory_row_base_label(row),
4743            self.inventory_row_visible_mod_signature(row),
4744        )
4745    }
4746
4747    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
4748    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4749        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4750        for row in self.inventory_selectable_rows() {
4751            if row.stack.item_instance_id.is_none() {
4752                continue;
4753            }
4754            let key = self.inventory_row_instance_identity_key(&row);
4755            *counts.entry(key).or_default() += 1;
4756        }
4757        counts
4758            .into_iter()
4759            .filter(|(_, n)| *n > 1)
4760            .map(|(k, _)| k)
4761            .collect()
4762    }
4763
4764    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4765        let hex: String = id
4766            .as_simple()
4767            .to_string()
4768            .chars()
4769            .filter(|c| c.is_ascii_hexdigit())
4770            .collect();
4771        let short = if hex.len() >= 4 {
4772            &hex[hex.len() - 4..]
4773        } else {
4774            hex.as_str()
4775        };
4776        format!("Instance {id} (#{short})")
4777    }
4778
4779    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
4780    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4781        let cat = self
4782            .inventory_item_category(&row.stack.template_id)
4783            .unwrap_or("");
4784        let label = self.inventory_row_base_label(row);
4785        let hint: String = if row.is_equip_shell {
4786            " [worn — Enter to unequip]".into()
4787        } else if row.is_chest_shell {
4788            let (locked, lodging_note) = match &row.from {
4789                flatland_protocol::InventoryLocation::Placed { container_id } => {
4790                    let locked = self
4791                        .placed_containers
4792                        .iter()
4793                        .find(|c| c.id == *container_id)
4794                        .map(|c| c.locked)
4795                        .unwrap_or(false);
4796                    let lodging_note = self
4797                        .lodging_occupancy_label(container_id)
4798                        .map(|who| format!(" [lodging: {who}]"))
4799                        .unwrap_or_default();
4800                    (locked, lodging_note)
4801                }
4802                _ => (false, String::new()),
4803            };
4804            if locked {
4805                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4806            } else {
4807                format!(" [Enter pick up · l lock]{lodging_note}")
4808            }
4809        } else if cat == "key" {
4810            self.key_inventory_hint(&row.stack)
4811        } else {
4812            match cat {
4813                "weapon" => " [weapon]".into(),
4814                "container" => " [bag/chest/belt]".into(),
4815                "lodging" => " [worker lodging]".into(),
4816                "armor" => " [armor]".into(),
4817                _ => String::new(),
4818            }
4819        };
4820        let qty = if row.stack.quantity > 1 {
4821            format!(" ×{}", row.stack.quantity)
4822        } else {
4823            String::new()
4824        };
4825        let bindings =
4826            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4827        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4828            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4829            let mode = Self::grant_mode(&row.stack);
4830            format!(" [grant {effect} · {mode} — e apply]")
4831        } else {
4832            String::new()
4833        };
4834        let mass = self.stack_mass(&row.stack);
4835        let mass_kg = (mass >= 0.05).then_some(mass);
4836        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
4837        let volume = self.container_volume_stats(row);
4838        let vol_str = self.container_volume_label(row);
4839
4840        let mut title = label.clone();
4841        title.push_str(&qty);
4842        if row.is_equip_shell {
4843            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4844                title.push_str(&format!(" ({})", body_slot_label(slot)));
4845            }
4846        }
4847
4848        InventoryRowView {
4849            depth: row.depth,
4850            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4851            title: format!("{title}{grant_hint}{bindings}"),
4852            mass_kg,
4853            volume,
4854            instance_tooltip: None,
4855        }
4856    }
4857
4858    fn push_browser_item(
4859        &self,
4860        lines: &mut Vec<InventoryBrowserLine>,
4861        row: &InventoryRow,
4862        global_idx: &mut usize,
4863        target: usize,
4864        highlight: bool,
4865        ambiguous_instance_keys: &HashSet<(String, String, String)>,
4866    ) {
4867        let mut view = self.format_inventory_row(row);
4868        if let Some(id) = row.stack.item_instance_id {
4869            let key = self.inventory_row_instance_identity_key(row);
4870            if ambiguous_instance_keys.contains(&key) {
4871                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4872            }
4873        }
4874        lines.push(InventoryBrowserLine::Item {
4875            selectable_index: *global_idx,
4876            selected: highlight && *global_idx == target,
4877            depth: view.depth,
4878            text: view.text,
4879            title: view.title,
4880            mass_kg: view.mass_kg,
4881            volume: view.volume,
4882            instance_tooltip: view.instance_tooltip,
4883        });
4884        *global_idx += 1;
4885    }
4886
4887    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
4888    /// `selectable_index` matching `inventory_menu_index`.
4889    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4890        let mut lines = Vec::new();
4891        let target = self.inventory_menu_index;
4892        let highlight = !self.show_move_picker && !self.show_grant_picker;
4893        let filter = self.inventory_filter.as_str();
4894        let mut global_idx = 0usize;
4895        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4896
4897        match self.inventory_tab {
4898            InventoryTab::OnPerson => {
4899                lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4900                let carried = self.carried_worn_rows_filtered(filter);
4901                if carried.is_empty() {
4902                    lines.push(InventoryBrowserLine::Hint(
4903                        "  (no items in carried bags)".into(),
4904                    ));
4905                } else {
4906                    for row in &carried {
4907                        self.push_browser_item(
4908                            &mut lines,
4909                            row,
4910                            &mut global_idx,
4911                            target,
4912                            highlight,
4913                            &ambiguous_instance_keys,
4914                        );
4915                    }
4916                }
4917
4918                lines.push(InventoryBrowserLine::Blank);
4919                lines.push(InventoryBrowserLine::Section(
4920                    "— On you (loose, not worn) —".into(),
4921                ));
4922                let person = self.person_rows_filtered(filter);
4923                if person.is_empty() {
4924                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
4925                } else {
4926                    let mut last_group: Option<&'static str> = None;
4927                    for row in &person {
4928                        if row.depth == 0 {
4929                            let cat = row
4930                                .stack
4931                                .category
4932                                .as_deref()
4933                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
4934                                .unwrap_or("");
4935                            let (group, _) = inventory_category_group(cat);
4936                            if last_group != Some(group) {
4937                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
4938                                last_group = Some(group);
4939                            }
4940                        }
4941                        self.push_browser_item(
4942                            &mut lines,
4943                            row,
4944                            &mut global_idx,
4945                            target,
4946                            highlight,
4947                            &ambiguous_instance_keys,
4948                        );
4949                    }
4950                }
4951            }
4952            InventoryTab::Nearby => {
4953                let nearby = self.nearby_containers();
4954                if nearby.is_empty() {
4955                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4956                    lines.push(InventoryBrowserLine::Hint(
4957                        "  (none within reach — walk up to a chest)".into(),
4958                    ));
4959                    lines.push(InventoryBrowserLine::Hint(
4960                        "  Select an on-person item, then m / Enter → move into chest.".into(),
4961                    ));
4962                } else {
4963                    let mut any_visible = false;
4964                    for nc in &nearby {
4965                        let shell = nc.rows.first();
4966                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
4967                            nc.rows.iter().skip(1).collect()
4968                        } else {
4969                            let shell_hit = shell
4970                                .map(|s| {
4971                                    let f = filter.to_ascii_lowercase();
4972                                    let name = s
4973                                        .stack
4974                                        .display_name
4975                                        .as_deref()
4976                                        .unwrap_or("")
4977                                        .to_ascii_lowercase();
4978                                    let tid = s.stack.template_id.to_ascii_lowercase();
4979                                    name.contains(&f) || tid.contains(&f)
4980                                })
4981                                .unwrap_or(false);
4982                            if shell_hit {
4983                                nc.rows.iter().skip(1).collect()
4984                            } else {
4985                                nc.rows
4986                                    .iter()
4987                                    .skip(1)
4988                                    .filter(|r| stack_matches_filter(&r.stack, filter))
4989                                    .collect()
4990                            }
4991                        };
4992                        let shell_visible = filter.is_empty()
4993                            || shell
4994                                .map(|s| stack_matches_filter(&s.stack, filter))
4995                                .unwrap_or(false)
4996                            || !contents.is_empty();
4997                        if !shell_visible && shell.is_some() {
4998                            continue;
4999                        }
5000                        any_visible = true;
5001                        lines.push(InventoryBrowserLine::Blank);
5002                        let lock_note = if nc.view.locked && nc.view.accessible {
5003                            "  unlocked with your key"
5004                        } else if nc.view.locked {
5005                            "  locked"
5006                        } else {
5007                            ""
5008                        };
5009                        lines.push(InventoryBrowserLine::Section(format!(
5010                            "— {} ({:.0}m away){lock_note} —",
5011                            nc.view.display_name, nc.distance_m
5012                        )));
5013                        if !nc.view.accessible {
5014                            lines.push(InventoryBrowserLine::Hint(
5015                                "  locked — need the matching key (l to try)".into(),
5016                            ));
5017                        } else if nc.rows.is_empty() {
5018                            lines.push(InventoryBrowserLine::Hint(
5019                                "  (empty — switch to On person, select an item, m to move in)"
5020                                    .into(),
5021                            ));
5022                        } else if let Some(shell_row) = shell {
5023                            self.push_browser_item(
5024                                &mut lines,
5025                                shell_row,
5026                                &mut global_idx,
5027                                target,
5028                                highlight,
5029                                &ambiguous_instance_keys,
5030                            );
5031                            for row in contents {
5032                                self.push_browser_item(
5033                                    &mut lines,
5034                                    row,
5035                                    &mut global_idx,
5036                                    target,
5037                                    highlight,
5038                                    &ambiguous_instance_keys,
5039                                );
5040                            }
5041                        }
5042                    }
5043                    if !any_visible {
5044                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5045                        lines.push(InventoryBrowserLine::Hint(
5046                            "  (no matching items — clear filter with Esc)".into(),
5047                        ));
5048                    }
5049                }
5050            }
5051        }
5052        lines
5053    }
5054
5055    /// Destinations for picking up a placed chest/crate into inventory.
5056    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5057        let mut opts = Vec::new();
5058        opts.push(MoveOption {
5059            label: "Relocate…".into(),
5060            kind: MoveOptionKind::RelocatePlaced {
5061                container_id: container_id.to_string(),
5062            },
5063        });
5064        opts.push(MoveOption {
5065            label: "On your person (loose)".into(),
5066            kind: MoveOptionKind::PickupPlaced {
5067                container_id: container_id.to_string(),
5068                nest_location: flatland_protocol::InventoryLocation::Root,
5069                nest_parent_instance_id: None,
5070            },
5071        });
5072        for (slot, item) in &self.worn {
5073            if item.category.as_deref() != Some("container") {
5074                continue;
5075            }
5076            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5077                continue;
5078            }
5079            let Some(parent_id) = item.item_instance_id else {
5080                continue;
5081            };
5082            let shell_name = item
5083                .display_name
5084                .clone()
5085                .unwrap_or_else(|| item.template_id.clone());
5086            opts.push(MoveOption {
5087                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5088                kind: MoveOptionKind::PickupPlaced {
5089                    container_id: container_id.to_string(),
5090                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
5091                    nest_parent_instance_id: Some(parent_id),
5092                },
5093            });
5094            // Nested pouches inside the worn bag.
5095            Self::append_chest_pickup_nested(
5096                &mut opts,
5097                container_id,
5098                flatland_protocol::InventoryLocation::Worn { slot: *slot },
5099                item,
5100                &format!("in {shell_name}"),
5101            );
5102        }
5103        opts.push(MoveOption {
5104            label: "Cancel".into(),
5105            kind: MoveOptionKind::Cancel,
5106        });
5107        opts
5108    }
5109
5110    fn append_chest_pickup_nested(
5111        opts: &mut Vec<MoveOption>,
5112        container_id: &str,
5113        location: flatland_protocol::InventoryLocation,
5114        parent: &flatland_protocol::ItemStack,
5115        context: &str,
5116    ) {
5117        for child in &parent.contents {
5118            if child.category.as_deref() != Some("container") {
5119                continue;
5120            }
5121            if !Self::is_volume_container_stack(child) {
5122                continue;
5123            }
5124            // Skip placeable nested chests — relocating into another chest is not useful.
5125            if child.world_placeable == Some(true) {
5126                continue;
5127            }
5128            let Some(child_id) = child.item_instance_id else {
5129                continue;
5130            };
5131            let name = child
5132                .display_name
5133                .clone()
5134                .unwrap_or_else(|| child.template_id.clone());
5135            opts.push(MoveOption {
5136                label: format!("{name} ({context})"),
5137                kind: MoveOptionKind::PickupPlaced {
5138                    container_id: container_id.to_string(),
5139                    nest_location: location.clone(),
5140                    nest_parent_instance_id: Some(child_id),
5141                },
5142            });
5143            Self::append_chest_pickup_nested(
5144                opts,
5145                container_id,
5146                location.clone(),
5147                child,
5148                &format!("in {name}"),
5149            );
5150        }
5151    }
5152
5153    /// Build the "move to…" destination list for an item currently at `from`.
5154    pub fn move_destinations_for(
5155        &self,
5156        from: &flatland_protocol::InventoryLocation,
5157        from_parent_instance_id: Option<uuid::Uuid>,
5158        moving_instance_id: Option<uuid::Uuid>,
5159        moving_template_id: &str,
5160    ) -> Vec<MoveOption> {
5161        let mut opts = Vec::new();
5162        if *from != flatland_protocol::InventoryLocation::Root {
5163            opts.push(MoveOption {
5164                label: "On your person (loose)".into(),
5165                kind: MoveOptionKind::Move {
5166                    location: flatland_protocol::InventoryLocation::Root,
5167                    parent_instance_id: None,
5168                },
5169            });
5170        }
5171        for (slot, item) in &self.worn {
5172            if item.category.as_deref() != Some("container") {
5173                continue;
5174            }
5175            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5176            let shell_name = item
5177                .display_name
5178                .clone()
5179                .unwrap_or_else(|| item.template_id.clone());
5180
5181            // Backpack and other worn volume containers — store directly inside the shell.
5182            if *slot != BodySlot::Waist
5183                && item.item_instance_id != moving_instance_id
5184                && Self::is_volume_container_stack(item)
5185            {
5186                Self::push_move_destination(
5187                    &mut opts,
5188                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
5189                    location.clone(),
5190                    item.item_instance_id,
5191                    from,
5192                    from_parent_instance_id,
5193                );
5194            }
5195
5196            // Belt loops only accept pouch attachments — not loose materials.
5197            if *slot == BodySlot::Waist
5198                && Self::attaches_to_belt_loop(moving_template_id)
5199                && item.item_instance_id != moving_instance_id
5200            {
5201                Self::push_move_destination(
5202                    &mut opts,
5203                    format!("{shell_name} (belt loop)"),
5204                    location.clone(),
5205                    item.item_instance_id,
5206                    from,
5207                    from_parent_instance_id,
5208                );
5209            }
5210
5211            let context = if *slot == BodySlot::Waist {
5212                format!("on {shell_name}")
5213            } else {
5214                format!("in {shell_name}")
5215            };
5216            Self::append_nested_container_destinations(
5217                &mut opts,
5218                location,
5219                item,
5220                &context,
5221                from,
5222                from_parent_instance_id,
5223                moving_instance_id,
5224            );
5225        }
5226        for nc in self.nearby_containers() {
5227            if !nc.view.accessible {
5228                continue;
5229            }
5230            let location = flatland_protocol::InventoryLocation::Placed {
5231                container_id: nc.view.id.clone(),
5232            };
5233            Self::push_move_destination(
5234                &mut opts,
5235                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5236                location,
5237                nc.view.item_instance_id,
5238                from,
5239                from_parent_instance_id,
5240            );
5241        }
5242        let allow_drop = moving_instance_id
5243            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5244            .unwrap_or(true)
5245            && moving_instance_id
5246                .and_then(|id| self.stack_for_instance(id))
5247                .map(|stack| {
5248                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5249                })
5250                .unwrap_or(
5251                    moving_template_id != KEY_TEMPLATE
5252                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
5253                );
5254        if allow_drop {
5255            opts.push(MoveOption {
5256                label: "Drop on the ground".into(),
5257                kind: MoveOptionKind::Drop,
5258            });
5259        }
5260        opts.push(MoveOption {
5261            label: "Cancel".into(),
5262            kind: MoveOptionKind::Cancel,
5263        });
5264        opts
5265    }
5266
5267    fn is_same_container_dest(
5268        dest_location: &flatland_protocol::InventoryLocation,
5269        dest_parent: Option<uuid::Uuid>,
5270        from: &flatland_protocol::InventoryLocation,
5271        from_parent: Option<uuid::Uuid>,
5272    ) -> bool {
5273        dest_location == from && dest_parent == from_parent
5274    }
5275
5276    fn push_move_destination(
5277        opts: &mut Vec<MoveOption>,
5278        label: String,
5279        location: flatland_protocol::InventoryLocation,
5280        parent_instance_id: Option<uuid::Uuid>,
5281        from: &flatland_protocol::InventoryLocation,
5282        from_parent_instance_id: Option<uuid::Uuid>,
5283    ) {
5284        if Self::is_same_container_dest(
5285            &location,
5286            parent_instance_id,
5287            from,
5288            from_parent_instance_id,
5289        ) {
5290            return;
5291        }
5292        opts.push(MoveOption {
5293            label,
5294            kind: MoveOptionKind::Move {
5295                location,
5296                parent_instance_id,
5297            },
5298        });
5299    }
5300
5301    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5302        stack.capacity_volume.is_some_and(|c| c > 0.0)
5303    }
5304
5305    fn attaches_to_belt_loop(template_id: &str) -> bool {
5306        matches!(template_id, "leather_pouch" | "dimensional_pouch")
5307    }
5308
5309    fn append_nested_container_destinations(
5310        opts: &mut Vec<MoveOption>,
5311        location: flatland_protocol::InventoryLocation,
5312        container: &flatland_protocol::ItemStack,
5313        context: &str,
5314        from: &flatland_protocol::InventoryLocation,
5315        from_parent_instance_id: Option<uuid::Uuid>,
5316        moving_instance_id: Option<uuid::Uuid>,
5317    ) {
5318        for child in &container.contents {
5319            if Self::is_volume_container_stack(child)
5320                && child.item_instance_id != moving_instance_id
5321            {
5322                let name = child
5323                    .display_name
5324                    .clone()
5325                    .unwrap_or_else(|| child.template_id.clone());
5326                Self::push_move_destination(
5327                    opts,
5328                    format!("{name} ({context})"),
5329                    location.clone(),
5330                    child.item_instance_id,
5331                    from,
5332                    from_parent_instance_id,
5333                );
5334            }
5335            let nested_context = format!(
5336                "in {}",
5337                child.display_name.as_deref().unwrap_or(&child.template_id)
5338            );
5339            Self::append_nested_container_destinations(
5340                opts,
5341                location.clone(),
5342                child,
5343                &nested_context,
5344                from,
5345                from_parent_instance_id,
5346                moving_instance_id,
5347            );
5348        }
5349    }
5350
5351    fn clamp_inventory_indices(&mut self) {
5352        let n = self.inventory_selectable_rows().len();
5353        self.inventory_menu_index = if n == 0 {
5354            0
5355        } else {
5356            self.inventory_menu_index.min(n - 1)
5357        };
5358        if let Some(picker) = &self.move_picker {
5359            let pn = picker.options.len();
5360            self.move_picker_index = if pn == 0 {
5361                0
5362            } else {
5363                self.move_picker_index.min(pn - 1)
5364            };
5365        }
5366    }
5367
5368    /// Drop interior map layers whenever the player is outdoors.
5369    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
5370    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
5371    /// force `build_grid` into a full-map z pass on every click until client restart.
5372    fn sync_interior_map_context(&mut self) {
5373        if self.effective_inside_building().is_none() {
5374            self.interior_map = None;
5375            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5376                self.z_platforms = platforms;
5377                self.z_transitions = transitions;
5378            }
5379            return;
5380        }
5381        self.sync_interior_z_bands();
5382    }
5383
5384    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
5385    fn sync_interior_z_bands(&mut self) {
5386        if self.effective_inside_building().is_some() {
5387            if let Some(map) = &self.interior_map {
5388                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5389                    if self.z_bands_outdoor_backup.is_none() {
5390                        self.z_bands_outdoor_backup = Some((
5391                            std::mem::take(&mut self.z_platforms),
5392                            std::mem::take(&mut self.z_transitions),
5393                        ));
5394                    }
5395                    self.z_platforms = map.z_platforms.clone();
5396                    self.z_transitions = map.z_transitions.clone();
5397                }
5398            }
5399        }
5400    }
5401
5402    fn apply_snapshot_fields(
5403        &mut self,
5404        snapshot: &flatland_protocol::Snapshot,
5405        entity_id: EntityId,
5406    ) {
5407        self.tick = snapshot.tick;
5408        self.chunk_rev = snapshot.chunk_rev;
5409        self.content_rev = snapshot.content_rev;
5410        self.publish_rev = snapshot.publish_rev;
5411        self.resource_nodes = snapshot.resource_nodes.clone();
5412        self.ground_drops = snapshot.ground_drops.clone();
5413        self.placed_containers = snapshot.placed_containers.clone();
5414        self.world_x0 = snapshot.world_x0;
5415        self.world_y0 = snapshot.world_y0;
5416        self.world_width_m = snapshot.world_width_m;
5417        self.world_height_m = snapshot.world_height_m;
5418        self.world_clock = snapshot.world_clock;
5419        self.terrain_zones = snapshot.terrain_zones.clone();
5420        self.z_platforms = snapshot.z_platforms.clone();
5421        self.z_transitions = snapshot.z_transitions.clone();
5422        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
5423        self.z_bands_outdoor_backup = None;
5424        self.buildings = snapshot.buildings.clone();
5425        self.doors = snapshot.doors.clone();
5426        self.interior_map = snapshot.interior_map.clone();
5427        self.npcs = snapshot.npcs.clone();
5428        self.blueprints = snapshot.blueprints.clone();
5429        self.building_materials = snapshot.building_materials.clone();
5430        self.sync_inventory_from_stacks(&snapshot.inventory);
5431        self.player = snapshot
5432            .entities
5433            .iter()
5434            .find(|e| e.id == entity_id)
5435            .cloned();
5436        self.entities = snapshot.entities.clone();
5437        self.quest_log = snapshot.quest_log.clone();
5438        self.apply_hired_workers(snapshot.hired_workers.clone());
5439        self.interactables = snapshot.interactables.clone();
5440        self.ledger = snapshot.ledger.clone();
5441        self.career = snapshot.career.clone();
5442        self.combat_fx = snapshot.combat_fx.clone();
5443        self.ground_hazards = snapshot.ground_hazards.clone();
5444        self.property_zones = snapshot.property_zones.clone();
5445        self.tax_zones = snapshot.tax_zones.clone();
5446        self.growth_zones = snapshot.growth_zones.clone();
5447        self.biome_zones = snapshot.biome_zones.clone();
5448        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5449        self.property_plots = snapshot.property_plots.clone();
5450        self.property_plot_settings = snapshot.property_plot_settings.clone();
5451        self.sync_item_catalog(&snapshot.item_catalog);
5452        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
5453        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
5454        if self.effective_inside_building().is_some() {
5455            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5456        }
5457        self.sync_interior_map_context();
5458        self.refresh_whisper_range();
5459        self.sync_gameplay_audio();
5460    }
5461
5462    /// Re-derive inventory selection state after a snapshot/tick — chests that
5463    /// went out of range or got locked simply vanish from the row list, and the
5464    /// move picker (if any) closes once its item is no longer reachable.
5465    fn refresh_inventory_ui(&mut self) {
5466        if let Some(picker) = &self.move_picker {
5467            let instance_id = picker.item_instance_id;
5468            let still_exists = self
5469                .inventory_selectable_rows()
5470                .iter()
5471                .any(|r| r.stack.item_instance_id == Some(instance_id));
5472            if !still_exists {
5473                self.move_picker = None;
5474                self.show_move_picker = false;
5475            }
5476        }
5477        if let Some(picker) = &self.destroy_picker {
5478            let instance_id = picker.item_instance_id;
5479            let still_exists = self
5480                .inventory_selectable_rows()
5481                .iter()
5482                .any(|r| r.stack.item_instance_id == Some(instance_id));
5483            if !still_exists {
5484                self.destroy_picker = None;
5485                self.show_destroy_picker = false;
5486                self.destroy_confirm_pending = false;
5487            }
5488        }
5489        self.clamp_inventory_indices();
5490    }
5491
5492    /// Replace the hired-worker list from a snapshot/delta.
5493    ///
5494    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
5495    /// selected worker (and its step line) does not jump when the server rebuilds
5496    /// the list.
5497    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5498        let selected_id = self
5499            .hired_workers
5500            .get(self.workers_menu_index)
5501            .map(|w| w.instance_id.clone());
5502        let previous_worker_ids: HashSet<String> = self
5503            .hired_workers
5504            .iter()
5505            .map(|worker| worker.instance_id.clone())
5506            .collect();
5507        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5508        let now = Instant::now();
5509        let saw_new_worker = workers
5510            .iter()
5511            .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5512        for worker in &workers {
5513            let was_hit = self
5514                .hired_workers
5515                .iter()
5516                .find(|previous| previous.instance_id == worker.instance_id)
5517                .is_some_and(|previous| {
5518                    matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5519                        && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5520                });
5521            if was_hit {
5522                self.worker_health_ring_until
5523                    .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5524            }
5525        }
5526        let worker_entity_ids: HashSet<EntityId> =
5527            workers.iter().map(|worker| worker.entity_id).collect();
5528        self.worker_health_ring_until
5529            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5530        for w in &workers {
5531            let prev_err = self
5532                .hired_workers
5533                .iter()
5534                .find(|p| p.instance_id == w.instance_id)
5535                .and_then(|p| p.last_error.as_deref());
5536            let new_err = w.last_error.as_deref();
5537            if new_err != prev_err {
5538                if let Some(err) = new_err {
5539                    if !worker_error_is_transient(err) {
5540                        self.push_log(format!("Worker {}: {err}", w.label));
5541                    }
5542                }
5543            }
5544        }
5545        let mut next_display = BTreeMap::new();
5546        let mut next_errors = BTreeMap::new();
5547        for w in &workers {
5548            let mut sticky = self
5549                .worker_step_display
5550                .remove(&w.instance_id)
5551                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5552            sticky.observe(&w.step_label, now);
5553            next_display.insert(w.instance_id.clone(), sticky);
5554
5555            let mut err_sticky = self
5556                .worker_error_display
5557                .remove(&w.instance_id)
5558                .unwrap_or_default();
5559            err_sticky.observe(w.last_error.as_deref(), now);
5560            if err_sticky.shown(now).is_some() {
5561                next_errors.insert(w.instance_id.clone(), err_sticky);
5562            }
5563        }
5564        self.worker_step_display = next_display;
5565        self.worker_error_display = next_errors;
5566        self.hired_workers = workers;
5567        if saw_new_worker {
5568            self.pending_worker_hire_since = None;
5569        }
5570        self.sync_worker_take_picker_from_hired();
5571        if let Some(id) = selected_id {
5572            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5573                self.workers_menu_index = idx;
5574                return;
5575            }
5576        }
5577        if self.workers_menu_index >= self.hired_workers.len() {
5578            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5579        }
5580    }
5581
5582    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
5583    fn sync_worker_take_picker_from_hired(&mut self) {
5584        if !self.show_worker_take_picker {
5585            return;
5586        }
5587        let Some(picker) = self.worker_take_picker.clone() else {
5588            return;
5589        };
5590        let Some(worker) = self
5591            .hired_workers
5592            .iter()
5593            .find(|w| w.instance_id == picker.worker_instance_id)
5594            .cloned()
5595        else {
5596            self.show_worker_take_picker = false;
5597            self.worker_take_picker = None;
5598            self.worker_take_picker_index = 0;
5599            return;
5600        };
5601        let options: Vec<WorkerGiveOption> = worker
5602            .inventory
5603            .iter()
5604            .filter_map(|stack| {
5605                let item_instance_id = stack.item_instance_id?;
5606                let label = stack
5607                    .display_name
5608                    .clone()
5609                    .unwrap_or_else(|| stack.template_id.clone());
5610                let label = if stack.quantity > 1 {
5611                    format!("{label} ×{}", stack.quantity)
5612                } else {
5613                    label
5614                };
5615                Some(WorkerGiveOption {
5616                    item_instance_id,
5617                    label,
5618                    quantity: stack.quantity,
5619                    template_id: stack.template_id.clone(),
5620                })
5621            })
5622            .collect();
5623        if options.is_empty() {
5624            self.show_worker_take_picker = false;
5625            self.worker_take_picker = None;
5626            self.worker_take_picker_index = 0;
5627            return;
5628        }
5629        let prev_id = picker
5630            .options
5631            .get(self.worker_take_picker_index)
5632            .map(|o| o.item_instance_id);
5633        let idx = prev_id
5634            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5635            .unwrap_or(0)
5636            .min(options.len().saturating_sub(1));
5637        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5638        let quantity = picker.quantity.clamp(1, max_qty);
5639        self.worker_take_picker_index = idx;
5640        self.worker_take_picker = Some(WorkerTakePicker {
5641            worker_instance_id: picker.worker_instance_id,
5642            worker_label: picker.worker_label,
5643            options,
5644            quantity,
5645        });
5646    }
5647
5648    /// Held coarse step label for the workers menu (`step:` line).
5649    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5650        self.worker_step_display
5651            .get(worker_instance_id)
5652            .map(|s| s.shown.as_str())
5653            .or_else(|| {
5654                self.hired_workers
5655                    .iter()
5656                    .find(|w| w.instance_id == worker_instance_id)
5657                    .map(|w| w.step_label.as_str())
5658            })
5659            .unwrap_or("")
5660    }
5661
5662    /// Held error line for workers UI (detail + compact).
5663    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5664        let now = Instant::now();
5665        self.worker_error_display
5666            .get(worker_instance_id)
5667            .and_then(|s| s.shown(now))
5668            .or_else(|| {
5669                self.hired_workers
5670                    .iter()
5671                    .find(|w| w.instance_id == worker_instance_id)
5672                    .and_then(|w| w.last_error.as_deref())
5673                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5674            })
5675            .filter(|e| !worker_error_is_hud_noise(e))
5676    }
5677
5678    fn apply_combat_hud(&mut self, combat: &CombatHud) {
5679        self.in_combat = combat.in_combat;
5680        self.auto_attack = combat.auto_attack;
5681        self.combat_has_los = combat.has_los;
5682        self.attack_cd_ticks = combat.attack_cd_ticks;
5683        self.gcd_ticks = combat.gcd_ticks;
5684        self.weapon_ability_id = combat.ability_id.clone();
5685        self.mainhand_template_id = combat.mainhand_template_id.clone();
5686        self.mainhand_label = combat.mainhand_label.clone();
5687        self.mainhand_instance_id = combat.mainhand_instance_id;
5688        self.offhand_template_id = combat.offhand_template_id.clone();
5689        self.offhand_label = combat.offhand_label.clone();
5690        self.offhand_instance_id = combat.offhand_instance_id;
5691        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5692            1
5693        } else {
5694            combat.mainhand_hand_slots
5695        };
5696        self.defense = combat.defense.clone();
5697        self.worn = combat.worn.iter().cloned().collect();
5698        self.carry_mass = combat.carry_mass;
5699        self.carry_mass_max = combat.carry_mass_max;
5700        self.encumbrance = combat.encumbrance;
5701        self.move_speed_mps = combat.move_speed_mps;
5702        self.move_speed_mult = combat.move_speed_mult;
5703        self.cast_progress = combat.cast.clone();
5704        self.timed_channel = combat.timed_channel.clone();
5705        if self.active_craft_channel().is_none() {
5706            self.craft_channel_blueprint_id = None;
5707        }
5708        self.plot_build_offer = combat.plot_build.clone();
5709        self.ability_cooldowns = combat.ability_cooldowns.clone();
5710        self.blocking_active = combat.blocking_active;
5711        self.max_target_slots = combat.max_target_slots.max(1);
5712        self.combat_slots = combat.slots.clone();
5713        self.rotation_presets = combat.rotation_presets.clone();
5714        self.known_abilities = combat.known_abilities.clone();
5715        self.ability_meta = combat
5716            .ability_meta
5717            .iter()
5718            .cloned()
5719            .map(|meta| (meta.id.clone(), meta))
5720            .collect();
5721        self.ability_mastery = combat
5722            .ability_mastery
5723            .iter()
5724            .cloned()
5725            .map(|row| (row.ability_id.clone(), row))
5726            .collect();
5727        self.hotbar = combat.hotbar.clone();
5728        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5729        self.keychain_stacks = combat.keychain.clone();
5730        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5731        self.combat_target_detail = combat.target.clone();
5732        self.statuses = combat.statuses.clone();
5733        self.combat_target = combat.target_entity_id;
5734        if combat.progression_xp_base > 0.0 {
5735            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5736                baseline_display: combat.progression_baseline,
5737                xp_base: combat.progression_xp_base,
5738                xp_growth: combat.progression_xp_growth,
5739            });
5740        }
5741        if let Some(xp) = &combat.progression_xp {
5742            if let Some(player) = &mut self.player {
5743                player.progression_xp = Some(xp.clone());
5744                if let Some(attrs) = combat.attributes {
5745                    player.attributes = Some(attrs);
5746                }
5747                if let Some(skills) = &combat.skills {
5748                    player.skills = Some(skills.clone());
5749                }
5750            }
5751        }
5752        if let Some(label) = &combat.target_label {
5753            self.combat_target_label = Some(label.clone());
5754        } else if let Some(id) = combat.target_entity_id {
5755            self.combat_target_label = self
5756                .entities
5757                .iter()
5758                .find(|e| e.id == id)
5759                .map(|e| e.label.clone())
5760                .or_else(|| self.combat_target_label.clone());
5761        }
5762        self.refresh_inventory_ui();
5763    }
5764
5765    /// Entity id assigned to a combat slot (from server HUD).
5766    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5767        self.combat_slots
5768            .iter()
5769            .find(|s| s.slot_index == slot)
5770            .and_then(|s| s.target_entity_id)
5771            .or_else(|| if slot == 1 { self.combat_target } else { None })
5772    }
5773
5774    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
5775    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5776        self.ability_meta
5777            .get(ability_id)
5778            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5779            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
5780            // the server validates aim_mode.
5781            .unwrap_or(self.ground_target.is_some())
5782    }
5783
5784    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
5785    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5786        self.ability_meta
5787            .get(ability_id)
5788            .map(|meta| meta.aim_mode == "ground")
5789            .unwrap_or(false)
5790    }
5791
5792    /// True when the ability may be placed in an auto-rotation preset.
5793    /// Missing meta (pre-sync) defaults to eligible; server still validates.
5794    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5795        self.ability_meta
5796            .get(ability_id)
5797            .map(|meta| meta.auto_rotation_eligible)
5798            .unwrap_or(true)
5799    }
5800
5801    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
5802    pub fn set_ground_target(&mut self, x: f32, y: f32) {
5803        self.ground_target = Some((x, y, 0.0));
5804    }
5805
5806    /// Clear the local free-aim ground target.
5807    pub fn clear_ground_target(&mut self) {
5808        self.ground_target = None;
5809    }
5810
5811    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
5812    /// May be an ability id or `item:<template_id>` consumable binding.
5813    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5814        if !(1..=9).contains(&slot_1_to_9) {
5815            return None;
5816        }
5817        self.hotbar
5818            .get((slot_1_to_9 - 1) as usize)
5819            .and_then(|a| a.as_deref())
5820            .filter(|id| !id.is_empty())
5821    }
5822
5823    /// Short label for a hotbar slot (ability id, or consumable name × qty).
5824    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5825        let binding = self.hotbar_ability(slot_1_to_9)?;
5826        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5827            let name = self
5828                .inventory_hints
5829                .get(template_id)
5830                .map(|h| h.display_name.as_str())
5831                .unwrap_or(template_id);
5832            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5833            Some(format!("{name}×{qty}"))
5834        } else {
5835            Some(binding.to_string())
5836        }
5837    }
5838
5839    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
5840    pub fn loadout_ability_choices(&self) -> Vec<String> {
5841        let mut out = self.known_abilities.clone();
5842        let weapon = self.weapon_ability_id.trim();
5843        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5844            out.push(weapon.to_string());
5845        }
5846        out
5847    }
5848
5849    /// Hotbar bind candidates: learned abilities, then on-person consumables.
5850    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5851        let mut out = Vec::new();
5852        for ability in self.loadout_ability_choices() {
5853            let meta = if ability == self.weapon_ability_id {
5854                Some("weapon".into())
5855            } else {
5856                None
5857            };
5858            out.push(LoadoutHotbarChoice {
5859                binding: ability.clone(),
5860                label: ability,
5861                meta,
5862            });
5863        }
5864        let mut consumables: Vec<(String, String, u32)> = Vec::new();
5865        for stack in &self.inventory_stacks {
5866            if Self::stack_is_item_grant(stack) {
5867                continue;
5868            }
5869            if Self::stack_is_blueprint_scroll(stack) {
5870                continue;
5871            }
5872            if self.inventory_item_category(&stack.template_id) != Some("consumable")
5873                && !Self::stack_is_serving(stack)
5874            {
5875                continue;
5876            }
5877            let qty = stack.quantity.max(1);
5878            if let Some((_, _, existing)) = consumables
5879                .iter_mut()
5880                .find(|(id, _, _)| id == &stack.template_id)
5881            {
5882                *existing = existing.saturating_add(qty);
5883            } else {
5884                let label = stack
5885                    .display_name
5886                    .clone()
5887                    .or_else(|| {
5888                        self.inventory_hints
5889                            .get(&stack.template_id)
5890                            .map(|h| h.display_name.clone())
5891                    })
5892                    .unwrap_or_else(|| stack.template_id.clone());
5893                consumables.push((stack.template_id.clone(), label, qty));
5894            }
5895        }
5896        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5897        for (template_id, label, qty) in consumables {
5898            out.push(LoadoutHotbarChoice {
5899                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5900                label: format!("{label} ×{qty}"),
5901                meta: Some("use".into()),
5902            });
5903        }
5904        out
5905    }
5906
5907    /// Hostile wildlife / monsters for T1 (`Tab`).
5908    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5909        self.combat_candidates()
5910    }
5911
5912    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
5913    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5914        let (px, py) = self.player_position();
5915        let dist = |id: EntityId| {
5916            self.entities
5917                .iter()
5918                .find(|e| e.id == id)
5919                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5920                .unwrap_or(f32::MAX)
5921        };
5922
5923        let mut allies = Vec::new();
5924        // Self first — heal_touch on T2.
5925        if let Some(me) = self.player.as_ref() {
5926            let alive = me
5927                .vitals
5928                .as_ref()
5929                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5930                .unwrap_or(true);
5931            if alive {
5932                allies.push((self.entity_id, "Yourself".into()));
5933            }
5934        }
5935        for entity in &self.entities {
5936            if entity.id == self.entity_id {
5937                continue;
5938            }
5939            if entity.vitals.is_some() {
5940                let alive = entity
5941                    .vitals
5942                    .as_ref()
5943                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5944                    .unwrap_or(true);
5945                if alive {
5946                    allies.push((entity.id, entity.label.clone()));
5947                }
5948            }
5949        }
5950        allies.sort_by(|(a, _), (b, _)| {
5951            if *a == self.entity_id {
5952                return std::cmp::Ordering::Less;
5953            }
5954            if *b == self.entity_id {
5955                return std::cmp::Ordering::Greater;
5956            }
5957            dist(*a)
5958                .partial_cmp(&dist(*b))
5959                .unwrap_or(std::cmp::Ordering::Equal)
5960        });
5961
5962        let mut monsters = self.combat_candidates();
5963        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
5964        allies.into_iter().chain(monsters).collect()
5965    }
5966
5967    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
5968        match slot_index {
5969            2 => self.t2_candidates(),
5970            _ => self.t1_candidates(),
5971        }
5972    }
5973
5974    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
5975    pub fn pick_combat_target_at(
5976        &self,
5977        wx: f32,
5978        wy: f32,
5979        slot_index: u8,
5980        radius_m: f32,
5981    ) -> Option<(EntityId, String)> {
5982        let mut best: Option<(f32, EntityId, String)> = None;
5983        for (id, label) in self.candidates_for_slot(slot_index) {
5984            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5985                // NPCs may only appear in NpcView — fall back to npc list coords.
5986                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5987                    let d = distance(wx, wy, npc.x, npc.y);
5988                    if d <= radius_m {
5989                        best = match best {
5990                            Some((bd, _, _)) if bd <= d => best,
5991                            _ => Some((d, id, label)),
5992                        };
5993                    }
5994                }
5995                continue;
5996            };
5997            let d = distance(
5998                wx,
5999                wy,
6000                entity.transform.position.x,
6001                entity.transform.position.y,
6002            );
6003            if d <= radius_m {
6004                best = match best {
6005                    Some((bd, _, _)) if bd <= d => best,
6006                    _ => Some((d, id, label)),
6007                };
6008            }
6009        }
6010        best.map(|(_, id, label)| (id, label))
6011    }
6012
6013    /// Full client reset after Welcome (initial connect or reconnect).
6014    pub(crate) fn restore_from_welcome(
6015        &mut self,
6016        session_id: SessionId,
6017        entity_id: EntityId,
6018        snapshot: &flatland_protocol::Snapshot,
6019    ) {
6020        self.clear_harvest_state();
6021        self.disconnect_reason = None;
6022        self.show_stats = false;
6023        self.show_craft_menu = false;
6024        self.show_shop_menu = false;
6025        self.shop_catalog = None;
6026        self.show_inventory_menu = false;
6027        self.session_id = session_id;
6028        self.entity_id = entity_id;
6029        self.connected = true;
6030        self.apply_snapshot_fields(snapshot, entity_id);
6031        if let Some(combat) = &snapshot.combat {
6032            self.apply_combat_hud(combat);
6033            let stacks = self.inventory_stacks.clone();
6034            self.sync_inventory_from_stacks(&stacks);
6035        }
6036    }
6037
6038    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6039        self.tick = delta.tick;
6040        self.world_clock = delta.world_clock;
6041
6042        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
6043        if delta.entities.is_empty() {
6044            self.ground_drops = delta.ground_drops.clone();
6045            self.combat_fx = delta.combat_fx.clone();
6046            self.ground_hazards = delta.ground_hazards.clone();
6047            self.property_plots = delta.property_plots.clone();
6048            self.apply_terrain_overlays(&delta.terrain_overlays);
6049            if let Some(combat) = &delta.combat {
6050                self.apply_combat_hud(combat);
6051                let stacks = self.inventory_stacks.clone();
6052                self.sync_inventory_from_stacks(&stacks);
6053            }
6054            // Peer vanished from AOI — drop directed whisper.
6055            self.refresh_whisper_range();
6056            self.sync_gameplay_audio();
6057            return;
6058        }
6059        if !delta.buildings.is_empty() {
6060            self.buildings = delta.buildings.clone();
6061        }
6062        if !delta.blueprints.is_empty() {
6063            self.blueprints = delta.blueprints.clone();
6064        }
6065        if !delta.building_materials.is_empty() {
6066            self.building_materials = delta.building_materials.clone();
6067        }
6068        self.sync_inventory_from_stacks(&delta.inventory);
6069
6070        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6071            self.player = Some(updated.clone());
6072        }
6073        self.entities = delta.entities.clone();
6074        if self.player.is_none() {
6075            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6076        }
6077
6078        self.sync_interior_map_context();
6079
6080        // Static world layers: ticks often omit these (indoors, or unchanged).
6081        // Never wipe the welcome snapshot with an empty vec — except when indoors,
6082        // where empty means "no nodes in this building" (clear outdoor leftovers).
6083        if !delta.resource_nodes.is_empty() {
6084            self.resource_nodes = delta.resource_nodes.clone();
6085        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6086            self.resource_nodes = delta.resource_nodes.clone();
6087        }
6088        self.ground_drops = delta.ground_drops.clone();
6089        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
6090        self.placed_containers = delta.placed_containers.clone();
6091        if !delta.doors.is_empty() {
6092            self.doors = delta.doors.clone();
6093        }
6094        if self.effective_inside_building().is_some() {
6095            if let Some(map) = &delta.interior_map {
6096                self.interior_map = Some(map.clone());
6097            }
6098        } else {
6099            self.interior_map = None;
6100        }
6101        self.sync_interior_z_bands();
6102        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
6103        self.npcs = delta.npcs.clone();
6104        if !delta.quest_log.is_empty() {
6105            self.quest_log = delta.quest_log.clone();
6106        }
6107        self.apply_hired_workers(delta.hired_workers.clone());
6108        if !delta.interactables.is_empty() {
6109            self.interactables = delta.interactables.clone();
6110        }
6111        if delta.ledger.is_some() {
6112            self.ledger = delta.ledger.clone();
6113        }
6114        if delta.career.is_some() {
6115            self.career = delta.career.clone();
6116        }
6117        self.combat_fx = delta.combat_fx.clone();
6118        self.ground_hazards = delta.ground_hazards.clone();
6119        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
6120        if !delta.property_plots.is_empty() {
6121            self.property_plots = delta.property_plots.clone();
6122        }
6123        self.apply_terrain_overlays(&delta.terrain_overlays);
6124        if let Some(combat) = &delta.combat {
6125            self.apply_combat_hud(combat);
6126            let stacks = self.inventory_stacks.clone();
6127            self.sync_inventory_from_stacks(&stacks);
6128        } else {
6129            self.refresh_inventory_ui();
6130        }
6131        self.refresh_whisper_range();
6132        self.sync_gameplay_audio();
6133    }
6134
6135    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
6136    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
6137    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6138        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6139        self.terrain_zones.extend(overlays.iter().cloned());
6140    }
6141
6142    /// End directed whisper when the peer is farther than interact range (or missing).
6143    /// Each client runs this locally so both players drop whisper mode when either walks away.
6144    fn refresh_whisper_range(&mut self) {
6145        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6146            return;
6147        };
6148        let (px, py) = self.player_position();
6149        let in_range = self.entities.iter().any(|e| {
6150            e.id == peer
6151                && distance(px, py, e.transform.position.x, e.transform.position.y)
6152                    <= INTERACTION_RADIUS_M
6153        });
6154        if !in_range {
6155            self.social_chat.cancel_whisper_out_of_range();
6156        }
6157    }
6158
6159    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
6160    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6161        let (px, py) = self.player_position();
6162        let mut out = Vec::new();
6163        for npc in &self.npcs {
6164            let Some(eid) = npc.entity_id else {
6165                continue;
6166            };
6167            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6168            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6169            if alive && has_hp {
6170                out.push((eid, npc.label.clone()));
6171            }
6172        }
6173        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6174            let dist = |id: EntityId| {
6175                self.entities
6176                    .iter()
6177                    .find(|e| e.id == id)
6178                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6179                    .unwrap_or(f32::MAX)
6180            };
6181            dist(*a_id)
6182                .partial_cmp(&dist(*b_id))
6183                .unwrap_or(std::cmp::Ordering::Equal)
6184                .then_with(|| a_label.cmp(b_label))
6185                .then_with(|| a_id.cmp(b_id))
6186        });
6187        out
6188    }
6189
6190    pub fn refresh_combat_target_label(&mut self) {
6191        let Some(id) = self.combat_target else {
6192            return;
6193        };
6194        if let Some((_, label)) = self
6195            .combat_candidates()
6196            .into_iter()
6197            .find(|(eid, _)| *eid == id)
6198        {
6199            self.combat_target_label = Some(label);
6200        } else if let Some(label) = self
6201            .entities
6202            .iter()
6203            .find(|e| e.id == id)
6204            .map(|e| e.label.clone())
6205        {
6206            self.combat_target_label = Some(label);
6207        }
6208    }
6209
6210    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6211        self.quest_log
6212            .iter()
6213            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6214            .collect()
6215    }
6216
6217    /// True when the observer has at least one free lodging slot (max workers = beds).
6218    pub fn has_worker_lodging(&self) -> bool {
6219        self.free_worker_lodging_slots() > 0
6220    }
6221
6222    /// Owned lodging capacity minus currently hired workers.
6223    pub fn free_worker_lodging_slots(&self) -> i64 {
6224        let slots: u32 = self
6225            .placed_containers
6226            .iter()
6227            .filter(|c| match (self.character_id, c.owner_character_id) {
6228                (Some(me), Some(owner)) => me == owner,
6229                (Some(_), None) => false,
6230                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6231            })
6232            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6233            .sum();
6234        let used = self.hired_workers.len() as u32;
6235        slots as i64 - used as i64
6236    }
6237
6238    /// Display names of hired workers assigned to this lodging container.
6239    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6240        let mut names: Vec<String> = self
6241            .hired_workers
6242            .iter()
6243            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6244            .map(|w| w.label.clone())
6245            .collect();
6246        names.sort();
6247        names
6248    }
6249
6250    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
6251    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6252        let is_lodging = self
6253            .placed_containers
6254            .iter()
6255            .find(|c| c.id == container_id)
6256            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6257        if !is_lodging {
6258            return None;
6259        }
6260        let names = self.lodging_occupant_labels(container_id);
6261        Some(if names.is_empty() {
6262            "vacant".into()
6263        } else {
6264            names.join(", ")
6265        })
6266    }
6267
6268    /// True when a lodging bed has at least one hired worker assigned.
6269    pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6270        matches!(
6271            self.lodging_occupancy_label(container_id),
6272            Some(label) if label != "vacant"
6273        )
6274    }
6275
6276    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6277        self.quest_log
6278            .iter()
6279            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6280            .or_else(|| {
6281                self.quest_log
6282                    .iter()
6283                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6284            })
6285    }
6286
6287    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
6288    pub fn nearby_lockable_door(&self) -> bool {
6289        let (px, py) = self.player_position();
6290        self.doors
6291            .iter()
6292            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6293    }
6294
6295    /// True when an open player-house door is in range for Enter-to-go-inside.
6296    pub fn nearby_open_player_door(&self) -> bool {
6297        if self.effective_inside_building().is_some() {
6298            return false;
6299        }
6300        let (px, py) = self.player_position();
6301        self.doors.iter().any(|d| {
6302            if !d.open || d.locked {
6303                return false;
6304            }
6305            let player_house = self
6306                .buildings
6307                .iter()
6308                .find(|b| b.id == d.building_id)
6309                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6310            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6311        })
6312    }
6313
6314    /// True when inside a player house near the exterior portal (Enter exits freely).
6315    pub fn nearby_player_exit_door(&self) -> bool {
6316        let Some(bid) = self.effective_inside_building() else {
6317            return false;
6318        };
6319        let (px, py) = self.player_position();
6320        self.doors.iter().any(|d| {
6321            if d.building_id != bid || d.portal.is_none() {
6322                return false;
6323            }
6324            let player_house = self
6325                .buildings
6326                .iter()
6327                .find(|b| b.id == d.building_id)
6328                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6329            player_house && (d.x - px).hypot(d.y - py) <= 1.5
6330        })
6331    }
6332
6333    /// Nearest interactable target for `f` (doors, NPCs, quest boards).
6334    pub fn nearest_interact_target(&self) -> Option<String> {
6335        let (px, py) = self.player_position();
6336        let inside = self.effective_inside_building();
6337
6338        #[derive(Clone, Copy, PartialEq, Eq)]
6339        enum Kind {
6340            Player,
6341            Npc,
6342            HiredWorker,
6343            QuestBoard,
6344            ExitDoor,
6345            EnterDoor,
6346        }
6347
6348        fn kind_class(kind: Kind) -> u8 {
6349            match kind {
6350                Kind::EnterDoor => 0,
6351                Kind::QuestBoard => 1,
6352                Kind::Player | Kind::Npc => 2,
6353                Kind::ExitDoor => 3,
6354                Kind::HiredWorker => 4,
6355            }
6356        }
6357
6358        fn kind_priority(kind: Kind) -> u8 {
6359            match kind {
6360                Kind::EnterDoor => 0,
6361                Kind::QuestBoard => 1,
6362                Kind::Player | Kind::Npc => 2,
6363                Kind::ExitDoor => 3,
6364                Kind::HiredWorker => 4,
6365            }
6366        }
6367
6368        let mut best: Option<(f32, Kind, String)> = None;
6369
6370        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6371            if dist > max {
6372                return;
6373            }
6374            let replace = match best {
6375                None => true,
6376                Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6377                Some((bd, bk, _))
6378                    if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6379                {
6380                    true
6381                }
6382                Some((bd, bk, _))
6383                    if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6384                {
6385                    kind_priority(kind) < kind_priority(bk)
6386                }
6387                _ => false,
6388            };
6389            if replace {
6390                best = Some((dist, kind, id));
6391            }
6392        };
6393
6394        for npc in &self.npcs {
6395            consider(
6396                distance(px, py, npc.x, npc.y),
6397                INTERACTION_RADIUS_M,
6398                Kind::Npc,
6399                npc.id.clone(),
6400            );
6401        }
6402
6403        for worker in &self.hired_workers {
6404            consider(
6405                distance(px, py, worker.x, worker.y),
6406                INTERACTION_RADIUS_M,
6407                Kind::HiredWorker,
6408                worker.instance_id.clone(),
6409            );
6410        }
6411
6412        for entity in &self.entities {
6413            if entity.id == self.entity_id
6414                || entity.vitals.is_none()
6415                || entity.label.trim().is_empty()
6416            {
6417                continue;
6418            }
6419            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
6420            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6421                continue;
6422            }
6423            consider(
6424                distance(
6425                    px,
6426                    py,
6427                    entity.transform.position.x,
6428                    entity.transform.position.y,
6429                ),
6430                INTERACTION_RADIUS_M,
6431                Kind::Player,
6432                entity.id.to_string(),
6433            );
6434        }
6435
6436        for door in &self.doors {
6437            if let Some(ref bid) = inside {
6438                if door.building_id != *bid {
6439                    continue;
6440                }
6441                let is_exit = door.portal.is_some();
6442                let max = if is_exit {
6443                    INTERACTION_RADIUS_M
6444                } else {
6445                    DOOR_INTERACTION_RADIUS_M
6446                };
6447                let kind = if is_exit {
6448                    Kind::ExitDoor
6449                } else {
6450                    Kind::EnterDoor
6451                };
6452                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6453                continue;
6454            }
6455            consider(
6456                distance(px, py, door.x, door.y),
6457                DOOR_INTERACTION_RADIUS_M,
6458                Kind::EnterDoor,
6459                door.id.clone(),
6460            );
6461        }
6462
6463        if inside.is_none() {
6464            for inter in &self.interactables {
6465                if inter.kind == "quest_board" {
6466                    consider(
6467                        distance(px, py, inter.x, inter.y),
6468                        QUEST_BOARD_INTERACTION_RADIUS_M,
6469                        Kind::QuestBoard,
6470                        inter.id.clone(),
6471                    );
6472                }
6473            }
6474        }
6475
6476        best.map(|(_, _, id)| id)
6477    }
6478
6479    /// Nearest quest board and distance (any distance), for out-of-range feedback.
6480    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6481        if self.effective_inside_building().is_some() {
6482            return None;
6483        }
6484        let (px, py) = self.player_position();
6485        self.interactables
6486            .iter()
6487            .filter(|i| i.kind == "quest_board")
6488            .map(|i| {
6489                let label = if i.label.is_empty() {
6490                    "Quest board".to_string()
6491                } else {
6492                    i.label.clone()
6493                };
6494                (label, distance(px, py, i.x, i.y))
6495            })
6496            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6497    }
6498
6499    /// Human-readable template name from inventory hints, then the synced item catalog.
6500    pub fn template_display_name(&self, template_id: &str) -> String {
6501        if let Some(name) = self
6502            .inventory_hints
6503            .get(template_id)
6504            .map(|h| h.display_name.clone())
6505            .filter(|n| !n.is_empty())
6506        {
6507            return name;
6508        }
6509        if let Some(entry) = self.item_catalog.get(template_id) {
6510            if !entry.display_name.trim().is_empty() {
6511                return entry.display_name.clone();
6512            }
6513        }
6514        humanize_template_id(template_id)
6515    }
6516
6517    pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6518        self.item_catalog.get(template_id)
6519    }
6520
6521    /// Prefer blueprint-view display name, then inventory hints / humanized id.
6522    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6523        if !display_name.is_empty() {
6524            display_name.to_string()
6525        } else {
6526            self.template_display_name(template_id)
6527        }
6528    }
6529
6530    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6531        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6532    }
6533
6534    pub fn blueprint_ingredient_label(
6535        &self,
6536        input: &flatland_protocol::BlueprintIngredientView,
6537    ) -> String {
6538        self.blueprint_item_label(&input.template_id, &input.display_name)
6539    }
6540
6541    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6542        self.blueprint_item_label(&tool.item, &tool.display_name)
6543    }
6544
6545    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
6546    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6547        use crate::worker_route_editor::{
6548            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6549        };
6550        let lodging = self
6551            .worker_route_editor
6552            .as_ref()
6553            .and_then(|ed| ed.lodging_container_id.as_deref());
6554        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6555            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
6556            None => node_candidates_stable(&self.resource_nodes),
6557        }
6558    }
6559
6560    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6561        if dist_m.is_nan() {
6562            return "—".into();
6563        }
6564        let from_bed = self
6565            .worker_route_editor
6566            .as_ref()
6567            .and_then(|ed| ed.lodging_container_id.as_deref())
6568            .and_then(|id| {
6569                self.placed_containers
6570                    .iter()
6571                    .find(|c| c.id == id)
6572                    .map(|c| c.display_name.clone())
6573            });
6574        match from_bed {
6575            Some(bed) => format!("{dist_m:.0}m from {bed}"),
6576            None => format!("{dist_m:.0}m"),
6577        }
6578    }
6579
6580    /// Chest label for the location panel — generic type unless this player owns it.
6581    pub fn placed_container_public_label(
6582        &self,
6583        c: &flatland_protocol::PlacedContainerView,
6584    ) -> String {
6585        let is_owner = match (self.character_id, c.owner_character_id) {
6586            (Some(me), Some(owner)) => me == owner,
6587            _ => false,
6588        };
6589        if is_owner {
6590            c.display_name.clone()
6591        } else {
6592            self.template_display_name(&c.template_id)
6593        }
6594    }
6595
6596    /// Keys on person and stowed on the keychain for the keychain overlay.
6597    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6598        let mut out = Vec::new();
6599        for stack in &self.inventory_stacks {
6600            if stack.template_id == KEY_TEMPLATE {
6601                out.push(KeychainEntry {
6602                    stack: stack.clone(),
6603                    stowed: false,
6604                });
6605            }
6606        }
6607        for stack in &self.keychain_stacks {
6608            if stack.template_id == KEY_TEMPLATE {
6609                out.push(KeychainEntry {
6610                    stack: stack.clone(),
6611                    stowed: true,
6612                });
6613            }
6614        }
6615        out
6616    }
6617
6618    /// Display name of the chest a `container_key` opens, when known on the client.
6619    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6620        if stack.template_id != KEY_TEMPLATE {
6621            return None;
6622        }
6623        if let Some(name) = stack
6624            .props
6625            .get(PROP_OPENS_CONTAINER_NAME)
6626            .filter(|n| !n.is_empty())
6627        {
6628            return Some(name.clone());
6629        }
6630        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6631        self.container_name_for_lock_id(opens)
6632    }
6633
6634    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
6635    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6636        if stack.template_id == KEY_TEMPLATE {
6637            self.template_display_name(KEY_TEMPLATE)
6638        } else {
6639            stack
6640                .display_name
6641                .clone()
6642                .unwrap_or_else(|| stack.template_id.clone())
6643        }
6644    }
6645
6646    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
6647    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6648        if stack.template_id != KEY_TEMPLATE {
6649            return String::new();
6650        }
6651        match self.key_pair_chest_label(stack) {
6652            Some(chest) if self.key_drop_blocked(stack) => {
6653                format!(" [key for {chest} — can't drop while locked]")
6654            }
6655            Some(chest) => format!(" [key for {chest}]"),
6656            None => " [key — unpaired]".into(),
6657        }
6658    }
6659
6660    /// Resolve a lock id to a container label (placed chest or one still on your person).
6661    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6662        for c in &self.placed_containers {
6663            if c.lock_id.as_deref() == Some(lock) {
6664                return Some(c.display_name.clone());
6665            }
6666        }
6667        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6668            self.worn
6669                .values()
6670                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6671        })
6672    }
6673
6674    /// Keys cannot be dropped while their paired chest is locked.
6675    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6676        if stack.template_id != KEY_TEMPLATE {
6677            return false;
6678        }
6679        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6680            return false;
6681        };
6682        for c in &self.placed_containers {
6683            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6684                return true;
6685            }
6686        }
6687        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6688            return true;
6689        }
6690        self.worn
6691            .values()
6692            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6693    }
6694
6695    /// Property deeds cannot be dropped or destroyed (store or trade only).
6696    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6697        stack.template_id == PROPERTY_DEED_TEMPLATE
6698    }
6699
6700    pub fn is_property_deed_template(template_id: &str) -> bool {
6701        template_id == PROPERTY_DEED_TEMPLATE
6702    }
6703
6704    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6705        stack
6706            .props
6707            .get("plot_id")
6708            .and_then(|s| uuid::Uuid::parse_str(s).ok())
6709    }
6710
6711    /// World position (cell center) of the plot cell the player is standing on, if tillable.
6712    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6713        let (px, py) = self.player_position();
6714        let (cx, cy) = self.farm_plot_cell_under_player()?;
6715        let tx = cx as f32 + 0.5;
6716        let ty = cy as f32 + 0.5;
6717        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6718            return None;
6719        }
6720        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6721        if kind == Some(TerrainKindView::Tilled) {
6722            return None;
6723        }
6724        if matches!(
6725            kind,
6726            Some(TerrainKindView::ShallowWater)
6727                | Some(TerrainKindView::DeepWater)
6728                | Some(TerrainKindView::Rock)
6729        ) {
6730            return None;
6731        }
6732        Some((tx, ty))
6733    }
6734
6735    fn container_name_in_stacks(
6736        stacks: &[flatland_protocol::ItemStack],
6737        lock: &str,
6738    ) -> Option<String> {
6739        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6740            for s in stacks {
6741                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6742                    return Some(GameState::stack_container_label(s));
6743                }
6744                if let Some(name) = walk(&s.contents, lock) {
6745                    return Some(name);
6746                }
6747            }
6748            None
6749        }
6750        walk(stacks, lock)
6751    }
6752
6753    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6754        stack
6755            .props
6756            .get(PROP_CUSTOM_NAME)
6757            .cloned()
6758            .or_else(|| stack.display_name.clone())
6759            .unwrap_or_else(|| stack.template_id.clone())
6760    }
6761
6762    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6763        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6764            for s in stacks {
6765                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6766                    return true;
6767                }
6768                if walk(&s.contents, lock) {
6769                    return true;
6770                }
6771            }
6772            false
6773        }
6774        walk(stacks, lock)
6775    }
6776
6777    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6778        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6779            return Some(stack.clone());
6780        }
6781        for worn in self.worn.values() {
6782            if worn.item_instance_id == Some(instance_id) {
6783                return Some(worn.clone());
6784            }
6785            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6786                return Some(stack.clone());
6787            }
6788        }
6789        None
6790    }
6791
6792    /// Highest-`z_order` property zone covering `(x, y)`.
6793    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6794        self.property_zones
6795            .iter()
6796            .enumerate()
6797            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6798            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6799            .map(|(_, z)| z)
6800    }
6801
6802    /// Highest-`z_order` tax zone covering `(x, y)`.
6803    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6804        self.tax_zones
6805            .iter()
6806            .enumerate()
6807            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6808            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6809            .map(|(_, z)| z)
6810    }
6811
6812    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
6813    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6814        let mut max_bps = 0u32;
6815        let mut y = y0 + 0.5;
6816        while y < y1 {
6817            let mut x = x0 + 0.5;
6818            while x < x1 {
6819                if let Some(tz) = self.tax_zone_at(x, y) {
6820                    max_bps = max_bps.max(tz.rate_bps);
6821                }
6822                x += 1.0;
6823            }
6824            y += 1.0;
6825        }
6826        max_bps
6827    }
6828
6829    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
6830    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6831        let mode = self.claim_mode.as_ref()?;
6832        let w = mode.width_m.max(1) as f32;
6833        let h = mode.height_m.max(1) as f32;
6834        Some((
6835            mode.anchor_x,
6836            mode.anchor_y,
6837            mode.anchor_x + w,
6838            mode.anchor_y + h,
6839        ))
6840    }
6841
6842    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
6843    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6844        let mode = self.relocate_mode.as_ref()?;
6845        let x0 = mode.cursor_x.floor();
6846        let y0 = mode.cursor_y.floor();
6847        Some((x0, y0, x0 + 1.0, y0 + 1.0))
6848    }
6849
6850    /// Client quote matching server `quote_plot`:
6851    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
6852    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6853        let mode = self.claim_mode.as_ref()?;
6854        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6855        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6856        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6857        let zone_area = zone_view_area_m2(zone).max(1.0);
6858        let area_frac = (area / zone_area).clamp(0.0, 1.0);
6859        let weight = self
6860            .property_plot_settings
6861            .as_ref()
6862            .map(|s| s.tax_premium_weight)
6863            .unwrap_or(0.5)
6864            .max(0.0);
6865        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6866        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6867        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6868            .ceil()
6869            .max(0.0) as u64;
6870        let upkeep = if zone.upkeep_copper_per_day == 0 {
6871            0
6872        } else {
6873            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6874                .ceil()
6875                .max(1.0) as u64
6876        };
6877        let copper = crate::currency::copper_from_counts(&self.inventory);
6878        let can_afford = copper >= purchase;
6879        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6880        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6881    }
6882
6883    fn validate_claim_footprint(
6884        &self,
6885        zone: &flatland_protocol::PropertyZoneView,
6886        x0: f32,
6887        y0: f32,
6888        x1: f32,
6889        y1: f32,
6890        area: f32,
6891    ) -> (bool, String) {
6892        let min_area = self
6893            .property_plot_settings
6894            .as_ref()
6895            .map(|s| s.min_plot_area_m2)
6896            .unwrap_or(4.0);
6897        if area + f32::EPSILON < min_area {
6898            return (false, "plot too small".into());
6899        }
6900        if zone.max_area_m2.is_some_and(|m| area > m) {
6901            return (false, "plot exceeds max area".into());
6902        }
6903        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6904            return (false, "plot must lie inside the property zone".into());
6905        }
6906        if self
6907            .property_plots
6908            .iter()
6909            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6910        {
6911            return (false, "plot overlaps an existing claim".into());
6912        }
6913        (true, String::new())
6914    }
6915
6916    /// Standing in a property zone on a free cell (not already claimed).
6917    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6918        let (px, py) = self.player_position();
6919        let zone = self.property_zone_at(px, py)?;
6920        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6921            return None;
6922        }
6923        Some(zone)
6924    }
6925
6926    /// Own plot covering the player (`is_mine`).
6927    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6928        let (px, py) = self.player_position();
6929        self.property_plots
6930            .iter()
6931            .find(|p| p.is_mine && point_in_plot(px, py, p))
6932    }
6933
6934    /// Plot under the player that they may farm (deed, grant, or public).
6935    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6936        let (px, py) = self.player_position();
6937        self.property_plots
6938            .iter()
6939            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6940    }
6941
6942    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
6943    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6944        if self.farmable_plot_under_player().is_none() {
6945            return None;
6946        }
6947        let (px, py) = self.player_position();
6948        Some((px.floor() as i32, py.floor() as i32))
6949    }
6950
6951    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6952        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6953        self.resource_nodes.iter().any(|n| {
6954            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6955            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6956        })
6957    }
6958
6959    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6960        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6961        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6962            || self
6963                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6964                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6965        if !tilled {
6966            return false;
6967        }
6968        !self.resource_node_occupies_farm_cell(cx, cy)
6969    }
6970
6971    /// Empty tilled soil on the cell the player is standing on (no crop node).
6972    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6973        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6974            return false;
6975        };
6976        self.free_tilled_plant_slot_at(cx, cy)
6977    }
6978
6979    /// True when a free tilled cell (no resource node) is within interact range.
6980    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6981        let (px, py) = self.player_position();
6982        for dy in -2..=2 {
6983            for dx in -2..=2 {
6984                let cx = px.floor() as i32 + dx;
6985                let cy = py.floor() as i32 + dy;
6986                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6987                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6988                    continue;
6989                }
6990                if self.free_tilled_plant_slot_at(cx, cy) {
6991                    return true;
6992                }
6993            }
6994        }
6995        false
6996    }
6997
6998    fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
6999        if stack.quantity == 0 {
7000            return false;
7001        }
7002        if stack.props.contains_key("seed_for") {
7003            return true;
7004        }
7005        if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7006            if entry.is_farm_seed() {
7007                return true;
7008            }
7009        }
7010        stack.template_id.ends_with("_seed")
7011    }
7012
7013    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
7014    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7015        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7016        fn walk(
7017            stacks: &[flatland_protocol::ItemStack],
7018            state: &GameState,
7019            counts: &mut std::collections::HashMap<String, u32>,
7020        ) {
7021            for s in stacks {
7022                if state.stack_is_farm_seed(s) {
7023                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7024                }
7025                walk(&s.contents, state, counts);
7026            }
7027        }
7028        walk(&self.inventory_stacks, self, &mut counts);
7029        for worn in self.worn.values() {
7030            walk(std::slice::from_ref(worn), self, &mut counts);
7031        }
7032        let mut out: Vec<_> = counts
7033            .into_iter()
7034            .map(|(template_id, quantity)| {
7035                let label = self.template_display_name(&template_id);
7036                (template_id, quantity, label)
7037            })
7038            .collect();
7039        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7040        out
7041    }
7042
7043    /// First farm seed template in inventory (root + nested bags).
7044    pub fn first_farm_seed_template(&self) -> Option<String> {
7045        self.farm_seed_entries()
7046            .into_iter()
7047            .next()
7048            .map(|(id, _, _)| id)
7049    }
7050
7051    pub fn clamp_plant_menu(&mut self) {
7052        let n = self.farm_seed_entries().len();
7053        if n == 0 {
7054            self.plant_menu_index = 0;
7055            self.plant_quantity = 1;
7056            return;
7057        }
7058        self.plant_menu_index = self.plant_menu_index.min(n - 1);
7059        let max_qty = self
7060            .farm_seed_entries()
7061            .get(self.plant_menu_index)
7062            .map(|(_, q, _)| *q)
7063            .unwrap_or(1)
7064            .max(1);
7065        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7066    }
7067
7068    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7069        let entries = self.farm_seed_entries();
7070        let (id, max, label) = entries.get(self.plant_menu_index)?;
7071        let qty = self.plant_quantity.min(*max).max(1);
7072        Some((id.clone(), qty, label.clone()))
7073    }
7074
7075    /// Terrain, interactables, and map objects near the player for the HUD location panel.
7076    pub fn location_context_lines(&self) -> Vec<ContextLine> {
7077        let (px, py) = self.player_position();
7078        let inside = self.effective_inside_building();
7079        let mut lines = Vec::new();
7080
7081        if let Some(kind) = self.terrain_at(px, py) {
7082            lines.push(ContextLine {
7083                on_top: true,
7084                text: format!("Terrain: {}", terrain_kind_label(kind)),
7085            });
7086        }
7087
7088        if let Some(id) = inside.as_ref() {
7089            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7090                lines.push(ContextLine {
7091                    on_top: true,
7092                    text: format!("Inside: {}", b.label),
7093                });
7094            }
7095        }
7096
7097        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7098
7099        for node in &self.resource_nodes {
7100            if node.id.starts_with("preview:") {
7101                continue;
7102            }
7103            let dist = distance(px, py, node.x, node.y);
7104            if dist > NEARBY_SCAN_M {
7105                continue;
7106            }
7107            let on_top = dist <= ON_TOP_RADIUS_M;
7108            let prefix = if on_top { "On" } else { "Near" };
7109            let name = resource_node_near_display_label(&node.label);
7110            let action = resource_node_near_action_suffix(node);
7111            nearby.push((
7112                dist,
7113                ContextLine {
7114                    on_top,
7115                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7116                },
7117            ));
7118        }
7119
7120        for drop in &self.ground_drops {
7121            let dist = distance(px, py, drop.x, drop.y);
7122            if dist > INTERACTION_RADIUS_M {
7123                continue;
7124            }
7125            let on_top = dist <= ON_TOP_RADIUS_M;
7126            let name = self.template_display_name(&drop.template_id);
7127            let prefix = if on_top { "On" } else { "Near" };
7128            let qty = if drop.quantity > 1 {
7129                format!(" ×{}", drop.quantity)
7130            } else {
7131                String::new()
7132            };
7133            nearby.push((
7134                dist,
7135                ContextLine {
7136                    on_top,
7137                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7138                },
7139            ));
7140        }
7141
7142        for c in &self.placed_containers {
7143            if !self.placed_container_in_current_space(c) {
7144                continue;
7145            }
7146            let dist = distance(px, py, c.x, c.y);
7147            if dist > CONTAINER_RANGE_M {
7148                continue;
7149            }
7150            let on_top = dist <= ON_TOP_RADIUS_M;
7151            let name = self.placed_container_public_label(c);
7152            let lock = if c.locked { " [locked]" } else { "" };
7153            let prefix = if on_top { "On" } else { "Near" };
7154            nearby.push((
7155                dist,
7156                ContextLine {
7157                    on_top,
7158                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7159                },
7160            ));
7161        }
7162
7163        for npc in &self.npcs {
7164            let dist = distance(px, py, npc.x, npc.y);
7165            if dist > NEARBY_SCAN_M {
7166                continue;
7167            }
7168            let on_top = dist <= ON_TOP_RADIUS_M;
7169            let prefix = if on_top { "On" } else { "Near" };
7170            nearby.push((
7171                dist,
7172                ContextLine {
7173                    on_top,
7174                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7175                },
7176            ));
7177        }
7178
7179        for door in &self.doors {
7180            let dist = distance(px, py, door.x, door.y);
7181            if dist > DOOR_INTERACTION_RADIUS_M {
7182                continue;
7183            }
7184            let building = self
7185                .buildings
7186                .iter()
7187                .find(|b| b.id == door.building_id)
7188                .map(|b| b.label.as_str())
7189                .unwrap_or(door.building_id.as_str());
7190            let player_house = self
7191                .buildings
7192                .iter()
7193                .find(|b| b.id == door.building_id)
7194                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7195            let action = if inside.is_some() && door.portal.is_some() {
7196                if player_house {
7197                    if door.locked {
7198                        "locked — l unlock · Enter exit".to_string()
7199                    } else if door.open {
7200                        "close · Enter exit · l lock".to_string()
7201                    } else {
7202                        "open · Enter exit · l lock".to_string()
7203                    }
7204                } else {
7205                    "exit".to_string()
7206                }
7207            } else if player_house {
7208                if door.locked {
7209                    "locked — l unlock".to_string()
7210                } else if door.open {
7211                    "close · Enter go inside · l lock".to_string()
7212                } else {
7213                    "open · l lock".to_string()
7214                }
7215            } else {
7216                "enter".to_string()
7217            };
7218            nearby.push((
7219                dist,
7220                ContextLine {
7221                    on_top: dist <= ON_TOP_RADIUS_M,
7222                    text: format!("{building} door ({dist:.1}m) — f {action}"),
7223                },
7224            ));
7225        }
7226
7227        if inside.is_none() {
7228            for inter in &self.interactables {
7229                if inter.kind != "quest_board" {
7230                    continue;
7231                }
7232                let dist = distance(px, py, inter.x, inter.y);
7233                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7234                    continue;
7235                }
7236                let on_top = dist <= ON_TOP_RADIUS_M;
7237                let prefix = if on_top { "On" } else { "Near" };
7238                let label = if inter.label.is_empty() {
7239                    "Quest board".to_string()
7240                } else {
7241                    inter.label.clone()
7242                };
7243                nearby.push((
7244                    dist,
7245                    ContextLine {
7246                        on_top,
7247                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7248                    },
7249                ));
7250            }
7251        }
7252
7253        if self.near_liquid_fill_source() {
7254            let on_water = matches!(
7255                self.terrain_at(px, py),
7256                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7257            );
7258            let well = self.buildings.iter().find(|b| {
7259                b.tags.iter().any(|t| t == "well") && {
7260                    let hw = b.width_m * 0.5;
7261                    let hd = b.depth_m * 0.5;
7262                    let nx = px.clamp(b.x - hw, b.x + hw);
7263                    let ny = py.clamp(b.y - hd, b.y + hd);
7264                    let dx = px - nx;
7265                    let dy = py - ny;
7266                    dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7267                }
7268            });
7269            if let Some(well) = well {
7270                let name = if well.label.trim().is_empty() {
7271                    "Well"
7272                } else {
7273                    well.label.as_str()
7274                };
7275                nearby.push((
7276                    0.0,
7277                    ContextLine {
7278                        on_top: true,
7279                        text: format!("{name} — Use a vessel from inventory to fill"),
7280                    },
7281                ));
7282            } else if on_water {
7283                if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7284                    line.text
7285                        .push_str(" — Use a vessel from inventory to fill");
7286                }
7287            } else {
7288                nearby.push((
7289                    0.0,
7290                    ContextLine {
7291                        on_top: true,
7292                        text: "Water nearby — Use a vessel from inventory to fill".into(),
7293                    },
7294                ));
7295            }
7296        }
7297
7298        if self.claim_mode.is_some() {
7299            nearby.push((
7300                0.0,
7301                ContextLine {
7302                    on_top: true,
7303                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7304                        .into(),
7305                },
7306            ));
7307        } else if let Some(plot) = self.my_plot_under_player() {
7308            let name = plot_public_label(plot);
7309            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7310                format!("{name} — f again to sell to crown")
7311            } else {
7312                format!(
7313                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7314                )
7315            };
7316            nearby.push((
7317                0.0,
7318                ContextLine {
7319                    on_top: true,
7320                    text: prompt,
7321                },
7322            ));
7323        } else if let Some(plot) = self.farmable_plot_under_player() {
7324            let name = plot_public_label(plot);
7325            let disc = if plot.farm_public {
7326                plot.public_tax_discount_bps / 100
7327            } else {
7328                plot.farm_allow
7329                    .iter()
7330                    .find(|g| Some(g.character_id) == self.character_id)
7331                    .map(|g| g.tax_discount_bps / 100)
7332                    .unwrap_or(0)
7333            };
7334            nearby.push((
7335                0.0,
7336                ContextLine {
7337                    on_top: true,
7338                    text: format!(
7339                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7340                    ),
7341                },
7342            ));
7343        } else if let Some(zone) = self.free_property_zone_under_player() {
7344            let label = zone
7345                .label
7346                .as_deref()
7347                .filter(|s| !s.trim().is_empty())
7348                .unwrap_or(zone.id.as_str());
7349            nearby.push((
7350                0.0,
7351                ContextLine {
7352                    on_top: true,
7353                    text: format!("Claimable land: {label} — k buy plot"),
7354                },
7355            ));
7356        }
7357
7358        for entity in &self.entities {
7359            if entity.id == self.entity_id {
7360                continue;
7361            }
7362            let dist = distance(
7363                px,
7364                py,
7365                entity.transform.position.x,
7366                entity.transform.position.y,
7367            );
7368            if dist > NEARBY_SCAN_M {
7369                continue;
7370            }
7371            let label = if entity.label.is_empty() {
7372                format!("entity {}", entity.id)
7373            } else {
7374                entity.label.clone()
7375            };
7376            nearby.push((
7377                dist,
7378                ContextLine {
7379                    on_top: dist <= ON_TOP_RADIUS_M,
7380                    text: format!("Near: {label} ({dist:.1}m)"),
7381                },
7382            ));
7383        }
7384
7385        nearby.sort_by(|a, b| {
7386            a.0.partial_cmp(&b.0)
7387                .unwrap_or(std::cmp::Ordering::Equal)
7388                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7389        });
7390        lines.extend(nearby.into_iter().map(|(_, l)| l));
7391
7392        if lines.is_empty() {
7393            lines.push(ContextLine {
7394                on_top: false,
7395                text: "(nothing notable nearby)".into(),
7396            });
7397        }
7398
7399        lines
7400    }
7401}
7402
7403/// HUD line for the location / nearby panel.
7404#[derive(Debug, Clone)]
7405pub struct ContextLine {
7406    pub on_top: bool,
7407    pub text: String,
7408}
7409
7410const ON_TOP_RADIUS_M: f32 = 0.65;
7411const NEARBY_SCAN_M: f32 = 5.0;
7412
7413/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
7414pub fn resource_node_near_display_label(label: &str) -> String {
7415    label
7416        .strip_suffix(" (growing)")
7417        .unwrap_or(label)
7418        .to_string()
7419}
7420
7421fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7422    let t = label.trim();
7423    if t.is_empty() || t == id {
7424        return true;
7425    }
7426    let lower = t.to_ascii_lowercase();
7427    if lower.contains("_copy") {
7428        return true;
7429    }
7430    false
7431}
7432
7433fn humanize_item_template_label(template: &str) -> String {
7434    let base = template.rsplit('/').next().unwrap_or(template).trim();
7435    if base.is_empty() {
7436        return "Resource".into();
7437    }
7438    let stripped = base
7439        .strip_prefix("crop-")
7440        .or_else(|| base.strip_prefix("crop_"))
7441        .unwrap_or(base);
7442    stripped
7443        .split(|c: char| c == '-' || c == '_')
7444        .filter(|p| !p.is_empty())
7445        .map(|p| {
7446            let mut chars = p.chars();
7447            match chars.next() {
7448                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7449                None => String::new(),
7450            }
7451        })
7452        .collect::<Vec<_>>()
7453        .join(" ")
7454}
7455
7456/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
7457pub fn resource_node_id_suffix(id: &str) -> String {
7458    let chars: Vec<char> = id
7459        .chars()
7460        .rev()
7461        .filter(|c| c.is_ascii_alphanumeric())
7462        .take(4)
7463        .collect();
7464    chars.into_iter().rev().collect()
7465}
7466
7467/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
7468pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7469    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7470}
7471
7472pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7473    let cleaned = resource_node_near_display_label(label);
7474    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7475        cleaned
7476    } else if !item_template.trim().is_empty() {
7477        humanize_item_template_label(item_template)
7478    } else {
7479        id.to_string()
7480    };
7481    let suffix = resource_node_id_suffix(id);
7482    if suffix.is_empty() {
7483        friendly
7484    } else {
7485        format!("{friendly} ({suffix})")
7486    }
7487}
7488
7489/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
7490pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7491    use flatland_protocol::ResourceNodeState;
7492    if node.harvest_off {
7493        return " (decorative)".to_string();
7494    }
7495    if let Some(p) = node.growth_progress {
7496        if p < 1.0 - f32::EPSILON {
7497            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7498            return format!(" (growing, {pct}%)");
7499        }
7500        return " — f harvest".to_string();
7501    }
7502    match node.state {
7503        ResourceNodeState::Available => " — f harvest".to_string(),
7504        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7505        ResourceNodeState::Cooldown => " (depleted)".to_string(),
7506    }
7507}
7508
7509fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7510    use flatland_protocol::TerrainKindView;
7511    match kind {
7512        TerrainKindView::Grass => "Grass",
7513        TerrainKindView::Dirt => "Dirt",
7514        TerrainKindView::Tilled => "Tilled",
7515        TerrainKindView::Desert => "Desert",
7516        TerrainKindView::Hill => "Hills",
7517        TerrainKindView::Bog => "Bog",
7518        TerrainKindView::Beach => "Beach",
7519        TerrainKindView::ShallowWater => "Shallow water",
7520        TerrainKindView::DeepWater => "Deep water",
7521        TerrainKindView::Trail => "Trail",
7522        TerrainKindView::Road => "Road",
7523        TerrainKindView::Rock => "Rock",
7524    }
7525}
7526
7527fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7528    crate::world_zones::zone_rects_contain(rects, x, y)
7529}
7530
7531fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7532    zone.rects
7533        .iter()
7534        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7535        .sum()
7536}
7537
7538fn claim_rect_fully_inside_zone(
7539    zone: &flatland_protocol::PropertyZoneView,
7540    x0: f32,
7541    y0: f32,
7542    x1: f32,
7543    y1: f32,
7544) -> bool {
7545    let mut y = y0 + 0.5;
7546    while y < y1 {
7547        let mut x = x0 + 0.5;
7548        while x < x1 {
7549            if !zone_rects_contain(&zone.rects, x, y) {
7550                return false;
7551            }
7552            x += 1.0;
7553        }
7554        y += 1.0;
7555    }
7556    true
7557}
7558
7559fn rects_overlap_half_open(
7560    ax0: f32,
7561    ay0: f32,
7562    ax1: f32,
7563    ay1: f32,
7564    bx0: f32,
7565    by0: f32,
7566    bx1: f32,
7567    by1: f32,
7568) -> bool {
7569    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7570}
7571
7572fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7573    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7574}
7575
7576fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7577    plot_public_label(p)
7578}
7579
7580fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7581    let w = (p.x1 - p.x0).abs();
7582    let d = (p.y1 - p.y0).abs();
7583    format!("Plot ({w:.0}×{d:.0} m)")
7584}
7585
7586/// Canonical plot name: `{owner} — {zone} — {label}`.
7587pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7588    let zone = p
7589        .zone_label
7590        .as_deref()
7591        .filter(|s| !s.trim().is_empty())
7592        .unwrap_or_else(|| {
7593            if p.property_zone_id.is_empty() {
7594                "Homestead"
7595            } else {
7596                p.property_zone_id.as_str()
7597            }
7598        });
7599    let label = if !p.label.trim().is_empty() {
7600        p.label.clone()
7601    } else if !p.plot_code.trim().is_empty() {
7602        p.plot_code.clone()
7603    } else {
7604        plot_size_fallback_label(p)
7605    };
7606    match p
7607        .owner_label
7608        .as_deref()
7609        .map(str::trim)
7610        .filter(|s| !s.is_empty())
7611    {
7612        Some(owner) => format!("{owner} — {zone} — {label}"),
7613        None => format!("{zone} — {label}"),
7614    }
7615}
7616
7617/// Resolve a farm-plot stop target for player-facing route UI.
7618///
7619/// Uses [`plot_public_label`] when the plot is in the observer's view; otherwise a
7620/// short constructed fallback (never the full UUID).
7621pub fn plot_stop_label(
7622    plots: &[flatland_protocol::PropertyPlotView],
7623    plot_id: uuid::Uuid,
7624) -> String {
7625    plots
7626        .iter()
7627        .find(|p| p.plot_id == plot_id)
7628        .map(plot_public_label)
7629        .unwrap_or_else(|| {
7630            let s = plot_id.to_string();
7631            format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7632        })
7633}
7634
7635/// Match `flatland_sim::economy_zones::snap_claim_rect`.
7636fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7637    let a = x0.min(x1).floor();
7638    let b = y0.min(y1).floor();
7639    let mut c = x0.max(x1).ceil();
7640    let mut d = y0.max(y1).ceil();
7641    if (c - a) < 1.0 {
7642        c = a + 1.0;
7643    }
7644    if (d - b) < 1.0 {
7645        d = b + 1.0;
7646    }
7647    (a, b, c, d)
7648}
7649
7650fn humanize_template_id(template_id: &str) -> String {
7651    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
7652    if looks_like_template_uuid(template_id) {
7653        return "Unknown item".into();
7654    }
7655    template_id
7656        .split('_')
7657        .map(|word| {
7658            let mut chars = word.chars();
7659            match chars.next() {
7660                None => String::new(),
7661                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7662            }
7663        })
7664        .collect::<Vec<_>>()
7665        .join(" ")
7666}
7667
7668fn looks_like_template_uuid(template_id: &str) -> bool {
7669    let bytes = template_id.as_bytes();
7670    if bytes.len() != 36 {
7671        return false;
7672    }
7673    let is_hex = |b: u8| b.is_ascii_hexdigit();
7674    let groups = [8usize, 4, 4, 4, 12];
7675    let mut i = 0;
7676    for (gi, &len) in groups.iter().enumerate() {
7677        if gi > 0 {
7678            if bytes.get(i) != Some(&b'-') {
7679                return false;
7680            }
7681            i += 1;
7682        }
7683        for _ in 0..len {
7684            if !bytes.get(i).copied().is_some_and(is_hex) {
7685                return false;
7686            }
7687            i += 1;
7688        }
7689    }
7690    true
7691}
7692
7693/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
7694const HARVEST_RANGE_M: f32 = 1.5;
7695
7696pub struct GameClient<S: PlayConnection> {
7697    session: S,
7698    seq: Seq,
7699    pub state: GameState,
7700    last_move_forward: f32,
7701    last_move_strafe: f32,
7702}
7703
7704impl<S: PlayConnection> GameClient<S> {
7705    pub fn new(session: S) -> Self {
7706        let session_id = session.session_id();
7707        let entity_id = session.entity_id();
7708        let mut client = Self {
7709            session,
7710            seq: 0,
7711            last_move_forward: 0.0,
7712            last_move_strafe: 0.0,
7713            state: GameState {
7714                session_id,
7715                entity_id,
7716                character_id: None,
7717                tick: 0,
7718                chunk_rev: 0,
7719                content_rev: 0,
7720                publish_rev: 0,
7721                entities: Vec::new(),
7722                player: None,
7723                resource_nodes: Vec::new(),
7724                ground_drops: Vec::new(),
7725                placed_containers: Vec::new(),
7726                buildings: Vec::new(),
7727                doors: Vec::new(),
7728                interior_map: None,
7729                npcs: Vec::new(),
7730                blueprints: Vec::new(),
7731                building_materials: Vec::new(),
7732                world_x0: 0.0,
7733                world_y0: 0.0,
7734                world_width_m: 0.0,
7735                world_height_m: 0.0,
7736                terrain_zones: Vec::new(),
7737                z_platforms: Vec::new(),
7738                z_transitions: Vec::new(),
7739                z_bands_outdoor_backup: None,
7740                world_clock: flatland_protocol::WorldClock::default(),
7741                inventory: std::collections::HashMap::new(),
7742                inventory_hints: std::collections::HashMap::new(),
7743                item_catalog: std::collections::HashMap::new(),
7744                logs: VecDeque::new(),
7745                intents_sent: 0,
7746                ticks_received: 0,
7747                connected: false,
7748                disconnect_reason: None,
7749                show_stats: false,
7750                hud_log_hidden: false,
7751                show_equip_menu: false,
7752                equip_menu_index: 0,
7753                show_craft_menu: false,
7754                show_plot_build_menu: false,
7755                plot_build_focus_wall: true,
7756                plot_build_wall_index: 0,
7757                plot_build_roof_index: 0,
7758                craft_menu_index: 0,
7759                craft_batch_quantity: 1,
7760                craft_tab: CraftTab::Ready,
7761                craft_filter: String::new(),
7762                craft_filter_focused: false,
7763                craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7764                show_shop_menu: false,
7765                shop_catalog: None,
7766                bank_panel: None,
7767                bank_menu_index: 0,
7768                bank_ui_mode: BankUiMode::Menu,
7769                storage_panel: None,
7770                market_panel: None,
7771                market_menu_index: 0,
7772                market_filter: String::new(),
7773                market_filter_focused: false,
7774                market_category_filter: None,
7775                market_buy_confirm: None,
7776                market_ui_mode: MarketUiMode::Browse,
7777                storage_menu_index: 0,
7778                storage_ui_mode: StorageUiMode::Menu,
7779                shop_tab: ShopTab::default(),
7780                shop_menu_index: 0,
7781                shop_quantity: 1,
7782                shop_trade_log: VecDeque::new(),
7783                show_npc_verb_menu: false,
7784                npc_verb_target: None,
7785                npc_verb_index: 0,
7786                npc_verb_notice: None,
7787                player_verbs: crate::social::PlayerVerbState::default(),
7788                social_chat: crate::social::SocialChatState::default(),
7789                trade_ui: crate::social::TradeUiState::default(),
7790                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7791                show_npc_chat: false,
7792                npc_chat: None,
7793                show_inventory_menu: false,
7794                inventory_menu_index: 0,
7795                inventory_tab: InventoryTab::OnPerson,
7796                inventory_filter: String::new(),
7797                inventory_filter_focused: false,
7798                show_move_picker: false,
7799                show_rename_prompt: false,
7800                rename_plot_id: None,
7801                highlighted_plot_id: None,
7802                show_worker_rename: false,
7803                rename_buffer: String::new(),
7804                move_picker_index: 0,
7805                move_picker: None,
7806                show_grant_picker: false,
7807                grant_picker_index: 0,
7808                grant_picker: None,
7809                show_destroy_picker: false,
7810                destroy_confirm_pending: false,
7811                destroy_picker: None,
7812                combat_target: None,
7813                combat_target_label: None,
7814                ground_target: None,
7815                combat_fx: Vec::new(),
7816                ground_hazards: Vec::new(),
7817                property_zones: Vec::new(),
7818                tax_zones: Vec::new(),
7819                growth_zones: Vec::new(),
7820                biome_zones: Vec::new(),
7821                terrain_kind_nav: Vec::new(),
7822                property_plots: Vec::new(),
7823                property_plot_settings: None,
7824                claim_mode: None,
7825                relocate_mode: None,
7826                sell_plot_confirm: None,
7827                sell_plot_armed_at: None,
7828                show_plant_menu: false,
7829                plant_menu_index: 0,
7830                show_farm_access: false,
7831                farm_access_name_draft: String::new(),
7832                farm_access_discount_bps: 0,
7833                farm_access_index: 0,
7834                plant_quantity: 1,
7835                in_combat: false,
7836                auto_attack: true,
7837                combat_has_los: false,
7838                attack_cd_ticks: 0,
7839                gcd_ticks: 0,
7840                weapon_ability_id: "unarmed".into(),
7841                mainhand_template_id: None,
7842                mainhand_label: None,
7843                mainhand_instance_id: None,
7844                offhand_template_id: None,
7845                offhand_label: None,
7846                offhand_instance_id: None,
7847                mainhand_hand_slots: 1,
7848                defense: None,
7849                worn: BTreeMap::new(),
7850                carry_mass: 0.0,
7851                carry_mass_max: 0.0,
7852                encumbrance: flatland_protocol::EncumbranceState::Light,
7853                move_speed_mps: 0.0,
7854                move_speed_mult: 0.0,
7855                inventory_stacks: Vec::new(),
7856                keychain_stacks: Vec::new(),
7857                whisper_pouch_stacks: Vec::new(),
7858                combat_target_detail: None,
7859                statuses: Vec::new(),
7860                cast_progress: None,
7861                timed_channel: None,
7862                plot_build_offer: None,
7863                ability_cooldowns: Vec::new(),
7864                blocking_active: false,
7865                max_target_slots: 1,
7866                combat_slots: Vec::new(),
7867                rotation_presets: Vec::new(),
7868                known_abilities: Vec::new(),
7869                ability_meta: std::collections::HashMap::new(),
7870                ability_mastery: std::collections::HashMap::new(),
7871                hotbar: vec![None; 9],
7872                max_abilities_per_rotation: 0,
7873                show_loadout_menu: false,
7874                show_keychain_menu: false,
7875                keychain_menu_index: 0,
7876                show_rotation_editor: false,
7877                loadout_menu_index: 0,
7878                loadout_hotbar_slot: 1,
7879                loadout_ability_index: 0,
7880                loadout_focus_presets: false,
7881                rotation_editor: RotationEditorState::default(),
7882                harvest_in_progress: false,
7883                harvest_started_at: None,
7884                pending_craft_ack: None,
7885                craft_channel_blueprint_id: None,
7886                pending_worker_job_ack: None,
7887                attending_worker_instance_id: None,
7888                quest_log: Vec::new(),
7889                interactables: Vec::new(),
7890                ledger: None,
7891                career: None,
7892                character_sheet_tab: CharacterSheetTab::Character,
7893                ledger_period: LedgerPeriod::Day,
7894                show_quest_offer: false,
7895                pending_quest_offers: Vec::new(),
7896                quest_offer_index: 0,
7897                show_quest_menu: false,
7898                quest_menu_index: 0,
7899                quest_withdraw_confirm: false,
7900                hired_workers: Vec::new(),
7901                show_workers_menu: false,
7902                workers_menu_index: 0,
7903                worker_dismiss_confirmation: None,
7904                workers_menu_compact: false,
7905                worker_step_display: BTreeMap::new(),
7906                worker_error_display: BTreeMap::new(),
7907                worker_health_ring_until: BTreeMap::new(),
7908                pending_worker_hire_since: None,
7909                show_worker_give_picker: false,
7910                worker_give_picker_index: 0,
7911                worker_give_picker: None,
7912                show_worker_give_target_picker: false,
7913                worker_give_target_picker_index: 0,
7914                worker_give_target_picker: None,
7915                show_worker_take_picker: false,
7916                worker_take_picker_index: 0,
7917                worker_take_picker: None,
7918                show_worker_teach_picker: false,
7919                worker_teach_picker_index: 0,
7920                worker_teach_picker: None,
7921                worker_route_editor: None,
7922                progression_curve: None,
7923            },
7924        };
7925        client.state.apply_client_ui_prefs();
7926        client
7927    }
7928
7929    pub fn entity_id(&self) -> EntityId {
7930        self.state.entity_id
7931    }
7932
7933    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7934        if self.state.connected {
7935            return Ok(());
7936        }
7937
7938        loop {
7939            match self.session.next_event().await {
7940                Some(SessionEvent::Welcome {
7941                    session_id,
7942                    entity_id,
7943                    snapshot,
7944                }) => {
7945                    self.state
7946                        .restore_from_welcome(session_id, entity_id, &snapshot);
7947                    self.state.apply_client_ui_prefs();
7948                    self.state.push_log(format!(
7949                        "Connected — session {session_id}, entity {entity_id}"
7950                    ));
7951                    return Ok(());
7952                }
7953                Some(SessionEvent::Disconnected { .. }) => {
7954                    anyhow::bail!("disconnected before welcome");
7955                }
7956                Some(_) => continue,
7957                None => anyhow::bail!("session closed before welcome"),
7958            }
7959        }
7960    }
7961
7962    /// Drain all pending server events (non-blocking).
7963    pub fn drain_events(&mut self) {
7964        while let Some(event) = self.session.try_next_event() {
7965            if self.handle_event_sync(event).is_err() {
7966                break;
7967            }
7968        }
7969    }
7970
7971    /// Wait for the next server event.
7972    pub async fn next_event(&mut self) -> Option<SessionEvent> {
7973        self.session.next_event().await
7974    }
7975
7976    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7977        self.handle_event_sync(event)
7978    }
7979
7980    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7981        match event {
7982            SessionEvent::Welcome {
7983                session_id,
7984                entity_id,
7985                snapshot,
7986            } => {
7987                let resumed = self.state.connected;
7988                self.state
7989                    .restore_from_welcome(session_id, entity_id, &snapshot);
7990                if resumed {
7991                    self.state.push_log(format!(
7992                        "Session restored — session {session_id}, entity {entity_id}"
7993                    ));
7994                }
7995            }
7996            SessionEvent::ContentUpdated { snapshot } => {
7997                self.state
7998                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
7999                self.state.push_log(format!(
8000                    "World updated (content rev {})",
8001                    snapshot.content_rev
8002                ));
8003            }
8004            SessionEvent::QuestCatalogUpdated(update) => {
8005                self.state.push_log(format!(
8006                    "Quest board updated (revision {}, {} new, {} retired)",
8007                    update.revision,
8008                    update.accepted.len(),
8009                    update.retired.len()
8010                ));
8011            }
8012            SessionEvent::Tick(delta) => {
8013                self.state.apply_tick_fields(&delta, self.state.entity_id);
8014                self.state.ticks_received += 1;
8015            }
8016            SessionEvent::IntentAck {
8017                entity_id,
8018                seq,
8019                tick,
8020            } => {
8021                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8022                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8023                    if *craft_seq == seq {
8024                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8025                        if batches > 1 {
8026                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
8027                        } else {
8028                            self.state.push_log(format!("Crafting {label}…"));
8029                        }
8030                    }
8031                }
8032                if self
8033                    .state
8034                    .pending_worker_job_ack
8035                    .as_ref()
8036                    .is_some_and(|p| p.seq == seq)
8037                {
8038                    let pending = self.state.pending_worker_job_ack.take().unwrap();
8039                    if pending.idle {
8040                        self.state.push_log(format!(
8041                            "Route cleared for {} — worker idle",
8042                            pending.worker_label
8043                        ));
8044                    } else {
8045                        self.state.push_log(format!(
8046                            "Route saved for {} — {} stop(s), job loop active",
8047                            pending.worker_label, pending.stop_count
8048                        ));
8049                    }
8050                    if self
8051                        .state
8052                        .worker_route_editor
8053                        .as_ref()
8054                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8055                    {
8056                        self.close_worker_route_editor();
8057                    }
8058                }
8059            }
8060            SessionEvent::Chat(msg) => {
8061                let label = match msg.channel {
8062                    flatland_protocol::ChatChannel::Nearby => "nearby",
8063                    flatland_protocol::ChatChannel::Direct => "speak",
8064                    flatland_protocol::ChatChannel::Whisper => "whisper",
8065                    flatland_protocol::ChatChannel::WhisperStone => "stone",
8066                };
8067                let clarity = match msg.clarity {
8068                    flatland_protocol::ChatClarity::Clear => "",
8069                    flatland_protocol::ChatClarity::Partial => "~",
8070                    flatland_protocol::ChatClarity::Heavy => "…",
8071                };
8072                self.state.push_log(format!(
8073                    "[{label}{clarity}] {}: {}",
8074                    msg.from_name, msg.text
8075                ));
8076                let now_ms = std::time::SystemTime::now()
8077                    .duration_since(std::time::UNIX_EPOCH)
8078                    .map(|d| d.as_millis() as u64)
8079                    .unwrap_or(0);
8080                self.state
8081                    .social_chat
8082                    .note_speech(&msg, self.state.entity_id, now_ms);
8083                self.state
8084                    .social_chat
8085                    .push(crate::social::ChatLogEntry::from_message(
8086                        msg,
8087                        self.state.entity_id,
8088                    ));
8089            }
8090            SessionEvent::TradeOpened(panel) => {
8091                self.state.social_chat.pending_trade = None;
8092                let peer = panel.peer_name.clone();
8093                self.state.trade_ui.open(panel);
8094                self.state.social_chat.push_system(format!(
8095                    "Trade open with {peer} — p present · r ready · Esc cancel"
8096                ));
8097                self.state
8098                    .social_chat
8099                    .push_cue(crate::social::AudioCue::TradeOpened);
8100            }
8101            SessionEvent::TradeClosed { reason } => {
8102                self.state.push_log(reason.clone());
8103                self.state.social_chat.push_system(reason);
8104                self.state.trade_ui.close();
8105            }
8106            SessionEvent::HarvestResult(result) => {
8107                self.state.clear_harvest_state();
8108                crate::harvest_trace!(
8109                    entity_id = self.state.entity_id,
8110                    node_id = %result.node_id,
8111                    template = %result.item_template,
8112                    quantity = result.quantity,
8113                    client_tick = self.state.tick,
8114                    "client applied harvest result"
8115                );
8116                let msg = if result.quantity == 0 {
8117                    format!(
8118                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8119                        result.item_template
8120                    )
8121                } else {
8122                    format!(
8123                        "Harvested {} x{} (on the ground — press P to pick up)",
8124                        result.item_template, result.quantity
8125                    )
8126                };
8127                self.state.push_log(msg);
8128            }
8129            SessionEvent::CraftResult(result) => {
8130                for stack in &result.consumed {
8131                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8132                        *qty = qty.saturating_sub(stack.quantity);
8133                        if *qty == 0 {
8134                            self.state.inventory.remove(&stack.template_id);
8135                        }
8136                    }
8137                }
8138                for stack in &result.outputs {
8139                    *self
8140                        .state
8141                        .inventory
8142                        .entry(stack.template_id.clone())
8143                        .or_insert(0) += stack.quantity;
8144                }
8145                self.state.craft_record_completed(&result.blueprint_id);
8146                if let Some(output) = result.outputs.first() {
8147                    if result.batch_total > 1 {
8148                        self.state.push_log(format!(
8149                            "Crafted {} x{} ({}/{})",
8150                            output.template_id,
8151                            output.quantity,
8152                            result.batch_index,
8153                            result.batch_total
8154                        ));
8155                    } else {
8156                        self.state.push_log(format!(
8157                            "Crafted {} x{}",
8158                            output.template_id, output.quantity
8159                        ));
8160                    }
8161                } else {
8162                    self.state
8163                        .push_log(format!("Craft finished: {}", result.blueprint_id));
8164                }
8165            }
8166            SessionEvent::Death(notice) => {
8167                self.state.clear_harvest_state();
8168                self.state.push_log(notice.message.clone());
8169                self.state.push_log(format!(
8170                    "Respawned at ({:.1}, {:.1})",
8171                    notice.respawn_x, notice.respawn_y
8172                ));
8173            }
8174            SessionEvent::Interaction(notice) => {
8175                if notice.message.starts_with("Harvest failed:") {
8176                    self.state.clear_harvest_state();
8177                }
8178                if notice.message.starts_with("Can't do that:") {
8179                    self.state.pending_worker_hire_since = None;
8180                    self.state.pending_craft_ack = None;
8181                    self.state.craft_channel_blueprint_id = None;
8182                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
8183                        if let Some(w) = self
8184                            .state
8185                            .hired_workers
8186                            .iter_mut()
8187                            .find(|w| w.instance_id == pending.worker_instance_id)
8188                        {
8189                            w.route = pending.prev_route;
8190                            w.mode = pending.prev_mode;
8191                            w.step_label = pending.prev_step_label;
8192                            w.last_error = pending.prev_last_error;
8193                        }
8194                        let reason = notice
8195                            .message
8196                            .strip_prefix("Can't do that:")
8197                            .unwrap_or(&notice.message)
8198                            .trim();
8199                        self.state.push_log(format!(
8200                            "Route save failed for {}: {reason}",
8201                            pending.worker_label
8202                        ));
8203                    }
8204                    let reason = notice
8205                        .message
8206                        .strip_prefix("Can't do that:")
8207                        .unwrap_or(&notice.message)
8208                        .trim();
8209                    if reason.contains("already tilled") {
8210                        if let Some(plot) = self.state.my_plot_under_player() {
8211                            self.state.sell_plot_confirm = Some(plot.plot_id);
8212                            self.state.sell_plot_armed_at = Some(Instant::now());
8213                        }
8214                    }
8215                }
8216                if notice.message.starts_with("Cast failed:") {
8217                    self.state.cast_progress = None;
8218                }
8219                if notice.message.contains("slain the") {
8220                    self.state.combat_target = None;
8221                    self.state.combat_target_label = None;
8222                }
8223                // Inbound trade request → inline Y/N in the CHAT column (no popup).
8224                if notice.message.contains("wants to trade") {
8225                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8226                        let from_name = notice
8227                            .message
8228                            .split(" wants to trade")
8229                            .next()
8230                            .unwrap_or("Player")
8231                            .to_string();
8232                        self.state.social_chat.pending_trade =
8233                            Some(crate::social::PendingTradeRequest {
8234                                from_entity,
8235                                from_name: from_name.clone(),
8236                            });
8237                        self.state.social_chat.push_system(format!(
8238                            "{from_name} wants to trade — [Y] accept · [N] decline"
8239                        ));
8240                        self.state
8241                            .social_chat
8242                            .push_cue(crate::social::AudioCue::TradeOffer);
8243                    }
8244                }
8245                if notice.message.starts_with("trade request declined") {
8246                    self.state.social_chat.push_system(notice.message.clone());
8247                    self.state
8248                        .social_chat
8249                        .push_cue(crate::social::AudioCue::TradeDeclined);
8250                }
8251                // Verb dock hides the floating LOG — keep refuse lines on the panel + toast.
8252                if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8253                    self.state.npc_verb_notice = Some(notice.message.clone());
8254                    self.state
8255                        .social_chat
8256                        .push_cue(crate::social::AudioCue::UiError);
8257                }
8258                self.state.apply_interaction_notice(&notice);
8259                self.state.push_log(notice.message.clone());
8260            }
8261            SessionEvent::ShopOpened(catalog) => {
8262                self.state.apply_shop_catalog(catalog);
8263            }
8264            SessionEvent::BankOpened(panel) => {
8265                self.state.apply_bank_panel(panel);
8266            }
8267            SessionEvent::StorageOpened(panel) => {
8268                self.state.apply_storage_panel(panel);
8269            }
8270            SessionEvent::MarketOpened(panel) => {
8271                self.state.apply_market_panel(panel);
8272            }
8273            SessionEvent::NpcTalkOpened(opened) => {
8274                self.state.show_npc_verb_menu = false;
8275                if self.state.npc_verb_target.is_none() {
8276                    self.state.npc_verb_target = Some(opened.npc_id.clone());
8277                }
8278                let label = opened.npc_label.clone();
8279                let banner = if !opened.trade_allowed {
8280                    Some("Trade is unavailable right now.".to_string())
8281                } else {
8282                    None
8283                };
8284                self.state.show_npc_chat = true;
8285                self.state.npc_chat = Some(NpcChatState {
8286                    npc_id: opened.npc_id,
8287                    npc_label: opened.npc_label,
8288                    lines: if opened.greeting.is_empty() {
8289                        vec![]
8290                    } else {
8291                        vec![format!("{label}: {}", opened.greeting)]
8292                    },
8293                    input: String::new(),
8294                    pending: opened.greeting.is_empty(),
8295                    talk_depth: opened.talk_depth,
8296                    trade_allowed: opened.trade_allowed,
8297                    banner,
8298                    suggested_topics: opened.suggested_topics,
8299                });
8300            }
8301            SessionEvent::NpcTalkPending(_) => {
8302                if let Some(chat) = self.state.npc_chat.as_mut() {
8303                    chat.pending = true;
8304                }
8305            }
8306            SessionEvent::NpcTalkReply(reply) => {
8307                if let Some(chat) = self.state.npc_chat.as_mut() {
8308                    if chat.npc_id == reply.npc_id {
8309                        chat.pending = false;
8310                        if reply.trade_disabled {
8311                            chat.trade_allowed = false;
8312                            chat.banner = Some("Trade is unavailable right now.".to_string());
8313                        }
8314                        if reply.wind_down {
8315                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8316                            if chat.banner.is_none() {
8317                                chat.banner =
8318                                    Some("They're wrapping up — keep it brief.".to_string());
8319                            }
8320                        }
8321                        chat.lines
8322                            .push(format!("{}: {}", chat.npc_label, reply.line));
8323                    }
8324                }
8325            }
8326            SessionEvent::NpcTalkClosed(closed) => {
8327                if self
8328                    .state
8329                    .npc_chat
8330                    .as_ref()
8331                    .is_some_and(|c| c.npc_id == closed.npc_id)
8332                {
8333                    self.state.show_npc_chat = false;
8334                    self.state.npc_chat = None;
8335                }
8336            }
8337            SessionEvent::NpcTalkError(err) => {
8338                self.state.push_log(format!("Talk failed: {}", err.reason));
8339                if let Some(chat) = self.state.npc_chat.as_mut() {
8340                    chat.pending = false;
8341                }
8342            }
8343            SessionEvent::UseResult(result) => {
8344                // Inventory count hint only — the Interaction notice already logs
8345                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
8346                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8347                    *qty = qty.saturating_sub(1);
8348                    if *qty == 0 {
8349                        self.state.inventory.remove(&result.template_id);
8350                    }
8351                }
8352            }
8353            SessionEvent::QuestOffer(offer) => {
8354                let title = offer.title.clone();
8355                self.state.push_quest_offer(offer);
8356                self.state.push_log(format!("Quest offered: {title}"));
8357            }
8358            SessionEvent::QuestAccepted(notice) => {
8359                self.state.remove_quest_offer(&notice.quest_id);
8360                self.state.push_log(notice.message);
8361            }
8362            SessionEvent::QuestWithdrawn(notice) => {
8363                self.state.show_quest_menu = false;
8364                self.state.quest_withdraw_confirm = false;
8365                self.state.push_log(notice.message);
8366            }
8367            SessionEvent::QuestStepCompleted(notice) => {
8368                self.state.push_log(notice.message);
8369            }
8370            SessionEvent::QuestCompleted(notice) => {
8371                self.state.push_log(notice.message);
8372            }
8373            SessionEvent::Disconnected { reason } => {
8374                self.state.clear_harvest_state();
8375                self.state.connected = false;
8376                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8377                if let Some(r) = &self.state.disconnect_reason {
8378                    self.state.push_log(format!("Disconnected: {r}"));
8379                } else {
8380                    self.state.push_log("Disconnected from server");
8381                }
8382            }
8383        }
8384        Ok(())
8385    }
8386
8387    pub fn is_connected(&self) -> bool {
8388        self.state.connected
8389    }
8390
8391    pub fn close_overlays(&mut self) {
8392        self.state.show_stats = false;
8393        self.state.show_craft_menu = false;
8394        self.state.show_plot_build_menu = false;
8395        self.state.show_shop_menu = false;
8396        self.state.shop_catalog = None;
8397        self.state.show_npc_verb_menu = false;
8398        self.state.npc_verb_target = None;
8399        self.state.show_npc_chat = false;
8400        self.state.npc_chat = None;
8401        self.state.show_inventory_menu = false;
8402        self.state.show_loadout_menu = false;
8403        self.state.show_rotation_editor = false;
8404        self.state.rotation_editor.reset();
8405        self.state.show_rename_prompt = false;
8406        self.state.show_worker_rename = false;
8407        self.state.rename_buffer.clear();
8408        self.state.show_move_picker = false;
8409        self.state.move_picker = None;
8410        self.state.show_destroy_picker = false;
8411        self.state.destroy_confirm_pending = false;
8412        self.state.destroy_picker = None;
8413        self.state.show_quest_offer = false;
8414        self.state.clear_quest_offers();
8415        self.state.show_quest_menu = false;
8416        self.state.quest_withdraw_confirm = false;
8417        self.state.show_workers_menu = false;
8418        self.close_worker_give_picker();
8419        self.close_worker_give_target_picker();
8420        self.close_worker_take_picker();
8421        self.close_worker_teach_picker();
8422        self.state.worker_route_editor = None;
8423        self.state.claim_mode = None;
8424        self.state.relocate_mode = None;
8425        self.state.sell_plot_confirm = None;
8426        self.state.sell_plot_armed_at = None;
8427        self.close_farm_access_panel();
8428        if self.state.show_plant_menu {
8429            self.close_plant_menu();
8430        }
8431    }
8432
8433    /// Esc / back — pop one UI layer instead of closing every overlay at once.
8434    pub fn back_on_esc(&mut self) -> bool {
8435        if self.state.social_chat.composer_open() {
8436            self.state.social_chat.close_composer();
8437            return true;
8438        }
8439        if self.state.player_verbs.open {
8440            self.state.player_verbs.close();
8441            return true;
8442        }
8443        if self.state.whisper_pouch_ui.open {
8444            self.state.whisper_pouch_ui.open = false;
8445            return true;
8446        }
8447        if self.state.trade_ui.panel.is_some() {
8448            // async cancel — caller should prefer trade_cancel; close UI optimistically
8449            self.state.trade_ui.close();
8450            return true;
8451        }
8452        if self.state.show_rename_prompt {
8453            self.cancel_rename_prompt();
8454            return true;
8455        }
8456        if self.state.show_worker_rename {
8457            self.cancel_worker_rename();
8458            return true;
8459        }
8460        if self.state.show_destroy_picker {
8461            if self.state.destroy_confirm_pending {
8462                self.cancel_destroy_confirm();
8463            } else {
8464                self.close_destroy_picker();
8465            }
8466            return true;
8467        }
8468        if self.state.claim_mode.is_some() {
8469            self.cancel_claim_mode();
8470            return true;
8471        }
8472        if self.state.relocate_mode.is_some() {
8473            self.cancel_relocate_mode();
8474            return true;
8475        }
8476        if self.state.show_plant_menu {
8477            self.close_plant_menu();
8478            return true;
8479        }
8480        if self.state.show_farm_access {
8481            self.close_farm_access_panel();
8482            return true;
8483        }
8484        if self.state.sell_plot_confirm.is_some() {
8485            self.state.sell_plot_confirm = None;
8486            self.state.sell_plot_armed_at = None;
8487            self.state.push_log("Sell cancelled");
8488            return true;
8489        }
8490        if self.state.show_move_picker {
8491            self.close_move_picker();
8492            return true;
8493        }
8494        if self.state.show_rotation_editor {
8495            match self.state.rotation_editor.mode {
8496                RotationEditorMode::List => {
8497                    self.state.show_rotation_editor = false;
8498                    self.state.rotation_editor.reset();
8499                }
8500                RotationEditorMode::EditLabel => {
8501                    self.state.rotation_editor.label_buffer.clear();
8502                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8503                }
8504                RotationEditorMode::PickAbility => {
8505                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8506                }
8507                RotationEditorMode::EditSequence => {
8508                    self.state.rotation_editor.draft = None;
8509                    self.state.rotation_editor.mode = RotationEditorMode::List;
8510                }
8511            }
8512            return true;
8513        }
8514        if self.state.show_inventory_menu {
8515            self.close_inventory_menu();
8516            return true;
8517        }
8518        if self.state.show_craft_menu {
8519            self.close_craft_menu();
8520            return true;
8521        }
8522        if self.state.show_plot_build_menu {
8523            self.close_plot_build_menu();
8524            return true;
8525        }
8526        if self.state.show_keychain_menu {
8527            self.close_keychain_menu();
8528            return true;
8529        }
8530        if self.state.show_quest_offer {
8531            self.quest_offer_decline();
8532            return true;
8533        }
8534        if self.state.show_shop_menu {
8535            // Caller must await shop close (npc_interaction_back / play-loop).
8536            return false;
8537        }
8538        if self.state.bank_panel.is_some() {
8539            return false;
8540        }
8541        if self.state.storage_panel.is_some() {
8542            return false;
8543        }
8544        if self.state.market_panel.is_some() {
8545            return false;
8546        }
8547        if self.state.show_npc_chat {
8548            // play.rs calls npc_interaction_back().await on Esc
8549            return false;
8550        }
8551        if self.state.show_npc_verb_menu {
8552            self.state.show_npc_verb_menu = false;
8553            self.state.npc_verb_target = None;
8554            self.state.npc_verb_notice = None;
8555            return true;
8556        }
8557        if self.state.show_quest_menu {
8558            if self.state.quest_withdraw_confirm {
8559                self.state.quest_withdraw_confirm = false;
8560            } else {
8561                self.state.show_quest_menu = false;
8562            }
8563            return true;
8564        }
8565        if self.state.worker_route_editor.is_some() {
8566            // Pop one sheet level; only close the editor at the root stop list.
8567            if self.re_at_root_sheet() {
8568                let reopen = self.state.attending_worker_instance_id.clone();
8569                self.close_worker_route_editor();
8570                if let Some(id) = reopen {
8571                    if let Some(idx) = self
8572                        .state
8573                        .hired_workers
8574                        .iter()
8575                        .position(|w| w.instance_id == id)
8576                    {
8577                        self.state.workers_menu_index = idx;
8578                        self.state.show_workers_menu = true;
8579                    }
8580                }
8581            } else {
8582                self.re_sheet_back();
8583            }
8584            return true;
8585        }
8586        if self.state.show_worker_give_picker {
8587            self.close_worker_give_picker();
8588            return true;
8589        }
8590        if self.state.show_worker_give_target_picker {
8591            self.close_worker_give_target_picker();
8592            return true;
8593        }
8594        if self.state.show_worker_take_picker {
8595            self.close_worker_take_picker();
8596            return true;
8597        }
8598        if self.state.show_worker_teach_picker {
8599            self.close_worker_teach_picker();
8600            return true;
8601        }
8602        if self.state.show_workers_menu {
8603            self.close_workers_menu_ui();
8604            return true;
8605        }
8606        if self.state.show_loadout_menu {
8607            self.state.show_loadout_menu = false;
8608            return true;
8609        }
8610        if self.state.show_stats {
8611            self.state.show_stats = false;
8612            return true;
8613        }
8614        if self.state.show_equip_menu {
8615            self.state.show_equip_menu = false;
8616            return true;
8617        }
8618        false
8619    }
8620
8621    pub fn toggle_stats(&mut self) {
8622        self.state.show_stats = !self.state.show_stats;
8623        if self.state.show_stats {
8624            self.state.character_sheet_tab = CharacterSheetTab::Character;
8625            self.state.show_craft_menu = false;
8626            self.state.show_shop_menu = false;
8627            self.state.shop_catalog = None;
8628            self.state.show_inventory_menu = false;
8629            self.state.show_equip_menu = false;
8630        }
8631    }
8632
8633    pub fn toggle_equip_menu(&mut self) {
8634        self.state.show_equip_menu = !self.state.show_equip_menu;
8635        if self.state.show_equip_menu {
8636            self.state.show_stats = false;
8637            self.state.show_craft_menu = false;
8638            self.state.show_shop_menu = false;
8639            self.state.shop_catalog = None;
8640            self.state.show_inventory_menu = false;
8641            self.state.show_loadout_menu = false;
8642        }
8643    }
8644
8645    pub fn cycle_character_sheet_tab(&mut self) {
8646        if self.state.show_stats {
8647            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8648        }
8649    }
8650
8651    pub fn set_ledger_period_digit(&mut self, c: char) {
8652        if self.state.show_stats {
8653            if let Some(p) = LedgerPeriod::from_digit(c) {
8654                self.state.ledger_period = p;
8655                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8656            }
8657        }
8658    }
8659
8660    pub fn cycle_ledger_period(&mut self) {
8661        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8662            self.state.ledger_period = self.state.ledger_period.cycle();
8663        }
8664    }
8665
8666    pub fn open_inventory_menu(&mut self) {
8667        self.state.show_inventory_menu = true;
8668        self.state.show_craft_menu = false;
8669        self.state.show_shop_menu = false;
8670        self.state.shop_catalog = None;
8671        self.state.show_stats = false;
8672        self.state.show_move_picker = false;
8673        self.state.move_picker = None;
8674        self.state.show_destroy_picker = false;
8675        self.state.destroy_confirm_pending = false;
8676        self.state.destroy_picker = None;
8677        self.state.show_rename_prompt = false;
8678        self.state.rename_plot_id = None;
8679        self.state.rename_buffer.clear();
8680        self.state.inventory_filter_focused = false;
8681        self.state.clamp_inventory_indices();
8682    }
8683
8684    pub fn close_inventory_menu(&mut self) {
8685        self.state.show_inventory_menu = false;
8686        self.state.show_move_picker = false;
8687        self.state.move_picker = None;
8688        self.close_grant_picker();
8689        self.state.show_destroy_picker = false;
8690        self.state.destroy_confirm_pending = false;
8691        self.state.destroy_picker = None;
8692        self.state.show_rename_prompt = false;
8693        self.state.rename_plot_id = None;
8694        self.state.rename_buffer.clear();
8695        self.state.inventory_filter_focused = false;
8696    }
8697
8698    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8699        let Some(row) = self.state.inventory_selected_row() else {
8700            anyhow::bail!("inventory empty");
8701        };
8702        if GameState::is_property_deed_template(&row.stack.template_id) {
8703            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8704                anyhow::bail!("deed has no plot id");
8705            };
8706            let label = self
8707                .state
8708                .property_plots
8709                .iter()
8710                .find(|p| p.plot_id == plot_id)
8711                .map(|p| {
8712                    if p.label.trim().is_empty() {
8713                        p.plot_code.clone()
8714                    } else {
8715                        p.label.clone()
8716                    }
8717                })
8718                .unwrap_or_else(|| {
8719                    row.stack
8720                        .display_name
8721                        .clone()
8722                        .unwrap_or_else(|| "plot".into())
8723                });
8724            self.state.rename_buffer = label;
8725            self.state.rename_plot_id = Some(plot_id);
8726            self.state.highlighted_plot_id = Some(plot_id);
8727            self.state.show_rename_prompt = true;
8728            self.state.show_worker_rename = false;
8729            self.state.show_move_picker = false;
8730            self.state.show_destroy_picker = false;
8731            self.state.destroy_confirm_pending = false;
8732            return Ok(());
8733        }
8734        if !self.state.row_is_renameable_container(&row) {
8735            anyhow::bail!("only storage containers or deeds can be renamed");
8736        }
8737        let current = row
8738            .stack
8739            .display_name
8740            .clone()
8741            .unwrap_or_else(|| row.stack.template_id.clone());
8742        self.state.rename_buffer = current;
8743        self.state.rename_plot_id = None;
8744        self.state.show_rename_prompt = true;
8745        self.state.show_worker_rename = false;
8746        self.state.show_move_picker = false;
8747        self.state.show_destroy_picker = false;
8748        self.state.destroy_confirm_pending = false;
8749        Ok(())
8750    }
8751
8752    /// Rename the owned plot under the player's feet (`n` in world).
8753    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8754        let Some(plot) = self.state.my_plot_under_player().cloned() else {
8755            anyhow::bail!("stand on your plot to rename it");
8756        };
8757        let label = if plot.label.trim().is_empty() {
8758            plot.plot_code.clone()
8759        } else {
8760            plot.label.clone()
8761        };
8762        self.state.rename_buffer = label;
8763        self.state.rename_plot_id = Some(plot.plot_id);
8764        self.state.highlighted_plot_id = Some(plot.plot_id);
8765        self.state.show_rename_prompt = true;
8766        self.state.show_worker_rename = false;
8767        Ok(())
8768    }
8769
8770    pub fn cancel_rename_prompt(&mut self) {
8771        self.state.show_rename_prompt = false;
8772        self.state.rename_plot_id = None;
8773        self.state.rename_buffer.clear();
8774    }
8775
8776    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8777        let name = self.state.rename_buffer.trim().to_string();
8778        if name.is_empty() {
8779            anyhow::bail!("name cannot be empty");
8780        }
8781        if let Some(plot_id) = self.state.rename_plot_id {
8782            if name.chars().count() > 48 {
8783                anyhow::bail!("label must be 1–48 characters");
8784            }
8785            self.seq += 1;
8786            self.session
8787                .submit_intent(Intent::RenamePropertyPlot {
8788                    entity_id: self.state.entity_id,
8789                    plot_id,
8790                    label: name,
8791                    seq: self.seq,
8792                })
8793                .await?;
8794            self.state.intents_sent += 1;
8795            self.state.show_rename_prompt = false;
8796            self.state.rename_plot_id = None;
8797            self.state.rename_buffer.clear();
8798            return Ok(());
8799        }
8800        if name.chars().count() > 32 {
8801            anyhow::bail!("name must be 1–32 characters");
8802        }
8803        let Some(row) = self.state.inventory_selected_row() else {
8804            anyhow::bail!("inventory empty");
8805        };
8806        let Some(instance_id) = row.stack.item_instance_id else {
8807            anyhow::bail!("item has no instance id");
8808        };
8809        self.seq += 1;
8810        self.session
8811            .submit_intent(Intent::RenameContainer {
8812                entity_id: self.state.entity_id,
8813                item_instance_id: instance_id,
8814                location: row.from.clone(),
8815                name,
8816                seq: self.seq,
8817            })
8818            .await?;
8819        self.state.intents_sent += 1;
8820        self.state.show_rename_prompt = false;
8821        self.state.rename_buffer.clear();
8822        Ok(())
8823    }
8824
8825    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8826        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8827            anyhow::bail!("no worker selected");
8828        };
8829        self.state.rename_buffer = worker.label.clone();
8830        self.state.show_worker_rename = true;
8831        self.state.show_rename_prompt = false;
8832        Ok(())
8833    }
8834
8835    pub fn cancel_worker_rename(&mut self) {
8836        self.state.show_worker_rename = false;
8837        self.state.rename_buffer.clear();
8838    }
8839
8840    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8841        let name = self.state.rename_buffer.trim().to_string();
8842        if name.is_empty() {
8843            anyhow::bail!("name cannot be empty");
8844        }
8845        if name.chars().count() > 32 {
8846            anyhow::bail!("name must be 1–32 characters");
8847        }
8848        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8849            anyhow::bail!("no worker selected");
8850        };
8851        let worker_instance_id = worker.instance_id.clone();
8852        self.seq += 1;
8853        self.session
8854            .submit_intent(Intent::RenameHiredWorker {
8855                entity_id: self.state.entity_id,
8856                worker_instance_id: worker_instance_id.clone(),
8857                name: name.clone(),
8858                seq: self.seq,
8859            })
8860            .await?;
8861        self.state.intents_sent += 1;
8862        if let Some(w) = self
8863            .state
8864            .hired_workers
8865            .iter_mut()
8866            .find(|w| w.instance_id == worker_instance_id)
8867        {
8868            w.label = name.clone();
8869        }
8870        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8871            if ed.worker_instance_id == worker_instance_id {
8872                ed.worker_label = name.clone();
8873            }
8874        }
8875        self.state.show_worker_rename = false;
8876        self.state.rename_buffer.clear();
8877        self.state.push_log(format!("Renamed worker to \"{name}\""));
8878        Ok(())
8879    }
8880
8881    pub fn toggle_inventory_menu(&mut self) {
8882        if self.state.show_inventory_menu {
8883            self.close_inventory_menu();
8884        } else {
8885            self.open_inventory_menu();
8886        }
8887    }
8888
8889    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
8890    pub fn inventory_menu_move(&mut self, delta: i32) {
8891        if self.state.show_grant_picker {
8892            let Some(picker) = self.state.grant_picker.as_ref() else {
8893                return;
8894            };
8895            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8896            let filter = picker.filter.clone();
8897            let n = labels.len();
8898            if n == 0 {
8899                return;
8900            }
8901            self.state.grant_picker_index =
8902                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8903                    list_label_matches(&labels[i], &filter)
8904                });
8905            return;
8906        }
8907        if self.state.show_move_picker {
8908            let Some(picker) = self.state.move_picker.as_ref() else {
8909                return;
8910            };
8911            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8912            let filter = picker.filter.clone();
8913            let n = labels.len();
8914            if n == 0 {
8915                return;
8916            }
8917            self.state.move_picker_index =
8918                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8919                    list_label_matches(&labels[i], &filter)
8920                });
8921            self.state.clamp_move_picker_quantity();
8922            return;
8923        }
8924        let n = self.state.inventory_selectable_rows().len();
8925        if n == 0 {
8926            return;
8927        }
8928        let idx = self.state.inventory_menu_index as i32;
8929        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8930    }
8931
8932    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
8933    pub fn inventory_menu_page(&mut self, pages: i32) {
8934        if self.state.show_grant_picker {
8935            let Some(picker) = self.state.grant_picker.as_ref() else {
8936                return;
8937            };
8938            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8939            let filter = picker.filter.clone();
8940            let n = labels.len();
8941            self.state.grant_picker_index =
8942                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8943                    list_label_matches(&labels[i], &filter)
8944                });
8945            return;
8946        }
8947        if self.state.show_move_picker {
8948            let Some(picker) = self.state.move_picker.as_ref() else {
8949                return;
8950            };
8951            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8952            let filter = picker.filter.clone();
8953            let n = labels.len();
8954            self.state.move_picker_index =
8955                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8956                    list_label_matches(&labels[i], &filter)
8957                });
8958            self.state.clamp_move_picker_quantity();
8959            return;
8960        }
8961        let n = self.state.inventory_selectable_rows().len();
8962        self.state.inventory_menu_index =
8963            page_list_index(self.state.inventory_menu_index, pages, n);
8964    }
8965
8966    pub fn cycle_inventory_tab(&mut self, forward: bool) {
8967        if self.state.show_move_picker
8968            || self.state.show_grant_picker
8969            || self.state.show_destroy_picker
8970            || self.state.show_rename_prompt
8971            || self.state.inventory_filter_focused
8972        {
8973            return;
8974        }
8975        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8976        self.state.inventory_menu_index = 0;
8977        self.state.clamp_inventory_indices();
8978    }
8979
8980    pub fn focus_inventory_filter(&mut self) {
8981        if self.state.show_grant_picker {
8982            if let Some(p) = self.state.grant_picker.as_mut() {
8983                p.filter_focused = true;
8984            }
8985            return;
8986        }
8987        if self.state.show_move_picker {
8988            if let Some(p) = self.state.move_picker.as_mut() {
8989                p.filter_focused = true;
8990            }
8991            return;
8992        }
8993        self.state.inventory_filter_focused = true;
8994    }
8995
8996    pub fn set_inventory_filter(&mut self, filter: String) {
8997        self.state.inventory_filter = filter;
8998        self.state.inventory_menu_index = 0;
8999        self.state.clamp_inventory_indices();
9000    }
9001
9002    pub fn append_inventory_filter_char(&mut self, ch: char) {
9003        if !is_list_filter_char(ch) {
9004            return;
9005        }
9006        if self.state.show_grant_picker {
9007            if let Some(p) = self.state.grant_picker.as_mut() {
9008                if p.filter_focused {
9009                    p.filter.push(ch);
9010                    self.state.grant_picker_index = 0;
9011                }
9012            }
9013            return;
9014        }
9015        if self.state.show_move_picker {
9016            if let Some(p) = self.state.move_picker.as_mut() {
9017                if p.filter_focused {
9018                    p.filter.push(ch);
9019                    self.state.move_picker_index = 0;
9020                    self.state.clamp_move_picker_quantity();
9021                }
9022            }
9023            return;
9024        }
9025        if !self.state.inventory_filter_focused {
9026            return;
9027        }
9028        self.state.inventory_filter.push(ch);
9029        self.state.inventory_menu_index = 0;
9030        self.state.clamp_inventory_indices();
9031    }
9032
9033    pub fn inventory_filter_backspace(&mut self) {
9034        if self.state.show_grant_picker {
9035            if let Some(p) = self.state.grant_picker.as_mut() {
9036                if p.filter_focused {
9037                    p.filter.pop();
9038                    self.state.grant_picker_index = 0;
9039                }
9040            }
9041            return;
9042        }
9043        if self.state.show_move_picker {
9044            if let Some(p) = self.state.move_picker.as_mut() {
9045                if p.filter_focused {
9046                    p.filter.pop();
9047                    self.state.move_picker_index = 0;
9048                    self.state.clamp_move_picker_quantity();
9049                }
9050            }
9051            return;
9052        }
9053        if !self.state.inventory_filter_focused {
9054            return;
9055        }
9056        self.state.inventory_filter.pop();
9057        self.state.inventory_menu_index = 0;
9058        self.state.clamp_inventory_indices();
9059    }
9060
9061    /// Esc while filter focused: clear filter then blur. Returns true if handled.
9062    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9063        if self.state.show_grant_picker {
9064            if let Some(p) = self.state.grant_picker.as_mut() {
9065                if p.filter_focused {
9066                    if !p.filter.is_empty() {
9067                        p.filter.clear();
9068                        self.state.grant_picker_index = 0;
9069                    } else {
9070                        p.filter_focused = false;
9071                    }
9072                    return true;
9073                }
9074                if !p.filter.is_empty() {
9075                    p.filter.clear();
9076                    self.state.grant_picker_index = 0;
9077                    return true;
9078                }
9079            }
9080            return false;
9081        }
9082        if self.state.show_move_picker {
9083            if let Some(p) = self.state.move_picker.as_mut() {
9084                if p.filter_focused {
9085                    if !p.filter.is_empty() {
9086                        p.filter.clear();
9087                        self.state.move_picker_index = 0;
9088                        self.state.clamp_move_picker_quantity();
9089                    } else {
9090                        p.filter_focused = false;
9091                    }
9092                    return true;
9093                }
9094                if !p.filter.is_empty() {
9095                    p.filter.clear();
9096                    self.state.move_picker_index = 0;
9097                    self.state.clamp_move_picker_quantity();
9098                    return true;
9099                }
9100            }
9101            return false;
9102        }
9103        if self.state.inventory_filter_focused {
9104            if !self.state.inventory_filter.is_empty() {
9105                self.state.inventory_filter.clear();
9106                self.state.inventory_menu_index = 0;
9107                self.state.clamp_inventory_indices();
9108            } else {
9109                self.state.inventory_filter_focused = false;
9110            }
9111            return true;
9112        }
9113        if !self.state.inventory_filter.is_empty() {
9114            self.state.inventory_filter.clear();
9115            self.state.inventory_menu_index = 0;
9116            self.state.clamp_inventory_indices();
9117            return true;
9118        }
9119        false
9120    }
9121
9122    pub fn craft_menu_page(&mut self, pages: i32) {
9123        let n = self.state.craft_filtered_indices().len();
9124        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9125        self.state.clamp_craft_batch_quantity();
9126    }
9127
9128    pub fn shop_menu_page(&mut self, pages: i32) {
9129        let n = self.state.shop_list_len();
9130        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9131        self.state.clamp_shop_quantity();
9132    }
9133
9134    pub fn workers_menu_page(&mut self, pages: i32) {
9135        let n = self.state.hired_workers.len();
9136        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9137    }
9138
9139    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
9140    /// chest — or fall back to the "move to…" destination picker (including
9141    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
9142    /// Placed chest shells open a pick-up destination picker (`l` still locks).
9143    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9144        if self.state.show_destroy_picker {
9145            if self.state.destroy_confirm_pending {
9146                return self.confirm_destroy_item().await;
9147            }
9148            return self.request_destroy_confirm();
9149        }
9150        if self.state.show_grant_picker {
9151            return self.confirm_grant_picker().await;
9152        }
9153        if self.state.show_move_picker {
9154            return self.confirm_move_picker().await;
9155        }
9156        let Some(row) = self.state.inventory_selected_row() else {
9157            anyhow::bail!("inventory empty");
9158        };
9159        if row.is_equip_shell {
9160            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9161                anyhow::bail!("not a worn item");
9162            };
9163            return self.equip_worn(slot, None).await;
9164        }
9165        if row.is_chest_shell {
9166            return self.open_chest_pickup_picker();
9167        }
9168        let template_id = row.stack.template_id.clone();
9169        let instance_id = row.stack.item_instance_id;
9170        let category = self.state.inventory_item_category(&template_id);
9171        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9172
9173        if category == Some("weapon") {
9174            return self.equip_mainhand(Some(template_id)).await;
9175        }
9176        if category == Some("lodging") && on_person {
9177            if let Some(inst) = instance_id {
9178                return self.place_container(inst).await;
9179            }
9180        }
9181        // Placeable workshop tools (kiln, furnace) — Enter places them like chests.
9182        if on_person {
9183            if let Some(inst) = instance_id {
9184                if row.stack.world_placeable == Some(true) {
9185                    return self.place_container(inst).await;
9186                }
9187            }
9188        }
9189        if (category == Some("container") || category == Some("armor")) && on_person {
9190            if let Some(inst) = instance_id {
9191                let world_placeable =
9192                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
9193                if world_placeable {
9194                    return self.place_container(inst).await;
9195                }
9196                // Pouches no longer equip directly — they clip onto a worn belt's loops
9197                // instead, so fall through to the move picker (offers "belt loop" when a
9198                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
9199                if let Some(slot) = guess_body_slot(&template_id) {
9200                    return self.equip_worn(slot, Some(inst)).await;
9201                }
9202            }
9203        }
9204        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
9205        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
9206        // instead of guessing.
9207        self.open_move_picker()
9208    }
9209
9210    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
9211    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9212        let Some(row) = self.state.inventory_selected_row() else {
9213            anyhow::bail!("inventory empty");
9214        };
9215        if row.from != flatland_protocol::InventoryLocation::Root {
9216            anyhow::bail!("select a consumable on your person");
9217        }
9218        if GameState::stack_is_item_grant(&row.stack) {
9219            return self.open_grant_target_picker();
9220        }
9221        if GameState::is_property_deed_template(&row.stack.template_id) {
9222            return self.open_move_picker();
9223        }
9224        let category = self.state.inventory_item_category(&row.stack.template_id);
9225        if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9226            anyhow::bail!("selected item is not usable");
9227        }
9228        self.use_item(&row.stack.template_id).await
9229    }
9230
9231    /// Open picker: apply selected grant item onto inventory / worn gear.
9232    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9233        let Some(row) = self.state.inventory_selected_row() else {
9234            anyhow::bail!("inventory empty");
9235        };
9236        if row.from != flatland_protocol::InventoryLocation::Root {
9237            anyhow::bail!("select a grant item on your person");
9238        }
9239        if !GameState::stack_is_item_grant(&row.stack) {
9240            anyhow::bail!("selected item does not grant onto gear");
9241        }
9242        let Some(grant_instance_id) = row.stack.item_instance_id else {
9243            anyhow::bail!("grant has no instance id");
9244        };
9245        let effect_id = GameState::grant_effect_id(&row.stack)
9246            .unwrap_or("?")
9247            .to_string();
9248        let mode = GameState::grant_mode(&row.stack).to_string();
9249        let options = self.state.grant_target_options(&row.stack);
9250        if options.is_empty() {
9251            anyhow::bail!("no valid gear to apply {effect_id} to");
9252        }
9253        let grant_label = row
9254            .stack
9255            .display_name
9256            .clone()
9257            .unwrap_or_else(|| row.stack.template_id.clone());
9258        self.state.show_grant_picker = true;
9259        self.state.grant_picker_index = 0;
9260        self.state.grant_picker = Some(GrantTargetPicker {
9261            grant_instance_id,
9262            grant_label,
9263            effect_id,
9264            mode,
9265            options,
9266            filter: String::new(),
9267            filter_focused: false,
9268        });
9269        Ok(())
9270    }
9271
9272    pub fn close_grant_picker(&mut self) {
9273        self.state.show_grant_picker = false;
9274        self.state.grant_picker = None;
9275        self.state.grant_picker_index = 0;
9276    }
9277
9278    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9279        let Some(picker) = self.state.grant_picker.clone() else {
9280            self.close_grant_picker();
9281            return Ok(());
9282        };
9283        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9284            self.close_grant_picker();
9285            return Ok(());
9286        };
9287        self.close_grant_picker();
9288        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9289            .await?;
9290        self.state
9291            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9292        Ok(())
9293    }
9294
9295    /// `m`: always open the "move to…" picker for the selected item, even for
9296    /// weapons/wearables that Enter would otherwise equip/wear directly.
9297    /// Placed chests open the pick-up destination picker instead.
9298    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9299        let Some(row) = self.state.inventory_selected_row() else {
9300            anyhow::bail!("inventory empty");
9301        };
9302        if row.is_equip_shell {
9303            anyhow::bail!("this is a worn bag — press Enter to unequip it");
9304        }
9305        if row.is_chest_shell {
9306            return self.open_chest_pickup_picker();
9307        }
9308        let Some(instance_id) = row.stack.item_instance_id else {
9309            anyhow::bail!("item has no instance id");
9310        };
9311        let mut options = self.state.move_destinations_for(
9312            &row.from,
9313            row.from_parent_instance_id,
9314            row.stack.item_instance_id,
9315            &row.stack.template_id,
9316        );
9317        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9318        let category = self.state.inventory_item_category(&row.stack.template_id);
9319        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9320            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9321                options.insert(
9322                    0,
9323                    MoveOption {
9324                        label: "Sell plot to crown…".into(),
9325                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
9326                    },
9327                );
9328            }
9329        }
9330        if on_person && category == Some("consumable") {
9331            if GameState::stack_is_item_grant(&row.stack) {
9332                options.insert(
9333                    0,
9334                    MoveOption {
9335                        label: "Apply onto gear…".into(),
9336                        kind: MoveOptionKind::GrantApply,
9337                    },
9338                );
9339            } else {
9340                let study = GameState::stack_is_blueprint_scroll(&row.stack);
9341                options.insert(
9342                    0,
9343                    MoveOption {
9344                        label: if study {
9345                            "Study".into()
9346                        } else {
9347                            "Use (eat / drink)".into()
9348                        },
9349                        kind: MoveOptionKind::Use,
9350                    },
9351                );
9352            }
9353        } else if on_person && GameState::stack_is_serving(&row.stack) {
9354            let label = if GameState::stack_is_food_serving(&row.stack) {
9355                "Use (eat)"
9356            } else {
9357                "Use (fill / drink)"
9358            };
9359            options.insert(
9360                0,
9361                MoveOption {
9362                    label: label.into(),
9363                    kind: MoveOptionKind::Use,
9364                },
9365            );
9366        }
9367        let item_label = row
9368            .stack
9369            .display_name
9370            .clone()
9371            .unwrap_or_else(|| row.stack.template_id.clone());
9372        // Default to 1 so withdrawing from storage is partial unless the player
9373        // presses `a` for max fit (full stack when the destination allows it).
9374        let initial_qty = if row.stack.quantity > 1 {
9375            1
9376        } else {
9377            row.stack.quantity
9378        };
9379        self.state.move_picker = Some(MovePicker {
9380            item_instance_id: instance_id,
9381            from: row.from,
9382            item_label,
9383            template_id: row.stack.template_id.clone(),
9384            stack_quantity: row.stack.quantity,
9385            quantity: initial_qty.max(1),
9386            options,
9387            filter: String::new(),
9388            filter_focused: false,
9389        });
9390        self.state.move_picker_index = 0;
9391        self.state.show_move_picker = true;
9392        self.state.show_destroy_picker = false;
9393        self.state.destroy_confirm_pending = false;
9394        self.state.destroy_picker = None;
9395        self.state.clamp_move_picker_quantity();
9396        Ok(())
9397    }
9398
9399    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
9400    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9401        let Some(row) = self.state.inventory_selected_row() else {
9402            anyhow::bail!("inventory empty");
9403        };
9404        if !row.is_chest_shell {
9405            anyhow::bail!("not a placed chest");
9406        }
9407        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9408            anyhow::bail!("not a placed chest");
9409        };
9410        let Some(instance_id) = row.stack.item_instance_id else {
9411            anyhow::bail!("chest has no instance id");
9412        };
9413        let chest = self
9414            .state
9415            .placed_containers
9416            .iter()
9417            .find(|c| c.id == *container_id)
9418            .cloned()
9419            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9420        let (px, py) = self.state.player_position();
9421        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9422            anyhow::bail!("too far from {}", chest.display_name);
9423        }
9424        if chest.locked && !chest.accessible {
9425            anyhow::bail!(
9426                "need the matching key for {} before picking it up",
9427                chest.display_name
9428            );
9429        }
9430        let options = self.state.chest_pickup_destinations(container_id);
9431        let item_label = row
9432            .stack
9433            .display_name
9434            .clone()
9435            .unwrap_or_else(|| row.stack.template_id.clone());
9436        self.state.move_picker = Some(MovePicker {
9437            item_instance_id: instance_id,
9438            from: row.from.clone(),
9439            item_label,
9440            template_id: row.stack.template_id.clone(),
9441            stack_quantity: 1,
9442            quantity: 1,
9443            options,
9444            filter: String::new(),
9445            filter_focused: false,
9446        });
9447        self.state.move_picker_index = 0;
9448        self.state.show_move_picker = true;
9449        self.state.show_destroy_picker = false;
9450        self.state.destroy_confirm_pending = false;
9451        self.state.destroy_picker = None;
9452        Ok(())
9453    }
9454
9455    pub fn close_move_picker(&mut self) {
9456        self.state.show_move_picker = false;
9457        self.state.move_picker = None;
9458    }
9459
9460    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9461        self.state.move_picker_adjust_quantity(delta);
9462    }
9463
9464    pub fn move_picker_set_quantity_max(&mut self) {
9465        self.state.move_picker_set_quantity_max();
9466    }
9467
9468    pub fn move_picker_set_quantity_min(&mut self) {
9469        self.state.move_picker_set_quantity_min();
9470    }
9471
9472    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9473        self.state.destroy_picker_adjust_quantity(delta);
9474    }
9475
9476    pub fn destroy_picker_set_quantity_max(&mut self) {
9477        self.state.destroy_picker_set_quantity_max();
9478    }
9479
9480    pub fn destroy_picker_set_quantity_min(&mut self) {
9481        self.state.destroy_picker_set_quantity_min();
9482    }
9483
9484    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9485        let Some(picker) = self.state.move_picker.clone() else {
9486            self.close_move_picker();
9487            return Ok(());
9488        };
9489        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9490            self.close_move_picker();
9491            return Ok(());
9492        };
9493        match option.kind {
9494            MoveOptionKind::Cancel => {
9495                self.close_move_picker();
9496            }
9497            MoveOptionKind::Use => {
9498                self.close_move_picker();
9499                self.use_item(&picker.template_id).await?;
9500            }
9501            MoveOptionKind::GrantApply => {
9502                self.close_move_picker();
9503                self.open_grant_target_picker()?;
9504            }
9505            MoveOptionKind::SellPlotToCrown { plot_id } => {
9506                self.close_move_picker();
9507                self.confirm_sell_plot_to_crown(plot_id).await?;
9508            }
9509            MoveOptionKind::RelocatePlaced { container_id } => {
9510                self.close_move_picker();
9511                self.state.show_inventory_menu = false;
9512                self.begin_relocate_container(&container_id)?;
9513            }
9514            MoveOptionKind::Drop => {
9515                self.close_move_picker();
9516                if self
9517                    .state
9518                    .hand_equipped_instance_ids()
9519                    .contains(&picker.item_instance_id)
9520                {
9521                    anyhow::bail!("unequip that item first");
9522                }
9523                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9524                    if self.state.deed_bound(&stack) {
9525                        anyhow::bail!(
9526                            "cannot drop a property deed — store it or trade it to another player"
9527                        );
9528                    }
9529                    if self.state.key_drop_blocked(&stack) {
9530                        anyhow::bail!("cannot drop the key while its chest is locked");
9531                    }
9532                }
9533                self.drop_item(picker.item_instance_id, picker.from).await?;
9534                self.state
9535                    .push_log(format!("Dropped {}", picker.item_label));
9536            }
9537            MoveOptionKind::PickupPlaced {
9538                container_id,
9539                nest_location,
9540                nest_parent_instance_id,
9541            } => {
9542                self.close_move_picker();
9543                self.pickup_container(container_id.clone()).await?;
9544                let nest_into_bag = nest_parent_instance_id.is_some()
9545                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9546                if nest_into_bag {
9547                    self.move_item(
9548                        picker.item_instance_id,
9549                        flatland_protocol::InventoryLocation::Root,
9550                        nest_location,
9551                        nest_parent_instance_id,
9552                        None,
9553                    )
9554                    .await?;
9555                    self.state
9556                        .push_log(format!("Picked up {} into bag", picker.item_label));
9557                } else {
9558                    self.state
9559                        .push_log(format!("Picked up {}", picker.item_label));
9560                }
9561            }
9562            MoveOptionKind::Move {
9563                location,
9564                parent_instance_id,
9565            } => {
9566                self.close_move_picker();
9567                let qty = if picker.quantity >= picker.stack_quantity {
9568                    None
9569                } else {
9570                    Some(picker.quantity)
9571                };
9572                self.move_item(
9573                    picker.item_instance_id,
9574                    picker.from,
9575                    location,
9576                    parent_instance_id,
9577                    qty,
9578                )
9579                .await?;
9580                let moved = qty.unwrap_or(picker.stack_quantity);
9581                if moved >= picker.stack_quantity {
9582                    self.state.push_log(format!("Moved {}", picker.item_label));
9583                } else {
9584                    self.state.push_log(format!(
9585                        "Moved {} ×{} of {}",
9586                        picker.item_label, moved, picker.stack_quantity
9587                    ));
9588                }
9589            }
9590        }
9591        Ok(())
9592    }
9593
9594    /// `d`: place furniture (kiln, chest, bed) at your feet, or drop other items
9595    /// as ground loot. Placeable tools must not become loot piles — companions
9596    /// would vacuum them and they'd look "gone".
9597    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9598        let Some(row) = self.state.inventory_selected_row() else {
9599            anyhow::bail!("inventory empty");
9600        };
9601        if row.is_equip_shell {
9602            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9603        }
9604        if row.is_chest_shell {
9605            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9606        }
9607        let Some(inst) = row.stack.item_instance_id else {
9608            anyhow::bail!("item has no instance id");
9609        };
9610        if self.state.hand_equipped_instance_ids().contains(&inst) {
9611            anyhow::bail!("unequip that item first");
9612        }
9613        if self.state.deed_bound(&row.stack) {
9614            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9615        }
9616        if self.state.key_drop_blocked(&row.stack) {
9617            anyhow::bail!("cannot drop the key while its chest is locked");
9618        }
9619        let label = row
9620            .stack
9621            .display_name
9622            .clone()
9623            .unwrap_or_else(|| row.stack.template_id.clone());
9624        let placeable = row.stack.world_placeable == Some(true)
9625            || row.from == flatland_protocol::InventoryLocation::Root
9626                && matches!(
9627                    self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9628                    Some("lodging")
9629                );
9630        if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9631            self.place_container(inst).await?;
9632            self.state.push_log(format!("Placed {label}"));
9633            return Ok(());
9634        }
9635        self.drop_item(inst, row.from).await?;
9636        self.state.push_log(format!("Dropped {label}"));
9637        Ok(())
9638    }
9639
9640    pub async fn drop_item(
9641        &mut self,
9642        item_instance_id: uuid::Uuid,
9643        from: flatland_protocol::InventoryLocation,
9644    ) -> anyhow::Result<()> {
9645        self.seq += 1;
9646        self.session
9647            .submit_intent(Intent::DropItem {
9648                entity_id: self.state.entity_id,
9649                item_instance_id,
9650                from,
9651                seq: self.seq,
9652            })
9653            .await?;
9654        self.state.intents_sent += 1;
9655        Ok(())
9656    }
9657
9658    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
9659    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9660        let Some(row) = self.state.inventory_selected_row() else {
9661            anyhow::bail!("inventory empty");
9662        };
9663        if row.is_equip_shell {
9664            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9665        }
9666        if row.is_chest_shell {
9667            anyhow::bail!("can't destroy a placed chest from the inventory list");
9668        }
9669        let Some(instance_id) = row.stack.item_instance_id else {
9670            anyhow::bail!("item has no instance id");
9671        };
9672        if self
9673            .state
9674            .hand_equipped_instance_ids()
9675            .contains(&instance_id)
9676        {
9677            anyhow::bail!("unequip that item first");
9678        }
9679        if self.state.deed_bound(&row.stack) {
9680            anyhow::bail!(
9681                "cannot destroy a property deed — store it or trade it to another player"
9682            );
9683        }
9684        if self.state.key_drop_blocked(&row.stack) {
9685            anyhow::bail!("cannot destroy the key while its chest is locked");
9686        }
9687        let item_label = row
9688            .stack
9689            .display_name
9690            .clone()
9691            .unwrap_or_else(|| row.stack.template_id.clone());
9692        self.state.destroy_picker = Some(DestroyPicker {
9693            item_instance_id: instance_id,
9694            from: row.from,
9695            item_label,
9696            stack_quantity: row.stack.quantity,
9697            quantity: row.stack.quantity,
9698        });
9699        self.state.destroy_confirm_pending = false;
9700        self.state.show_destroy_picker = true;
9701        self.state.show_move_picker = false;
9702        self.state.move_picker = None;
9703        Ok(())
9704    }
9705
9706    pub fn close_destroy_picker(&mut self) {
9707        self.state.show_destroy_picker = false;
9708        self.state.destroy_confirm_pending = false;
9709        self.state.destroy_picker = None;
9710    }
9711
9712    pub fn cancel_destroy_confirm(&mut self) {
9713        self.state.destroy_confirm_pending = false;
9714    }
9715
9716    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9717        if self.state.destroy_picker.is_none() {
9718            self.close_destroy_picker();
9719            return Ok(());
9720        }
9721        self.state.destroy_confirm_pending = true;
9722        Ok(())
9723    }
9724
9725    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9726        let Some(picker) = self.state.destroy_picker.clone() else {
9727            self.close_destroy_picker();
9728            return Ok(());
9729        };
9730        let qty = if picker.quantity >= picker.stack_quantity {
9731            None
9732        } else {
9733            Some(picker.quantity)
9734        };
9735        self.destroy_item(picker.item_instance_id, picker.from, qty)
9736            .await?;
9737        let destroyed = qty.unwrap_or(picker.stack_quantity);
9738        if destroyed >= picker.stack_quantity {
9739            self.state
9740                .push_log(format!("Destroyed {}", picker.item_label));
9741        } else {
9742            self.state.push_log(format!(
9743                "Destroyed {} ×{} of {}",
9744                picker.item_label, destroyed, picker.stack_quantity
9745            ));
9746        }
9747        self.close_destroy_picker();
9748        Ok(())
9749    }
9750
9751    pub async fn destroy_item(
9752        &mut self,
9753        item_instance_id: uuid::Uuid,
9754        from: flatland_protocol::InventoryLocation,
9755        quantity: Option<u32>,
9756    ) -> anyhow::Result<()> {
9757        self.seq += 1;
9758        self.session
9759            .submit_intent(Intent::DestroyItem {
9760                entity_id: self.state.entity_id,
9761                item_instance_id,
9762                from,
9763                quantity,
9764                seq: self.seq,
9765            })
9766            .await?;
9767        self.state.intents_sent += 1;
9768        Ok(())
9769    }
9770
9771    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
9772    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9773        if let Some(row) = self.state.inventory_selected_row() {
9774            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9775                return self.toggle_placed_chest_lock(container_id).await;
9776            }
9777        }
9778        self.toggle_nearby_chest_lock().await
9779    }
9780
9781    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9782        let chest = self
9783            .state
9784            .placed_containers
9785            .iter()
9786            .find(|c| c.id == container_id)
9787            .cloned()
9788            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9789        let (px, py) = self.state.player_position();
9790        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9791            anyhow::bail!("too far from {}", chest.display_name);
9792        }
9793        if !chest.accessible && chest.locked {
9794            anyhow::bail!(
9795                "need the matching key for {} (each crafted chest has its own key)",
9796                chest.display_name
9797            );
9798        }
9799        let lock = !chest.locked;
9800        self.set_container_locked(
9801            flatland_protocol::InventoryLocation::Placed {
9802                container_id: chest.id.clone(),
9803            },
9804            lock,
9805        )
9806        .await?;
9807        self.state.push_log(if lock {
9808            format!("Locked {}", chest.display_name)
9809        } else {
9810            format!("Unlocked {}", chest.display_name)
9811        });
9812        Ok(())
9813    }
9814
9815    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
9816    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9817        let chest = self
9818            .state
9819            .nearest_placed_container(CONTAINER_RANGE_M)
9820            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9821        self.toggle_placed_chest_lock(&chest.id).await
9822    }
9823
9824    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9825        self.equip_mainhand(None).await
9826    }
9827
9828    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9829        if !self.state.is_alive() {
9830            anyhow::bail!("you are dead");
9831        }
9832        self.seq += 1;
9833        self.session
9834            .submit_intent(Intent::EquipOffhand {
9835                entity_id: self.state.entity_id,
9836                template_id,
9837                instance_id: None,
9838                seq: self.seq,
9839            })
9840            .await?;
9841        self.state.intents_sent += 1;
9842        Ok(())
9843    }
9844
9845    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9846        self.equip_offhand(None).await
9847    }
9848
9849    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9850        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9851        for slot in slots {
9852            self.equip_worn(slot, None).await?;
9853        }
9854        Ok(())
9855    }
9856
9857    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9858        let (px, py) = self.state.player_position();
9859        let in_range: Vec<_> = self
9860            .state
9861            .placed_containers
9862            .iter()
9863            .filter(|c| self.state.placed_container_in_current_space(c))
9864            .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9865            .collect();
9866        let nearest_free = in_range
9867            .iter()
9868            .copied()
9869            .filter(|c| !self.state.lodging_is_occupied(&c.id))
9870            .min_by(|a, b| {
9871                let da = (a.x - px).hypot(a.y - py);
9872                let db = (b.x - px).hypot(b.y - py);
9873                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9874            })
9875            .cloned();
9876        if let Some(chest) = nearest_free {
9877            return self.pickup_container(chest.id).await;
9878        }
9879        if in_range
9880            .iter()
9881            .any(|c| self.state.lodging_is_occupied(&c.id))
9882        {
9883            anyhow::bail!("dismiss or reassign workers before picking up lodging");
9884        }
9885        if in_range.is_empty() {
9886            anyhow::bail!("no chest nearby");
9887        }
9888        anyhow::bail!("too far from chest");
9889    }
9890
9891    pub async fn equip_worn(
9892        &mut self,
9893        slot: BodySlot,
9894        instance_id: Option<uuid::Uuid>,
9895    ) -> anyhow::Result<()> {
9896        self.seq += 1;
9897        self.session
9898            .submit_intent(Intent::EquipWorn {
9899                entity_id: self.state.entity_id,
9900                slot,
9901                instance_id,
9902                seq: self.seq,
9903            })
9904            .await?;
9905        self.state.intents_sent += 1;
9906        Ok(())
9907    }
9908
9909    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9910        self.seq += 1;
9911        self.session
9912            .submit_intent(Intent::PlaceContainer {
9913                entity_id: self.state.entity_id,
9914                item_instance_id,
9915                seq: self.seq,
9916            })
9917            .await?;
9918        self.state.intents_sent += 1;
9919        Ok(())
9920    }
9921
9922    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9923        self.seq += 1;
9924        self.session
9925            .submit_intent(Intent::PickupContainer {
9926                entity_id: self.state.entity_id,
9927                container_id,
9928                seq: self.seq,
9929            })
9930            .await?;
9931        self.state.intents_sent += 1;
9932        Ok(())
9933    }
9934
9935    pub async fn move_item(
9936        &mut self,
9937        item_instance_id: uuid::Uuid,
9938        from: flatland_protocol::InventoryLocation,
9939        to: flatland_protocol::InventoryLocation,
9940        to_parent_instance_id: Option<uuid::Uuid>,
9941        quantity: Option<u32>,
9942    ) -> anyhow::Result<()> {
9943        self.seq += 1;
9944        self.session
9945            .submit_intent(Intent::MoveItem {
9946                entity_id: self.state.entity_id,
9947                item_instance_id,
9948                from,
9949                to,
9950                to_parent_instance_id,
9951                quantity,
9952                seq: self.seq,
9953            })
9954            .await?;
9955        self.state.intents_sent += 1;
9956        Ok(())
9957    }
9958
9959    pub async fn set_container_locked(
9960        &mut self,
9961        location: flatland_protocol::InventoryLocation,
9962        locked: bool,
9963    ) -> anyhow::Result<()> {
9964        self.seq += 1;
9965        self.session
9966            .submit_intent(Intent::SetContainerLocked {
9967                entity_id: self.state.entity_id,
9968                location,
9969                locked,
9970                seq: self.seq,
9971            })
9972            .await?;
9973        self.state.intents_sent += 1;
9974        Ok(())
9975    }
9976
9977    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
9978        if !self.state.is_alive() {
9979            anyhow::bail!("you are dead");
9980        }
9981        self.seq += 1;
9982        self.session
9983            .submit_intent(Intent::Use {
9984                entity_id: self.state.entity_id,
9985                template_id: template_id.to_string(),
9986                seq: self.seq,
9987            })
9988            .await?;
9989        self.state.intents_sent += 1;
9990        Ok(())
9991    }
9992
9993    /// Bind a grant consumable onto a specific item instance (unique gear status).
9994    pub async fn use_grant(
9995        &mut self,
9996        grant_instance_id: uuid::Uuid,
9997        target_instance_id: uuid::Uuid,
9998    ) -> anyhow::Result<()> {
9999        if !self.state.is_alive() {
10000            anyhow::bail!("you are dead");
10001        }
10002        self.seq += 1;
10003        self.session
10004            .submit_intent(Intent::UseGrant {
10005                entity_id: self.state.entity_id,
10006                grant_instance_id,
10007                target_instance_id,
10008                seq: self.seq,
10009            })
10010            .await?;
10011        self.state.intents_sent += 1;
10012        Ok(())
10013    }
10014
10015    pub fn open_craft_menu(&mut self) {
10016        self.state.show_craft_menu = true;
10017        self.state.show_shop_menu = false;
10018        self.state.shop_catalog = None;
10019        self.state.show_stats = false;
10020        self.state.show_inventory_menu = false;
10021        self.state.reload_craft_prefs();
10022        self.state.craft_tab = CraftTab::Ready;
10023        self.state.craft_filter.clear();
10024        self.state.craft_filter_focused = false;
10025        self.state.craft_menu_index = 0;
10026        self.state.clamp_craft_menu_index();
10027        self.state.craft_batch_quantity = 1;
10028        self.state.clamp_craft_batch_quantity();
10029    }
10030
10031    pub fn close_craft_menu(&mut self) {
10032        self.state.show_craft_menu = false;
10033        self.state.craft_filter_focused = false;
10034    }
10035
10036    pub fn toggle_keychain_menu(&mut self) {
10037        if self.state.show_keychain_menu {
10038            self.close_keychain_menu();
10039        } else {
10040            self.state.show_keychain_menu = true;
10041            self.state.show_craft_menu = false;
10042            self.state.show_shop_menu = false;
10043            self.state.show_inventory_menu = false;
10044            let n = self.state.keychain_entries().len();
10045            if n == 0 {
10046                self.state.keychain_menu_index = 0;
10047            } else {
10048                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10049            }
10050        }
10051    }
10052
10053    pub fn close_keychain_menu(&mut self) {
10054        self.state.show_keychain_menu = false;
10055    }
10056
10057    pub fn keychain_menu_move(&mut self, delta: i32) {
10058        let n = self.state.keychain_entries().len();
10059        if n == 0 {
10060            self.state.keychain_menu_index = 0;
10061            return;
10062        }
10063        let idx = self.state.keychain_menu_index as i32 + delta;
10064        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10065    }
10066
10067    pub fn keychain_menu_page(&mut self, pages: i32) {
10068        let n = self.state.keychain_entries().len();
10069        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10070    }
10071
10072    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10073        if !self.state.is_alive() {
10074            anyhow::bail!("you are dead");
10075        }
10076        let entries = self.state.keychain_entries();
10077        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10078            anyhow::bail!("nothing selected");
10079        };
10080        let Some(instance_id) = entry.stack.item_instance_id else {
10081            anyhow::bail!("key has no instance id");
10082        };
10083        if entry.stowed {
10084            self.move_item(
10085                instance_id,
10086                flatland_protocol::InventoryLocation::Keychain,
10087                flatland_protocol::InventoryLocation::Root,
10088                None,
10089                Some(1),
10090            )
10091            .await
10092        } else {
10093            self.move_item(
10094                instance_id,
10095                flatland_protocol::InventoryLocation::Root,
10096                flatland_protocol::InventoryLocation::Keychain,
10097                None,
10098                Some(1),
10099            )
10100            .await
10101        }
10102    }
10103
10104    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10105        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10106        self.state.show_shop_menu = false;
10107        self.state.shop_catalog = None;
10108        self.state.clear_shop_trade_log();
10109        if let Some(npc_id) = npc_id {
10110            self.seq += 1;
10111            self.session
10112                .submit_intent(Intent::ShopClose {
10113                    entity_id: self.state.entity_id,
10114                    npc_id,
10115                    seq: self.seq,
10116                })
10117                .await?;
10118            self.state.intents_sent += 1;
10119        }
10120        Ok(())
10121    }
10122
10123    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10124        let Some(panel) = self.state.bank_panel.clone() else {
10125            return Ok(());
10126        };
10127        self.seq += 1;
10128        self.session
10129            .submit_intent(Intent::BankDeposit {
10130                entity_id: self.state.entity_id,
10131                npc_id: panel.npc_id,
10132                amount_copper,
10133                seq: self.seq,
10134            })
10135            .await?;
10136        self.state.intents_sent += 1;
10137        Ok(())
10138    }
10139
10140    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10141        let Some(panel) = self.state.bank_panel.clone() else {
10142            return Ok(());
10143        };
10144        self.seq += 1;
10145        self.session
10146            .submit_intent(Intent::BankWithdraw {
10147                entity_id: self.state.entity_id,
10148                npc_id: panel.npc_id,
10149                amount_copper,
10150                seq: self.seq,
10151            })
10152            .await?;
10153        self.state.intents_sent += 1;
10154        Ok(())
10155    }
10156
10157    pub async fn bank_transfer(
10158        &mut self,
10159        to_character_id: Option<uuid::Uuid>,
10160        to_name: String,
10161        amount_copper: u64,
10162    ) -> anyhow::Result<()> {
10163        let Some(panel) = self.state.bank_panel.clone() else {
10164            return Ok(());
10165        };
10166        self.seq += 1;
10167        self.session
10168            .submit_intent(Intent::BankTransfer {
10169                entity_id: self.state.entity_id,
10170                npc_id: panel.npc_id,
10171                to_character_id,
10172                to_name,
10173                amount_copper,
10174                seq: self.seq,
10175            })
10176            .await?;
10177        self.state.intents_sent += 1;
10178        Ok(())
10179    }
10180
10181    pub fn bank_menu_move(&mut self, delta: i32) {
10182        let n = self.state.bank_menu_options().len();
10183        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10184            return;
10185        }
10186        let idx = self.state.bank_menu_index as i32;
10187        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10188    }
10189
10190    pub fn storage_menu_move(&mut self, delta: i32) {
10191        let n = self.state.storage_menu_options().len();
10192        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10193            return;
10194        }
10195        let idx = self.state.storage_menu_index as i32;
10196        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10197    }
10198
10199    pub fn storage_pick_move(&mut self, delta: i32) {
10200        let n = match &self.state.storage_ui_mode {
10201            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10202            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10203                self.state.storage_vault_options().len()
10204            }
10205            StorageUiMode::Menu
10206            | StorageUiMode::StoreAmount { .. }
10207            | StorageUiMode::TakeAmount { .. }
10208            | StorageUiMode::ShipAmount { .. } => 0,
10209        };
10210        if n == 0 {
10211            return;
10212        }
10213        match &mut self.state.storage_ui_mode {
10214            StorageUiMode::StorePick { index }
10215            | StorageUiMode::TakePick { index }
10216            | StorageUiMode::ShipPick { index, .. } => {
10217                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10218            }
10219            StorageUiMode::Menu
10220            | StorageUiMode::StoreAmount { .. }
10221            | StorageUiMode::TakeAmount { .. }
10222            | StorageUiMode::ShipAmount { .. } => {}
10223        }
10224    }
10225
10226    pub fn storage_ui_back(&mut self) {
10227        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10228            StorageUiMode::StoreAmount { pick_index, .. } => {
10229                StorageUiMode::StorePick { index: *pick_index }
10230            }
10231            StorageUiMode::TakeAmount { pick_index, .. } => {
10232                StorageUiMode::TakePick { index: *pick_index }
10233            }
10234            StorageUiMode::ShipAmount {
10235                dest_building_id,
10236                dest_label,
10237                pick_index,
10238                ..
10239            } => StorageUiMode::ShipPick {
10240                dest_building_id: dest_building_id.clone(),
10241                dest_label: dest_label.clone(),
10242                index: *pick_index,
10243            },
10244            StorageUiMode::StorePick { .. }
10245            | StorageUiMode::TakePick { .. }
10246            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10247            StorageUiMode::Menu => StorageUiMode::Menu,
10248        };
10249    }
10250
10251    pub fn storage_amount_append_char(&mut self, c: char) {
10252        match &mut self.state.storage_ui_mode {
10253            StorageUiMode::StoreAmount { input, .. }
10254            | StorageUiMode::TakeAmount { input, .. }
10255            | StorageUiMode::ShipAmount { input, .. } => {
10256                if c.is_ascii_digit() && input.len() < 8 {
10257                    input.push(c);
10258                }
10259            }
10260            _ => {}
10261        }
10262    }
10263
10264    pub fn storage_amount_backspace(&mut self) {
10265        match &mut self.state.storage_ui_mode {
10266            StorageUiMode::StoreAmount { input, .. }
10267            | StorageUiMode::TakeAmount { input, .. }
10268            | StorageUiMode::ShipAmount { input, .. } => {
10269                input.pop();
10270            }
10271            _ => {}
10272        }
10273    }
10274
10275    pub fn storage_ui_typing(&self) -> bool {
10276        matches!(
10277            self.state.storage_ui_mode,
10278            StorageUiMode::StoreAmount { .. }
10279                | StorageUiMode::TakeAmount { .. }
10280                | StorageUiMode::ShipAmount { .. }
10281        )
10282    }
10283
10284    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10285        match self.state.storage_ui_mode.clone() {
10286            StorageUiMode::Menu => {
10287                let index = self.state.storage_menu_index;
10288                match index {
10289                    0 => {
10290                        let opts = self.state.storage_store_options();
10291                        if opts.is_empty() {
10292                            self.state.push_log("Nothing loose to store.");
10293                            return Ok(());
10294                        }
10295                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10296                    }
10297                    1 => {
10298                        let opts = self.state.storage_vault_options();
10299                        if opts.is_empty() {
10300                            self.state.push_log("Vault is empty.");
10301                            return Ok(());
10302                        }
10303                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10304                    }
10305                    n => {
10306                        let dest = self
10307                            .state
10308                            .storage_panel
10309                            .as_ref()
10310                            .and_then(|p| p.ship_destinations.get(n - 2))
10311                            .cloned();
10312                        let Some(dest) = dest else {
10313                            return Ok(());
10314                        };
10315                        let opts = self.state.storage_vault_options();
10316                        if opts.is_empty() {
10317                            self.state.push_log("Vault is empty — nothing to ship.");
10318                            return Ok(());
10319                        }
10320                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
10321                            dest_building_id: dest.building_id,
10322                            dest_label: dest.label,
10323                            index: 0,
10324                        };
10325                    }
10326                }
10327            }
10328            StorageUiMode::StorePick { index } => {
10329                let opts = self.state.storage_store_options();
10330                let Some(opt) = opts.get(index) else {
10331                    self.state.push_log("Nothing loose to store.");
10332                    self.state.storage_ui_mode = StorageUiMode::Menu;
10333                    return Ok(());
10334                };
10335                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10336                    pick_index: index,
10337                    item_instance_id: opt.item_instance_id,
10338                    label: opt.label.clone(),
10339                    max_qty: opt.quantity.max(1),
10340                    input: String::new(),
10341                };
10342            }
10343            StorageUiMode::TakePick { index } => {
10344                let opts = self.state.storage_vault_options();
10345                let Some(opt) = opts.get(index) else {
10346                    self.state.push_log("Vault is empty.");
10347                    self.state.storage_ui_mode = StorageUiMode::Menu;
10348                    return Ok(());
10349                };
10350                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10351                    pick_index: index,
10352                    item_instance_id: opt.item_instance_id,
10353                    label: opt.label.clone(),
10354                    max_qty: opt.quantity.max(1),
10355                    input: String::new(),
10356                };
10357            }
10358            StorageUiMode::ShipPick {
10359                dest_building_id,
10360                dest_label,
10361                index,
10362            } => {
10363                let opts = self.state.storage_vault_options();
10364                let Some(opt) = opts.get(index) else {
10365                    self.state.push_log("Vault is empty — nothing to ship.");
10366                    self.state.storage_ui_mode = StorageUiMode::Menu;
10367                    return Ok(());
10368                };
10369                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10370                    dest_building_id,
10371                    dest_label,
10372                    pick_index: index,
10373                    item_instance_id: opt.item_instance_id,
10374                    label: opt.label.clone(),
10375                    max_qty: opt.quantity.max(1),
10376                    input: String::new(),
10377                };
10378            }
10379            StorageUiMode::StoreAmount {
10380                item_instance_id,
10381                max_qty,
10382                input,
10383                ..
10384            } => {
10385                let Some(qty) = parse_storage_quantity(&input) else {
10386                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10387                    return Ok(());
10388                };
10389                let qty = qty.map(|n| n.min(max_qty).max(1));
10390                self.storage_store(item_instance_id, qty).await?;
10391                self.state.storage_ui_mode = StorageUiMode::Menu;
10392            }
10393            StorageUiMode::TakeAmount {
10394                item_instance_id,
10395                max_qty,
10396                input,
10397                ..
10398            } => {
10399                let Some(qty) = parse_storage_quantity(&input) else {
10400                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10401                    return Ok(());
10402                };
10403                let qty = qty.map(|n| n.min(max_qty).max(1));
10404                self.storage_take(item_instance_id, qty).await?;
10405                self.state.storage_ui_mode = StorageUiMode::Menu;
10406            }
10407            StorageUiMode::ShipAmount {
10408                dest_building_id,
10409                item_instance_id,
10410                max_qty,
10411                input,
10412                ..
10413            } => {
10414                let Some(qty) = parse_storage_quantity(&input) else {
10415                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10416                    return Ok(());
10417                };
10418                let qty = qty.map(|n| n.min(max_qty).max(1));
10419                self.storage_ship(dest_building_id, item_instance_id, qty)
10420                    .await?;
10421                self.state.storage_ui_mode = StorageUiMode::Menu;
10422            }
10423        }
10424        Ok(())
10425    }
10426
10427    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10428        match self.state.bank_ui_mode.clone() {
10429            BankUiMode::Menu => {
10430                let choice = self
10431                    .state
10432                    .bank_menu_options()
10433                    .get(self.state.bank_menu_index)
10434                    .copied()
10435                    .unwrap_or("Deposit…");
10436                match choice {
10437                    "Withdraw…" => {
10438                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10439                            input: String::new(),
10440                        };
10441                    }
10442                    "Deposit all" => self.bank_deposit(0).await?,
10443                    "Withdraw all" => self.bank_withdraw(0).await?,
10444                    "Transfer…" => {
10445                        self.state.bank_ui_mode = BankUiMode::TransferName {
10446                            input: String::new(),
10447                        };
10448                    }
10449                    _ => {
10450                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
10451                            input: String::new(),
10452                        };
10453                    }
10454                }
10455            }
10456            BankUiMode::DepositAmount { input } => {
10457                let Some(amount) = parse_bank_copper_amount(&input) else {
10458                    self.state
10459                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
10460                    return Ok(());
10461                };
10462                self.bank_deposit(amount).await?;
10463                self.state.bank_ui_mode = BankUiMode::Menu;
10464            }
10465            BankUiMode::WithdrawAmount { input } => {
10466                let Some(amount) = parse_bank_copper_amount(&input) else {
10467                    self.state
10468                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
10469                    return Ok(());
10470                };
10471                self.bank_withdraw(amount).await?;
10472                self.state.bank_ui_mode = BankUiMode::Menu;
10473            }
10474            BankUiMode::TransferName { input } => {
10475                let name = input.trim().to_string();
10476                if name.is_empty() {
10477                    self.state.push_log("Enter the recipient character name.");
10478                    return Ok(());
10479                }
10480                self.state.bank_ui_mode = BankUiMode::TransferAmount {
10481                    to_name: name,
10482                    input: String::new(),
10483                };
10484            }
10485            BankUiMode::TransferAmount { to_name, input } => {
10486                let amount: u64 = match input.trim().parse() {
10487                    Ok(v) if v > 0 => v,
10488                    _ => {
10489                        self.state
10490                            .push_log("Enter a positive copper amount to transfer.");
10491                        return Ok(());
10492                    }
10493                };
10494                self.bank_transfer(None, to_name, amount).await?;
10495                self.state.bank_ui_mode = BankUiMode::Menu;
10496            }
10497        }
10498        Ok(())
10499    }
10500
10501    pub fn bank_transfer_back(&mut self) {
10502        match &self.state.bank_ui_mode {
10503            BankUiMode::TransferAmount { to_name, .. } => {
10504                self.state.bank_ui_mode = BankUiMode::TransferName {
10505                    input: to_name.clone(),
10506                };
10507            }
10508            BankUiMode::TransferName { .. }
10509            | BankUiMode::DepositAmount { .. }
10510            | BankUiMode::WithdrawAmount { .. } => {
10511                self.state.bank_ui_mode = BankUiMode::Menu;
10512            }
10513            BankUiMode::Menu => {}
10514        }
10515    }
10516
10517    pub fn bank_transfer_append_char(&mut self, c: char) {
10518        match &mut self.state.bank_ui_mode {
10519            BankUiMode::TransferName { input } => {
10520                if input.len() < 32 && !c.is_control() {
10521                    input.push(c);
10522                }
10523            }
10524            BankUiMode::DepositAmount { input }
10525            | BankUiMode::WithdrawAmount { input }
10526            | BankUiMode::TransferAmount { input, .. } => {
10527                if c.is_ascii_digit() && input.len() < 12 {
10528                    input.push(c);
10529                }
10530            }
10531            BankUiMode::Menu => {}
10532        }
10533    }
10534
10535    pub fn bank_transfer_backspace(&mut self) {
10536        match &mut self.state.bank_ui_mode {
10537            BankUiMode::TransferName { input }
10538            | BankUiMode::DepositAmount { input }
10539            | BankUiMode::WithdrawAmount { input }
10540            | BankUiMode::TransferAmount { input, .. } => {
10541                input.pop();
10542            }
10543            BankUiMode::Menu => {}
10544        }
10545    }
10546
10547    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10548        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10549        self.state.clear_bank_panel();
10550        if let Some(npc_id) = npc_id {
10551            self.seq += 1;
10552            self.session
10553                .submit_intent(Intent::BankClose {
10554                    entity_id: self.state.entity_id,
10555                    npc_id,
10556                    seq: self.seq,
10557                })
10558                .await?;
10559            self.state.intents_sent += 1;
10560        }
10561        Ok(())
10562    }
10563
10564    pub async fn storage_store(
10565        &mut self,
10566        item_instance_id: uuid::Uuid,
10567        quantity: Option<u32>,
10568    ) -> anyhow::Result<()> {
10569        let Some(panel) = self.state.storage_panel.clone() else {
10570            return Ok(());
10571        };
10572        self.seq += 1;
10573        self.session
10574            .submit_intent(Intent::StorageStore {
10575                entity_id: self.state.entity_id,
10576                npc_id: panel.npc_id,
10577                item_instance_id,
10578                quantity,
10579                seq: self.seq,
10580            })
10581            .await?;
10582        self.state.intents_sent += 1;
10583        Ok(())
10584    }
10585
10586    pub async fn storage_take(
10587        &mut self,
10588        item_instance_id: uuid::Uuid,
10589        quantity: Option<u32>,
10590    ) -> anyhow::Result<()> {
10591        let Some(panel) = self.state.storage_panel.clone() else {
10592            return Ok(());
10593        };
10594        self.seq += 1;
10595        self.session
10596            .submit_intent(Intent::StorageTake {
10597                entity_id: self.state.entity_id,
10598                npc_id: panel.npc_id,
10599                item_instance_id,
10600                quantity,
10601                seq: self.seq,
10602            })
10603            .await?;
10604        self.state.intents_sent += 1;
10605        Ok(())
10606    }
10607
10608    pub async fn storage_ship(
10609        &mut self,
10610        dest_building_id: String,
10611        item_instance_id: uuid::Uuid,
10612        quantity: Option<u32>,
10613    ) -> anyhow::Result<()> {
10614        let Some(panel) = self.state.storage_panel.clone() else {
10615            return Ok(());
10616        };
10617        self.seq += 1;
10618        self.session
10619            .submit_intent(Intent::StorageShip {
10620                entity_id: self.state.entity_id,
10621                npc_id: panel.npc_id,
10622                dest_building_id,
10623                item_instance_id,
10624                quantity,
10625                seq: self.seq,
10626            })
10627            .await?;
10628        self.state.intents_sent += 1;
10629        Ok(())
10630    }
10631
10632    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10633        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10634        self.state.clear_storage_panel();
10635        if let Some(npc_id) = npc_id {
10636            self.seq += 1;
10637            self.session
10638                .submit_intent(Intent::StorageClose {
10639                    entity_id: self.state.entity_id,
10640                    npc_id,
10641                    seq: self.seq,
10642                })
10643                .await?;
10644            self.state.intents_sent += 1;
10645        }
10646        Ok(())
10647    }
10648
10649    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10650        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10651        self.state.clear_market_panel();
10652        if let Some(npc_id) = npc_id {
10653            self.seq += 1;
10654            self.session
10655                .submit_intent(Intent::MarketClose {
10656                    entity_id: self.state.entity_id,
10657                    npc_id,
10658                    seq: self.seq,
10659                })
10660                .await?;
10661            self.state.intents_sent += 1;
10662        }
10663        Ok(())
10664    }
10665
10666    pub fn market_move_selection(&mut self, delta: i32) {
10667        let indices = self.state.market_filtered_listing_indices();
10668        let n = indices.len();
10669        if n == 0 {
10670            self.state.market_menu_index = 0;
10671            return;
10672        }
10673        let cur = self.state.market_menu_index as i32;
10674        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10675    }
10676
10677    pub fn market_page_selection(&mut self, pages: i32) {
10678        let indices = self.state.market_filtered_listing_indices();
10679        let n = indices.len();
10680        if n == 0 {
10681            self.state.market_menu_index = 0;
10682            return;
10683        }
10684        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10685    }
10686
10687    pub fn market_list_page(&mut self, pages: i32) {
10688        match &self.state.market_ui_mode {
10689            MarketUiMode::ListSource { index } => {
10690                let n = self.state.market_list_source_options().len();
10691                if n == 0 {
10692                    return;
10693                }
10694                let next = page_list_index(*index, pages, n);
10695                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10696            }
10697            MarketUiMode::ListPricingMode { index, .. } => {
10698                let next = page_list_index(*index, pages, 2);
10699                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10700                {
10701                    *index = next;
10702                }
10703            }
10704            MarketUiMode::ListPick { source, index } => {
10705                let opts = self.state.market_list_item_options(source);
10706                let n = opts.len();
10707                if n == 0 {
10708                    return;
10709                }
10710                let next = page_list_index(*index, pages, n);
10711                self.state.market_ui_mode = MarketUiMode::ListPick {
10712                    source: source.clone(),
10713                    index: next,
10714                };
10715            }
10716            _ => {}
10717        }
10718    }
10719
10720    pub fn market_cycle_category(&mut self, delta: i32) {
10721        let groups = self.state.market_available_category_groups();
10722        // All + groups
10723        let mut labels: Vec<Option<&'static str>> = vec![None];
10724        labels.extend(groups.into_iter().map(Some));
10725        let n = labels.len() as i32;
10726        let cur = labels
10727            .iter()
10728            .position(|g| *g == self.state.market_category_filter)
10729            .unwrap_or(0) as i32;
10730        let next = (cur + delta).rem_euclid(n) as usize;
10731        self.state.market_category_filter = labels[next];
10732        self.state.market_menu_index = 0;
10733        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10734            let source = source.clone();
10735            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10736        }
10737    }
10738
10739    pub fn focus_market_filter(&mut self) {
10740        self.state.market_filter_focused = true;
10741    }
10742
10743    pub fn append_market_filter_char(&mut self, ch: char) {
10744        if !self.state.market_filter_focused {
10745            return;
10746        }
10747        if !is_list_filter_char(ch) {
10748            return;
10749        }
10750        if self.state.market_filter.len() < 48 {
10751            self.state.market_filter.push(ch);
10752            self.state.market_menu_index = 0;
10753            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10754                let source = source.clone();
10755                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10756            }
10757        }
10758    }
10759
10760    pub fn market_filter_backspace(&mut self) {
10761        if !self.state.market_filter_focused {
10762            return;
10763        }
10764        self.state.market_filter.pop();
10765        self.state.market_menu_index = 0;
10766        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10767            let source = source.clone();
10768            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10769        }
10770    }
10771
10772    /// Clears filter text, blurs search, or returns false if already idle.
10773    pub fn clear_or_blur_market_filter(&mut self) -> bool {
10774        if self.state.market_filter_focused {
10775            if !self.state.market_filter.is_empty() {
10776                self.state.market_filter.clear();
10777                self.state.market_menu_index = 0;
10778                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10779                    let source = source.clone();
10780                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10781                }
10782                return true;
10783            }
10784            self.state.market_filter_focused = false;
10785            return true;
10786        }
10787        if !self.state.market_filter.is_empty() {
10788            self.state.market_filter.clear();
10789            self.state.market_menu_index = 0;
10790            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10791                let source = source.clone();
10792                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10793            }
10794            return true;
10795        }
10796        false
10797    }
10798
10799    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10800        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10801            return self.market_confirm_buy(listing_id, qty).await;
10802        }
10803        let Some(panel) = self.state.market_panel.clone() else {
10804            return Ok(());
10805        };
10806        let indices = self.state.market_filtered_listing_indices();
10807        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10808            return Ok(());
10809        };
10810        let Some(listing) = panel.listings.get(raw_idx) else {
10811            return Ok(());
10812        };
10813        if listing.mine {
10814            self.seq += 1;
10815            self.session
10816                .submit_intent(Intent::MarketDelist {
10817                    entity_id: self.state.entity_id,
10818                    npc_id: panel.npc_id.clone(),
10819                    listing_id: listing.listing_id,
10820                    dest: flatland_protocol::GoodsLocation::Person,
10821                    seq: self.seq,
10822                })
10823                .await?;
10824            self.state.intents_sent += 1;
10825            return Ok(());
10826        }
10827        if listing.npc_price {
10828            self.state
10829                .push_log("NPC-price listings are bought by merchants only.");
10830            return Ok(());
10831        }
10832        let qty = 1u32.min(listing.quantity).max(1);
10833        let line = listing.unit_price_copper.saturating_mul(qty as u64);
10834        self.state.market_buy_confirm = Some((
10835            listing.listing_id,
10836            qty,
10837            listing.unit_price_copper,
10838            line,
10839            listing.display_name.clone(),
10840        ));
10841        Ok(())
10842    }
10843
10844    pub async fn market_confirm_buy(
10845        &mut self,
10846        listing_id: uuid::Uuid,
10847        quantity: u32,
10848    ) -> anyhow::Result<()> {
10849        let Some(panel) = self.state.market_panel.clone() else {
10850            self.state.market_buy_confirm = None;
10851            return Ok(());
10852        };
10853        self.state.market_buy_confirm = None;
10854        self.seq += 1;
10855        self.session
10856            .submit_intent(Intent::MarketBuy {
10857                entity_id: self.state.entity_id,
10858                npc_id: panel.npc_id,
10859                listing_id,
10860                quantity,
10861                dest: flatland_protocol::GoodsLocation::Person,
10862                seq: self.seq,
10863            })
10864            .await?;
10865        self.state.intents_sent += 1;
10866        Ok(())
10867    }
10868
10869    /// Begin the list wizard (`l` in market browse).
10870    pub fn market_begin_list(&mut self) {
10871        if self.state.market_panel.is_none() {
10872            return;
10873        }
10874        let sources = self.state.market_list_source_options();
10875        if sources.is_empty() {
10876            self.state.push_log("Nothing to list from.");
10877            return;
10878        }
10879        // One source (person only) → skip straight to item pick when it has stacks.
10880        if sources.len() == 1 {
10881            let (source, _) = sources[0].clone();
10882            let opts = self.state.market_list_item_options(&source);
10883            if opts.is_empty() {
10884                self.state.push_log("Nothing loose to list.");
10885                return;
10886            }
10887            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10888            self.state.market_buy_confirm = None;
10889            return;
10890        }
10891        self.state.market_buy_confirm = None;
10892        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10893    }
10894
10895    pub fn market_ui_back(&mut self) {
10896        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10897            MarketUiMode::Browse => MarketUiMode::Browse,
10898            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10899            MarketUiMode::ListPick { .. } => {
10900                if self.state.market_list_source_options().len() <= 1 {
10901                    MarketUiMode::Browse
10902                } else {
10903                    MarketUiMode::ListSource { index: 0 }
10904                }
10905            }
10906            MarketUiMode::ListAmount {
10907                source, pick_index, ..
10908            } => MarketUiMode::ListPick {
10909                source,
10910                index: pick_index,
10911            },
10912            MarketUiMode::ListPricingMode {
10913                source,
10914                item_instance_id,
10915                template_id,
10916                label,
10917                max_qty,
10918                quantity,
10919                pick_index,
10920                ..
10921            } => {
10922                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10923                MarketUiMode::ListAmount {
10924                    source,
10925                    pick_index,
10926                    item_instance_id,
10927                    template_id,
10928                    label,
10929                    max_qty,
10930                    input,
10931                }
10932            }
10933            MarketUiMode::ListPrice {
10934                source,
10935                pick_index,
10936                item_instance_id,
10937                template_id,
10938                label,
10939                max_qty,
10940                quantity,
10941                ..
10942            } => MarketUiMode::ListPricingMode {
10943                source,
10944                pick_index,
10945                item_instance_id,
10946                template_id,
10947                label,
10948                quantity,
10949                max_qty,
10950                index: 1,
10951            },
10952        };
10953    }
10954
10955    pub fn market_list_move(&mut self, delta: i32) {
10956        match &self.state.market_ui_mode {
10957            MarketUiMode::ListSource { index } => {
10958                let n = self.state.market_list_source_options().len();
10959                if n == 0 {
10960                    return;
10961                }
10962                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10963                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10964            }
10965            MarketUiMode::ListPricingMode { index, .. } => {
10966                let next = (*index as i32 + delta).rem_euclid(2) as usize;
10967                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10968                {
10969                    *index = next;
10970                }
10971            }
10972            MarketUiMode::ListPick { source, index } => {
10973                let opts = self.state.market_list_item_options(source);
10974                let n = opts.len();
10975                if n == 0 {
10976                    return;
10977                }
10978                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10979                self.state.market_ui_mode = MarketUiMode::ListPick {
10980                    source: source.clone(),
10981                    index: next,
10982                };
10983            }
10984            _ => {}
10985        }
10986    }
10987
10988    pub fn market_list_amount_append_char(&mut self, c: char) {
10989        if !c.is_ascii_digit() {
10990            return;
10991        }
10992        match &mut self.state.market_ui_mode {
10993            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10994                if input.len() < 12 {
10995                    input.push(c);
10996                }
10997            }
10998            _ => {}
10999        }
11000    }
11001
11002    pub fn market_list_amount_backspace(&mut self) {
11003        match &mut self.state.market_ui_mode {
11004            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11005                input.pop();
11006            }
11007            _ => {}
11008        }
11009    }
11010
11011    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11012        match self.state.market_ui_mode.clone() {
11013            MarketUiMode::Browse => Ok(()),
11014            MarketUiMode::ListSource { index } => {
11015                let sources = self.state.market_list_source_options();
11016                let Some((source, _)) = sources.get(index).cloned() else {
11017                    return Ok(());
11018                };
11019                let opts = self.state.market_list_item_options(&source);
11020                if opts.is_empty() {
11021                    self.state.push_log("Nothing to list from that source.");
11022                    return Ok(());
11023                }
11024                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11025                Ok(())
11026            }
11027            MarketUiMode::ListPick { source, index } => {
11028                let opts = self.state.market_list_item_options(&source);
11029                let Some(opt) = opts.get(index) else {
11030                    self.state.push_log("Nothing to list.");
11031                    self.state.market_ui_mode = MarketUiMode::Browse;
11032                    return Ok(());
11033                };
11034                self.state.market_ui_mode = MarketUiMode::ListAmount {
11035                    source,
11036                    pick_index: index,
11037                    item_instance_id: opt.item_instance_id,
11038                    template_id: opt.template_id.clone(),
11039                    label: opt.label.clone(),
11040                    max_qty: opt.quantity.max(1),
11041                    input: String::new(),
11042                };
11043                Ok(())
11044            }
11045            MarketUiMode::ListAmount {
11046                source,
11047                pick_index,
11048                item_instance_id,
11049                template_id,
11050                label,
11051                max_qty,
11052                input,
11053                ..
11054            } => {
11055                let Some(qty_opt) = parse_storage_quantity(&input) else {
11056                    self.state.push_log("Enter a quantity (blank = all).");
11057                    return Ok(());
11058                };
11059                if let Some(q) = qty_opt {
11060                    if q > max_qty {
11061                        self.state.push_log(format!("Only {max_qty} available."));
11062                        return Ok(());
11063                    }
11064                }
11065                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11066                    source,
11067                    pick_index,
11068                    item_instance_id,
11069                    template_id,
11070                    label,
11071                    quantity: qty_opt,
11072                    max_qty,
11073                    index: 0,
11074                };
11075                Ok(())
11076            }
11077            MarketUiMode::ListPricingMode {
11078                source,
11079                pick_index,
11080                item_instance_id,
11081                template_id,
11082                label,
11083                quantity,
11084                max_qty,
11085                index,
11086            } => {
11087                if index == 0 {
11088                    if self
11089                        .state
11090                        .npc_market_dump_unit_estimate(&template_id)
11091                        .is_none()
11092                    {
11093                        self.state
11094                            .push_log("That item has no NPC value — use a fixed price instead.");
11095                        return Ok(());
11096                    }
11097                    return self
11098                        .submit_market_list_intent(
11099                            source,
11100                            item_instance_id,
11101                            quantity,
11102                            0,
11103                            true,
11104                            &label,
11105                        )
11106                        .await;
11107                }
11108                self.state.market_ui_mode = MarketUiMode::ListPrice {
11109                    source,
11110                    pick_index,
11111                    item_instance_id,
11112                    template_id,
11113                    label,
11114                    quantity,
11115                    max_qty,
11116                    input: String::new(),
11117                };
11118                Ok(())
11119            }
11120            MarketUiMode::ListPrice {
11121                source,
11122                item_instance_id,
11123                label,
11124                quantity,
11125                input,
11126                ..
11127            } => {
11128                let price = input.trim().parse::<u64>().unwrap_or(0);
11129                if price == 0 {
11130                    self.state
11131                        .push_log("Enter a unit price of at least 1 copper.");
11132                    return Ok(());
11133                }
11134                self.submit_market_list_intent(
11135                    source,
11136                    item_instance_id,
11137                    quantity,
11138                    price,
11139                    false,
11140                    &label,
11141                )
11142                .await
11143            }
11144        }
11145    }
11146
11147    async fn submit_market_list_intent(
11148        &mut self,
11149        source: MarketListSourceKind,
11150        item_instance_id: uuid::Uuid,
11151        quantity: Option<u32>,
11152        unit_price_copper: u64,
11153        npc_price: bool,
11154        label: &str,
11155    ) -> anyhow::Result<()> {
11156        let Some(panel) = self.state.market_panel.clone() else {
11157            self.state.market_ui_mode = MarketUiMode::Browse;
11158            return Ok(());
11159        };
11160        let goods = match source {
11161            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11162            MarketListSourceKind::TownStorage { building_id } => {
11163                flatland_protocol::GoodsLocation::TownStorage { building_id }
11164            }
11165        };
11166        self.seq += 1;
11167        self.session
11168            .submit_intent(Intent::MarketList {
11169                entity_id: self.state.entity_id,
11170                npc_id: panel.npc_id,
11171                source: goods,
11172                item_instance_id,
11173                quantity,
11174                unit_price_copper,
11175                npc_price,
11176                seq: self.seq,
11177            })
11178            .await?;
11179        self.state.intents_sent += 1;
11180        if npc_price {
11181            self.state
11182                .push_log(format!("Listing {label} at NPC price…"));
11183        } else {
11184            self.state
11185                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11186        }
11187        self.state.market_ui_mode = MarketUiMode::Browse;
11188        Ok(())
11189    }
11190
11191    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
11192    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11193        let return_to_verbs = self.state.npc_verb_target.is_some();
11194        self.close_shop_menu().await?;
11195        if return_to_verbs {
11196            self.state.show_npc_verb_menu = true;
11197            self.state.npc_verb_notice = None;
11198        }
11199        Ok(())
11200    }
11201
11202    pub fn shop_tab_toggle(&mut self) {
11203        self.state.shop_tab = match self.state.shop_tab {
11204            ShopTab::Buy => ShopTab::Sell,
11205            ShopTab::Sell => ShopTab::Buy,
11206        };
11207        self.state.shop_menu_index = 0;
11208        if self.state.shop_tab == ShopTab::Sell {
11209            self.state.shop_quantity_set_max();
11210        }
11211        self.state.clamp_shop_selection();
11212    }
11213
11214    pub fn shop_menu_move(&mut self, delta: i32) {
11215        self.state.shop_menu_move(delta);
11216    }
11217
11218    pub fn shop_quantity_adjust(&mut self, delta: i32) {
11219        self.state.shop_quantity_adjust(delta);
11220    }
11221
11222    pub fn shop_quantity_set_max(&mut self) {
11223        self.state.shop_quantity_set_max();
11224    }
11225
11226    pub fn shop_quantity_set_min(&mut self) {
11227        self.state.shop_quantity_set_min();
11228    }
11229
11230    pub fn toggle_quest_menu(&mut self) {
11231        self.state.show_quest_menu = !self.state.show_quest_menu;
11232        if self.state.show_quest_menu {
11233            self.state.quest_menu_index = 0;
11234            self.state.quest_withdraw_confirm = false;
11235            self.state.show_workers_menu = false;
11236        }
11237    }
11238
11239    pub fn toggle_workers_menu(&mut self) {
11240        if self.state.show_workers_menu {
11241            self.close_workers_menu_ui();
11242        } else {
11243            self.state.show_workers_menu = true;
11244            // Keep prior selection/scroll focus; only clamp if the roster shrank.
11245            if self.state.workers_menu_index >= self.state.hired_workers.len() {
11246                self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11247            }
11248            self.state.show_quest_menu = false;
11249            self.close_worker_give_picker();
11250            self.close_worker_give_target_picker();
11251            self.close_worker_take_picker();
11252            self.close_worker_teach_picker();
11253            self.cancel_worker_rename();
11254        }
11255    }
11256
11257    /// Close workers UI layers without network (caller should release attend).
11258    pub fn close_workers_menu_ui(&mut self) {
11259        self.state.show_workers_menu = false;
11260        self.cancel_worker_dismissal();
11261        self.close_worker_give_picker();
11262        self.close_worker_give_target_picker();
11263        self.close_worker_take_picker();
11264        self.close_worker_teach_picker();
11265        self.cancel_worker_rename();
11266    }
11267
11268    /// Open the workers menu focused on `instance_id` and pause that worker's job.
11269    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11270        let Some(idx) = self
11271            .state
11272            .hired_workers
11273            .iter()
11274            .position(|w| w.instance_id == instance_id)
11275        else {
11276            anyhow::bail!("worker not found");
11277        };
11278        let label = self.state.hired_workers[idx].label.clone();
11279        self.state.show_workers_menu = true;
11280        self.state.workers_menu_index = idx;
11281        self.state.show_quest_menu = false;
11282        self.close_worker_give_picker();
11283        self.close_worker_give_target_picker();
11284        self.close_worker_take_picker();
11285        self.close_worker_teach_picker();
11286        self.cancel_worker_rename();
11287        self.set_worker_attending(instance_id, true).await?;
11288        self.state
11289            .push_log(format!("Managing {label} — job paused while menu is open"));
11290        Ok(())
11291    }
11292
11293    /// Close workers menu and release any attend pause.
11294    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11295        self.close_workers_menu_ui();
11296        self.release_worker_attend().await
11297    }
11298
11299    async fn set_worker_attending(
11300        &mut self,
11301        instance_id: &str,
11302        attending: bool,
11303    ) -> anyhow::Result<()> {
11304        if attending {
11305            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11306                return Ok(());
11307            }
11308            // Switch target: release previous first.
11309            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11310                if prev != instance_id {
11311                    self.send_attend_hired_worker(&prev, false).await?;
11312                }
11313            }
11314            self.send_attend_hired_worker(instance_id, true).await?;
11315            self.state.attending_worker_instance_id = Some(instance_id.to_string());
11316        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11317            self.send_attend_hired_worker(instance_id, false).await?;
11318            self.state.attending_worker_instance_id = None;
11319        }
11320        Ok(())
11321    }
11322
11323    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11324        let Some(id) = self.state.attending_worker_instance_id.take() else {
11325            return Ok(());
11326        };
11327        self.send_attend_hired_worker(&id, false).await
11328    }
11329
11330    async fn send_attend_hired_worker(
11331        &mut self,
11332        worker_instance_id: &str,
11333        attending: bool,
11334    ) -> anyhow::Result<()> {
11335        self.seq += 1;
11336        self.session
11337            .submit_intent(Intent::AttendHiredWorker {
11338                entity_id: self.state.entity_id,
11339                worker_instance_id: worker_instance_id.to_string(),
11340                attending,
11341                seq: self.seq,
11342            })
11343            .await?;
11344        self.state.intents_sent += 1;
11345        Ok(())
11346    }
11347
11348    pub fn workers_menu_move(&mut self, delta: i32) {
11349        let n = self.state.hired_workers.len();
11350        if n == 0 {
11351            return;
11352        }
11353        let idx = self.state.workers_menu_index as i32;
11354        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11355    }
11356
11357    pub fn toggle_workers_menu_compact(&mut self) {
11358        self.state.workers_menu_compact = !self.state.workers_menu_compact;
11359        let mut cfg = crate::client_config::ClientConfig::load();
11360        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11361    }
11362
11363    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11364        let Some(worker) = self
11365            .state
11366            .hired_workers
11367            .get(self.state.workers_menu_index)
11368            .cloned()
11369        else {
11370            anyhow::bail!("no worker selected");
11371        };
11372        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11373            .await
11374    }
11375
11376    /// Open a confirmation dialog before permanently dismissing the selected worker.
11377    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11378        let Some(worker) = self
11379            .state
11380            .hired_workers
11381            .get(self.state.workers_menu_index)
11382            .cloned()
11383        else {
11384            anyhow::bail!("no worker selected");
11385        };
11386        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11387            worker_instance_id: worker.instance_id,
11388            worker_label: worker.label,
11389        });
11390        Ok(())
11391    }
11392
11393    pub fn cancel_worker_dismissal(&mut self) {
11394        self.state.worker_dismiss_confirmation = None;
11395    }
11396
11397    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11398        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11399            return Ok(());
11400        };
11401        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11402            .await?;
11403        self.cancel_worker_dismissal();
11404        Ok(())
11405    }
11406
11407    async fn dismiss_worker_by_id(
11408        &mut self,
11409        worker_instance_id: &str,
11410        worker_label: &str,
11411    ) -> anyhow::Result<()> {
11412        self.seq += 1;
11413        self.session
11414            .submit_intent(Intent::DismissWorker {
11415                entity_id: self.state.entity_id,
11416                worker_instance_id: worker_instance_id.to_string(),
11417                seq: self.seq,
11418            })
11419            .await?;
11420        self.state.intents_sent += 1;
11421        self.state
11422            .hired_workers
11423            .retain(|w| w.instance_id != worker_instance_id);
11424        if self.state.workers_menu_index >= self.state.hired_workers.len() {
11425            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11426        }
11427        self.state.push_log(format!("Dismissed {worker_label}"));
11428        Ok(())
11429    }
11430
11431    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11432        let Some(worker) = self
11433            .state
11434            .hired_workers
11435            .get(self.state.workers_menu_index)
11436            .cloned()
11437        else {
11438            anyhow::bail!("no worker selected");
11439        };
11440        let mode = match worker.mode {
11441            flatland_protocol::WorkerModeView::Companion => "defender",
11442            flatland_protocol::WorkerModeView::Defender => "job_loop",
11443            flatland_protocol::WorkerModeView::JobLoop => "idle",
11444            flatland_protocol::WorkerModeView::Idle => "companion",
11445        };
11446        self.seq += 1;
11447        self.session
11448            .submit_intent(Intent::SetWorkerMode {
11449                entity_id: self.state.entity_id,
11450                worker_instance_id: worker.instance_id,
11451                mode: mode.into(),
11452                seq: self.seq,
11453            })
11454            .await?;
11455        self.state.intents_sent += 1;
11456        Ok(())
11457    }
11458
11459    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11460        let Some(worker) = self
11461            .state
11462            .hired_workers
11463            .get(self.state.workers_menu_index)
11464            .cloned()
11465        else {
11466            anyhow::bail!("no worker selected");
11467        };
11468        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11469            anyhow::bail!("switch the worker to companion mode first");
11470        }
11471        if worker.step_label.starts_with("delivering to ")
11472            || worker.step_label == "returning to you"
11473        {
11474            anyhow::bail!("worker is already delivering to storage");
11475        }
11476        self.seq += 1;
11477        self.session
11478            .submit_intent(Intent::DeliverWorkerToNearestStorage {
11479                entity_id: self.state.entity_id,
11480                worker_instance_id: worker.instance_id.clone(),
11481                seq: self.seq,
11482            })
11483            .await?;
11484        self.state.intents_sent += 1;
11485        self.state.push_log(format!(
11486            "{} is delivering carried items to storage",
11487            worker.label
11488        ));
11489        Ok(())
11490    }
11491
11492    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11493        let Some(worker) = self
11494            .state
11495            .hired_workers
11496            .get(self.state.workers_menu_index)
11497            .cloned()
11498        else {
11499            anyhow::bail!("no worker selected");
11500        };
11501        if !(worker.step_label.starts_with("delivering to ")
11502            || worker.step_label == "returning to you")
11503        {
11504            anyhow::bail!("worker has no active delivery");
11505        }
11506        self.seq += 1;
11507        self.session
11508            .submit_intent(Intent::CancelWorkerDelivery {
11509                entity_id: self.state.entity_id,
11510                worker_instance_id: worker.instance_id,
11511                seq: self.seq,
11512            })
11513            .await?;
11514        self.state.intents_sent += 1;
11515        self.state
11516            .push_log(format!("Canceled delivery for {}", worker.label));
11517        Ok(())
11518    }
11519
11520    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11521        if self.state.hired_workers.is_empty() {
11522            return self.hire_worker_laborer().await;
11523        }
11524        self.workers_toggle_mode_selected().await
11525    }
11526
11527    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
11528    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
11529    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11530        let row = self
11531            .state
11532            .inventory_selected_row()
11533            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11534            .clone();
11535        if row.from != flatland_protocol::InventoryLocation::Root {
11536            anyhow::bail!("select a carried item to give");
11537        }
11538        let Some(instance_id) = row.stack.item_instance_id else {
11539            anyhow::bail!("that stack can't be given");
11540        };
11541        let options = self.nearby_worker_give_targets();
11542        if options.is_empty() {
11543            anyhow::bail!(
11544                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11545            );
11546        }
11547        let item_label = row
11548            .stack
11549            .display_name
11550            .as_deref()
11551            .unwrap_or(&row.stack.template_id)
11552            .to_string();
11553        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11554            item_instance_id: instance_id,
11555            item_label,
11556            quantity: None,
11557            options,
11558        });
11559        self.state.worker_give_target_picker_index = 0;
11560        self.state.show_worker_give_target_picker = true;
11561        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
11562        self.state.show_inventory_menu = false;
11563        Ok(())
11564    }
11565
11566    /// Hired workers within give/take range, nearest first.
11567    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11568        let (px, py, _) = self.state.player_position_with_z();
11569        let mut options: Vec<WorkerGiveTargetOption> = self
11570            .state
11571            .hired_workers
11572            .iter()
11573            .filter_map(|w| {
11574                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11575                if dist > WORKER_GIVE_RANGE_M {
11576                    return None;
11577                }
11578                Some(WorkerGiveTargetOption {
11579                    instance_id: w.instance_id.clone(),
11580                    label: w.label.clone(),
11581                    distance_m: dist,
11582                })
11583            })
11584            .collect();
11585        options.sort_by(|a, b| {
11586            a.distance_m
11587                .partial_cmp(&b.distance_m)
11588                .unwrap_or(std::cmp::Ordering::Equal)
11589        });
11590        options
11591    }
11592
11593    pub fn close_worker_give_target_picker(&mut self) {
11594        self.state.show_worker_give_target_picker = false;
11595        self.state.worker_give_target_picker = None;
11596        self.state.worker_give_target_picker_index = 0;
11597    }
11598
11599    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11600        let Some(picker) = &self.state.worker_give_target_picker else {
11601            return;
11602        };
11603        let n = picker.options.len();
11604        if n == 0 {
11605            return;
11606        }
11607        let idx = self.state.worker_give_target_picker_index as i32;
11608        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11609    }
11610
11611    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11612        let Some(picker) = self.state.worker_give_target_picker.clone() else {
11613            anyhow::bail!("give target picker not open");
11614        };
11615        let Some(opt) = picker
11616            .options
11617            .get(self.state.worker_give_target_picker_index)
11618            .cloned()
11619        else {
11620            anyhow::bail!("no worker selected");
11621        };
11622        let Some(worker) = self
11623            .state
11624            .hired_workers
11625            .iter()
11626            .find(|w| w.instance_id == opt.instance_id)
11627            .cloned()
11628        else {
11629            self.close_worker_give_target_picker();
11630            anyhow::bail!("worker no longer hired");
11631        };
11632        self.give_item_to_worker(
11633            &worker.instance_id,
11634            &worker.label,
11635            worker.x,
11636            worker.y,
11637            picker.item_instance_id,
11638            &picker.item_label,
11639            picker.quantity,
11640        )
11641        .await?;
11642        self.close_worker_give_target_picker();
11643        Ok(())
11644    }
11645
11646    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
11647    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11648        self.open_worker_give_target_picker()
11649    }
11650
11651    /// Open the workers-menu give picker for the selected worker (must be in range).
11652    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11653        let Some(worker) = self
11654            .state
11655            .hired_workers
11656            .get(self.state.workers_menu_index)
11657            .cloned()
11658        else {
11659            anyhow::bail!("select a hired worker first");
11660        };
11661        let (px, py, _) = self.state.player_position_with_z();
11662        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11663        if dist > WORKER_GIVE_RANGE_M {
11664            anyhow::bail!(
11665                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11666                worker.label
11667            );
11668        }
11669        let options = self.state.giveable_inventory_options();
11670        if options.is_empty() {
11671            anyhow::bail!("nothing in inventory to give");
11672        }
11673        self.state.worker_give_picker = Some(WorkerGivePicker {
11674            worker_instance_id: worker.instance_id,
11675            worker_label: worker.label,
11676            options,
11677        });
11678        self.state.worker_give_picker_index = 0;
11679        self.state.show_worker_give_picker = true;
11680        Ok(())
11681    }
11682
11683    pub fn close_worker_give_picker(&mut self) {
11684        self.state.show_worker_give_picker = false;
11685        self.state.worker_give_picker = None;
11686        self.state.worker_give_picker_index = 0;
11687    }
11688
11689    pub fn worker_give_picker_move(&mut self, delta: i32) {
11690        let Some(picker) = &self.state.worker_give_picker else {
11691            return;
11692        };
11693        let n = picker.options.len();
11694        if n == 0 {
11695            return;
11696        }
11697        let idx = self.state.worker_give_picker_index as i32;
11698        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11699    }
11700
11701    /// Confirm the selected row in the workers-menu give picker.
11702    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11703        let Some(picker) = self.state.worker_give_picker.clone() else {
11704            anyhow::bail!("give picker not open");
11705        };
11706        let Some(opt) = picker
11707            .options
11708            .get(self.state.worker_give_picker_index)
11709            .cloned()
11710        else {
11711            anyhow::bail!("no item selected");
11712        };
11713        let Some(worker) = self
11714            .state
11715            .hired_workers
11716            .iter()
11717            .find(|w| w.instance_id == picker.worker_instance_id)
11718            .cloned()
11719        else {
11720            self.close_worker_give_picker();
11721            anyhow::bail!("worker no longer hired");
11722        };
11723        self.give_item_to_worker(
11724            &worker.instance_id,
11725            &worker.label,
11726            worker.x,
11727            worker.y,
11728            opt.item_instance_id,
11729            &opt.label,
11730            None,
11731        )
11732        .await?;
11733        // Refresh options (stack may be gone / reduced) or close when empty.
11734        let options = self.state.giveable_inventory_options();
11735        if options.is_empty() {
11736            self.close_worker_give_picker();
11737        } else {
11738            self.state.worker_give_picker = Some(WorkerGivePicker {
11739                worker_instance_id: picker.worker_instance_id,
11740                worker_label: picker.worker_label,
11741                options,
11742            });
11743            if self.state.worker_give_picker_index
11744                >= self
11745                    .state
11746                    .worker_give_picker
11747                    .as_ref()
11748                    .map(|p| p.options.len())
11749                    .unwrap_or(0)
11750            {
11751                self.state.worker_give_picker_index = self
11752                    .state
11753                    .worker_give_picker
11754                    .as_ref()
11755                    .map(|p| p.options.len().saturating_sub(1))
11756                    .unwrap_or(0);
11757            }
11758        }
11759        Ok(())
11760    }
11761
11762    /// Open the workers-menu teach picker for the selected worker (must be in range).
11763    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11764        let Some(worker) = self
11765            .state
11766            .hired_workers
11767            .get(self.state.workers_menu_index)
11768            .cloned()
11769        else {
11770            anyhow::bail!("select a hired worker first");
11771        };
11772        let (px, py, _) = self.state.player_position_with_z();
11773        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11774        if dist > WORKER_GIVE_RANGE_M {
11775            anyhow::bail!(
11776                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11777                worker.label
11778            );
11779        }
11780        let options = self.state.teachable_blueprint_options(&worker);
11781        if options.is_empty() {
11782            anyhow::bail!("no recipes you know that {} still needs", worker.label);
11783        }
11784        self.state.worker_teach_picker = Some(WorkerTeachPicker {
11785            worker_instance_id: worker.instance_id,
11786            worker_label: worker.label,
11787            worker_level: worker.level,
11788            options,
11789        });
11790        self.state.worker_teach_picker_index = 0;
11791        self.state.show_worker_teach_picker = true;
11792        Ok(())
11793    }
11794
11795    pub fn close_worker_teach_picker(&mut self) {
11796        self.state.show_worker_teach_picker = false;
11797        self.state.worker_teach_picker = None;
11798        self.state.worker_teach_picker_index = 0;
11799    }
11800
11801    pub fn worker_teach_picker_move(&mut self, delta: i32) {
11802        let Some(picker) = &self.state.worker_teach_picker else {
11803            return;
11804        };
11805        let n = picker.options.len();
11806        if n == 0 {
11807            return;
11808        }
11809        let idx = self.state.worker_teach_picker_index as i32;
11810        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11811    }
11812
11813    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11814        let Some(picker) = self.state.worker_teach_picker.clone() else {
11815            anyhow::bail!("teach picker not open");
11816        };
11817        let Some(opt) = picker
11818            .options
11819            .get(self.state.worker_teach_picker_index)
11820            .cloned()
11821        else {
11822            anyhow::bail!("nothing selected");
11823        };
11824        if !opt.level_ok {
11825            anyhow::bail!(
11826                "{} needs level {} (is level {})",
11827                picker.worker_label,
11828                opt.min_level,
11829                opt.worker_level
11830            );
11831        }
11832        if !opt.can_afford {
11833            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11834        }
11835        let Some(worker) = self
11836            .state
11837            .hired_workers
11838            .iter()
11839            .find(|w| w.instance_id == picker.worker_instance_id)
11840            .cloned()
11841        else {
11842            anyhow::bail!("worker gone");
11843        };
11844        let (px, py, _) = self.state.player_position_with_z();
11845        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11846        if dist > WORKER_GIVE_RANGE_M {
11847            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11848        }
11849        self.seq += 1;
11850        self.session
11851            .submit_intent(Intent::TeachWorkerBlueprint {
11852                entity_id: self.state.entity_id,
11853                worker_instance_id: picker.worker_instance_id.clone(),
11854                blueprint_id: opt.blueprint_id.clone(),
11855                seq: self.seq,
11856            })
11857            .await?;
11858        self.state.intents_sent += 1;
11859        self.state.push_log(format!(
11860            "Teaching {} to {} ({} cp)",
11861            opt.label, picker.worker_label, opt.cost_copper
11862        ));
11863        self.close_worker_teach_picker();
11864        Ok(())
11865    }
11866
11867    async fn give_item_to_worker(
11868        &mut self,
11869        worker_instance_id: &str,
11870        worker_label: &str,
11871        worker_x: f32,
11872        worker_y: f32,
11873        item_instance_id: uuid::Uuid,
11874        item_label: &str,
11875        quantity: Option<u32>,
11876    ) -> anyhow::Result<()> {
11877        let (px, py, _) = self.state.player_position_with_z();
11878        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11879        if dist > WORKER_GIVE_RANGE_M {
11880            anyhow::bail!("worker {worker_label} too far — stand next to them");
11881        }
11882        self.seq += 1;
11883        self.session
11884            .submit_intent(Intent::GiveWorkerItem {
11885                entity_id: self.state.entity_id,
11886                worker_instance_id: worker_instance_id.to_string(),
11887                item_instance_id,
11888                quantity,
11889                seq: self.seq,
11890            })
11891            .await?;
11892        self.state.intents_sent += 1;
11893        self.state
11894            .remove_carried_instance(item_instance_id, quantity);
11895        self.state
11896            .push_log(format!("Gave {item_label} to {worker_label}"));
11897        Ok(())
11898    }
11899
11900    /// Equip one item from the player's inventory on the selected worker.
11901    /// The server validates the requested hand/body slot and replaces the
11902    /// previous item into the worker's pack.
11903    pub async fn equip_item_on_worker(
11904        &mut self,
11905        worker_instance_id: &str,
11906        item_instance_id: uuid::Uuid,
11907        slot: &str,
11908    ) -> anyhow::Result<()> {
11909        let Some(worker) = self
11910            .state
11911            .hired_workers
11912            .iter()
11913            .find(|worker| worker.instance_id == worker_instance_id)
11914            .cloned()
11915        else {
11916            anyhow::bail!("worker not found");
11917        };
11918        let (px, py, _) = self.state.player_position_with_z();
11919        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11920            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11921        }
11922        self.seq += 1;
11923        self.session
11924            .submit_intent(Intent::EquipWorkerItem {
11925                entity_id: self.state.entity_id,
11926                worker_instance_id: worker.instance_id.clone(),
11927                item_instance_id,
11928                slot: slot.to_string(),
11929                seq: self.seq,
11930            })
11931            .await?;
11932        self.state.intents_sent += 1;
11933        self.state
11934            .push_log(format!("Equipped {slot} on {}", worker.label));
11935        Ok(())
11936    }
11937
11938    /// Open the workers-menu take picker for the selected worker (must be in range).
11939    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11940        let Some(worker) = self
11941            .state
11942            .hired_workers
11943            .get(self.state.workers_menu_index)
11944            .cloned()
11945        else {
11946            anyhow::bail!("select a hired worker first");
11947        };
11948        let (px, py, _) = self.state.player_position_with_z();
11949        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11950        if dist > WORKER_GIVE_RANGE_M {
11951            anyhow::bail!(
11952                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11953                worker.label
11954            );
11955        }
11956        let options = Self::worker_inventory_options(&worker);
11957        if options.is_empty() {
11958            anyhow::bail!("{} isn't carrying anything", worker.label);
11959        }
11960        let initial_qty = options
11961            .first()
11962            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
11963            .unwrap_or(1);
11964        self.state.worker_take_picker = Some(WorkerTakePicker {
11965            worker_instance_id: worker.instance_id,
11966            worker_label: worker.label,
11967            options,
11968            quantity: initial_qty,
11969        });
11970        self.state.worker_take_picker_index = 0;
11971        self.state.show_worker_take_picker = true;
11972        Ok(())
11973    }
11974
11975    fn worker_inventory_options(
11976        worker: &flatland_protocol::HiredWorkerView,
11977    ) -> Vec<WorkerGiveOption> {
11978        worker
11979            .inventory
11980            .iter()
11981            .filter_map(|stack| {
11982                let item_instance_id = stack.item_instance_id?;
11983                let label = stack
11984                    .display_name
11985                    .clone()
11986                    .unwrap_or_else(|| stack.template_id.clone());
11987                let label = if stack.quantity > 1 {
11988                    format!("{label} ×{}", stack.quantity)
11989                } else {
11990                    label
11991                };
11992                Some(WorkerGiveOption {
11993                    item_instance_id,
11994                    label,
11995                    quantity: stack.quantity,
11996                    template_id: stack.template_id.clone(),
11997                })
11998            })
11999            .collect()
12000    }
12001
12002    pub fn close_worker_take_picker(&mut self) {
12003        self.state.show_worker_take_picker = false;
12004        self.state.worker_take_picker = None;
12005        self.state.worker_take_picker_index = 0;
12006    }
12007
12008    pub fn worker_take_picker_move(&mut self, delta: i32) {
12009        let Some(picker) = &self.state.worker_take_picker else {
12010            return;
12011        };
12012        let n = picker.options.len();
12013        if n == 0 {
12014            return;
12015        }
12016        let idx = self.state.worker_take_picker_index as i32;
12017        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12018        self.clamp_worker_take_quantity();
12019    }
12020
12021    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12022        let Some(picker) = &mut self.state.worker_take_picker else {
12023            return;
12024        };
12025        let max = picker
12026            .options
12027            .get(self.state.worker_take_picker_index)
12028            .map(|o| o.quantity.max(1))
12029            .unwrap_or(1);
12030        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12031        picker.quantity = next as u32;
12032    }
12033
12034    pub fn worker_take_picker_set_quantity_max(&mut self) {
12035        let Some(picker) = &mut self.state.worker_take_picker else {
12036            return;
12037        };
12038        let max = picker
12039            .options
12040            .get(self.state.worker_take_picker_index)
12041            .map(|o| o.quantity.max(1))
12042            .unwrap_or(1);
12043        picker.quantity = max;
12044    }
12045
12046    pub fn worker_take_picker_set_quantity_min(&mut self) {
12047        let Some(picker) = &mut self.state.worker_take_picker else {
12048            return;
12049        };
12050        picker.quantity = 1;
12051        self.clamp_worker_take_quantity();
12052    }
12053
12054    fn clamp_worker_take_quantity(&mut self) {
12055        let Some(picker) = &mut self.state.worker_take_picker else {
12056            return;
12057        };
12058        let max = picker
12059            .options
12060            .get(self.state.worker_take_picker_index)
12061            .map(|o| o.quantity.max(1))
12062            .unwrap_or(1);
12063        if picker.quantity == 0 || picker.quantity > max {
12064            picker.quantity = if max > 1 { 1 } else { max };
12065        }
12066    }
12067
12068    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12069        let Some(picker) = self.state.worker_take_picker.clone() else {
12070            anyhow::bail!("take picker not open");
12071        };
12072        let Some(opt) = picker
12073            .options
12074            .get(self.state.worker_take_picker_index)
12075            .cloned()
12076        else {
12077            anyhow::bail!("no item selected");
12078        };
12079        let Some(worker) = self
12080            .state
12081            .hired_workers
12082            .iter()
12083            .find(|w| w.instance_id == picker.worker_instance_id)
12084            .cloned()
12085        else {
12086            self.close_worker_take_picker();
12087            anyhow::bail!("worker no longer hired");
12088        };
12089        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12090        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12091        self.take_item_from_worker(
12092            &worker.instance_id,
12093            &worker.label,
12094            worker.x,
12095            worker.y,
12096            opt.item_instance_id,
12097            &opt.label,
12098            intent_qty,
12099        )
12100        .await?;
12101        // Leave the picker open — server Interaction / hired-workers sync refresh
12102        // options. Do not optimistic-strip stacks (rejects used to look like success).
12103        Ok(())
12104    }
12105
12106    async fn take_item_from_worker(
12107        &mut self,
12108        worker_instance_id: &str,
12109        worker_label: &str,
12110        worker_x: f32,
12111        worker_y: f32,
12112        item_instance_id: uuid::Uuid,
12113        item_label: &str,
12114        quantity: Option<u32>,
12115    ) -> anyhow::Result<()> {
12116        let (px, py, _) = self.state.player_position_with_z();
12117        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12118        if dist > WORKER_GIVE_RANGE_M {
12119            anyhow::bail!("worker {worker_label} too far — stand next to them");
12120        }
12121        self.seq += 1;
12122        self.session
12123            .submit_intent(Intent::TakeWorkerItem {
12124                entity_id: self.state.entity_id,
12125                worker_instance_id: worker_instance_id.to_string(),
12126                item_instance_id,
12127                quantity,
12128                seq: self.seq,
12129            })
12130            .await?;
12131        self.state.intents_sent += 1;
12132        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12133        self.state.push_log(format!(
12134            "Taking {item_label}{qty_note} from {worker_label}…"
12135        ));
12136        Ok(())
12137    }
12138
12139    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12140        if let Some(since) = self.state.pending_worker_hire_since {
12141            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12142                anyhow::bail!("hire request still pending — wait for the worker roster update");
12143            }
12144            self.state.pending_worker_hire_since = None;
12145        }
12146        if !self.state.has_worker_lodging() {
12147            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12148        }
12149        self.seq += 1;
12150        self.session
12151            .submit_intent(Intent::HireWorker {
12152                entity_id: self.state.entity_id,
12153                def_id: "worker_laborer".into(),
12154                wage_copper_per_interval: 8,
12155                lodging_container_id: None,
12156                job_yaml: None,
12157                seq: self.seq,
12158            })
12159            .await?;
12160        self.state.intents_sent += 1;
12161        self.state.pending_worker_hire_since = Some(Instant::now());
12162        Ok(())
12163    }
12164
12165    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12166        let Some(worker) = self
12167            .state
12168            .hired_workers
12169            .get(self.state.workers_menu_index)
12170            .cloned()
12171        else {
12172            anyhow::bail!("select a hired worker first");
12173        };
12174        let lodging = worker.lodging_container_id.clone().or_else(|| {
12175            crate::worker_route_editor::owned_lodging_container_ids(
12176                &self.state.placed_containers,
12177                self.state.character_id,
12178            )
12179            .into_iter()
12180            .next()
12181            .map(|(id, _)| id)
12182        });
12183        let label = worker.label.clone();
12184        let editor = if let Some(route) = &worker.route {
12185            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12186                worker.instance_id,
12187                worker.label,
12188                route,
12189                lodging,
12190            )
12191        } else {
12192            crate::worker_route_editor::WorkerRouteEditorState::new(
12193                worker.instance_id,
12194                worker.label,
12195                lodging,
12196            )
12197        };
12198        self.state.worker_route_editor = Some(editor);
12199        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12200            if let Some(collapsed) =
12201                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12202            {
12203                ed.panel_collapsed = collapsed;
12204            }
12205        }
12206        self.state.show_workers_menu = false;
12207        self.state.push_log(format!(
12208            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12209        ));
12210        Ok(())
12211    }
12212
12213    pub fn close_worker_route_editor(&mut self) {
12214        self.state.worker_route_editor = None;
12215    }
12216
12217    pub fn worker_route_editor_toggle_panel(&mut self) {
12218        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12219            ed.toggle_panel_collapsed();
12220            let collapsed = ed.panel_collapsed;
12221            let mut cfg = crate::client_config::ClientConfig::load();
12222            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12223        }
12224    }
12225
12226    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12227        let n = {
12228            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12229                return;
12230            };
12231            ed.append_waypoint(x, y, z);
12232            ed.stop_count()
12233        };
12234        self.state
12235            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12236    }
12237
12238    // ---- picker candidate snapshots ----------------------------------
12239
12240    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12241        let (px, py, _) = self.state.player_position_with_z();
12242        let inside = self.state.effective_inside_building();
12243        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12244            &self.state.placed_containers,
12245            &self.state.buildings,
12246            self.state.character_id,
12247            px,
12248            py,
12249            &self.state.hired_workers,
12250            inside.as_deref(),
12251        )
12252    }
12253
12254    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12255        self.state.route_editor_node_candidates()
12256    }
12257
12258    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12259        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12260        let nodes = self.state.route_editor_node_candidates();
12261        let index = if nodes.is_empty() {
12262            ROUTE_PICKER_DONE_ROW
12263        } else {
12264            index.max(1).min(nodes.len())
12265        };
12266        self.re_open_sheet(S::HarvestPicker {
12267            index,
12268            picked,
12269            nodes,
12270        });
12271    }
12272
12273    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12274        let (px, py, _) = self.state.player_position_with_z();
12275        let templates = self.re_template_candidates();
12276        crate::worker_route_editor::trade_npc_candidates(
12277            &self.state.npcs,
12278            px,
12279            py,
12280            &templates,
12281        )
12282    }
12283
12284    fn re_template_candidates(&self) -> Vec<String> {
12285        let mut extra = Vec::new();
12286        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12287            for stop in &ed.stops {
12288                match stop {
12289                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12290                        filter: Some(filter),
12291                        ..
12292                    } => extra.extend(filter.iter().cloned()),
12293                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12294                    | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12295                        template,
12296                        ..
12297                    } => {
12298                        extra.push(template.clone());
12299                    }
12300                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12301                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12302                        {
12303                            extra.push(bp.output.clone());
12304                            for input in &bp.inputs {
12305                                extra.push(input.template_id.clone());
12306                            }
12307                        }
12308                    }
12309                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12310                        for it in items {
12311                            extra.push(it.template.clone());
12312                        }
12313                    }
12314                    _ => {}
12315                }
12316            }
12317            // Worker-known recipe outputs even if the player hasn't learned them yet.
12318            if let Some(worker) = self
12319                .state
12320                .hired_workers
12321                .iter()
12322                .find(|w| w.instance_id == ed.worker_instance_id)
12323            {
12324                for recipe in &worker.known_blueprint_ids {
12325                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12326                        extra.push(bp.output.clone());
12327                    }
12328                }
12329                for stack in &worker.inventory {
12330                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12331                        extra.push(stack.template_id.clone());
12332                    }
12333                }
12334            }
12335        }
12336        crate::worker_route_editor::route_item_template_candidates(
12337            &self.state.placed_containers,
12338            self.state.character_id,
12339            &self.state.inventory,
12340            &self.state.blueprints,
12341            &self.state.resource_nodes,
12342            &extra,
12343            Some(&self.state.item_catalog),
12344        )
12345    }
12346
12347    fn re_blueprint_ids(&self) -> Vec<String> {
12348        let worker_known: Option<&[String]> = self
12349            .state
12350            .worker_route_editor
12351            .as_ref()
12352            .and_then(|ed| {
12353                self.state
12354                    .hired_workers
12355                    .iter()
12356                    .find(|w| w.instance_id == ed.worker_instance_id)
12357            })
12358            .map(|w| w.known_blueprint_ids.as_slice());
12359        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12360    }
12361
12362    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12363        crate::worker_route_editor::owned_lodging_container_ids(
12364            &self.state.placed_containers,
12365            self.state.character_id,
12366        )
12367    }
12368
12369    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12370        self.state
12371            .placed_containers
12372            .iter()
12373            .find(|c| c.id == container_id)
12374            .map(|c| c.contents.clone())
12375            .unwrap_or_default()
12376    }
12377
12378    // ---- sheet navigation (`plans/33`) --------------------------------
12379
12380    fn re_sheet_supports_filter(&self) -> bool {
12381        use crate::worker_route_editor::RouteEditorSheet as S;
12382        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12383            matches!(
12384                ed.sheet,
12385                S::HarvestPicker { .. }
12386                    | S::SellItem { .. }
12387                    | S::MarketListItem { .. }
12388                    | S::DepositFilter { .. }
12389                    | S::WithdrawItems { .. }
12390                    | S::WithdrawContainers { .. }
12391                    | S::DepositContainers { .. }
12392                    | S::SellNpcs { .. }
12393                    | S::CraftBlueprint { .. }
12394                    | S::BedPicker { .. }
12395            )
12396        })
12397    }
12398
12399    /// Whether a sheet row is shown under the current `/` filter.
12400    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12401        use crate::worker_route_editor::{
12402            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12403            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12404        };
12405        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12406            return false;
12407        };
12408        let filter = &ed.sheet_filter;
12409        match &ed.sheet {
12410            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12411            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12412                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12413                    return true;
12414                }
12415                let slot = row.saturating_sub(2);
12416                templates.get(slot).is_some_and(|t| {
12417                    let label = self.state.template_display_name(t);
12418                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12419                })
12420            }
12421            S::DepositFilter { rows, .. } => {
12422                if row >= rows.len() {
12423                    return true;
12424                }
12425                rows.get(row).is_some_and(|(t, _)| {
12426                    let label = self.state.template_display_name(t);
12427                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12428                })
12429            }
12430            S::WithdrawItems { lines, .. } => {
12431                if row >= lines.len() {
12432                    return true;
12433                }
12434                lines.get(row).is_some_and(|l| {
12435                    let label = self.state.template_display_name(&l.template);
12436                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12437                })
12438            }
12439            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12440                self.re_container_candidates().get(row).is_some_and(|c| {
12441                    list_filter_row_matches(
12442                        filter,
12443                        Some(c.dist),
12444                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12445                    )
12446                })
12447            }
12448            S::SellNpcs { .. } => {
12449                if row == 0 {
12450                    return true;
12451                }
12452                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12453                    list_filter_row_matches(
12454                        filter,
12455                        Some(n.dist),
12456                        &[n.label.as_str(), n.id.as_str()],
12457                    )
12458                })
12459            }
12460            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12461                let label = self
12462                    .state
12463                    .blueprints
12464                    .iter()
12465                    .find(|b| &b.id == id)
12466                    .map(|b| {
12467                        if b.label.is_empty() {
12468                            id.as_str()
12469                        } else {
12470                            b.label.as_str()
12471                        }
12472                    })
12473                    .unwrap_or(id.as_str());
12474                list_filter_row_matches(filter, None, &[id.as_str(), label])
12475            }),
12476            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12477                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12478            }),
12479            _ => true,
12480        }
12481    }
12482
12483    fn re_sheet_clamp_index(&mut self) {
12484        let count = self.re_sheet_row_count();
12485        if count == 0 {
12486            return;
12487        }
12488        let cur = self.re_sheet_index();
12489        if self.re_sheet_row_visible(cur) {
12490            return;
12491        }
12492        for offset in 1..count {
12493            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12494                self.re_sheet_set_index(cur + offset);
12495                return;
12496            }
12497            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12498                self.re_sheet_set_index(cur - offset);
12499                return;
12500            }
12501        }
12502    }
12503
12504    fn re_sheet_set_index(&mut self, index: usize) {
12505        use crate::worker_route_editor::RouteEditorSheet as S;
12506        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12507            return;
12508        };
12509        match &mut ed.sheet {
12510            S::AddMenu { index: slot }
12511            | S::WaypointMenu { index: slot }
12512            | S::HarvestPicker { index: slot, .. }
12513            | S::WithdrawContainers { index: slot }
12514            | S::DepositContainers { index: slot }
12515            | S::SellNpcs { index: slot }
12516            | S::CraftBlueprint { index: slot }
12517            | S::BedPicker { index: slot }
12518            | S::FarmPlotPicker { index: slot, .. }
12519            | S::FarmPlantSeed { index: slot, .. }
12520            | S::WithdrawItems { index: slot, .. }
12521            | S::DepositFilter { index: slot, .. }
12522            | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12523            _ => {}
12524        }
12525    }
12526
12527    pub fn re_focus_sheet_filter(&mut self) {
12528        if !self.re_sheet_supports_filter() {
12529            return;
12530        }
12531        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12532            ed.sheet_filter_focused = true;
12533        }
12534    }
12535
12536    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12537        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12538            return;
12539        };
12540        if !ed.sheet_filter_focused {
12541            return;
12542        }
12543        ed.sheet_filter_focused = false;
12544        self.re_sheet_clamp_index();
12545    }
12546
12547    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12548        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12549            return false;
12550        };
12551        if ed.sheet_filter_focused {
12552            ed.sheet_filter_focused = false;
12553            self.re_sheet_clamp_index();
12554            return true;
12555        }
12556        if !ed.sheet_filter.is_empty() {
12557            ed.sheet_filter.clear();
12558            self.re_sheet_clamp_index();
12559            return true;
12560        }
12561        false
12562    }
12563
12564    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12565        if ch.is_control() {
12566            return;
12567        }
12568        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12569            return;
12570        };
12571        if !ed.sheet_filter_focused {
12572            return;
12573        }
12574        ed.sheet_filter.push(ch);
12575        self.re_sheet_set_index(0);
12576        self.re_sheet_clamp_index();
12577    }
12578
12579    pub fn re_sheet_filter_backspace(&mut self) {
12580        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12581            return;
12582        };
12583        if !ed.sheet_filter_focused {
12584            return;
12585        }
12586        ed.sheet_filter.pop();
12587        self.re_sheet_set_index(0);
12588        self.re_sheet_clamp_index();
12589    }
12590
12591    /// Row count of the current sheet (for cursor wrapping).
12592    pub fn re_sheet_row_count(&self) -> usize {
12593        use crate::worker_route_editor::{
12594            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12595        };
12596        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12597            return 0;
12598        };
12599        match &ed.sheet {
12600            S::Stops => ed.stops.len(),
12601            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12602            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12603            S::WaypointMapPick => 0,
12604            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12605            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12606                self.re_container_candidates().len()
12607            }
12608            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12609            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12610            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12611            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12612                sell_item_picker_row_count(templates.len())
12613            }
12614            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12615            S::WaitEntry { .. } => 1,
12616            S::BedPicker { .. } => self.re_bed_candidates().len(),
12617            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12618            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12619        }
12620    }
12621
12622    /// Current sheet cursor index (0 for sheets without one).
12623    pub fn re_sheet_index(&self) -> usize {
12624        use crate::worker_route_editor::RouteEditorSheet as S;
12625        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12626            return 0;
12627        };
12628        match &ed.sheet {
12629            S::AddMenu { index }
12630            | S::WaypointMenu { index }
12631            | S::HarvestPicker { index, .. }
12632            | S::WithdrawContainers { index }
12633            | S::DepositContainers { index }
12634            | S::SellNpcs { index }
12635            | S::CraftBlueprint { index }
12636            | S::BedPicker { index }
12637            | S::FarmPlotPicker { index, .. }
12638            | S::FarmPlantSeed { index, .. }
12639            | S::WithdrawItems { index, .. }
12640            | S::DepositFilter { index, .. }
12641            | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12642            _ => 0,
12643        }
12644    }
12645
12646    /// Move the current sheet's cursor, wrapping within its rows.
12647    pub fn re_sheet_move(&mut self, delta: i32) {
12648        let count = self.re_sheet_row_count();
12649        if count == 0 {
12650            return;
12651        }
12652        let cur = self.re_sheet_index();
12653        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12654        self.re_sheet_set_index(next);
12655    }
12656
12657    pub fn re_sheet_page(&mut self, pages: i32) {
12658        let count = self.re_sheet_row_count();
12659        if count == 0 {
12660            return;
12661        }
12662        let cur = self.re_sheet_index();
12663        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12664        self.re_sheet_set_index(next);
12665    }
12666
12667    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
12668    pub fn re_sheet_adjust(&mut self, delta: i32) {
12669        use crate::worker_route_editor::RouteEditorSheet as S;
12670        let index = self.re_sheet_index();
12671        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12672            return;
12673        };
12674        match &mut ed.sheet {
12675            S::WithdrawItems { lines, .. } => {
12676                if let Some(line) = lines.get_mut(index) {
12677                    line.adjust_qty(delta);
12678                }
12679            }
12680            S::WaitEntry { ticks } => {
12681                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12682            }
12683            _ => {}
12684        }
12685    }
12686
12687    pub fn re_sheet_back(&mut self) {
12688        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12689            return;
12690        };
12691        use crate::worker_route_editor::RouteEditorSheet as S;
12692        let was_editing = ed.editing_index.is_some();
12693        let from_top_picker = matches!(
12694            ed.sheet,
12695            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12696        );
12697        ed.sheet_back();
12698        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12699            // Chest retarget commits on pick; Esc here just ends the edit sheet.
12700            self.state
12701                .push_log("Route: left edit sheet — press s to save current stops".to_string());
12702        }
12703    }
12704
12705    /// True when the editor is at its root sheet (Esc should close it).
12706    pub fn re_at_root_sheet(&self) -> bool {
12707        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12708            matches!(
12709                ed.sheet,
12710                crate::worker_route_editor::RouteEditorSheet::Stops
12711            )
12712        })
12713    }
12714
12715    pub fn re_open_add_menu(&mut self) {
12716        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12717            ed.open_add_menu();
12718        }
12719    }
12720
12721    pub fn re_open_bed_picker(&mut self) {
12722        let beds = self.re_bed_candidates();
12723        if beds.is_empty() {
12724            self.state
12725                .push_log("Route: place a camp bed first".to_string());
12726            return;
12727        }
12728        let current = self
12729            .state
12730            .worker_route_editor
12731            .as_ref()
12732            .and_then(|ed| ed.lodging_container_id.clone());
12733        let index = current
12734            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12735            .unwrap_or(0);
12736        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12737    }
12738
12739    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12740        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12741            ed.open_sheet(sheet);
12742        }
12743    }
12744
12745    /// Confirm a sheet's stop and log the outcome.
12746    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12747        let appended = self
12748            .state
12749            .worker_route_editor
12750            .as_mut()
12751            .is_some_and(|ed| ed.confirm_stop(stop));
12752        if appended {
12753            self.state.push_log(format!("Route: + {what}"));
12754        } else {
12755            self.state
12756                .push_log(format!("Route: {what} already in route — selected it"));
12757        }
12758    }
12759
12760    fn re_open_withdraw_items(&mut self, container_id: String) {
12761        use crate::worker_route_editor::{
12762            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12763        };
12764        let contents = self.re_container_contents(&container_id);
12765        // When editing, keep the stop's item picks even if the player retargets
12766        // to a different (possibly empty) chest — otherwise Done has nothing to
12767        // confirm and the edit appears to "revert".
12768        let existing = self
12769            .state
12770            .worker_route_editor
12771            .as_ref()
12772            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12773            .and_then(|stop| match stop {
12774                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12775                _ => None,
12776            })
12777            .unwrap_or_default();
12778        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12779        // Commit the new chest immediately while editing so Esc-before-Done still
12780        // keeps the retarget (items update only when Done is pressed).
12781        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12782            let _ = ed.retarget_withdraw_container(container_id.clone());
12783        }
12784        self.re_open_sheet(S::WithdrawItems {
12785            container_id,
12786            lines,
12787            index: 0,
12788        });
12789    }
12790
12791    fn re_withdraw_items_activate(&mut self, index: usize) {
12792        use crate::worker_route_editor::{
12793            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12794        };
12795        enum Outcome {
12796            Cycled,
12797            Confirmed(String),
12798            Empty,
12799        }
12800        let outcome = {
12801            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12802                return;
12803            };
12804            let S::WithdrawItems {
12805                container_id,
12806                lines,
12807                index: sheet_index,
12808            } = &mut ed.sheet
12809            else {
12810                return;
12811            };
12812            *sheet_index = index;
12813            if index < lines.len() {
12814                lines[index].cycle();
12815                Outcome::Cycled
12816            } else {
12817                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12818                if items.is_empty() {
12819                    Outcome::Empty
12820                } else {
12821                    let stop = WorkerRouteStop::WithdrawFrom {
12822                        container_id: container_id.clone(),
12823                        items,
12824                    };
12825                    let summary = stop.summary();
12826                    ed.confirm_stop(stop);
12827                    Outcome::Confirmed(summary)
12828                }
12829            }
12830        };
12831        match outcome {
12832            Outcome::Cycled => {}
12833            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12834            Outcome::Empty => self.state.push_log(
12835                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12836            ),
12837        }
12838    }
12839
12840    fn re_open_deposit_filter(&mut self, container_id: String) {
12841        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12842        // Preserve filter when retargeting the deposit chest while editing.
12843        let existing_filter = self
12844            .state
12845            .worker_route_editor
12846            .as_ref()
12847            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12848            .and_then(|stop| match stop {
12849                WorkerRouteStop::DepositAt { filter, .. } => {
12850                    Some(filter.clone().unwrap_or_default())
12851                }
12852                _ => None,
12853            });
12854        let mut candidates = self.re_template_candidates();
12855        if let Some(ref chosen) = existing_filter {
12856            for t in chosen {
12857                if !candidates.iter().any(|c| c == t) {
12858                    candidates.push(t.clone());
12859                }
12860            }
12861            candidates.sort();
12862            candidates.dedup();
12863        }
12864        let rows: Vec<(String, bool)> = match existing_filter {
12865            Some(chosen) => candidates
12866                .iter()
12867                .map(|t| (t.clone(), chosen.contains(t)))
12868                .collect(),
12869            None => candidates.into_iter().map(|t| (t, false)).collect(),
12870        };
12871        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12872            let _ = ed.retarget_deposit_container(container_id.clone());
12873        }
12874        self.re_open_sheet(S::DepositFilter {
12875            container_id,
12876            rows,
12877            index: 0,
12878        });
12879    }
12880
12881    fn re_deposit_filter_activate(&mut self, index: usize) {
12882        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12883        let mut confirmed: Option<String> = None;
12884        {
12885            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12886                return;
12887            };
12888            let S::DepositFilter {
12889                container_id,
12890                rows,
12891                index: sheet_index,
12892            } = &mut ed.sheet
12893            else {
12894                return;
12895            };
12896            *sheet_index = index;
12897            if index < rows.len() {
12898                rows[index].1 = !rows[index].1;
12899            } else {
12900                // "Done" row.
12901                let chosen: Vec<String> = rows
12902                    .iter()
12903                    .filter(|(_, on)| *on)
12904                    .map(|(t, _)| t.clone())
12905                    .collect();
12906                let filter = if chosen.is_empty() {
12907                    None
12908                } else {
12909                    Some(chosen)
12910                };
12911                let stop = WorkerRouteStop::DepositAt {
12912                    container_id: container_id.clone(),
12913                    filter,
12914                };
12915                confirmed = Some(stop.summary());
12916                ed.confirm_stop(stop);
12917            }
12918        }
12919        if let Some(what) = confirmed {
12920            self.state.push_log(format!("Route: + {what}"));
12921        }
12922    }
12923
12924    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12925        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12926        // Prefill when editing an existing sell stop.
12927        let (pre_npc, pre_template, pre_all) = self
12928            .state
12929            .worker_route_editor
12930            .as_ref()
12931            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12932            .and_then(|stop| match stop {
12933                WorkerRouteStop::TradeWith {
12934                    npc_id,
12935                    template,
12936                    sell_all,
12937                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12938                _ => None,
12939            })
12940            .unwrap_or((None, None, true));
12941        let npc_id = npc_id.or(pre_npc);
12942        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12943            &self.re_template_candidates(),
12944            &self.state.npcs,
12945            npc_id.as_deref(),
12946        );
12947        // Keep an existing invalid selection visible long enough to remove it
12948        // when editing a route after NPC buy lists change.
12949        if let Some(template) = pre_template.as_ref() {
12950            if !templates.iter().any(|candidate| candidate == template) {
12951                templates.push(template.clone());
12952                templates.sort();
12953            }
12954        }
12955        if templates.is_empty() {
12956            let msg = crate::worker_route_editor::sell_merchant_empty_reason(
12957                npc_id.as_deref(),
12958                &self.state.npcs,
12959                &self.re_template_candidates(),
12960            );
12961            self.state.push_log(msg);
12962            return;
12963        }
12964        let mut picked = std::collections::BTreeSet::new();
12965        if let Some(t) = pre_template {
12966            picked.insert(t);
12967        }
12968        self.re_open_sheet(S::SellItem {
12969            npc_id,
12970            templates,
12971            index: if picked.is_empty() {
12972                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12973            } else {
12974                2
12975            },
12976            sell_all: pre_all,
12977            picked,
12978        });
12979    }
12980
12981    fn re_sell_item_activate(&mut self, index: usize) {
12982        use crate::worker_route_editor::{
12983            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12984        };
12985        let mut batch_log: Option<String> = None;
12986        {
12987            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12988                return;
12989            };
12990            let S::SellItem {
12991                npc_id,
12992                templates,
12993                index: sheet_index,
12994                sell_all,
12995                picked,
12996            } = &mut ed.sheet
12997            else {
12998                return;
12999            };
13000            *sheet_index = index;
13001            if index == ROUTE_PICKER_DONE_ROW {
13002                if picked.is_empty() {
13003                    batch_log =
13004                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13005                } else {
13006                    let picks: Vec<String> = picked.iter().cloned().collect();
13007                    let npc = npc_id.clone();
13008                    let all = *sell_all;
13009                    let added = ed.confirm_trade_picks(npc, &picks, all);
13010                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
13011                }
13012            } else if index == SELL_ITEM_TOGGLE_ROW {
13013                *sell_all = !*sell_all;
13014            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13015                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13016                    std::slice::from_ref(template),
13017                    &self.state.npcs,
13018                    npc_id.as_deref(),
13019                )
13020                .iter()
13021                .any(|candidate| candidate == template);
13022                if !sellable && !picked.contains(template) {
13023                    return;
13024                }
13025                if picked.contains(template) {
13026                    picked.remove(template);
13027                } else {
13028                    picked.insert(template.clone());
13029                }
13030            }
13031        }
13032        if let Some(msg) = batch_log {
13033            self.state.push_log(msg);
13034        }
13035    }
13036
13037    fn re_open_market_list_item(&mut self) {
13038        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13039        let (pre_hall, pre_template, pre_all) = self
13040            .state
13041            .worker_route_editor
13042            .as_ref()
13043            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13044            .and_then(|stop| match stop {
13045                WorkerRouteStop::ListOnMarket {
13046                    hall_id,
13047                    template,
13048                    list_all,
13049                } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13050                _ => None,
13051            })
13052            .unwrap_or((None, None, true));
13053        let mut templates = self.re_template_candidates();
13054        // Prefer items with a known NPC-value hint when available; keep the rest
13055        // so the stop can still be authored (sim rejects no-value / unlistable).
13056        templates.sort_by_key(|t| {
13057            std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13058        });
13059        if let Some(template) = pre_template.as_ref() {
13060            if !templates.iter().any(|c| c == template) {
13061                templates.push(template.clone());
13062            }
13063        }
13064        if templates.is_empty() {
13065            self.state.push_log("Route: no item templates available for market list".to_string());
13066            return;
13067        }
13068        let mut picked = std::collections::BTreeSet::new();
13069        if let Some(t) = pre_template {
13070            picked.insert(t);
13071        }
13072        self.re_open_sheet(S::MarketListItem {
13073            hall_id: pre_hall,
13074            templates,
13075            index: if picked.is_empty() {
13076                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13077            } else {
13078                2
13079            },
13080            list_all: pre_all,
13081            picked,
13082        });
13083    }
13084
13085    fn re_market_list_item_activate(&mut self, index: usize) {
13086        use crate::worker_route_editor::{
13087            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13088        };
13089        let mut batch_log: Option<String> = None;
13090        {
13091            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13092                return;
13093            };
13094            let S::MarketListItem {
13095                hall_id,
13096                templates,
13097                index: sheet_index,
13098                list_all,
13099                picked,
13100            } = &mut ed.sheet
13101            else {
13102                return;
13103            };
13104            *sheet_index = index;
13105            if index == ROUTE_PICKER_DONE_ROW {
13106                if picked.is_empty() {
13107                    batch_log = Some(
13108                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
13109                    );
13110                } else {
13111                    let picks: Vec<String> = picked.iter().cloned().collect();
13112                    let hall = hall_id.clone();
13113                    let all = *list_all;
13114                    let added = ed.confirm_market_list_picks(hall, &picks, all);
13115                    batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13116                }
13117            } else if index == SELL_ITEM_TOGGLE_ROW {
13118                *list_all = !*list_all;
13119            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13120                if picked.contains(template) {
13121                    picked.remove(template);
13122                } else {
13123                    picked.insert(template.clone());
13124                }
13125            }
13126        }
13127        if let Some(msg) = batch_log {
13128            self.state.push_log(msg);
13129        }
13130    }
13131
13132    /// Enter on the stop list: open the selected stop's sheet, prefilled.
13133    pub fn re_edit_selected_stop(&mut self) {
13134        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13135        let Some(stop) = self
13136            .state
13137            .worker_route_editor
13138            .as_ref()
13139            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13140        else {
13141            self.state
13142                .push_log("Route: no stop selected — press a to add one".to_string());
13143            return;
13144        };
13145        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13146            ed.begin_edit_selected();
13147        }
13148        match stop {
13149            WorkerRouteStop::Waypoint { .. } => {
13150                self.re_open_sheet(S::WaypointMenu { index: 0 });
13151            }
13152            WorkerRouteStop::HarvestNode { node_id } => {
13153                let nodes = self.state.route_editor_node_candidates();
13154                if nodes.is_empty() {
13155                    self.re_cancel_edit();
13156                    self.state
13157                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
13158                } else {
13159                    let mut picked = std::collections::BTreeSet::new();
13160                    picked.insert(node_id.clone());
13161                    let index = nodes
13162                        .iter()
13163                        .position(|n| n.id == node_id)
13164                        .map(|i| i + 1)
13165                        .unwrap_or(1);
13166                    self.re_open_harvest_picker(index, picked);
13167                }
13168            }
13169            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13170                // Open the container picker so the player can retarget storage
13171                // (previously jumped straight to items on the same chest).
13172                let containers = self.re_container_candidates();
13173                if containers.is_empty() {
13174                    self.re_cancel_edit();
13175                    self.state
13176                        .push_log("Route: place a storage chest first".to_string());
13177                } else {
13178                    let index = containers
13179                        .iter()
13180                        .position(|c| c.id == container_id)
13181                        .unwrap_or(0);
13182                    self.re_open_sheet(S::WithdrawContainers { index });
13183                }
13184            }
13185            WorkerRouteStop::DepositAt { container_id, .. } => {
13186                let containers = self.re_container_candidates();
13187                if containers.is_empty() {
13188                    self.re_cancel_edit();
13189                    self.state
13190                        .push_log("Route: place a storage chest first".to_string());
13191                } else {
13192                    let index = containers
13193                        .iter()
13194                        .position(|c| c.id == container_id)
13195                        .unwrap_or(0);
13196                    self.re_open_sheet(S::DepositContainers { index });
13197                }
13198            }
13199            WorkerRouteStop::TradeWith { npc_id, .. } => {
13200                let npcs = self.re_npc_candidates();
13201                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13202                let index = npc_id
13203                    .as_ref()
13204                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13205                    .unwrap_or(0);
13206                self.re_open_sheet(S::SellNpcs { index });
13207            }
13208            WorkerRouteStop::ListOnMarket { .. } => {
13209                self.re_open_market_list_item();
13210            }
13211            WorkerRouteStop::CraftAt { blueprint, .. } => {
13212                let bps = self.re_blueprint_ids();
13213                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13214                if bps.is_empty() {
13215                    self.re_cancel_edit();
13216                    self.state
13217                        .push_log("Route: no known blueprints to retarget".to_string());
13218                } else {
13219                    self.re_open_sheet(S::CraftBlueprint { index });
13220                }
13221            }
13222            WorkerRouteStop::CultivatePlot { .. } => {
13223                self.re_open_farm_plot_picker(
13224                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13225                );
13226            }
13227            WorkerRouteStop::PlantPlot { .. } => {
13228                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13229            }
13230            WorkerRouteStop::HarvestPlot { .. } => {
13231                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13232            }
13233            WorkerRouteStop::RestIfNeeded => {
13234                self.re_cancel_edit();
13235                self.state
13236                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13237            }
13238            WorkerRouteStop::Wait { wait_ticks } => {
13239                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13240            }
13241        }
13242    }
13243
13244    fn re_cancel_edit(&mut self) {
13245        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13246            ed.editing_index = None;
13247        }
13248    }
13249
13250    // ---- mouse clicks inside the overlay ------------------------------
13251
13252    pub fn worker_route_editor_ui_click(
13253        &mut self,
13254        click: crate::worker_route_editor::RouteEditorClick,
13255    ) {
13256        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13257        match click {
13258            RouteEditorClick::SelectStop(i) => {
13259                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13260                    ed.sheet = S::Stops;
13261                    ed.select_stop(i);
13262                }
13263            }
13264            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13265            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13266            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13267        }
13268    }
13269
13270    /// Activate (Enter / click) a row of the current sheet.
13271    pub fn re_sheet_row_activate(&mut self, row: usize) {
13272        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13273        let Some(sheet) = self
13274            .state
13275            .worker_route_editor
13276            .as_ref()
13277            .map(|ed| ed.sheet.clone())
13278        else {
13279            return;
13280        };
13281        match sheet {
13282            S::Stops => {
13283                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13284                    ed.select_stop(row);
13285                }
13286            }
13287            S::AddMenu { .. } => match row {
13288                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13289                1 => {
13290                    if self.re_node_candidates().is_empty() {
13291                        self.state.push_log(
13292                            "Route: no harvestable nodes visible in this region".to_string(),
13293                        );
13294                    } else {
13295                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13296                    }
13297                }
13298                2 | 3 => {
13299                    if self.re_container_candidates().is_empty() {
13300                        self.state
13301                            .push_log("Route: place a storage chest first".to_string());
13302                    } else if row == 2 {
13303                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13304                    } else {
13305                        self.re_open_sheet(S::DepositContainers { index: 0 });
13306                    }
13307                }
13308                4 => {
13309                    if self.re_template_candidates().is_empty() {
13310                        self.state.push_log(
13311                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13312                                .to_string(),
13313                        );
13314                    } else {
13315                        self.re_open_sheet(S::SellNpcs { index: 0 });
13316                    }
13317                }
13318                5 => {
13319                    if self.re_template_candidates().is_empty() {
13320                        self.state.push_log(
13321                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13322                                .to_string(),
13323                        );
13324                    } else {
13325                        self.re_open_market_list_item();
13326                    }
13327                }
13328                6 => {
13329                    if self.re_blueprint_ids().is_empty() {
13330                        self.state.push_log(
13331                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13332                                .to_string(),
13333                        );
13334                    } else {
13335                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13336                    }
13337                }
13338                7 => self.re_confirm_stop(
13339                    WorkerRouteStop::RestIfNeeded,
13340                    "rest at lodging (if needed)".into(),
13341                ),
13342                8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13343                9 => self.re_open_farm_plot_picker(
13344                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13345                ),
13346                10 => {
13347                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13348                }
13349                11 => self
13350                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13351                _ => {}
13352            },
13353            S::WaypointMenu { .. } => match row {
13354                0 => {
13355                    let (x, y, z) = self.state.player_position_with_z();
13356                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13357                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13358                }
13359                1 => {
13360                    self.re_open_sheet(S::WaypointMapPick);
13361                    self.state.push_log(
13362                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13363                    );
13364                }
13365                _ => {}
13366            },
13367            S::HarvestPicker { .. } => {
13368                let mut log: Option<String> = None;
13369                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13370                    let S::HarvestPicker {
13371                        index: sheet_index,
13372                        picked,
13373                        nodes,
13374                    } = &mut ed.sheet
13375                    else {
13376                        return;
13377                    };
13378                    *sheet_index = row;
13379                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13380                        if picked.is_empty() {
13381                            log = Some(
13382                                "Route: pick at least one node (Space toggles, Done confirms)"
13383                                    .into(),
13384                            );
13385                        } else {
13386                            let ids: Vec<String> = picked.iter().cloned().collect();
13387                            let added = ed.confirm_harvest_picks(&ids);
13388                            log = Some(format!("Route: + {added} harvest stop(s)"));
13389                        }
13390                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13391                        if picked.contains(&n.id) {
13392                            picked.remove(&n.id);
13393                        } else {
13394                            picked.insert(n.id.clone());
13395                        }
13396                    }
13397                }
13398                if let Some(msg) = log {
13399                    self.state.push_log(msg);
13400                }
13401            }
13402            S::WithdrawContainers { .. } => {
13403                let containers = self.re_container_candidates();
13404                if let Some(c) = containers.get(row) {
13405                    let id = c.id.clone();
13406                    self.re_open_withdraw_items(id);
13407                }
13408            }
13409            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13410            S::DepositContainers { .. } => {
13411                let containers = self.re_container_candidates();
13412                if let Some(c) = containers.get(row) {
13413                    let id = c.id.clone();
13414                    self.re_open_deposit_filter(id);
13415                }
13416            }
13417            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13418            S::SellNpcs { .. } => {
13419                let templates = self.re_template_candidates();
13420                let npcs = self.re_npc_candidates();
13421                if row == 0 {
13422                    if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13423                        &self.state.npcs,
13424                        &templates,
13425                    ) {
13426                        self.state.push_log(
13427                            crate::worker_route_editor::sell_merchant_empty_reason(
13428                                None,
13429                                &self.state.npcs,
13430                                &templates,
13431                            ),
13432                        );
13433                        return;
13434                    }
13435                    self.re_open_sell_item(None);
13436                    return;
13437                }
13438                let Some(n) = npcs.get(row - 1) else {
13439                    return;
13440                };
13441                if !n.buys_route_item {
13442                    self.state.push_log(
13443                        crate::worker_route_editor::sell_merchant_empty_reason(
13444                            Some(n.id.as_str()),
13445                            &self.state.npcs,
13446                            &templates,
13447                        ),
13448                    );
13449                    return;
13450                }
13451                self.re_open_sell_item(Some(n.id.clone()));
13452            }
13453            S::SellItem { .. } => self.re_sell_item_activate(row),
13454            S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13455            S::CraftBlueprint { .. } => {
13456                let bps = self.re_blueprint_ids();
13457                if let Some(bp) = bps.get(row) {
13458                    let stop = WorkerRouteStop::CraftAt {
13459                        device: "hand".into(),
13460                        blueprint: bp.clone(),
13461                        qty: None,
13462                    };
13463                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13464                }
13465            }
13466            S::WaitEntry { ticks } => {
13467                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13468                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13469            }
13470            S::BedPicker { .. } => {
13471                let beds = self.re_bed_candidates();
13472                if let Some((id, name)) = beds.get(row) {
13473                    let (id, name) = (id.clone(), name.clone());
13474                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13475                        ed.lodging_container_id = Some(id.clone());
13476                        ed.sheet = S::Stops;
13477                    }
13478                    self.state
13479                        .push_log(format!("Route: rest bed set to {name}"));
13480                }
13481            }
13482            S::FarmPlotPicker { action, .. } => {
13483                let plots = self.re_farm_plot_candidates();
13484                let Some(plot) = plots.get(row).cloned() else {
13485                    return;
13486                };
13487                match action {
13488                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13489                        let label = plot_route_label(&plot);
13490                        self.re_confirm_stop(
13491                            WorkerRouteStop::CultivatePlot {
13492                                plot_id: plot.plot_id,
13493                            },
13494                            format!("cultivate {label}"),
13495                        );
13496                    }
13497                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13498                        let label = plot_route_label(&plot);
13499                        self.re_confirm_stop(
13500                            WorkerRouteStop::HarvestPlot {
13501                                plot_id: plot.plot_id,
13502                            },
13503                            format!("harvest {label}"),
13504                        );
13505                    }
13506                    crate::worker_route_editor::FarmPlotAction::Plant => {
13507                        let seeds = self.re_farm_seed_candidates();
13508                        if seeds.is_empty() {
13509                            self.state.push_log(
13510                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13511                            );
13512                            return;
13513                        }
13514                        self.re_open_sheet(S::FarmPlantSeed {
13515                            plot_id: plot.plot_id,
13516                            seeds,
13517                            index: 0,
13518                        });
13519                    }
13520                }
13521            }
13522            S::FarmPlantSeed { plot_id, seeds, .. } => {
13523                if let Some(seed) = seeds.get(row).cloned() {
13524                    self.re_confirm_stop(
13525                        WorkerRouteStop::PlantPlot {
13526                            plot_id,
13527                            seed_template: seed.clone(),
13528                        },
13529                        format!("plant {seed}"),
13530                    );
13531                }
13532            }
13533            S::WaypointMapPick => {}
13534        }
13535    }
13536
13537    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13538        use crate::worker_route_editor::RouteEditorSheet as S;
13539        if self.re_farm_plot_candidates().is_empty() {
13540            self.state
13541                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13542            return;
13543        }
13544        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13545    }
13546
13547    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13548        self.state
13549            .property_plots
13550            .iter()
13551            .filter(|p| p.is_mine || p.may_farm)
13552            .cloned()
13553            .collect()
13554    }
13555
13556    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13557    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13558    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13559    fn re_farm_seed_candidates(&self) -> Vec<String> {
13560        let mut set = std::collections::BTreeSet::new();
13561        let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13562            catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13563        };
13564        for (id, _, _) in self.state.farm_seed_entries() {
13565            set.insert(id);
13566        }
13567        for c in &self.state.placed_containers {
13568            let mine = match (self.state.character_id, c.owner_character_id) {
13569                (Some(a), Some(b)) => a == b,
13570                _ => false,
13571            };
13572            if !mine {
13573                continue;
13574            }
13575            for s in &c.contents {
13576                if s.quantity > 0
13577                    && (s.props.contains_key("seed_for")
13578                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13579                {
13580                    set.insert(s.template_id.clone());
13581                }
13582            }
13583        }
13584        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13585            for stop in &ed.stops {
13586                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13587                    stop
13588                {
13589                    for it in items {
13590                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13591                            set.insert(it.template.clone());
13592                        }
13593                    }
13594                }
13595                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13596                    seed_template,
13597                    ..
13598                } = stop
13599                {
13600                    if !seed_template.is_empty() {
13601                        set.insert(seed_template.clone());
13602                    }
13603                }
13604            }
13605        }
13606        for (id, entry) in &self.state.item_catalog {
13607            if entry.is_farm_seed() {
13608                set.insert(id.clone());
13609            }
13610        }
13611        set.into_iter().collect()
13612    }
13613
13614    // ---- map clicks (sheet-scoped accelerators) ------------------------
13615
13616    /// Map click while the route editor is open. When a picker sheet is open
13617    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
13618    /// container, NPC clicks the merchant, node clicks the harvest target);
13619    /// otherwise the click quick-adds the nearest target (context-aware).
13620    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13621        use crate::worker_route_editor as wre;
13622        use wre::RouteEditorSheet as S;
13623        if self.state.worker_route_editor.is_none() {
13624            return;
13625        }
13626        let sheet = self
13627            .state
13628            .worker_route_editor
13629            .as_ref()
13630            .map(|ed| ed.sheet.clone())
13631            .unwrap_or(S::Stops);
13632        match sheet {
13633            S::WaypointMapPick => {
13634                let (_, _, z) = self.state.player_position_with_z();
13635                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13636                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13637                // Stay in map-pick mode when adding (not editing) for fast pathing.
13638                let editing = self
13639                    .state
13640                    .worker_route_editor
13641                    .as_ref()
13642                    .is_some_and(|ed| ed.editing_index.is_some());
13643                if !editing {
13644                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13645                        ed.sheet = S::WaypointMapPick;
13646                    }
13647                }
13648            }
13649            S::HarvestPicker { .. } => {
13650                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13651                    let mut log: Option<String> = None;
13652                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13653                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13654                            return;
13655                        };
13656                        let selected = if picked.contains(&node.id) {
13657                            picked.remove(&node.id);
13658                            false
13659                        } else {
13660                            picked.insert(node.id.clone());
13661                            true
13662                        };
13663                        log = Some(format!(
13664                            "Route: {} {}",
13665                            if selected { "selected" } else { "deselected" },
13666                            resource_node_route_label(node)
13667                        ));
13668                    }
13669                    if let Some(msg) = log {
13670                        self.state.push_log(msg);
13671                    }
13672                }
13673            }
13674            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13675                // Chest click picks (or switches) the withdraw source.
13676                let inside = self.state.effective_inside_building();
13677                if let Some(cid) = wre::pick_storage_container_at(
13678                    &self.state.placed_containers,
13679                    self.state.character_id,
13680                    x,
13681                    y,
13682                    inside.as_deref(),
13683                ) {
13684                    self.re_open_withdraw_items(cid);
13685                }
13686            }
13687            S::DepositContainers { .. } | S::DepositFilter { .. } => {
13688                let inside = self.state.effective_inside_building();
13689                if let Some(cid) = wre::pick_storage_container_at(
13690                    &self.state.placed_containers,
13691                    self.state.character_id,
13692                    x,
13693                    y,
13694                    inside.as_deref(),
13695                ) {
13696                    self.re_open_deposit_filter(cid);
13697                }
13698            }
13699            S::SellNpcs { .. } => {
13700                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13701                    self.re_open_sell_item(Some(npc_id));
13702                }
13703            }
13704            S::SellItem { .. } => {
13705                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13706                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13707                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13708                            *slot = Some(npc_id.clone());
13709                        }
13710                    }
13711                    self.state
13712                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13713                }
13714            }
13715            // Stop list / other sheets: context-aware quick add.
13716            _ => self.worker_route_editor_quick_add_click(x, y),
13717        }
13718    }
13719
13720    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
13721    /// node wins; duplicates select the existing stop; a click on a merchant
13722    /// while a sell stop is selected pins it.
13723    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13724        use crate::worker_route_editor as wre;
13725        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13726            let dx = ax - bx;
13727            let dy = ay - by;
13728            (dx * dx + dy * dy).sqrt()
13729        };
13730
13731        // 1. Retarget the selected stop when the click names its target:
13732        //    sell stop → pin the merchant; withdraw stop → set the source chest.
13733        let selected_stop_kind = self
13734            .state
13735            .worker_route_editor
13736            .as_ref()
13737            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13738            .map(|s| match s {
13739                wre::WorkerRouteStop::TradeWith { .. } => 1,
13740                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13741                _ => 0,
13742            })
13743            .unwrap_or(0);
13744        if selected_stop_kind == 1 {
13745            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13746                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13747                    ed.set_selected_trade_npc(npc_id.clone());
13748                }
13749                self.state
13750                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13751                return;
13752            }
13753        }
13754        if selected_stop_kind == 2 {
13755            let inside = self.state.effective_inside_building();
13756            if let Some(cid) = wre::pick_storage_container_at(
13757                &self.state.placed_containers,
13758                self.state.character_id,
13759                x,
13760                y,
13761                inside.as_deref(),
13762            ) {
13763                let name = self
13764                    .state
13765                    .placed_containers
13766                    .iter()
13767                    .find(|c| c.id == cid)
13768                    .map(|c| c.display_name.clone())
13769                    .unwrap_or_else(|| "container".into());
13770                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13771                    ed.set_selected_withdraw_container(cid.clone());
13772                }
13773                self.state
13774                    .push_log(format!("Route: withdraw source → {name}"));
13775                return;
13776            }
13777        }
13778
13779        // 2. Nearest candidate of any kind wins. Category order is the
13780        //    tie-break for exact overlaps (bed over chest over merchant over node).
13781        enum Target {
13782            Bed(String),
13783            Container(String),
13784            Npc(String, String),
13785            Node(String, String),
13786        }
13787        let mut best: Option<(f32, u8, Target)> = None;
13788        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13789            let better = match best {
13790                None => true,
13791                Some((bd, brank, _)) => {
13792                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13793                }
13794            };
13795            if better {
13796                *best = Some((d, rank, t));
13797            }
13798        };
13799        let inside = self.state.effective_inside_building();
13800        if let Some(bed_id) = wre::pick_lodging_container_at(
13801            &self.state.placed_containers,
13802            self.state.character_id,
13803            x,
13804            y,
13805            inside.as_deref(),
13806        ) {
13807            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13808                // A bed that's already the rest bed doubles as a storage chest —
13809                // a second click on it means "deposit here", not "set bed again".
13810                let already_bed =
13811                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13812                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13813                    });
13814                if already_bed {
13815                    consider(
13816                        dist(x, y, c.x, c.y),
13817                        1,
13818                        Target::Container(bed_id),
13819                        &mut best,
13820                    );
13821                } else {
13822                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13823                }
13824            }
13825        }
13826        if let Some(cid) = wre::pick_storage_container_at(
13827            &self.state.placed_containers,
13828            self.state.character_id,
13829            x,
13830            y,
13831            inside.as_deref(),
13832        ) {
13833            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13834                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13835            }
13836        }
13837        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13838            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13839                consider(
13840                    dist(x, y, n.x, n.y),
13841                    2,
13842                    Target::Npc(npc_id, label),
13843                    &mut best,
13844                );
13845            }
13846        }
13847        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13848            let d = dist(x, y, node.x, node.y);
13849            let label = resource_node_route_label(node);
13850            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13851        }
13852
13853        match best.map(|(_, _, t)| t) {
13854            Some(Target::Bed(bed_id)) => {
13855                let name = self
13856                    .state
13857                    .placed_containers
13858                    .iter()
13859                    .find(|c| c.id == bed_id)
13860                    .map(|c| c.display_name.clone())
13861                    .unwrap_or_else(|| "camp bed".into());
13862                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13863                    ed.lodging_container_id = Some(bed_id.clone());
13864                }
13865                self.state
13866                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13867            }
13868            Some(Target::Container(cid)) => {
13869                let name = self
13870                    .state
13871                    .placed_containers
13872                    .iter()
13873                    .find(|c| c.id == cid)
13874                    .map(|c| c.display_name.clone())
13875                    .unwrap_or_else(|| "container".into());
13876                let added = self
13877                    .state
13878                    .worker_route_editor
13879                    .as_mut()
13880                    .is_some_and(|ed| ed.append_deposit_at(&cid));
13881                if added {
13882                    self.state
13883                        .push_log(format!("Route: + deposit at {name} ({cid})"));
13884                } else {
13885                    self.state.push_log(format!(
13886                        "Route: {name} already in route — selected it (d to remove)"
13887                    ));
13888                }
13889            }
13890            Some(Target::Npc(npc_id, label)) => {
13891                // Quick-add sell: first storage template (the full sell flow
13892                // lives in the a → Sell sheet).
13893                let template = self.re_template_candidates().into_iter().next();
13894                let Some(template) = template else {
13895                    self.state.push_log(
13896                        "Route: no items in your storage to sell — stock a chest first".to_string(),
13897                    );
13898                    return;
13899                };
13900                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13901                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13902                });
13903                if added {
13904                    self.state
13905                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13906                } else {
13907                    self.state.push_log(format!(
13908                        "Route: {label} already sells {template} — selected it (d to remove)"
13909                    ));
13910                }
13911            }
13912            Some(Target::Node(id, label)) => {
13913                let added = self
13914                    .state
13915                    .worker_route_editor
13916                    .as_mut()
13917                    .is_some_and(|ed| ed.append_harvest_node(&id));
13918                if added {
13919                    self.state
13920                        .push_log(format!("Route: + harvest node {label}"));
13921                } else {
13922                    self.state.push_log(format!(
13923                        "Route: {label} already in route — selected it (d to remove)"
13924                    ));
13925                }
13926            }
13927            None => {}
13928        }
13929    }
13930
13931    pub fn worker_route_editor_select(&mut self, delta: i32) {
13932        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13933            return;
13934        };
13935        if ed.stops.is_empty() {
13936            return;
13937        }
13938        let n = ed.stops.len() as i32;
13939        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13940        ed.selected_stop_index = next;
13941    }
13942
13943    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13944        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13945            return;
13946        };
13947        if delta < 0 {
13948            ed.move_selected_up();
13949        } else if delta > 0 {
13950            ed.move_selected_down();
13951        }
13952    }
13953
13954    pub fn worker_route_editor_delete_selected(&mut self) {
13955        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13956            let before = ed.stop_count();
13957            ed.remove_selected_stop();
13958            ed.stop_count() < before
13959        });
13960        if removed {
13961            self.state.push_log("Route: removed selected stop");
13962        }
13963    }
13964
13965    /// Clear all stops. Saving afterwards parks the worker in idle mode
13966    /// instead of leaving it in a broken job loop.
13967    pub fn worker_route_editor_clear_stops(&mut self) {
13968        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13969            return;
13970        };
13971        if ed.stops.is_empty() {
13972            self.state
13973                .push_log("Route: already empty — s saves an idle worker".to_string());
13974            return;
13975        }
13976        ed.stops.clear();
13977        ed.selected_stop_index = 0;
13978        self.state.push_log(
13979            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13980        );
13981    }
13982
13983    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13984        if self.state.pending_worker_job_ack.is_some() {
13985            anyhow::bail!("route save still pending — wait for server ack");
13986        }
13987        let Some(ed) = self.state.worker_route_editor.clone() else {
13988            anyhow::bail!("route editor not open");
13989        };
13990        // Empty route = stand down: park the worker in idle mode rather than
13991        // erroring out or leaving it marching a ghost loop.
13992        let (job_yaml, idle) = if ed.stops.is_empty() {
13993            (ed.build_idle_job_yaml(), true)
13994        } else {
13995            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13996        };
13997        let worker_id = ed.worker_instance_id.clone();
13998        let route_view = if idle { None } else { Some(ed.to_route_view()) };
13999        let mode = if idle {
14000            flatland_protocol::WorkerModeView::Idle
14001        } else {
14002            flatland_protocol::WorkerModeView::JobLoop
14003        };
14004        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14005            .state
14006            .hired_workers
14007            .iter()
14008            .find(|w| w.instance_id == worker_id)
14009            .map(|w| {
14010                (
14011                    w.route.clone(),
14012                    w.mode,
14013                    w.step_label.clone(),
14014                    w.last_error.clone(),
14015                )
14016            })
14017            .unwrap_or((
14018                None,
14019                flatland_protocol::WorkerModeView::Idle,
14020                String::new(),
14021                None,
14022            ));
14023        self.seq += 1;
14024        let seq = self.seq;
14025        self.session
14026            .submit_intent(Intent::SetWorkerJob {
14027                entity_id: self.state.entity_id,
14028                worker_instance_id: worker_id.clone(),
14029                job_yaml,
14030                seq,
14031            })
14032            .await?;
14033        self.state.intents_sent += 1;
14034        if let Some(w) = self
14035            .state
14036            .hired_workers
14037            .iter_mut()
14038            .find(|w| w.instance_id == worker_id)
14039        {
14040            w.route = route_view;
14041            w.mode = mode;
14042            w.last_error = None;
14043            if idle {
14044                w.step_label.clear();
14045                w.route_stop_index = None;
14046            }
14047        }
14048        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14049            seq,
14050            worker_instance_id: worker_id,
14051            worker_label: ed.worker_label.clone(),
14052            idle,
14053            stop_count: ed.stops.len(),
14054            prev_route,
14055            prev_mode,
14056            prev_step_label,
14057            prev_last_error,
14058        });
14059        self.state.push_log(format!(
14060            "Route: saving for {}… (waiting for server)",
14061            ed.worker_label
14062        ));
14063        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
14064        Ok(())
14065    }
14066    pub fn quest_menu_move(&mut self, delta: i32) {
14067        let n = self.state.active_quest_entries().len();
14068        if n == 0 {
14069            return;
14070        }
14071        let idx = self.state.quest_menu_index as i32;
14072        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14073    }
14074
14075    pub fn quest_menu_page(&mut self, pages: i32) {
14076        let n = self.state.active_quest_entries().len();
14077        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14078    }
14079
14080    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14081        let Some(offer) = self.state.selected_quest_offer().cloned() else {
14082            anyhow::bail!("no quest offer");
14083        };
14084        self.seq += 1;
14085        let seq = self.seq;
14086        self.session
14087            .submit_intent(Intent::AcceptQuest {
14088                entity_id: self.state.entity_id,
14089                quest_id: offer.quest_id,
14090                seq,
14091            })
14092            .await?;
14093        self.state.intents_sent += 1;
14094        Ok(())
14095    }
14096
14097    pub fn quest_offer_move(&mut self, delta: i32) {
14098        self.state.move_quest_offer_selection(delta);
14099    }
14100
14101    pub fn quest_offer_decline(&mut self) {
14102        self.state.clear_quest_offers();
14103        if !self.state.show_npc_chat
14104            && !self.state.show_shop_menu
14105            && self.state.npc_verb_target.is_some()
14106        {
14107            self.state.show_npc_verb_menu = true;
14108        }
14109    }
14110
14111    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14112        if !self.state.show_quest_menu {
14113            return Ok(());
14114        }
14115        let active: Vec<_> = self
14116            .state
14117            .active_quest_entries()
14118            .into_iter()
14119            .cloned()
14120            .collect();
14121        let Some(entry) = active.get(self.state.quest_menu_index) else {
14122            return Ok(());
14123        };
14124        if self.state.quest_withdraw_confirm {
14125            if !entry.can_withdraw {
14126                anyhow::bail!("quest cannot be withdrawn");
14127            }
14128            self.seq += 1;
14129            let seq = self.seq;
14130            self.session
14131                .submit_intent(Intent::WithdrawQuest {
14132                    entity_id: self.state.entity_id,
14133                    quest_id: entry.quest_id.clone(),
14134                    seq,
14135                })
14136                .await?;
14137            self.state.intents_sent += 1;
14138            self.state.quest_withdraw_confirm = false;
14139            return Ok(());
14140        }
14141        self.seq += 1;
14142        let seq = self.seq;
14143        self.session
14144            .submit_intent(Intent::TrackQuest {
14145                entity_id: self.state.entity_id,
14146                quest_id: entry.quest_id.clone(),
14147                seq,
14148            })
14149            .await?;
14150        self.state.intents_sent += 1;
14151        Ok(())
14152    }
14153
14154    pub fn quest_request_withdraw(&mut self) {
14155        if self.state.show_quest_menu {
14156            self.state.quest_withdraw_confirm = true;
14157        }
14158    }
14159
14160    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14161        if !self.state.is_alive() {
14162            anyhow::bail!("you are dead");
14163        }
14164        let Some(catalog) = self.state.shop_catalog.clone() else {
14165            anyhow::bail!("no shop open");
14166        };
14167        self.seq += 1;
14168        let seq = self.seq;
14169        match self.state.shop_tab {
14170            ShopTab::Buy => {
14171                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14172                    anyhow::bail!("nothing selected");
14173                };
14174                if offer.already_owned {
14175                    anyhow::bail!("already owned");
14176                }
14177                self.session
14178                    .submit_intent(Intent::ShopBuy {
14179                        entity_id: self.state.entity_id,
14180                        npc_id: catalog.npc_id.clone(),
14181                        offer_id: offer.offer_id.clone(),
14182                        quantity: self.state.shop_quantity,
14183                        seq,
14184                    })
14185                    .await?;
14186            }
14187            ShopTab::Sell => {
14188                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14189                    anyhow::bail!("nothing to sell");
14190                };
14191                if line.quantity == 0 {
14192                    anyhow::bail!("you have no {}", line.label);
14193                }
14194                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14195                self.session
14196                    .submit_intent(Intent::ShopSell {
14197                        entity_id: self.state.entity_id,
14198                        npc_id: catalog.npc_id.clone(),
14199                        template_id: line.template_id.clone(),
14200                        quantity,
14201                        seq,
14202                    })
14203                    .await?;
14204            }
14205        }
14206        self.state.intents_sent += 1;
14207        Ok(())
14208    }
14209
14210    pub fn craft_menu_move(&mut self, delta: i32) {
14211        let n = self.state.craft_filtered_indices().len();
14212        if n == 0 {
14213            return;
14214        }
14215        let idx = self.state.craft_menu_index as i32;
14216        let next = (idx + delta).rem_euclid(n as i32);
14217        self.state.craft_menu_index = next as usize;
14218        self.state.clamp_craft_batch_quantity();
14219    }
14220
14221    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14222        self.state.craft_batch_adjust_quantity(delta);
14223    }
14224
14225    pub fn craft_batch_set_max(&mut self) {
14226        self.state.craft_batch_set_max();
14227    }
14228
14229    pub fn craft_batch_set_min(&mut self) {
14230        self.state.craft_batch_set_min();
14231    }
14232
14233    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14234        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14235            anyhow::bail!("no blueprints in this tab");
14236        };
14237        if !self.state.can_craft_blueprint(&blueprint) {
14238            let hint = self
14239                .state
14240                .craft_missing_hint(&blueprint)
14241                .unwrap_or_else(|| "missing materials".into());
14242            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14243        }
14244        let count = self.state.craft_batch_quantity;
14245        let max = self.state.max_craft_batches(&blueprint);
14246        if max == 0 {
14247            anyhow::bail!("cannot craft {}", blueprint.label);
14248        }
14249        let batches = count.min(max);
14250        self.craft(&blueprint.id, Some(batches)).await?;
14251        // Keep the craft menu open so progress / blockers stay visible.
14252        Ok(())
14253    }
14254
14255    pub async fn move_by(
14256        &mut self,
14257        forward: f32,
14258        strafe: f32,
14259        vertical: f32,
14260        sprint: bool,
14261        sneak: bool,
14262    ) -> anyhow::Result<()> {
14263        if !self.state.is_alive() {
14264            anyhow::bail!("you are dead");
14265        }
14266        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14267            self.last_move_forward = forward;
14268            self.last_move_strafe = strafe;
14269        }
14270        self.seq += 1;
14271        self.session
14272            .submit_intent(Intent::Move {
14273                entity_id: self.state.entity_id,
14274                forward,
14275                strafe,
14276                vertical,
14277                sprint: sprint && !sneak,
14278                sneak,
14279                seq: self.seq,
14280            })
14281            .await?;
14282        self.state.intents_sent += 1;
14283        Ok(())
14284    }
14285
14286    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14287        if !self.state.connected {
14288            crate::harvest_trace!("harvest_nearest rejected: not connected");
14289            anyhow::bail!("not connected");
14290        }
14291        if !self.state.is_alive() {
14292            crate::harvest_trace!("harvest_nearest rejected: player dead");
14293            anyhow::bail!("you are dead");
14294        }
14295        if self.state.harvest_in_progress {
14296            if self.state.harvest_state_stale() {
14297                self.state.clear_harvest_state();
14298            } else {
14299                anyhow::bail!("already harvesting");
14300            }
14301        }
14302        let (px, py) = self
14303            .state
14304            .player
14305            .as_ref()
14306            .map(|p| (p.transform.position.x, p.transform.position.y))
14307            .unwrap_or((0.0, 0.0));
14308
14309        let available = self
14310            .state
14311            .resource_nodes
14312            .iter()
14313            .filter(|n| !n.harvest_off)
14314            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14315            .count();
14316        let node_id = self
14317            .state
14318            .resource_nodes
14319            .iter()
14320            .filter(|n| !n.harvest_off)
14321            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14322            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14323            .min_by(|a, b| {
14324                let da = distance(px, py, a.x, a.y);
14325                let db = distance(px, py, b.x, b.y);
14326                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14327            })
14328            .map(|n| n.id.clone());
14329
14330        let Some(node_id) = node_id else {
14331            let has_loot = self
14332                .state
14333                .ground_drops
14334                .iter()
14335                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14336            if has_loot {
14337                return self.pickup_nearest().await;
14338            }
14339            anyhow::bail!(
14340                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14341            );
14342        };
14343
14344        self.seq += 1;
14345        let seq = self.seq;
14346        crate::harvest_trace!(
14347            entity_id = self.state.entity_id,
14348            node_id = %node_id,
14349            seq,
14350            px,
14351            py,
14352            available_nodes = available,
14353            "submitting harvest intent"
14354        );
14355        self.session
14356            .submit_intent(Intent::Harvest {
14357                entity_id: self.state.entity_id,
14358                node_id,
14359                seq,
14360            })
14361            .await?;
14362        self.state.intents_sent += 1;
14363        self.state.harvest_in_progress = true;
14364        self.state.harvest_started_at = Some(Instant::now());
14365        self.state.push_log("Harvesting…");
14366        crate::harvest_trace!(
14367            entity_id = self.state.entity_id,
14368            seq,
14369            "harvest intent queued to session"
14370        );
14371        Ok(())
14372    }
14373
14374    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14375        if !self.state.is_alive() {
14376            anyhow::bail!("you are dead");
14377        }
14378        let blueprint_id = self
14379            .state
14380            .blueprints
14381            .iter()
14382            .find(|bp| self.state.can_craft_blueprint(bp))
14383            .map(|bp| bp.id.clone())
14384            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14385        self.craft(&blueprint_id, None).await
14386    }
14387
14388    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14389        if !self.state.is_alive() {
14390            anyhow::bail!("you are dead");
14391        }
14392        self.seq += 1;
14393        self.session
14394            .submit_intent(Intent::Craft {
14395                entity_id: self.state.entity_id,
14396                blueprint_id: blueprint_id.to_string(),
14397                count,
14398                seq: self.seq,
14399            })
14400            .await?;
14401        self.state.intents_sent += 1;
14402        let (label, batches) = self
14403            .state
14404            .blueprints
14405            .iter()
14406            .find(|b| b.id == blueprint_id)
14407            .map(|b| {
14408                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14409                (b.label.as_str(), n)
14410            })
14411            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14412        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14413        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14414        Ok(())
14415    }
14416
14417    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14418        if !self.state.is_alive() {
14419            anyhow::bail!("you are dead");
14420        }
14421        let target_id = match self.state.nearest_interact_target() {
14422            Some(id) => id,
14423            None => {
14424                anyhow::bail!("nothing to interact with nearby");
14425            }
14426        };
14427        if self.state.npcs.iter().any(|n| n.id == target_id) {
14428            self.state.show_npc_verb_menu = true;
14429            self.state.npc_verb_target = Some(target_id);
14430            self.state.npc_verb_index = 0;
14431            self.state.npc_verb_notice = None;
14432            return Ok(());
14433        }
14434        if self
14435            .state
14436            .hired_workers
14437            .iter()
14438            .any(|w| w.instance_id == target_id)
14439        {
14440            return self.open_workers_menu_for(&target_id).await;
14441        }
14442        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14443            if self
14444                .state
14445                .hired_workers
14446                .iter()
14447                .any(|w| w.entity_id == peer_id)
14448            {
14449                if let Some(w) = self
14450                    .state
14451                    .hired_workers
14452                    .iter()
14453                    .find(|w| w.entity_id == peer_id)
14454                {
14455                    let id = w.instance_id.clone();
14456                    return self.open_workers_menu_for(&id).await;
14457                }
14458            }
14459            if let Some(entity) = self
14460                .state
14461                .entities
14462                .iter()
14463                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14464            {
14465                self.state.player_verbs.open_for(peer_id, &entity.label);
14466                return Ok(());
14467            }
14468        }
14469        self.seq += 1;
14470        self.session
14471            .submit_intent(Intent::Interact {
14472                entity_id: self.state.entity_id,
14473                target_id: target_id.clone(),
14474                seq: self.seq,
14475            })
14476            .await?;
14477        self.state.intents_sent += 1;
14478        Ok(())
14479    }
14480
14481    /// Context-sensitive world use: loot → interact/door → chest → plot farm → harvest.
14482    ///
14483    /// After loot-first, follows [`crate::UseWorldKind::cascade_stage`] so HUD probe
14484    /// and `f` never diverge (doors beat lodging/chest pickup indoors).
14485    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14486        if !self.state.is_alive() {
14487            anyhow::bail!("you are dead");
14488        }
14489        let (px, py) = self.state.player_position();
14490        let has_loot = self
14491            .state
14492            .ground_drops
14493            .iter()
14494            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14495        if has_loot {
14496            return self.pickup_nearest().await;
14497        }
14498
14499        // Align with probe cascade: interact (doors/NPCs/boards) → chest → harvest.
14500        if let Some(primary) = self.state.probe_use_world().primary {
14501            match primary.kind.cascade_stage() {
14502                0 => return self.interact_nearest().await,
14503                2 => return self.pickup_nearest_container().await,
14504                3 => return self.harvest_nearest().await,
14505                _ => {}
14506            }
14507        }
14508
14509        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14510            // Double-tap f within 1.2s sells; otherwise farm the plot.
14511            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14512            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14513                && self
14514                    .state
14515                    .sell_plot_armed_at
14516                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14517            if sell_armed {
14518                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14519            }
14520            self.state.sell_plot_confirm = None;
14521            self.state.sell_plot_armed_at = None;
14522
14523            // Prefer farm actions on your own land unless an NPC/player/door/board
14524            // is in interact range (those still win).
14525            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14526                self.state.npcs.iter().any(|n| n.id == id)
14527                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14528                    || self.state.doors.iter().any(|d| d.id == id)
14529                    || self.state.interactables.iter().any(|i| {
14530                        i.id == id
14531                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14532                    })
14533                    || id.parse::<EntityId>().is_ok_and(|eid| {
14534                        self.state
14535                            .entities
14536                            .iter()
14537                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14538                    })
14539            });
14540            if !blocking_interact {
14541                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14542                match self.harvest_nearest().await {
14543                    Ok(()) => return Ok(()),
14544                    Err(err) => {
14545                        let msg = err.to_string();
14546                        if !(msg.contains("no harvestable")
14547                            || msg.contains("press p")
14548                            || msg.contains("press f")
14549                            || msg.contains("nothing"))
14550                        {
14551                            return Err(err);
14552                        }
14553                    }
14554                }
14555                return Ok(());
14556            }
14557        }
14558        if self.state.nearest_interact_target().is_some() {
14559            return self.interact_nearest().await;
14560        }
14561        // Prefer a clear "move closer" when a board is visible but out of reach,
14562        // instead of silently falling through to harvest.
14563        if let Some((label, dist)) = self.state.nearest_quest_board() {
14564            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14565                anyhow::bail!(
14566                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14567                );
14568            }
14569        }
14570
14571        match self.harvest_nearest().await {
14572            Ok(()) => Ok(()),
14573            Err(err) => {
14574                let msg = err.to_string();
14575                if msg.contains("no harvestable")
14576                    || msg.contains("press p")
14577                    || msg.contains("press f")
14578                {
14579                    anyhow::bail!(
14580                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14581                    );
14582                }
14583                Err(err)
14584            }
14585        }
14586    }
14587
14588    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14589    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14590        if !self.state.is_alive() {
14591            anyhow::bail!("you are dead");
14592        }
14593        if self.state.claim_mode.is_some() {
14594            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14595        }
14596        let zone = self
14597            .state
14598            .free_property_zone_under_player()
14599            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14600        let zone_id = zone.id.clone();
14601        let label = zone
14602            .label
14603            .as_deref()
14604            .filter(|s| !s.trim().is_empty())
14605            .unwrap_or(zone.id.as_str())
14606            .to_string();
14607        self.enter_claim_mode(&zone_id);
14608        self.state.push_log(format!(
14609            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14610        ));
14611        Ok(())
14612    }
14613
14614    /// Enter claim mode over a property zone (default 4×4 or min size).
14615    pub fn enter_claim_mode(&mut self, zone_id: &str) {
14616        let Some(zone) = self
14617            .state
14618            .property_zones
14619            .iter()
14620            .find(|z| z.id == zone_id)
14621            .cloned()
14622        else {
14623            self.state.push_log("unknown property zone");
14624            return;
14625        };
14626        self.state.sell_plot_confirm = None;
14627        self.state.sell_plot_armed_at = None;
14628        let min_area = self
14629            .state
14630            .property_plot_settings
14631            .as_ref()
14632            .map(|s| s.min_plot_area_m2)
14633            .unwrap_or(4.0)
14634            .max(1.0);
14635        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14636        let side = 4u32.max(min_side);
14637        let (px, py) = self.state.player_position();
14638        let anchor_x = px.floor();
14639        let anchor_y = py.floor();
14640        self.state.claim_mode = Some(ClaimModeState {
14641            zone_id: zone.id.clone(),
14642            width_m: side,
14643            height_m: side,
14644            anchor_x,
14645            anchor_y,
14646        });
14647        let label = zone
14648            .label
14649            .as_deref()
14650            .filter(|s| !s.trim().is_empty())
14651            .unwrap_or(zone.id.as_str());
14652        self.state.push_log(format!(
14653            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14654        ));
14655    }
14656
14657    pub fn cancel_claim_mode(&mut self) {
14658        if self.state.claim_mode.take().is_some() {
14659            self.state.push_log("Claim cancelled");
14660        }
14661    }
14662
14663    /// Enter relocate mode for a placed container (1×1 ghost).
14664    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14665        if !self.state.is_alive() {
14666            anyhow::bail!("you are dead");
14667        }
14668        if self.state.relocate_mode.is_some() {
14669            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14670        }
14671        if self.state.claim_mode.is_some() {
14672            anyhow::bail!("finish or cancel claim mode first");
14673        }
14674        let chest = self
14675            .state
14676            .placed_containers
14677            .iter()
14678            .find(|c| c.id == container_id)
14679            .cloned()
14680            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14681        let (px, py) = self.state.player_position();
14682        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14683            anyhow::bail!("too far from {}", chest.display_name);
14684        }
14685        if chest.locked && !chest.accessible {
14686            anyhow::bail!(
14687                "need the matching key for {} before moving it",
14688                chest.display_name
14689            );
14690        }
14691        let label = if chest.display_name.trim().is_empty() {
14692            chest.template_id.clone()
14693        } else {
14694            chest.display_name.clone()
14695        };
14696        self.state.relocate_mode = Some(RelocateModeState {
14697            container_id: chest.id.clone(),
14698            label: label.clone(),
14699            cursor_x: chest.x.floor() + 0.5,
14700            cursor_y: chest.y.floor() + 0.5,
14701        });
14702        self.state.push_log(format!(
14703            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14704        ));
14705        Ok(())
14706    }
14707
14708    /// World `Shift+m`: relocate nearest owned/accessible placed container.
14709    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14710        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14711            anyhow::bail!("no chest nearby to relocate");
14712        };
14713        if chest.locked && !chest.accessible {
14714            anyhow::bail!(
14715                "need the matching key for {} before moving it",
14716                chest.display_name
14717            );
14718        }
14719        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
14720        // Server enforces ownership; client starts on nearest accessible chest in range.
14721        self.begin_relocate_container(&chest.id)
14722    }
14723
14724    pub fn cancel_relocate_mode(&mut self) {
14725        if self.state.relocate_mode.take().is_some() {
14726            self.state.push_log("Relocate cancelled");
14727        }
14728    }
14729
14730    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14731        let Some(mode) = self.state.relocate_mode.as_mut() else {
14732            return;
14733        };
14734        let max_x = self.state.world_width_m.max(1.0);
14735        let max_y = self.state.world_height_m.max(1.0);
14736        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14737        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14738        mode.cursor_x = nx.floor() + 0.5;
14739        mode.cursor_y = ny.floor() + 0.5;
14740    }
14741
14742    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14743        let Some(mode) = self.state.relocate_mode.as_mut() else {
14744            return;
14745        };
14746        let max_x = self.state.world_width_m.max(1.0);
14747        let max_y = self.state.world_height_m.max(1.0);
14748        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14749        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14750    }
14751
14752    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14753        if !self.state.is_alive() {
14754            anyhow::bail!("you are dead");
14755        }
14756        let Some(mode) = self.state.relocate_mode.clone() else {
14757            anyhow::bail!("not relocating");
14758        };
14759        let (px, py) = self.state.player_position();
14760        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14761        if dist > 8.0 {
14762            anyhow::bail!("destination too far (max 8 m)");
14763        }
14764        self.seq += 1;
14765        self.session
14766            .submit_intent(Intent::MovePlacedContainer {
14767                entity_id: self.state.entity_id,
14768                container_id: mode.container_id.clone(),
14769                x: mode.cursor_x,
14770                y: mode.cursor_y,
14771                seq: self.seq,
14772            })
14773            .await?;
14774        self.state.intents_sent += 1;
14775        self.state.relocate_mode = None;
14776        self.state.push_log(format!("Moving {}…", mode.label));
14777        Ok(())
14778    }
14779
14780    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14781        let Some(mode) = self.state.claim_mode.as_mut() else {
14782            return;
14783        };
14784        mode.width_m = w.max(1);
14785        mode.height_m = h.max(1);
14786    }
14787
14788    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14789        let Some(mode) = self.state.claim_mode.as_mut() else {
14790            return;
14791        };
14792        let w = (mode.width_m as i32 + dw).max(1) as u32;
14793        let h = (mode.height_m as i32 + dh).max(1) as u32;
14794        mode.width_m = w;
14795        mode.height_m = h;
14796    }
14797
14798    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
14799    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14800        let Some(mode) = self.state.claim_mode.as_mut() else {
14801            return;
14802        };
14803        let max_x = self.state.world_width_m.max(1.0);
14804        let max_y = self.state.world_height_m.max(1.0);
14805        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14806        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14807        mode.anchor_x = nx.floor();
14808        mode.anchor_y = ny.floor();
14809    }
14810
14811    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14812        if !self.state.is_alive() {
14813            anyhow::bail!("you are dead");
14814        }
14815        let Some(mode) = self.state.claim_mode.clone() else {
14816            anyhow::bail!("not in claim mode");
14817        };
14818        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14819            self.state.claim_quote()
14820        else {
14821            anyhow::bail!("cannot quote claim");
14822        };
14823        if !valid {
14824            anyhow::bail!(reason);
14825        }
14826        if !can_afford {
14827            anyhow::bail!(
14828                "not enough copper (need {})",
14829                crate::currency::format_copper(purchase)
14830            );
14831        }
14832        let (x0, y0, x1, y1) = self
14833            .state
14834            .claim_footprint_rect()
14835            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14836        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14837        self.seq += 1;
14838        self.session
14839            .submit_intent(Intent::BuyPlot {
14840                entity_id: self.state.entity_id,
14841                zone_id: mode.zone_id,
14842                x0,
14843                y0,
14844                x1,
14845                y1,
14846                seq: self.seq,
14847            })
14848            .await?;
14849        self.state.intents_sent += 1;
14850        self.state.claim_mode = None;
14851        self.state.push_log(format!(
14852            "Buying plot for {}",
14853            crate::currency::format_copper(purchase)
14854        ));
14855        Ok(())
14856    }
14857
14858    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14859        if !self.state.is_alive() {
14860            anyhow::bail!("you are dead");
14861        }
14862        let zone_id = self
14863            .state
14864            .claim_mode
14865            .as_ref()
14866            .map(|m| m.zone_id.clone())
14867            .or_else(|| {
14868                self.state
14869                    .free_property_zone_under_player()
14870                    .map(|z| z.id.clone())
14871            })
14872            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14873        self.seq += 1;
14874        self.session
14875            .submit_intent(Intent::BuyPlotAllFree {
14876                entity_id: self.state.entity_id,
14877                zone_id,
14878                seq: self.seq,
14879            })
14880            .await?;
14881        self.state.intents_sent += 1;
14882        self.state.claim_mode = None;
14883        self.state.push_log("Claiming largest free plot…");
14884        Ok(())
14885    }
14886
14887    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14888        if !self.state.is_alive() {
14889            anyhow::bail!("you are dead");
14890        }
14891        self.seq += 1;
14892        self.session
14893            .submit_intent(Intent::SellPlotToCrown {
14894                entity_id: self.state.entity_id,
14895                plot_id,
14896                seq: self.seq,
14897            })
14898            .await?;
14899        self.state.intents_sent += 1;
14900        self.state.sell_plot_confirm = None;
14901        self.state.sell_plot_armed_at = None;
14902        self.state.push_log("Selling plot to the crown…");
14903        Ok(())
14904    }
14905
14906    pub async fn set_plot_farm_public(
14907        &mut self,
14908        plot_id: uuid::Uuid,
14909        public: bool,
14910        public_tax_discount_bps: u32,
14911    ) -> anyhow::Result<()> {
14912        self.seq += 1;
14913        self.session
14914            .submit_intent(Intent::SetPlotFarmPublic {
14915                entity_id: self.state.entity_id,
14916                plot_id,
14917                public,
14918                public_tax_discount_bps,
14919                seq: self.seq,
14920            })
14921            .await?;
14922        self.state.intents_sent += 1;
14923        Ok(())
14924    }
14925
14926    pub async fn plot_farm_allow_upsert(
14927        &mut self,
14928        plot_id: uuid::Uuid,
14929        character_id: Option<uuid::Uuid>,
14930        character_name: String,
14931        tax_discount_bps: u32,
14932    ) -> anyhow::Result<()> {
14933        self.seq += 1;
14934        self.session
14935            .submit_intent(Intent::PlotFarmAllowUpsert {
14936                entity_id: self.state.entity_id,
14937                plot_id,
14938                character_id,
14939                character_name,
14940                tax_discount_bps,
14941                seq: self.seq,
14942            })
14943            .await?;
14944        self.state.intents_sent += 1;
14945        Ok(())
14946    }
14947
14948    pub async fn plot_farm_allow_remove(
14949        &mut self,
14950        plot_id: uuid::Uuid,
14951        character_id: uuid::Uuid,
14952    ) -> anyhow::Result<()> {
14953        self.seq += 1;
14954        self.session
14955            .submit_intent(Intent::PlotFarmAllowRemove {
14956                entity_id: self.state.entity_id,
14957                plot_id,
14958                character_id,
14959                seq: self.seq,
14960            })
14961            .await?;
14962        self.state.intents_sent += 1;
14963        Ok(())
14964    }
14965
14966    pub fn open_farm_access_panel(&mut self) {
14967        let Some(plot) = self.state.my_plot_under_player() else {
14968            self.state
14969                .push_log("Stand on your deed plot to manage farm access");
14970            return;
14971        };
14972        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14973        self.state.farm_access_index = 0;
14974        self.state.show_farm_access = true;
14975    }
14976
14977    pub fn close_farm_access_panel(&mut self) {
14978        self.state.show_farm_access = false;
14979        self.state.farm_access_name_draft.clear();
14980        self.state.farm_access_index = 0;
14981    }
14982
14983    pub fn farm_access_move(&mut self, delta: i32) {
14984        let n = self.farm_access_row_count().max(1);
14985        let idx = self.state.farm_access_index as i32 + delta;
14986        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14987    }
14988
14989    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14990        let Some(plot) = self.state.my_plot_under_player() else {
14991            return vec![FarmAccessRow::PublicToggle];
14992        };
14993        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14994        for g in &plot.farm_allow {
14995            rows.push(FarmAccessRow::AllowRemove {
14996                character_id: g.character_id,
14997                label: if g.character_label.trim().is_empty() {
14998                    g.character_id.to_string()[..8].to_string()
14999                } else {
15000                    g.character_label.clone()
15001                },
15002                tax_discount_bps: g.tax_discount_bps,
15003            });
15004        }
15005        for e in &self.state.entities {
15006            if e.id == self.state.entity_id || e.label.trim().is_empty() {
15007                continue;
15008            }
15009            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15010                continue;
15011            }
15012            if self
15013                .state
15014                .npcs
15015                .iter()
15016                .any(|n| n.id == e.label || n.label == e.label)
15017            {
15018                continue;
15019            }
15020            if plot
15021                .farm_allow
15022                .iter()
15023                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15024            {
15025                continue;
15026            }
15027            rows.push(FarmAccessRow::NearbyAdd {
15028                name: e.label.clone(),
15029            });
15030        }
15031        rows
15032    }
15033
15034    pub fn farm_access_row_count(&self) -> usize {
15035        self.farm_access_rows().len().max(1)
15036    }
15037
15038    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15039        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15040            self.close_farm_access_panel();
15041            return Ok(());
15042        };
15043        let rows = self.farm_access_rows();
15044        let Some(row) = rows.get(self.state.farm_access_index) else {
15045            return Ok(());
15046        };
15047        match row {
15048            FarmAccessRow::PublicToggle => {
15049                self.set_plot_farm_public(
15050                    plot.plot_id,
15051                    !plot.farm_public,
15052                    plot.public_tax_discount_bps,
15053                )
15054                .await
15055            }
15056            FarmAccessRow::PublicDiscount => Ok(()),
15057            FarmAccessRow::AllowRemove { character_id, .. } => {
15058                self.plot_farm_allow_remove(plot.plot_id, *character_id)
15059                    .await
15060            }
15061            FarmAccessRow::NearbyAdd { name } => {
15062                let disc = self
15063                    .state
15064                    .farm_access_discount_bps
15065                    .max(plot.public_tax_discount_bps);
15066                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15067                    .await
15068            }
15069        }
15070    }
15071
15072    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15073        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15074            return Ok(());
15075        };
15076        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15077        self.state.farm_access_discount_bps = next;
15078        self.state.farm_access_index = 1;
15079        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15080            .await
15081    }
15082
15083    /// Till the cell under you on a farmable plot (`c`).
15084    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15085        if self.state.farmable_plot_under_player().is_none() {
15086            anyhow::bail!("stand on a farmable plot to cultivate");
15087        }
15088        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15089            let (px, py) = self.state.player_position();
15090            if self
15091                .state
15092                .terrain_at(px, py)
15093                .is_some_and(|k| k == TerrainKindView::Tilled)
15094            {
15095                anyhow::bail!("already tilled — stand on bare soil and press c");
15096            }
15097            anyhow::bail!("cannot till this cell — move onto soil on your plot");
15098        };
15099        self.cultivate_at(tx, ty).await
15100    }
15101
15102    /// Plant seeds on empty tilled soil under you (`p`).
15103    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15104        if self.state.farmable_plot_under_player().is_none() {
15105            anyhow::bail!("stand on a farmable plot to plant");
15106        }
15107        if !self.state.underfoot_free_tilled_plant_slot() {
15108            anyhow::bail!("stand on empty tilled soil and press p");
15109        }
15110        let seeds = self.state.farm_seed_entries();
15111        if seeds.is_empty() {
15112            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15113        }
15114        if seeds.len() == 1 {
15115            return self.plant_seeds(seeds[0].0.clone(), 1).await;
15116        }
15117        self.open_plant_menu();
15118        Ok(())
15119    }
15120
15121    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
15122    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15123        let Some(plot) = self.state.my_plot_under_player() else {
15124            anyhow::bail!("stand on your plot to build");
15125        };
15126        if plot.building_id.is_some() {
15127            anyhow::bail!("this plot already has a building");
15128        }
15129        let building_now = self
15130            .state
15131            .timed_channel
15132            .as_ref()
15133            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15134        if !building_now && self.state.building_materials.is_empty() {
15135            anyhow::bail!("no building materials loaded — wait a moment and try again");
15136        }
15137        self.state.show_plot_build_menu = true;
15138        self.state.show_craft_menu = false;
15139        self.state.show_shop_menu = false;
15140        self.state.shop_catalog = None;
15141        self.state.show_stats = false;
15142        self.state.show_inventory_menu = false;
15143        self.state.plot_build_focus_wall = true;
15144        let walls = self.state.plot_build_wall_options().len();
15145        let roofs = self.state.plot_build_roof_options().len();
15146        if walls > 0 {
15147            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15148        } else {
15149            self.state.plot_build_wall_index = 0;
15150        }
15151        if roofs > 0 {
15152            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15153        } else {
15154            self.state.plot_build_roof_index = 0;
15155        }
15156        Ok(())
15157    }
15158
15159    pub fn close_plot_build_menu(&mut self) {
15160        self.state.show_plot_build_menu = false;
15161    }
15162
15163    pub fn plot_build_menu_move(&mut self, delta: i32) {
15164        let walls = self.state.plot_build_wall_options();
15165        let roofs = self.state.plot_build_roof_options();
15166        if self.state.plot_build_focus_wall {
15167            if walls.is_empty() {
15168                return;
15169            }
15170            let n = walls.len() as i32;
15171            let cur = self.state.plot_build_wall_index as i32;
15172            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15173        } else {
15174            if roofs.is_empty() {
15175                return;
15176            }
15177            let n = roofs.len() as i32;
15178            let cur = self.state.plot_build_roof_index as i32;
15179            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15180        }
15181    }
15182
15183    pub fn plot_build_menu_toggle_focus(&mut self) {
15184        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15185    }
15186
15187    /// Confirm selection from the B menu.
15188    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15189        let wall = self
15190            .state
15191            .plot_build_selected_wall()
15192            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15193            .id
15194            .clone();
15195        let roof = self
15196            .state
15197            .plot_build_selected_roof()
15198            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15199            .id
15200            .clone();
15201        // Keep the B menu open so it switches to the in-progress panel.
15202        self.start_plot_build(&wall, &roof).await
15203    }
15204
15205    /// Cancel an in-progress plot build (materials returned).
15206    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15207        self.seq += 1;
15208        self.session
15209            .submit_intent(Intent::CancelPlotBuild {
15210                entity_id: self.state.entity_id,
15211                seq: self.seq,
15212            })
15213            .await?;
15214        self.state.intents_sent += 1;
15215        Ok(())
15216    }
15217
15218    /// Start timed shell craft on the plot's solid tilled rectangle.
15219    pub async fn start_plot_build(
15220        &mut self,
15221        wall_material_id: &str,
15222        roof_material_id: &str,
15223    ) -> anyhow::Result<()> {
15224        let Some(plot) = self.state.my_plot_under_player() else {
15225            anyhow::bail!("stand on your plot to build");
15226        };
15227        if plot.building_id.is_some() {
15228            anyhow::bail!("this plot already has a building");
15229        }
15230        let plot_id = plot.plot_id;
15231        self.seq += 1;
15232        self.session
15233            .submit_intent(Intent::StartPlotBuild {
15234                entity_id: self.state.entity_id,
15235                plot_id,
15236                wall_material_id: wall_material_id.to_string(),
15237                roof_material_id: roof_material_id.to_string(),
15238                seq: self.seq,
15239            })
15240            .await?;
15241        self.state.intents_sent += 1;
15242        Ok(())
15243    }
15244
15245    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15246    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15247        let (px, py) = self.state.player_position();
15248        let mut best: Option<(f32, String, bool)> = None;
15249        for d in &self.state.doors {
15250            if d.lock_id.is_none() {
15251                continue;
15252            }
15253            let dist = (d.x - px).hypot(d.y - py);
15254            if dist > DOOR_INTERACTION_RADIUS_M {
15255                continue;
15256            }
15257            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15258                best = Some((dist, d.id.clone(), d.locked));
15259            }
15260        }
15261        let Some((_, door_id, locked_now)) = best else {
15262            anyhow::bail!("no lockable door nearby");
15263        };
15264        let locked = !locked_now;
15265        self.seq += 1;
15266        self.session
15267            .submit_intent(Intent::SetDoorLocked {
15268                entity_id: self.state.entity_id,
15269                door_id,
15270                locked,
15271                seq: self.seq,
15272            })
15273            .await?;
15274        self.state.intents_sent += 1;
15275        Ok(())
15276    }
15277
15278    /// Enter through a nearby open player-building door (`Enter`).
15279    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15280        if !self.state.is_alive() {
15281            anyhow::bail!("you are dead");
15282        }
15283        if self.state.effective_inside_building().is_some() {
15284            anyhow::bail!("already inside");
15285        }
15286        let (px, py) = self.state.player_position();
15287        let mut best: Option<(f32, String)> = None;
15288        for d in &self.state.doors {
15289            if !d.open || d.locked {
15290                continue;
15291            }
15292            let player_house = self
15293                .state
15294                .buildings
15295                .iter()
15296                .find(|b| b.id == d.building_id)
15297                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15298            if !player_house {
15299                continue;
15300            }
15301            let dist = (d.x - px).hypot(d.y - py);
15302            if dist > DOOR_INTERACTION_RADIUS_M {
15303                continue;
15304            }
15305            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15306                best = Some((dist, d.id.clone()));
15307            }
15308        }
15309        let Some((_, door_id)) = best else {
15310            anyhow::bail!("no open house door nearby — open with f first");
15311        };
15312        self.seq += 1;
15313        self.session
15314            .submit_intent(Intent::EnterBuildingDoor {
15315                entity_id: self.state.entity_id,
15316                door_id,
15317                seq: self.seq,
15318            })
15319            .await?;
15320        self.state.intents_sent += 1;
15321        Ok(())
15322    }
15323
15324    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15325    /// Works even when the door is closed or locked.
15326    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15327        if !self.state.is_alive() {
15328            anyhow::bail!("you are dead");
15329        }
15330        let Some(bid) = self.state.effective_inside_building() else {
15331            anyhow::bail!("not inside a building");
15332        };
15333        let (px, py) = self.state.player_position();
15334        let mut best: Option<(f32, String)> = None;
15335        for d in &self.state.doors {
15336            if d.building_id != bid || d.portal.is_none() {
15337                continue;
15338            }
15339            let player_house = self
15340                .state
15341                .buildings
15342                .iter()
15343                .find(|b| b.id == d.building_id)
15344                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15345            if !player_house {
15346                continue;
15347            }
15348            let dist = (d.x - px).hypot(d.y - py);
15349            if dist > 1.5 {
15350                continue;
15351            }
15352            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15353                best = Some((dist, d.id.clone()));
15354            }
15355        }
15356        let Some((_, door_id)) = best else {
15357            anyhow::bail!("stand by the door to exit");
15358        };
15359        self.seq += 1;
15360        self.session
15361            .submit_intent(Intent::ExitBuildingDoor {
15362                entity_id: self.state.entity_id,
15363                door_id,
15364                seq: self.seq,
15365            })
15366            .await?;
15367        self.state.intents_sent += 1;
15368        Ok(())
15369    }
15370
15371    /// Confirm interior layout for your player building (copper charged on server).
15372    pub async fn confirm_interior_edit(
15373        &mut self,
15374        building_id: String,
15375        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15376        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15377    ) -> anyhow::Result<()> {
15378        self.seq += 1;
15379        self.session
15380            .submit_intent(Intent::ConfirmInteriorEdit {
15381                entity_id: self.state.entity_id,
15382                building_id,
15383                rooms,
15384                room_doors,
15385                seq: self.seq,
15386            })
15387            .await?;
15388        self.state.intents_sent += 1;
15389        Ok(())
15390    }
15391
15392    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15393        if !self.state.is_alive() {
15394            anyhow::bail!("you are dead");
15395        }
15396        self.seq += 1;
15397        self.session
15398            .submit_intent(Intent::Cultivate {
15399                entity_id: self.state.entity_id,
15400                x,
15401                y,
15402                seq: self.seq,
15403            })
15404            .await?;
15405        self.state.intents_sent += 1;
15406        Ok(())
15407    }
15408
15409    pub async fn plant_seeds(
15410        &mut self,
15411        seed_template_id: String,
15412        quantity: u32,
15413    ) -> anyhow::Result<()> {
15414        if !self.state.is_alive() {
15415            anyhow::bail!("you are dead");
15416        }
15417        self.seq += 1;
15418        self.session
15419            .submit_intent(Intent::PlantSeeds {
15420                entity_id: self.state.entity_id,
15421                seed_template_id: seed_template_id.clone(),
15422                quantity,
15423                seq: self.seq,
15424            })
15425            .await?;
15426        self.state.intents_sent += 1;
15427        self.state
15428            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15429        Ok(())
15430    }
15431
15432    pub fn open_plant_menu(&mut self) {
15433        if self.state.farm_seed_entries().is_empty() {
15434            self.state.push_log("No seeds in inventory to plant");
15435            return;
15436        }
15437        self.state.show_plant_menu = true;
15438        self.state.plant_menu_index = 0;
15439        self.state.plant_quantity = 1;
15440        self.state.clamp_plant_menu();
15441    }
15442
15443    pub fn close_plant_menu(&mut self) {
15444        self.state.show_plant_menu = false;
15445    }
15446
15447    pub fn plant_menu_move(&mut self, delta: i32) {
15448        let n = self.state.farm_seed_entries().len();
15449        if n == 0 {
15450            return;
15451        }
15452        let idx = self.state.plant_menu_index as i32 + delta;
15453        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15454        self.state.clamp_plant_menu();
15455    }
15456
15457    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15458        let next = self.state.plant_quantity as i32 + delta;
15459        self.state.plant_quantity = next.max(1) as u32;
15460        self.state.clamp_plant_menu();
15461    }
15462
15463    pub fn plant_menu_set_quantity_max(&mut self) {
15464        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15465            self.state.plant_quantity = max;
15466        }
15467        self.state.clamp_plant_menu();
15468    }
15469
15470    pub fn plant_menu_set_quantity_min(&mut self) {
15471        self.state.plant_quantity = 1;
15472        self.state.clamp_plant_menu();
15473    }
15474
15475    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15476        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15477            self.close_plant_menu();
15478            anyhow::bail!("no seeds to plant");
15479        };
15480        self.close_plant_menu();
15481        self.plant_seeds(seed, qty).await?;
15482        self.state.push_log(format!("Planted {qty}× {label}"));
15483        Ok(())
15484    }
15485
15486    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15487    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15488    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15489        if !self.state.is_alive() {
15490            anyhow::bail!("you are dead");
15491        }
15492        let binding = self
15493            .state
15494            .hotbar_ability(slot)
15495            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15496            .to_string();
15497        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15498            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15499            if qty == 0 {
15500                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15501            }
15502            return self.use_item(template_id).await;
15503        }
15504        let ability_id = binding;
15505        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15506            return self
15507                .cast_ability(&ability_id, Some(self.state.entity_id))
15508                .await;
15509        }
15510        let is_heal = ability_id == "heal_touch"
15511            || self
15512                .state
15513                .ability_meta
15514                .get(&ability_id)
15515                .map(|meta| meta.is_heal)
15516                .unwrap_or(false);
15517        let target = if is_heal {
15518            Some(
15519                self.state
15520                    .target_for_slot(2)
15521                    .unwrap_or(self.state.entity_id),
15522            )
15523        } else {
15524            self.state
15525                .target_for_slot(1)
15526                .or_else(|| self.state.target_for_slot(2))
15527        };
15528        let Some(target_id) = target else {
15529            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15530        };
15531        self.cast_ability(&ability_id, Some(target_id)).await
15532    }
15533
15534    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15535    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15536    pub async fn set_hotbar_slot(
15537        &mut self,
15538        slot: u8,
15539        ability_id: Option<&str>,
15540    ) -> anyhow::Result<()> {
15541        if !self.state.is_alive() {
15542            anyhow::bail!("you are dead");
15543        }
15544        if !(1..=9).contains(&slot) {
15545            anyhow::bail!("hotbar slot must be 1–9");
15546        }
15547        let ability_id = ability_id
15548            .map(str::trim)
15549            .filter(|id| !id.is_empty())
15550            .map(str::to_string);
15551        self.seq += 1;
15552        self.session
15553            .submit_intent(Intent::SetHotbarSlot {
15554                entity_id: self.state.entity_id,
15555                slot,
15556                ability_id: ability_id.clone(),
15557                seq: self.seq,
15558            })
15559            .await?;
15560        self.state.intents_sent += 1;
15561        let idx = (slot - 1) as usize;
15562        if self.state.hotbar.len() < 9 {
15563            self.state.hotbar.resize(9, None);
15564        }
15565        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15566            *slot_mut = ability_id.clone();
15567        }
15568        match ability_id {
15569            Some(id) => {
15570                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15571                    format!("use {tid}")
15572                } else {
15573                    id
15574                };
15575                self.state.push_log(format!("Hotbar {slot} → {label}"))
15576            }
15577            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15578        }
15579        Ok(())
15580    }
15581
15582    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15583        self.state.npc_verb_options()
15584    }
15585
15586    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15587        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15588            return Ok(());
15589        };
15590        let options = self.npc_verb_options();
15591        let choice = options
15592            .get(self.state.npc_verb_index)
15593            .cloned()
15594            .unwrap_or_else(GameState::talk_choice);
15595        match choice.action {
15596            NpcVerbAction::QuestGive { quest_id } => {
15597                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15598                    .await?;
15599                self.state.show_npc_verb_menu = false;
15600            }
15601            NpcVerbAction::Talk => {
15602                self.open_npc_talk(&npc_id, None).await?;
15603            }
15604            NpcVerbAction::QuestTalk { quest_id } => {
15605                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15606            }
15607            NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15608                self.seq += 1;
15609                self.session
15610                    .submit_intent(Intent::Interact {
15611                        entity_id: self.state.entity_id,
15612                        target_id: npc_id,
15613                        seq: self.seq,
15614                    })
15615                    .await?;
15616                self.state.intents_sent += 1;
15617            }
15618        }
15619        Ok(())
15620    }
15621
15622    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15623        self.seq += 1;
15624        self.session
15625            .submit_intent(Intent::NpcTalkOpen {
15626                entity_id: self.state.entity_id,
15627                npc_id: npc_id.to_string(),
15628                quest_id: quest_id.map(str::to_string),
15629                seq: self.seq,
15630            })
15631            .await?;
15632        self.state.intents_sent += 1;
15633        Ok(())
15634    }
15635
15636    async fn submit_npc_quest_turn_in(
15637        &mut self,
15638        npc_id: &str,
15639        quest_id: Option<&str>,
15640    ) -> anyhow::Result<()> {
15641        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15642        let pending: Vec<(String, u32, String)> = self
15643            .state
15644            .quest_log
15645            .iter()
15646            .filter(|q| {
15647                q.status == flatland_protocol::QuestStatusView::Active
15648                    && quest_id.is_none_or(|id| q.quest_id == id)
15649            })
15650            .flat_map(|q| q.objectives.iter())
15651            .filter(|o| {
15652                !o.done
15653                    && o.kind == "give_item"
15654                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15655            })
15656            .filter_map(|o| {
15657                let template = o.item_template.clone()?;
15658                let remaining = o.required.saturating_sub(o.current);
15659                if remaining == 0 {
15660                    return None;
15661                }
15662                Some((template, remaining, o.label.clone()))
15663            })
15664            .collect();
15665        if pending.is_empty() {
15666            self.state.push_log("Nothing to turn in here.");
15667            return Ok(());
15668        }
15669        let mut sent = 0u32;
15670        for (template, remaining, label) in pending {
15671            let held = self.state.count_inventory_template(&template);
15672            let qty = remaining.min(held);
15673            if qty == 0 {
15674                self.state.push_log(format!("Need {label}"));
15675                continue;
15676            }
15677            self.seq += 1;
15678            self.session
15679                .submit_intent(Intent::QuestGiveItem {
15680                    entity_id: self.state.entity_id,
15681                    npc_id: npc_id.to_string(),
15682                    template_id: template,
15683                    quantity: qty,
15684                    seq: self.seq,
15685                })
15686                .await?;
15687            self.state.intents_sent += 1;
15688            sent += 1;
15689        }
15690        if sent > 0 {
15691            self.state.push_log("Turning in quest items.");
15692        }
15693        Ok(())
15694    }
15695
15696    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15697        let Some(chat) = self.state.npc_chat.clone() else {
15698            return Ok(());
15699        };
15700        let message = chat.input.trim().to_string();
15701        if message.is_empty() || chat.pending {
15702            return Ok(());
15703        }
15704        if let Some(c) = self.state.npc_chat.as_mut() {
15705            c.lines.push(format!("You: {message}"));
15706            c.input.clear();
15707            c.pending = true;
15708        }
15709        self.seq += 1;
15710        self.session
15711            .submit_intent(Intent::NpcTalkSay {
15712                entity_id: self.state.entity_id,
15713                npc_id: chat.npc_id,
15714                message,
15715                seq: self.seq,
15716            })
15717            .await?;
15718        self.state.intents_sent += 1;
15719        Ok(())
15720    }
15721
15722    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15723        let topic = self
15724            .state
15725            .npc_chat
15726            .as_ref()
15727            .and_then(|c| c.suggested_topics.get(index))
15728            .cloned();
15729        let Some(topic) = topic else {
15730            return Ok(());
15731        };
15732        if let Some(c) = self.state.npc_chat.as_mut() {
15733            if c.pending {
15734                return Ok(());
15735            }
15736            c.input = topic;
15737        }
15738        self.npc_talk_send().await
15739    }
15740
15741    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15742        let return_to_verbs = self.state.npc_verb_target.is_some();
15743        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15744            self.state.show_npc_chat = false;
15745            if return_to_verbs {
15746                self.state.show_npc_verb_menu = true;
15747            }
15748            return Ok(());
15749        };
15750        self.seq += 1;
15751        self.session
15752            .submit_intent(Intent::NpcTalkClose {
15753                entity_id: self.state.entity_id,
15754                npc_id,
15755                seq: self.seq,
15756            })
15757            .await?;
15758        self.state.intents_sent += 1;
15759        self.state.show_npc_chat = false;
15760        self.state.npc_chat = None;
15761        if return_to_verbs {
15762            self.state.show_npc_verb_menu = true;
15763            self.state.npc_verb_notice = None;
15764        }
15765        Ok(())
15766    }
15767
15768    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
15769    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15770        if self.state.show_quest_offer
15771            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15772        {
15773            self.quest_offer_decline();
15774            return Ok(());
15775        }
15776        if self.state.show_npc_chat {
15777            return self.npc_talk_close().await;
15778        }
15779        if self.state.show_shop_menu {
15780            return self.back_from_shop_menu().await;
15781        }
15782        if self.state.bank_panel.is_some() {
15783            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15784                self.bank_transfer_back();
15785                return Ok(());
15786            }
15787            return self.close_bank_panel().await;
15788        }
15789        if self.state.storage_panel.is_some() {
15790            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15791                self.storage_ui_back();
15792                return Ok(());
15793            }
15794            return self.close_storage_panel().await;
15795        }
15796        if self.state.market_panel.is_some() {
15797            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15798                self.market_ui_back();
15799                return Ok(());
15800            }
15801            if self.state.market_buy_confirm.is_some() {
15802                self.state.market_buy_confirm = None;
15803                return Ok(());
15804            }
15805            return self.close_market_panel().await;
15806        }
15807        if self.state.show_npc_verb_menu {
15808            self.state.show_npc_verb_menu = false;
15809            self.state.npc_verb_target = None;
15810        }
15811        Ok(())
15812    }
15813
15814    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15815        self.seq += 1;
15816        self.session
15817            .submit_intent(Intent::TestDamage {
15818                entity_id: self.state.entity_id,
15819                amount,
15820                seq: self.seq,
15821            })
15822            .await?;
15823        self.state.intents_sent += 1;
15824        Ok(())
15825    }
15826
15827    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15828        self.cycle_combat_target_slot(1, reverse).await
15829    }
15830
15831    pub async fn cycle_combat_target_slot(
15832        &mut self,
15833        slot_index: u8,
15834        reverse: bool,
15835    ) -> anyhow::Result<()> {
15836        if !self.state.is_alive() {
15837            anyhow::bail!("you are dead");
15838        }
15839        let candidates = self.state.candidates_for_slot(slot_index);
15840        if candidates.is_empty() {
15841            anyhow::bail!("no targets nearby");
15842        }
15843        let current = self.state.target_for_slot(slot_index);
15844        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15845        let next_idx = match idx {
15846            None => 0,
15847            Some(i) if reverse => {
15848                if i == 0 {
15849                    candidates.len() - 1
15850                } else {
15851                    i - 1
15852                }
15853            }
15854            Some(i) => (i + 1) % candidates.len(),
15855        };
15856        if idx == Some(next_idx) && candidates.len() == 1 {
15857            self.clear_combat_target_slot(slot_index).await?;
15858            return Ok(());
15859        }
15860        let (target_id, label) = candidates[next_idx].clone();
15861        self.set_combat_target_slot(slot_index, target_id, &label)
15862            .await
15863    }
15864
15865    pub async fn set_combat_target_slot(
15866        &mut self,
15867        slot_index: u8,
15868        target_id: EntityId,
15869        label: &str,
15870    ) -> anyhow::Result<()> {
15871        if !self.state.is_alive() {
15872            anyhow::bail!("you are dead");
15873        }
15874        self.seq += 1;
15875        self.session
15876            .submit_intent(Intent::SetTargetSlot {
15877                entity_id: self.state.entity_id,
15878                slot_index,
15879                target_id,
15880                seq: self.seq,
15881            })
15882            .await?;
15883        self.state.intents_sent += 1;
15884        if slot_index == 1 {
15885            self.state.combat_target = Some(target_id);
15886            self.state.combat_target_label = Some(label.to_string());
15887        }
15888        self.state
15889            .push_log(format!("Slot {slot_index} target: {label}"));
15890        Ok(())
15891    }
15892
15893    pub async fn set_combat_target(
15894        &mut self,
15895        target_id: EntityId,
15896        label: &str,
15897    ) -> anyhow::Result<()> {
15898        self.set_combat_target_slot(1, target_id, label).await
15899    }
15900
15901    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15902        if slot_index == 1 && self.state.combat_target.is_none() {
15903            return Ok(());
15904        }
15905        self.seq += 1;
15906        self.session
15907            .submit_intent(Intent::ClearTargetSlot {
15908                entity_id: self.state.entity_id,
15909                slot_index,
15910                seq: self.seq,
15911            })
15912            .await?;
15913        if slot_index == 1 {
15914            self.state.combat_target = None;
15915            self.state.combat_target_label = None;
15916        }
15917        self.state.intents_sent += 1;
15918        self.state
15919            .push_log(format!("Slot {slot_index} target cleared"));
15920        Ok(())
15921    }
15922
15923    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15924        self.clear_combat_target_slot(1).await
15925    }
15926
15927    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15928        if !self.state.is_alive() {
15929            anyhow::bail!("you are dead");
15930        }
15931        self.seq += 1;
15932        self.session
15933            .submit_intent(Intent::AdvanceRotation {
15934                entity_id: self.state.entity_id,
15935                slot_index,
15936                seq: self.seq,
15937            })
15938            .await?;
15939        self.state.intents_sent += 1;
15940        Ok(())
15941    }
15942
15943    pub async fn assign_slot_preset(
15944        &mut self,
15945        slot_index: u8,
15946        preset_id: &str,
15947    ) -> anyhow::Result<()> {
15948        if !self.state.is_alive() {
15949            anyhow::bail!("you are dead");
15950        }
15951        self.seq += 1;
15952        self.session
15953            .submit_intent(Intent::AssignSlotPreset {
15954                entity_id: self.state.entity_id,
15955                slot_index,
15956                preset_id: preset_id.to_string(),
15957                seq: self.seq,
15958            })
15959            .await?;
15960        self.state.intents_sent += 1;
15961        if let Some(slot) = self
15962            .state
15963            .combat_slots
15964            .iter_mut()
15965            .find(|s| s.slot_index == slot_index)
15966        {
15967            slot.preset_id = Some(preset_id.to_string());
15968            if let Some(preset) = self
15969                .state
15970                .rotation_presets
15971                .iter()
15972                .find(|p| p.id == preset_id)
15973            {
15974                slot.preset_label = Some(preset.label.clone());
15975                slot.rotation = preset.abilities.clone();
15976                slot.rotation_index = 0;
15977            }
15978        }
15979        self.state
15980            .push_log(format!("T{slot_index} loadout → {preset_id}"));
15981        Ok(())
15982    }
15983
15984    pub async fn cast_ability(
15985        &mut self,
15986        ability_id: &str,
15987        target_id: Option<EntityId>,
15988    ) -> anyhow::Result<()> {
15989        if !self.state.is_alive() {
15990            anyhow::bail!("you are dead");
15991        }
15992        let allows_ground = self.state.ability_allows_ground(ability_id);
15993        let requires_ground = self.state.ability_requires_ground(ability_id);
15994        if requires_ground && self.state.ground_target.is_none() {
15995            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15996        }
15997        let (resolved_target_id, target_point) = if allows_ground {
15998            if let Some((x, y, z)) = self.state.ground_target {
15999                (
16000                    target_id.unwrap_or(self.state.entity_id),
16001                    Some(flatland_protocol::AimPoint { x, y, z }),
16002                )
16003            } else {
16004                (
16005                    target_id
16006                        .or_else(|| self.state.target_for_slot(2))
16007                        .or_else(|| self.state.target_for_slot(1))
16008                        .unwrap_or(self.state.entity_id),
16009                    None,
16010                )
16011            }
16012        } else {
16013            (
16014                target_id
16015                    .or_else(|| self.state.target_for_slot(2))
16016                    .or_else(|| self.state.target_for_slot(1))
16017                    .unwrap_or(self.state.entity_id),
16018                None,
16019            )
16020        };
16021        self.seq += 1;
16022        self.session
16023            .submit_intent(Intent::Cast {
16024                entity_id: self.state.entity_id,
16025                ability_id: ability_id.to_string(),
16026                target_id: resolved_target_id,
16027                target_point,
16028                seq: self.seq,
16029            })
16030            .await?;
16031        self.state.intents_sent += 1;
16032        match target_point {
16033            Some(point) => self.state.push_log(format!(
16034                "Cast {ability_id} → ({:.1}, {:.1})",
16035                point.x, point.y
16036            )),
16037            None => self
16038                .state
16039                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16040        }
16041        Ok(())
16042    }
16043
16044    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16045        self.seq += 1;
16046        self.session
16047            .submit_intent(Intent::UpsertRotationPreset {
16048                entity_id: self.state.entity_id,
16049                preset: preset.clone(),
16050                seq: self.seq,
16051            })
16052            .await?;
16053        self.state.intents_sent += 1;
16054        if let Some(existing) = self
16055            .state
16056            .rotation_presets
16057            .iter_mut()
16058            .find(|p| p.id == preset.id)
16059        {
16060            *existing = preset.clone();
16061        } else {
16062            self.state.rotation_presets.push(preset.clone());
16063        }
16064        for slot in &mut self.state.combat_slots {
16065            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16066                slot.preset_label = Some(preset.label.clone());
16067                slot.rotation = preset.abilities.clone();
16068            }
16069        }
16070        self.state
16071            .push_log(format!("Saved rotation: {}", preset.label));
16072        Ok(())
16073    }
16074
16075    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16076        self.seq += 1;
16077        self.session
16078            .submit_intent(Intent::DeleteRotationPreset {
16079                entity_id: self.state.entity_id,
16080                preset_id: preset_id.to_string(),
16081                seq: self.seq,
16082            })
16083            .await?;
16084        self.state.intents_sent += 1;
16085        self.state.rotation_presets.retain(|p| p.id != preset_id);
16086        for slot in &mut self.state.combat_slots {
16087            if slot.preset_id.as_deref() == Some(preset_id) {
16088                slot.preset_id = None;
16089                slot.preset_label = None;
16090                slot.rotation.clear();
16091                slot.rotation_index = 0;
16092            }
16093        }
16094        self.state
16095            .push_log(format!("Deleted rotation: {preset_id}"));
16096        Ok(())
16097    }
16098
16099    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16100        if !self.state.is_alive() {
16101            anyhow::bail!("you are dead");
16102        }
16103        let enabled = !self
16104            .state
16105            .combat_slots
16106            .iter()
16107            .find(|s| s.slot_index == slot_index)
16108            .map(|s| s.auto_enabled)
16109            .unwrap_or(false);
16110        self.seq += 1;
16111        self.session
16112            .submit_intent(Intent::SetAutoAttack {
16113                entity_id: self.state.entity_id,
16114                slot_index,
16115                enabled,
16116                seq: self.seq,
16117            })
16118            .await?;
16119        if slot_index == 1 {
16120            self.state.auto_attack = enabled;
16121        }
16122        self.state.intents_sent += 1;
16123        self.state.push_log(format!(
16124            "T{slot_index} auto {}",
16125            if enabled { "ON" } else { "OFF" }
16126        ));
16127        Ok(())
16128    }
16129
16130    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16131        if !self.state.connected {
16132            anyhow::bail!("not connected");
16133        }
16134        if !self.state.is_alive() {
16135            anyhow::bail!("you are dead");
16136        }
16137        let (px, py) = self.state.player_position();
16138        if self
16139            .state
16140            .ground_drops
16141            .iter()
16142            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16143        {
16144            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16145        }
16146        self.seq += 1;
16147        self.session
16148            .submit_intent(Intent::Pickup {
16149                entity_id: self.state.entity_id,
16150                drop_id: None,
16151                seq: self.seq,
16152            })
16153            .await?;
16154        self.state.intents_sent += 1;
16155        self.state.push_audio(crate::social::AudioCue::LootPickup);
16156        Ok(())
16157    }
16158
16159    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16160        if !self.state.is_alive() {
16161            anyhow::bail!("you are dead");
16162        }
16163        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
16164        self.seq += 1;
16165        self.session
16166            .submit_intent(Intent::Dodge {
16167                entity_id: self.state.entity_id,
16168                forward,
16169                strafe,
16170                seq: self.seq,
16171            })
16172            .await?;
16173        self.state.intents_sent += 1;
16174        self.state.push_log("Dodge!");
16175        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16176        Ok(())
16177    }
16178
16179    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16180        if !self.state.is_alive() {
16181            anyhow::bail!("you are dead");
16182        }
16183        let (forward, strafe) = self.last_move_axes();
16184        self.seq += 1;
16185        self.session
16186            .submit_intent(Intent::Lunge {
16187                entity_id: self.state.entity_id,
16188                forward,
16189                strafe,
16190                seq: self.seq,
16191            })
16192            .await?;
16193        self.state.intents_sent += 1;
16194        self.state.push_log("Lunge!");
16195        Ok(())
16196    }
16197
16198    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16199        if !self.state.is_alive() {
16200            anyhow::bail!("you are dead");
16201        }
16202        self.seq += 1;
16203        self.session
16204            .submit_intent(Intent::DirectionalJump {
16205                entity_id: self.state.entity_id,
16206                forward,
16207                strafe,
16208                seq: self.seq,
16209            })
16210            .await?;
16211        self.state.intents_sent += 1;
16212        self.state.push_log("Jump!");
16213        Ok(())
16214    }
16215
16216    /// Remembered WASD axes for lunge when not currently moving.
16217    pub fn last_move_axes(&self) -> (f32, f32) {
16218        (self.last_move_forward, self.last_move_strafe)
16219    }
16220
16221    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16222        if !self.state.is_alive() {
16223            anyhow::bail!("you are dead");
16224        }
16225        self.seq += 1;
16226        self.session
16227            .submit_intent(Intent::Block {
16228                entity_id: self.state.entity_id,
16229                enabled,
16230                seq: self.seq,
16231            })
16232            .await?;
16233        self.state.intents_sent += 1;
16234        if enabled {
16235            self.state.push_log("Blocking");
16236            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16237        }
16238        Ok(())
16239    }
16240
16241    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16242        if !self.state.is_alive() {
16243            anyhow::bail!("you are dead");
16244        }
16245        self.seq += 1;
16246        self.session
16247            .submit_intent(Intent::EquipMainhand {
16248                entity_id: self.state.entity_id,
16249                template_id,
16250                instance_id: None,
16251                seq: self.seq,
16252            })
16253            .await?;
16254        self.state.intents_sent += 1;
16255        Ok(())
16256    }
16257
16258    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16259    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16260        let idx = self.state.equip_menu_index;
16261        let slots = equip_paperdoll_rows(&self.state);
16262        let Some(row) = slots.get(idx) else {
16263            return Ok(());
16264        };
16265        match row {
16266            EquipPaperdollRow::Body { slot, filled } => {
16267                if *filled {
16268                    self.equip_worn(*slot, None).await
16269                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16270                    self.equip_worn(*slot, Some(inst)).await
16271                } else {
16272                    self.state
16273                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16274                    Ok(())
16275                }
16276            }
16277            EquipPaperdollRow::Mainhand { filled } => {
16278                if *filled {
16279                    self.unequip_mainhand().await
16280                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16281                    self.equip_mainhand(Some(tid)).await
16282                } else {
16283                    self.state.push_log("No weapon in inventory".to_string());
16284                    Ok(())
16285                }
16286            }
16287            EquipPaperdollRow::Offhand { filled, locked } => {
16288                if *locked {
16289                    self.state
16290                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16291                    Ok(())
16292                } else if *filled {
16293                    self.unequip_offhand().await
16294                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16295                    self.equip_offhand(Some(tid)).await
16296                } else {
16297                    self.state
16298                        .push_log("No offhand item in inventory".to_string());
16299                    Ok(())
16300                }
16301            }
16302        }
16303    }
16304
16305    pub async fn say(
16306        &mut self,
16307        channel: flatland_protocol::ChatChannel,
16308        text: &str,
16309    ) -> anyhow::Result<()> {
16310        self.say_to(channel, text, None).await
16311    }
16312
16313    pub async fn say_to(
16314        &mut self,
16315        channel: flatland_protocol::ChatChannel,
16316        text: &str,
16317        to_entity: Option<EntityId>,
16318    ) -> anyhow::Result<()> {
16319        self.seq += 1;
16320        self.session
16321            .submit_intent(Intent::Say {
16322                entity_id: self.state.entity_id,
16323                channel,
16324                text: text.to_string(),
16325                to_entity,
16326                seq: self.seq,
16327            })
16328            .await?;
16329        self.state.intents_sent += 1;
16330        Ok(())
16331    }
16332
16333    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16334        let Some(peer) = self.state.player_verbs.target_entity else {
16335            return Ok(());
16336        };
16337        let label = self.state.player_verbs.target_label.clone();
16338        let choice = crate::social::PlayerVerbState::options()
16339            .get(self.state.player_verbs.index)
16340            .copied()
16341            .unwrap_or("Whisper");
16342        self.state.player_verbs.close();
16343        match choice {
16344            "Trade" => {
16345                // Only request — peer must accept (Y in chat). Do not auto-respond;
16346                // TradeRespond requires a pending inbound request from the peer.
16347                self.seq += 1;
16348                self.session
16349                    .submit_intent(Intent::TradeRequest {
16350                        entity_id: self.state.entity_id,
16351                        peer_entity_id: peer,
16352                        seq: self.seq,
16353                    })
16354                    .await?;
16355                self.state.intents_sent += 1;
16356                self.state.social_chat.push_system(format!(
16357                    "Trade request sent to {label} — waiting for accept"
16358                ));
16359            }
16360            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16361            _ => self.state.social_chat.focus_nearby(),
16362        }
16363        Ok(())
16364    }
16365
16366    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16367        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16368            return Ok(());
16369        };
16370        self.seq += 1;
16371        self.session
16372            .submit_intent(Intent::TradeRespond {
16373                entity_id: self.state.entity_id,
16374                peer_entity_id: pending.from_entity,
16375                accept,
16376                seq: self.seq,
16377            })
16378            .await?;
16379        self.state.intents_sent += 1;
16380        if accept {
16381            self.state
16382                .social_chat
16383                .push_system(format!("Accepted trade with {}", pending.from_name));
16384        } else {
16385            self.state
16386                .social_chat
16387                .push_system(format!("Declined trade with {}", pending.from_name));
16388        }
16389        Ok(())
16390    }
16391
16392    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16393        let text = self.state.social_chat.buffer.trim().to_string();
16394        if text.is_empty() {
16395            return Ok(());
16396        }
16397        self.state.social_chat.buffer.clear();
16398        if crate::social::is_chat_slash_line(&text) {
16399            match crate::social::parse_chat_slash(&text) {
16400                Some(cmd) => return self.apply_chat_slash(cmd).await,
16401                None => {
16402                    self.state.social_chat.push_system(format!(
16403                        "Unknown command — {}",
16404                        crate::social::chat_slash_help_text()
16405                    ));
16406                    return Ok(());
16407                }
16408            }
16409        }
16410        let thread = self.state.social_chat.thread;
16411        let channel = thread.channel();
16412        let to = thread.to_entity();
16413        if let Some(peer) = to {
16414            let label = self.state.social_chat.peer_label.clone();
16415            self.state
16416                .social_chat
16417                .remember_whisper_peer(peer, &label, channel);
16418        }
16419        self.say_to(channel, &text, to).await
16420    }
16421
16422    async fn apply_chat_slash(
16423        &mut self,
16424        cmd: crate::social::ChatSlashCommand,
16425    ) -> anyhow::Result<()> {
16426        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16427        match cmd {
16428            ChatSlashCommand::Help => {
16429                self.state
16430                    .social_chat
16431                    .push_system(chat_slash_help_text().to_string());
16432                Ok(())
16433            }
16434            ChatSlashCommand::Nearby { message } => {
16435                self.state.social_chat.focus_nearby();
16436                self.state
16437                    .social_chat
16438                    .push_system("Nearby speech — everyone close can hear");
16439                if let Some(msg) = message {
16440                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16441                        .await
16442                } else {
16443                    Ok(())
16444                }
16445            }
16446            ChatSlashCommand::Reply { message } => {
16447                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16448                    self.state
16449                        .social_chat
16450                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16451                    return Ok(());
16452                };
16453                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16454                self.state
16455                    .social_chat
16456                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16457                self.state.social_chat.push_system(format!(
16458                    "Replying to {} — type and Enter · /nearby",
16459                    peer.label
16460                ));
16461                if let Some(msg) = message {
16462                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16463                } else {
16464                    Ok(())
16465                }
16466            }
16467            ChatSlashCommand::Whisper { name, message } => {
16468                let (peer_id, label, stone) = if let Some(name) = name {
16469                    match self.resolve_whisper_target(&name) {
16470                        Ok(t) => t,
16471                        Err(err) => {
16472                            self.state.social_chat.push_system(err);
16473                            return Ok(());
16474                        }
16475                    }
16476                } else {
16477                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16478                        self.state.social_chat.push_system(
16479                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16480                        );
16481                        return Ok(());
16482                    };
16483                    (
16484                        peer.entity_id,
16485                        peer.label,
16486                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16487                    )
16488                };
16489                self.state
16490                    .social_chat
16491                    .set_whisper_thread(peer_id, &label, stone);
16492                let channel = if stone {
16493                    flatland_protocol::ChatChannel::WhisperStone
16494                } else {
16495                    flatland_protocol::ChatChannel::Whisper
16496                };
16497                if let Some(msg) = message {
16498                    self.state
16499                        .social_chat
16500                        .push_system(format!("Whisper → {label}"));
16501                    self.say_to(channel, &msg, Some(peer_id)).await
16502                } else {
16503                    self.state.social_chat.push_system(format!(
16504                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16505                    ));
16506                    Ok(())
16507                }
16508            }
16509        }
16510    }
16511
16512    /// Resolve `/whisper Name` against AOI players (label match).
16513    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16514        let needle = name.trim().to_ascii_lowercase();
16515        if needle.is_empty() {
16516            return Err("Usage: /whisper Name [message]".into());
16517        }
16518        let mut candidates: Vec<(EntityId, String)> = self
16519            .state
16520            .entities
16521            .iter()
16522            .filter(|e| e.id != self.state.entity_id)
16523            .filter(|e| !e.label.trim().is_empty())
16524            .filter(|e| e.vitals.is_some())
16525            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16526            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16527            .map(|e| (e.id, e.label.clone()))
16528            .collect();
16529
16530        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16531        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16532            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16533                candidates.push((last.entity_id, last.label.clone()));
16534            }
16535        }
16536
16537        let exact: Vec<_> = candidates
16538            .iter()
16539            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16540            .cloned()
16541            .collect();
16542        let pool = if exact.len() == 1 {
16543            exact
16544        } else if exact.len() > 1 {
16545            return Err(format!(
16546                "Several players named '{name}' nearby — move closer and try again"
16547            ));
16548        } else {
16549            let starts: Vec<_> = candidates
16550                .iter()
16551                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16552                .cloned()
16553                .collect();
16554            if starts.len() == 1 {
16555                starts
16556            } else if starts.len() > 1 {
16557                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16558                return Err(format!(
16559                    "Ambiguous name '{name}' — matches: {}",
16560                    names.join(", ")
16561                ));
16562            } else {
16563                let contains: Vec<_> = candidates
16564                    .iter()
16565                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16566                    .cloned()
16567                    .collect();
16568                if contains.len() == 1 {
16569                    contains
16570                } else if contains.is_empty() {
16571                    return Err(format!(
16572                        "No player matching '{name}' in range — get closer or check the spelling"
16573                    ));
16574                } else {
16575                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16576                    return Err(format!(
16577                        "Ambiguous name '{name}' — matches: {}",
16578                        names.join(", ")
16579                    ));
16580                }
16581            }
16582        };
16583
16584        let (id, label) = pool.into_iter().next().unwrap();
16585        let stone = self
16586            .state
16587            .social_chat
16588            .last_whisper_peer
16589            .as_ref()
16590            .is_some_and(|p| {
16591                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16592            });
16593        Ok((id, label, stone))
16594    }
16595
16596    pub async fn trade_present_selected(
16597        &mut self,
16598        item_instance_id: uuid::Uuid,
16599    ) -> anyhow::Result<()> {
16600        self.trade_present_quantity(item_instance_id, None).await
16601    }
16602
16603    pub async fn trade_present_quantity(
16604        &mut self,
16605        item_instance_id: uuid::Uuid,
16606        quantity: Option<u32>,
16607    ) -> anyhow::Result<()> {
16608        self.seq += 1;
16609        self.session
16610            .submit_intent(Intent::TradePresent {
16611                entity_id: self.state.entity_id,
16612                item_instance_id,
16613                quantity,
16614                seq: self.seq,
16615            })
16616            .await?;
16617        self.state.intents_sent += 1;
16618        self.state.trade_ui.qty_entry = None;
16619        self.state.trade_ui.picking_inventory = false;
16620        Ok(())
16621    }
16622
16623    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
16624    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16625        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16626            let qty = self.state.trade_ui.present_quantity();
16627            return self
16628                .trade_present_quantity(entry.item_instance_id, qty)
16629                .await;
16630        }
16631        if !self.state.trade_ui.picking_inventory {
16632            return Ok(());
16633        }
16634        let stacks = self.state.trade_presentable_stacks();
16635        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16636            return Ok(());
16637        };
16638        let Some(id) = stack.item_instance_id else {
16639            return Ok(());
16640        };
16641        let label = stack
16642            .display_name
16643            .clone()
16644            .unwrap_or_else(|| stack.template_id.clone());
16645        if stack.quantity <= 1 {
16646            self.trade_present_quantity(id, Some(1)).await
16647        } else {
16648            self.state
16649                .trade_ui
16650                .begin_qty_entry(id, label, stack.quantity);
16651            Ok(())
16652        }
16653    }
16654
16655    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16656        self.seq += 1;
16657        self.session
16658            .submit_intent(Intent::TradeSetReady {
16659                entity_id: self.state.entity_id,
16660                ready,
16661                seq: self.seq,
16662            })
16663            .await?;
16664        self.state.intents_sent += 1;
16665        Ok(())
16666    }
16667
16668    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16669        self.seq += 1;
16670        self.session
16671            .submit_intent(Intent::TradeCancel {
16672                entity_id: self.state.entity_id,
16673                seq: self.seq,
16674            })
16675            .await?;
16676        self.state.intents_sent += 1;
16677        self.state.trade_ui.close();
16678        Ok(())
16679    }
16680
16681    pub async fn destroy_whisper_stone(
16682        &mut self,
16683        item_instance_id: uuid::Uuid,
16684    ) -> anyhow::Result<()> {
16685        self.seq += 1;
16686        self.session
16687            .submit_intent(Intent::DestroyWhisperStone {
16688                entity_id: self.state.entity_id,
16689                item_instance_id,
16690                seq: self.seq,
16691            })
16692            .await?;
16693        self.state.intents_sent += 1;
16694        Ok(())
16695    }
16696
16697    pub async fn stop(&mut self) -> anyhow::Result<()> {
16698        self.seq += 1;
16699        self.session
16700            .submit_intent(Intent::Stop {
16701                entity_id: self.state.entity_id,
16702                seq: self.seq,
16703            })
16704            .await?;
16705        self.state.intents_sent += 1;
16706        Ok(())
16707    }
16708
16709    pub fn disconnect(&self) {
16710        self.session.disconnect();
16711    }
16712}
16713
16714fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16715    let dx = ax - bx;
16716    let dy = ay - by;
16717    (dx * dx + dy * dy).sqrt()
16718}
16719
16720#[cfg(test)]
16721mod tests {
16722    use std::collections::BTreeMap;
16723
16724    use super::*;
16725    use flatland_protocol::{
16726        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16727    };
16728
16729    fn sample_state() -> GameState {
16730        let mut state = GameState {
16731            session_id: 1,
16732            entity_id: 1,
16733            character_id: None,
16734            tick: 0,
16735            chunk_rev: 0,
16736            content_rev: 0,
16737            publish_rev: 0,
16738            entities: vec![EntityState {
16739                id: 1,
16740                label: "You".into(),
16741                transform: Transform {
16742                    position: WorldCoord::surface(128.0, 128.0),
16743                    yaw: 0.0,
16744                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16745                },
16746                vitals: None,
16747                attributes: None,
16748                skills: None,
16749                inside_building: None,
16750                tile_id: None,
16751                paperdoll_ref: None,
16752                draw_scale: 1.0,
16753                presentation_state: None,
16754                sprite_mode: None,
16755                progression_xp: None,
16756                combat_cues: vec![],
16757                statuses: vec![],
16758            }],
16759            player: None,
16760            resource_nodes: vec![ResourceNodeView {
16761                id: "oak-1".into(),
16762                label: "Oak".into(),
16763                x: 130.0,
16764                y: 128.0,
16765                z: 0.0,
16766                item_template: "oak_log".into(),
16767                state: ResourceNodeState::Available,
16768                blocking: true,
16769                blocking_radius_m: 0.8,
16770                harvest_off: false,
16771                tile_id: None,
16772                yaw: 0.0,
16773                pitch: 0.0,
16774                roll: 0.0,
16775                draw_scale: 1.0,
16776                sprite_mode: None,
16777                growth_progress: None,
16778                presentation_state: None,
16779                channel_start_tick: None,
16780                channel_end_tick: None,
16781                harvest_drop_templates: vec![],
16782            }],
16783            ground_drops: vec![],
16784            placed_containers: vec![],
16785            buildings: vec![BuildingView {
16786                id: "broker-hut".into(),
16787                label: "Broker".into(),
16788                x: 148.0,
16789                y: 118.0,
16790                width_m: 8.0,
16791                depth_m: 6.0,
16792                interior_blueprint: Some("broker_hut".into()),
16793                tags: vec![],
16794                market_boundary_zone_ids: vec![],
16795                market_max_volume: None,
16796                wall_set: None,
16797                roof_set: None,
16798            }],
16799            doors: vec![flatland_protocol::DoorView {
16800                id: "door-1".into(),
16801                building_id: "broker-hut".into(),
16802                x: 148.0,
16803                y: 118.0,
16804                open: false,
16805                portal: Some("front".into()),
16806                locked: false,
16807                accessible: true,
16808                lock_id: None,
16809            }],
16810            interior_map: None,
16811            npcs: vec![],
16812            blueprints: vec![],
16813            building_materials: vec![],
16814            world_x0: 0.0,
16815            world_y0: 0.0,
16816            world_width_m: 256.0,
16817            world_height_m: 256.0,
16818            terrain_zones: Vec::new(),
16819            z_platforms: Vec::new(),
16820            z_transitions: Vec::new(),
16821            z_bands_outdoor_backup: None,
16822            world_clock: flatland_protocol::WorldClock::default(),
16823            inventory: std::collections::HashMap::new(),
16824            inventory_hints: std::collections::HashMap::new(),
16825            item_catalog: std::collections::HashMap::new(),
16826            logs: VecDeque::new(),
16827            intents_sent: 0,
16828            ticks_received: 0,
16829            connected: true,
16830            disconnect_reason: None,
16831            show_stats: false,
16832            hud_log_hidden: false,
16833            show_equip_menu: false,
16834            equip_menu_index: 0,
16835            show_craft_menu: false,
16836            show_plot_build_menu: false,
16837            plot_build_focus_wall: true,
16838            plot_build_wall_index: 0,
16839            plot_build_roof_index: 0,
16840            craft_menu_index: 0,
16841            craft_batch_quantity: 1,
16842            craft_tab: CraftTab::Ready,
16843            craft_filter: String::new(),
16844            craft_filter_focused: false,
16845            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16846            show_shop_menu: false,
16847            shop_catalog: None,
16848            bank_panel: None,
16849            bank_menu_index: 0,
16850            bank_ui_mode: BankUiMode::Menu,
16851            storage_panel: None,
16852            market_panel: None,
16853            market_menu_index: 0,
16854            market_filter: String::new(),
16855            market_filter_focused: false,
16856            market_category_filter: None,
16857            market_buy_confirm: None,
16858            market_ui_mode: MarketUiMode::Browse,
16859            storage_menu_index: 0,
16860            storage_ui_mode: StorageUiMode::Menu,
16861            shop_tab: ShopTab::default(),
16862            shop_menu_index: 0,
16863            shop_quantity: 1,
16864            shop_trade_log: VecDeque::new(),
16865            show_npc_verb_menu: false,
16866            npc_verb_target: None,
16867            npc_verb_index: 0,
16868            npc_verb_notice: None,
16869            player_verbs: crate::social::PlayerVerbState::default(),
16870            social_chat: crate::social::SocialChatState::default(),
16871            trade_ui: crate::social::TradeUiState::default(),
16872            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16873            show_npc_chat: false,
16874            npc_chat: None,
16875            show_inventory_menu: false,
16876            inventory_menu_index: 0,
16877            inventory_tab: InventoryTab::OnPerson,
16878            inventory_filter: String::new(),
16879            inventory_filter_focused: false,
16880            show_move_picker: false,
16881            show_rename_prompt: false,
16882            rename_plot_id: None,
16883            highlighted_plot_id: None,
16884            show_worker_rename: false,
16885            rename_buffer: String::new(),
16886            move_picker_index: 0,
16887            move_picker: None,
16888            show_grant_picker: false,
16889            grant_picker_index: 0,
16890            grant_picker: None,
16891            show_destroy_picker: false,
16892            destroy_confirm_pending: false,
16893            destroy_picker: None,
16894            combat_target: None,
16895            combat_target_label: None,
16896            ground_target: None,
16897            combat_fx: Vec::new(),
16898            ground_hazards: Vec::new(),
16899            property_zones: Vec::new(),
16900            tax_zones: Vec::new(),
16901            growth_zones: Vec::new(),
16902            biome_zones: Vec::new(),
16903            terrain_kind_nav: Vec::new(),
16904            property_plots: Vec::new(),
16905            property_plot_settings: None,
16906            claim_mode: None,
16907            relocate_mode: None,
16908            sell_plot_confirm: None,
16909            sell_plot_armed_at: None,
16910            show_plant_menu: false,
16911            plant_menu_index: 0,
16912            show_farm_access: false,
16913            farm_access_name_draft: String::new(),
16914            farm_access_discount_bps: 0,
16915            farm_access_index: 0,
16916            plant_quantity: 1,
16917            in_combat: false,
16918            auto_attack: true,
16919            combat_has_los: false,
16920            attack_cd_ticks: 0,
16921            gcd_ticks: 0,
16922            weapon_ability_id: "unarmed".into(),
16923            mainhand_template_id: None,
16924            mainhand_label: None,
16925            mainhand_instance_id: None,
16926            offhand_template_id: None,
16927            offhand_label: None,
16928            offhand_instance_id: None,
16929            mainhand_hand_slots: 1,
16930            defense: None,
16931            worn: BTreeMap::new(),
16932            carry_mass: 0.0,
16933            carry_mass_max: 0.0,
16934            encumbrance: flatland_protocol::EncumbranceState::Light,
16935            move_speed_mps: 0.0,
16936            move_speed_mult: 0.0,
16937            inventory_stacks: Vec::new(),
16938            keychain_stacks: Vec::new(),
16939            whisper_pouch_stacks: Vec::new(),
16940            combat_target_detail: None,
16941            statuses: Vec::new(),
16942            cast_progress: None,
16943            timed_channel: None,
16944            plot_build_offer: None,
16945            ability_cooldowns: Vec::new(),
16946            blocking_active: false,
16947            max_target_slots: 1,
16948            combat_slots: Vec::new(),
16949            rotation_presets: Vec::new(),
16950            known_abilities: Vec::new(),
16951            ability_meta: std::collections::HashMap::new(),
16952            ability_mastery: std::collections::HashMap::new(),
16953            hotbar: vec![None; 9],
16954            max_abilities_per_rotation: 0,
16955            show_loadout_menu: false,
16956            show_keychain_menu: false,
16957            keychain_menu_index: 0,
16958            show_rotation_editor: false,
16959            loadout_menu_index: 0,
16960            loadout_hotbar_slot: 1,
16961            loadout_ability_index: 0,
16962            loadout_focus_presets: false,
16963            rotation_editor: RotationEditorState::default(),
16964            harvest_in_progress: false,
16965            harvest_started_at: None,
16966            pending_craft_ack: None,
16967            craft_channel_blueprint_id: None,
16968            pending_worker_job_ack: None,
16969            attending_worker_instance_id: None,
16970            quest_log: Vec::new(),
16971            interactables: Vec::new(),
16972            ledger: None,
16973            career: None,
16974            character_sheet_tab: CharacterSheetTab::Character,
16975            ledger_period: LedgerPeriod::Day,
16976            show_quest_offer: false,
16977            pending_quest_offers: Vec::new(),
16978                quest_offer_index: 0,
16979            show_quest_menu: false,
16980            quest_menu_index: 0,
16981            quest_withdraw_confirm: false,
16982            hired_workers: Vec::new(),
16983            show_workers_menu: false,
16984            workers_menu_index: 0,
16985            worker_dismiss_confirmation: None,
16986            workers_menu_compact: false,
16987            worker_step_display: BTreeMap::new(),
16988            worker_error_display: BTreeMap::new(),
16989            worker_health_ring_until: BTreeMap::new(),
16990            pending_worker_hire_since: None,
16991            show_worker_give_picker: false,
16992            worker_give_picker_index: 0,
16993            worker_give_picker: None,
16994            show_worker_give_target_picker: false,
16995            worker_give_target_picker_index: 0,
16996            worker_give_target_picker: None,
16997            show_worker_take_picker: false,
16998            worker_take_picker_index: 0,
16999            worker_take_picker: None,
17000            show_worker_teach_picker: false,
17001            worker_teach_picker_index: 0,
17002            worker_teach_picker: None,
17003            worker_route_editor: None,
17004            progression_curve: None,
17005        };
17006        state.player = state.entities.first().cloned();
17007        state
17008    }
17009
17010    #[test]
17011    fn template_display_name_uses_item_catalog_for_uuid_ids() {
17012        let mut state = sample_state();
17013        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17014        assert_eq!(state.template_display_name(id), "Unknown item");
17015        state.item_catalog.insert(
17016            id.into(),
17017            ItemCatalogEntryView {
17018                template_id: id.into(),
17019                display_name: "Emerald".into(),
17020                category: "resource".into(),
17021                seed_for: None,
17022            },
17023        );
17024        assert_eq!(state.template_display_name(id), "Emerald");
17025    }
17026
17027    #[test]
17028    fn whisper_cancels_when_peer_walks_out_of_range() {
17029        let mut state = sample_state();
17030        state.player = state.entities.first().cloned();
17031        let mut peer = state.entities[0].clone();
17032        peer.id = 2;
17033        peer.label = "Ada".into();
17034        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
17035        state.entities.push(peer.clone());
17036        state.social_chat.focus_whisper(2, "Ada");
17037        state.refresh_whisper_range();
17038        assert!(matches!(
17039            state.social_chat.thread,
17040            crate::social::ChatThreadKind::Whisper { peer: 2 }
17041        ));
17042
17043        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
17044        state.entities[1] = peer;
17045        state.refresh_whisper_range();
17046        assert_eq!(
17047            state.social_chat.thread,
17048            crate::social::ChatThreadKind::Nearby
17049        );
17050        assert!(!state.social_chat.input_focused);
17051    }
17052
17053    #[test]
17054    fn probe_use_world_hired_worker_manage() {
17055        let mut state = sample_state();
17056        state
17057            .hired_workers
17058            .push(flatland_protocol::HiredWorkerView {
17059                instance_id: "worker-1".into(),
17060                entity_id: 42,
17061                def_id: "worker_laborer".into(),
17062                label: "Sam".into(),
17063                x: 129.0,
17064                y: 128.0,
17065                z: 0.0,
17066                mode: flatland_protocol::WorkerModeView::JobLoop,
17067                state: flatland_protocol::WorkerStateView::Working,
17068                step_label: "cultivate".into(),
17069                vitals: flatland_protocol::WorkerVitalsSummary {
17070                    health_pct: 100.0,
17071                    stamina_pct: 100.0,
17072                    mana_pct: 100.0,
17073                    hunger_pct: 100.0,
17074                    thirst_pct: 100.0,
17075                },
17076                carry_pct: 0.0,
17077                last_error: None,
17078                wage_copper_per_interval: 1,
17079                effective_wage_copper: 1,
17080                wage_meters_walked: 0.0,
17081                lodging_container_id: None,
17082                route: None,
17083                route_stop_index: None,
17084                known_blueprint_ids: Vec::new(),
17085                level: 1,
17086                worker_xp: 0.0,
17087                inventory: Vec::new(),
17088                equipment: flatland_protocol::WorkerEquipmentView::default(),
17089                issue_hint: None,
17090            });
17091        let probe = state.probe_use_world();
17092        let primary = probe.primary.expect("primary");
17093        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17094        assert_eq!(primary.id, "worker-1");
17095        assert!(primary.hint_line().contains("Manage"));
17096        assert!(primary.hint_line().contains("Sam"));
17097        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17098    }
17099
17100    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17101        flatland_protocol::HiredWorkerView {
17102            instance_id: "worker-1".into(),
17103            entity_id: 42,
17104            def_id: "worker_laborer".into(),
17105            label: "Sam".into(),
17106            x,
17107            y,
17108            z: 0.0,
17109            mode: flatland_protocol::WorkerModeView::JobLoop,
17110            state: flatland_protocol::WorkerStateView::Working,
17111            step_label: "follow".into(),
17112            vitals: flatland_protocol::WorkerVitalsSummary {
17113                health_pct: 100.0,
17114                stamina_pct: 100.0,
17115                mana_pct: 100.0,
17116                hunger_pct: 100.0,
17117                thirst_pct: 100.0,
17118            },
17119            carry_pct: 0.0,
17120            last_error: None,
17121            wage_copper_per_interval: 1,
17122            effective_wage_copper: 1,
17123            wage_meters_walked: 0.0,
17124            lodging_container_id: None,
17125            route: None,
17126            route_stop_index: None,
17127            known_blueprint_ids: Vec::new(),
17128            level: 1,
17129            worker_xp: 0.0,
17130            inventory: Vec::new(),
17131            equipment: flatland_protocol::WorkerEquipmentView::default(),
17132            issue_hint: None,
17133        }
17134    }
17135
17136    #[test]
17137    fn probe_harvest_beats_closer_hired_worker() {
17138        let mut state = sample_state();
17139        state.resource_nodes[0].x = 129.0;
17140        state.resource_nodes[0].y = 128.0;
17141        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17142        let probe = state.probe_use_world();
17143        let primary = probe.primary.expect("primary");
17144        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17145        assert_eq!(primary.id, "oak-1");
17146        assert!(state.harvestable_node_in_range());
17147        assert_eq!(
17148            state.nearest_interact_target().as_deref(),
17149            Some("worker-1"),
17150            "harvest is not Interact — worker remains the interact target"
17151        );
17152    }
17153
17154    #[test]
17155    fn probe_door_beats_closer_hired_worker() {
17156        let mut state = sample_state();
17157        state.doors[0].x = 129.2;
17158        state.doors[0].y = 128.0;
17159        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17160        let probe = state.probe_use_world();
17161        let primary = probe.primary.expect("primary");
17162        assert!(
17163            matches!(
17164                primary.kind,
17165                crate::UseWorldKind::EnterDoor
17166                    | crate::UseWorldKind::OpenDoor
17167                    | crate::UseWorldKind::CloseDoor
17168                    | crate::UseWorldKind::ExitDoor
17169            ),
17170            "door should win over closer worker, got {:?}",
17171            primary.kind
17172        );
17173        assert_eq!(primary.id, "door-1");
17174        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17175    }
17176
17177    #[test]
17178    fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17179        // MadSin-style house: exit door + lodging beds within chest pickup radius.
17180        // `f` / probe must prefer the door (cascade stage 0) over ChestPickup (stage 2).
17181        let mut state = sample_state();
17182        state.entities[0].inside_building = Some("player_house".into());
17183        state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17184        state.player = state.entities.first().cloned();
17185        state.buildings = vec![BuildingView {
17186            id: "player_house".into(),
17187            label: "MadSin's house".into(),
17188            x: 100.0,
17189            y: 100.0,
17190            width_m: 10.0,
17191            depth_m: 8.0,
17192            interior_blueprint: Some("player_house".into()),
17193            tags: vec!["player_built".into()],
17194            market_boundary_zone_ids: vec![],
17195            market_max_volume: None,
17196            wall_set: None,
17197            roof_set: None,
17198        }];
17199        state.doors = vec![flatland_protocol::DoorView {
17200            id: "house_exit".into(),
17201            building_id: "player_house".into(),
17202            x: 5.0,
17203            y: 1.0,
17204            open: true,
17205            portal: Some("front".into()),
17206            locked: false,
17207            accessible: true,
17208            lock_id: None,
17209        }];
17210        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17211            id: "lodging_bed".into(),
17212            template_id: "camp_bed".into(),
17213            display_name: "Camp bed".into(),
17214            x: 5.5,
17215            y: 2.4,
17216            z: 0.0,
17217            locked: false,
17218            accessible: true,
17219            owner_character_id: None,
17220            contents: vec![],
17221            lock_id: None,
17222            capacity_volume: None,
17223            item_instance_id: Some(uuid::Uuid::from_u128(99)),
17224            tile_id: None,
17225            worker_lodging_capacity: Some(1),
17226            blocking: false,
17227            blocking_radius_m: 0.0,
17228            building_id: Some("player_house".into()),
17229        }];
17230        // Worker assigned to the bed but standing far away — occupancy only.
17231        let mut worker = sample_hired_worker(40.0, 40.0);
17232        worker.lodging_container_id = Some("lodging_bed".into());
17233        state.hired_workers.push(worker);
17234
17235        let probe = state.probe_use_world();
17236        let primary = probe.primary.expect("primary");
17237        assert!(
17238            matches!(
17239                primary.kind,
17240                crate::UseWorldKind::ExitDoor
17241                    | crate::UseWorldKind::OpenDoor
17242                    | crate::UseWorldKind::CloseDoor
17243                    | crate::UseWorldKind::EnterDoor
17244            ),
17245            "indoor exit must beat lodging ChestPickup, got {:?}",
17246            primary.kind
17247        );
17248        assert_eq!(primary.id, "house_exit");
17249        assert_eq!(primary.kind.cascade_stage(), 0);
17250        assert!(
17251            probe
17252                .candidates
17253                .iter()
17254                .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17255            "lodging bed should still be an in-range chest candidate"
17256        );
17257        assert_eq!(
17258            state.nearest_interact_target().as_deref(),
17259            Some("house_exit"),
17260            "use_nearest interact path should target the door"
17261        );
17262        assert!(state.lodging_is_occupied("lodging_bed"));
17263    }
17264
17265    #[test]
17266    fn probe_worker_when_no_resource_or_door_in_range() {
17267        let mut state = sample_state();
17268        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17269        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17270        let probe = state.probe_use_world();
17271        let primary = probe.primary.expect("primary");
17272        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17273        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17274        assert!(!state.harvestable_node_in_range());
17275    }
17276
17277    #[test]
17278    fn market_clerk_verb_options_include_market() {
17279        let mut state = sample_state();
17280        state.npcs.push(flatland_protocol::NpcView {
17281            id: "mira_market".into(),
17282            label: "Mira".into(),
17283            role: "market_clerk".into(),
17284            x: 129.0,
17285            y: 128.0,
17286            building_id: Some("town_market".into()),
17287            entity_id: None,
17288            life_state: None,
17289            hp_pct: None,
17290            can_trade: false,
17291            buy_templates: vec![],
17292            tile_id: None,
17293            behavior_state: None,
17294            presentation_state: None,
17295            sprite_mode: None,
17296            paperdoll_ref: None,
17297            draw_scale: 1.0,
17298            yaw: None,
17299            perception_fov_deg: None,
17300            perception_sight_m: None,
17301            perception_hear_m: None,
17302            quest_verbs: Vec::new(),
17303        });
17304        state.npc_verb_target = Some("mira_market".into());
17305        assert_eq!(
17306            state
17307                .npc_verb_options()
17308                .iter()
17309                .map(|v| v.label.as_str())
17310                .collect::<Vec<_>>(),
17311            vec!["Market", "Talk"]
17312        );
17313    }
17314
17315    #[test]
17316    fn butcher_verb_options_include_turn_in_for_give_item() {
17317        let mut state = sample_state();
17318        state.npcs.push(flatland_protocol::NpcView {
17319            id: "town_butcher_1".into(),
17320            label: "Brutus".into(),
17321            role: "butcher".into(),
17322            x: 129.0,
17323            y: 128.0,
17324            building_id: None,
17325            entity_id: None,
17326            life_state: None,
17327            hp_pct: None,
17328            can_trade: true,
17329            buy_templates: vec!["raw_venison".into()],
17330            tile_id: None,
17331            behavior_state: None,
17332            presentation_state: None,
17333            sprite_mode: None,
17334            paperdoll_ref: None,
17335            draw_scale: 1.0,
17336            yaw: None,
17337            perception_fov_deg: None,
17338            perception_sight_m: None,
17339            perception_hear_m: None,
17340            quest_verbs: Vec::new(),
17341        });
17342        state.quest_log.push(flatland_protocol::QuestLogEntry {
17343            quest_id: "deer_threat".into(),
17344            title: "Deer threat".into(),
17345            description: String::new(),
17346            status: flatland_protocol::QuestStatusView::Active,
17347            current_step_id: Some("deliver".into()),
17348            current_step_title: "Deliver venison".into(),
17349            current_step_index: 0,
17350            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17351                label: "Give 3 Raw venison to Brutus".into(),
17352                current: 0,
17353                required: 3,
17354                done: false,
17355                kind: "give_item".into(),
17356                npc_ref: Some("town_butcher_1".into()),
17357                item_template: Some("raw_venison".into()),
17358                blueprint_id: None,
17359                building_id: None,
17360            }],
17361            current_step_reward: flatland_protocol::QuestRewardView::default(),
17362            completion_reward: flatland_protocol::QuestRewardView::default(),
17363            steps: Vec::new(),
17364            is_tracked: true,
17365            can_withdraw: true,
17366        });
17367        state.npc_verb_target = Some("town_butcher_1".into());
17368        assert_eq!(
17369            state
17370                .npc_verb_options()
17371                .iter()
17372                .map(|v| v.label.as_str())
17373                .collect::<Vec<_>>(),
17374            vec!["Turn in: Deer threat", "Talk", "Trade"]
17375        );
17376    }
17377
17378    #[test]
17379    fn ada_verb_options_include_quest_offer() {
17380        let mut state = sample_state();
17381        state.npcs.push(flatland_protocol::NpcView {
17382            id: "ada_broker".into(),
17383            label: "Ada".into(),
17384            role: "broker".into(),
17385            x: 129.0,
17386            y: 128.0,
17387            building_id: None,
17388            entity_id: None,
17389            life_state: None,
17390            hp_pct: None,
17391            can_trade: true,
17392            buy_templates: vec![],
17393            tile_id: None,
17394            behavior_state: None,
17395            presentation_state: None,
17396            sprite_mode: None,
17397            paperdoll_ref: Some("ada_broker".into()),
17398            draw_scale: 1.0,
17399            yaw: None,
17400            perception_fov_deg: None,
17401            perception_sight_m: None,
17402            perception_hear_m: None,
17403            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17404                quest_id: "ada_goblin_hunt".into(),
17405                label: "Ask about goblins".into(),
17406                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17407            }],
17408        });
17409        state.npc_verb_target = Some("ada_broker".into());
17410        assert_eq!(
17411            state
17412                .npc_verb_options()
17413                .iter()
17414                .map(|v| v.label.as_str())
17415                .collect::<Vec<_>>(),
17416            vec!["Ask about goblins", "Talk", "Trade"]
17417        );
17418    }
17419
17420    #[test]
17421    fn market_list_excludes_currency_stacks() {
17422        let mut state = sample_state();
17423        state.inventory_stacks = vec![
17424            flatland_protocol::ItemStack {
17425                template_id: "copper_coin".into(),
17426                quantity: 50,
17427                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17428                display_name: Some("Copper Coin".into()),
17429                ..Default::default()
17430            },
17431            flatland_protocol::ItemStack {
17432                template_id: "oak_log".into(),
17433                quantity: 2,
17434                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17435                display_name: Some("Oak Log".into()),
17436                ..Default::default()
17437            },
17438            flatland_protocol::ItemStack {
17439                template_id: "whisper_stone".into(),
17440                quantity: 1,
17441                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17442                display_name: Some("Whisper Stone".into()),
17443                category: Some("quest".into()),
17444                listable: Some(false),
17445                ..Default::default()
17446            },
17447        ];
17448        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17449        assert_eq!(opts.len(), 1);
17450        assert!(opts[0].label.contains("Oak"));
17451    }
17452
17453    #[test]
17454    fn market_browse_filters_by_category_and_search() {
17455        let mut state = sample_state();
17456        state.market_panel = Some(flatland_protocol::MarketPanel {
17457            npc_id: "mira_market".into(),
17458            npc_label: "Mira".into(),
17459            building_id: "town_market".into(),
17460            building_label: "Town Market".into(),
17461            used_volume: 0.0,
17462            max_volume: 100.0,
17463            listings: vec![
17464                flatland_protocol::MarketListingView {
17465                    listing_id: uuid::Uuid::from_u128(1),
17466                    seller_character_id: uuid::Uuid::from_u128(2),
17467                    seller_label: "Ada".into(),
17468                    hall_building_id: "town_market".into(),
17469                    hall_label: "Town Market".into(),
17470                    template_id: "oak_log".into(),
17471                    display_name: "Oak Log".into(),
17472                    category: "resource".into(),
17473                    quantity: 3,
17474                    unit_price_copper: 10,
17475                    line_total_copper: 30,
17476                    npc_price: false,
17477                    npc_dump_unit_copper: None,
17478                    mine: false,
17479                },
17480                flatland_protocol::MarketListingView {
17481                    listing_id: uuid::Uuid::from_u128(3),
17482                    seller_character_id: uuid::Uuid::from_u128(2),
17483                    seller_label: "Ada".into(),
17484                    hall_building_id: "town_market".into(),
17485                    hall_label: "Town Market".into(),
17486                    template_id: "short_sword".into(),
17487                    display_name: "Short Sword".into(),
17488                    category: "weapon".into(),
17489                    quantity: 1,
17490                    unit_price_copper: 100,
17491                    line_total_copper: 100,
17492                    npc_price: false,
17493                    npc_dump_unit_copper: None,
17494                    mine: false,
17495                },
17496            ],
17497            tax_bps: 0,
17498            tax_flat_copper: 0,
17499            list_vaults: vec![],
17500        });
17501        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17502        state.market_category_filter = Some("Weapons");
17503        let weapons = state.market_filtered_listing_indices();
17504        assert_eq!(weapons.len(), 1);
17505        assert_eq!(
17506            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17507            "Short Sword"
17508        );
17509        state.market_category_filter = None;
17510        state.market_filter = "oak".into();
17511        let oak = state.market_filtered_listing_indices();
17512        assert_eq!(oak.len(), 1);
17513        assert_eq!(
17514            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17515            "Oak Log"
17516        );
17517    }
17518
17519    #[test]
17520    fn market_list_source_includes_person_and_vaults() {
17521        let mut state = sample_state();
17522        let item_id = uuid::Uuid::from_u128(1);
17523        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17524            template_id: "oak_log".into(),
17525            quantity: 2,
17526            item_instance_id: Some(item_id),
17527            display_name: Some("Oak Log".into()),
17528            ..Default::default()
17529        }];
17530        state.market_panel = Some(flatland_protocol::MarketPanel {
17531            npc_id: "mira_market".into(),
17532            npc_label: "Mira".into(),
17533            building_id: "town_market".into(),
17534            building_label: "Town Market".into(),
17535            used_volume: 0.0,
17536            max_volume: 100.0,
17537            listings: vec![],
17538            tax_bps: 0,
17539            tax_flat_copper: 0,
17540            list_vaults: vec![flatland_protocol::MarketListVault {
17541                building_id: "town_storage".into(),
17542                building_label: "Town Storage".into(),
17543                contents: vec![flatland_protocol::ItemStack {
17544                    template_id: "lumber".into(),
17545                    quantity: 1,
17546                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17547                    display_name: Some("Lumber".into()),
17548                    ..Default::default()
17549                }],
17550            }],
17551        });
17552        let sources = state.market_list_source_options();
17553        assert_eq!(sources.len(), 2);
17554        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17555        assert!(matches!(
17556            sources[1].0,
17557            MarketListSourceKind::TownStorage { .. }
17558        ));
17559        assert!(sources[1].1.contains("Town Storage"));
17560    }
17561
17562    #[test]
17563    fn npc_market_dump_estimate_from_town_storage_vault() {
17564        let mut state = sample_state();
17565        state.market_panel = Some(flatland_protocol::MarketPanel {
17566            npc_id: "mira_market".into(),
17567            npc_label: "Mira".into(),
17568            building_id: "town_market".into(),
17569            building_label: "Town Market".into(),
17570            used_volume: 0.0,
17571            max_volume: 100.0,
17572            listings: vec![],
17573            tax_bps: 0,
17574            tax_flat_copper: 0,
17575            list_vaults: vec![flatland_protocol::MarketListVault {
17576                building_id: "town_storage".into(),
17577                building_label: "Town Storage".into(),
17578                contents: vec![flatland_protocol::ItemStack {
17579                    template_id: "lumber".into(),
17580                    quantity: 3,
17581                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17582                    display_name: Some("Lumber".into()),
17583                    base_value_copper: Some(20),
17584                    ..Default::default()
17585                }],
17586            }],
17587        });
17588        assert_eq!(
17589            state.npc_market_dump_unit_estimate("lumber"),
17590            Some(9),
17591            "vault stack base_value should enable NPC price estimate"
17592        );
17593    }
17594
17595    #[test]
17596    fn probe_use_world_npc_beats_nearby_loot() {
17597        let mut state = sample_state();
17598        state.npcs.push(flatland_protocol::NpcView {
17599            id: "ada".into(),
17600            label: "Ada".into(),
17601            role: "broker".into(),
17602            x: 129.0,
17603            y: 128.0,
17604            building_id: None,
17605            entity_id: None,
17606            life_state: None,
17607            hp_pct: None,
17608            can_trade: true,
17609            buy_templates: vec!["lumber".into()],
17610            tile_id: None,
17611            behavior_state: None,
17612            presentation_state: None,
17613            sprite_mode: None,
17614            paperdoll_ref: None,
17615            draw_scale: 1.0,
17616            yaw: None,
17617            perception_fov_deg: None,
17618            perception_sight_m: None,
17619            perception_hear_m: None,
17620            quest_verbs: Vec::new(),
17621        });
17622        state.ground_drops.push(flatland_protocol::GroundDropView {
17623            id: "d1".into(),
17624            template_id: "lumber".into(),
17625            quantity: 1,
17626            x: 128.5,
17627            y: 128.0,
17628            z: 0.0,
17629            tile_id: None,
17630            display_name: None,
17631            yaw: 0.0,
17632            pitch: 0.0,
17633            roll: 0.0,
17634            draw_scale: 1.0,
17635            item_instance_id: None,
17636            props: Default::default(),
17637            status_bindings: Vec::new(),
17638        });
17639        let probe = state.probe_use_world();
17640        let primary = probe.primary.expect("primary");
17641        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17642        assert_eq!(primary.id, "ada");
17643    }
17644
17645    #[test]
17646    fn probe_use_world_harvest_when_in_range() {
17647        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
17648        let probe = state.probe_use_world();
17649        assert!(
17650            probe.primary.is_none(),
17651            "oak is 2m away, out of harvest range"
17652        );
17653        assert!(probe
17654            .candidates
17655            .iter()
17656            .any(|c| c.kind == crate::UseWorldKind::Harvest));
17657
17658        let mut state = sample_state();
17659        state.resource_nodes[0].x = 129.0;
17660        let probe = state.probe_use_world();
17661        let primary = probe.primary.expect("primary");
17662        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17663    }
17664
17665    #[test]
17666    fn probe_use_world_door_uses_building_label() {
17667        let mut state = sample_state();
17668        state.doors[0].x = 129.0;
17669        state.doors[0].y = 128.0;
17670        let probe = state.probe_use_world();
17671        let primary = probe.primary.expect("primary");
17672        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17673        assert_eq!(primary.label, "Broker");
17674        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17675    }
17676
17677    #[test]
17678    fn empty_entity_tick_preserves_welcome_snapshot() {
17679        let mut state = sample_state();
17680        state.inventory.insert("carrot".into(), 3);
17681        let delta = TickDelta {
17682            tick: 1,
17683            entities: vec![],
17684            resource_nodes: vec![],
17685            ground_drops: vec![],
17686            placed_containers: vec![],
17687            buildings: vec![],
17688            doors: vec![],
17689            interior_map: None,
17690            npcs: vec![],
17691            inventory: vec![],
17692            blueprints: vec![],
17693            building_materials: vec![],
17694            world_clock: flatland_protocol::WorldClock::default(),
17695            combat: None,
17696            quest_log: vec![],
17697            hired_workers: Vec::new(),
17698            interactables: vec![],
17699            ledger: None,
17700            career: None,
17701            combat_fx: Vec::new(),
17702            ground_hazards: Vec::new(),
17703            property_plots: Vec::new(),
17704            terrain_overlays: Vec::new(),
17705        };
17706
17707        state.apply_tick_fields(&delta, 1);
17708
17709        assert_eq!(state.entities.len(), 1);
17710        assert!(state.player.is_some());
17711        assert_eq!(state.inventory.get("carrot"), Some(&3));
17712        assert_eq!(state.resource_nodes.len(), 1);
17713    }
17714
17715    #[test]
17716    fn tick_preserves_world_layers_when_delta_omits_them() {
17717        let mut state = sample_state();
17718        let delta = TickDelta {
17719            tick: 1,
17720            entities: state.entities.clone(),
17721            resource_nodes: vec![],
17722            ground_drops: vec![],
17723            placed_containers: vec![],
17724            buildings: vec![],
17725            doors: vec![],
17726            interior_map: None,
17727            npcs: vec![],
17728            inventory: vec![],
17729            blueprints: vec![],
17730            building_materials: vec![],
17731            world_clock: flatland_protocol::WorldClock::default(),
17732            combat: None,
17733            quest_log: vec![],
17734            hired_workers: Vec::new(),
17735            interactables: vec![],
17736            ledger: None,
17737            career: None,
17738            combat_fx: Vec::new(),
17739            ground_hazards: Vec::new(),
17740            property_plots: Vec::new(),
17741            terrain_overlays: Vec::new(),
17742        };
17743
17744        state.apply_tick_fields(&delta, 1);
17745
17746        assert_eq!(state.resource_nodes.len(), 1);
17747        assert_eq!(state.buildings.len(), 1);
17748        assert_eq!(state.doors.len(), 1);
17749    }
17750
17751    #[test]
17752    fn tick_updates_resource_nodes_when_server_sends_them() {
17753        let mut state = sample_state();
17754        let delta = TickDelta {
17755            tick: 1,
17756            entities: state.entities.clone(),
17757            resource_nodes: vec![ResourceNodeView {
17758                id: "oak-1".into(),
17759                label: "Oak".into(),
17760                x: 130.0,
17761                y: 128.0,
17762                z: 0.0,
17763                item_template: "oak_log".into(),
17764                state: ResourceNodeState::Cooldown,
17765                blocking: true,
17766                blocking_radius_m: 0.8,
17767                harvest_off: false,
17768                tile_id: None,
17769                yaw: 0.0,
17770                pitch: 0.0,
17771                roll: 0.0,
17772                draw_scale: 1.0,
17773                sprite_mode: None,
17774                growth_progress: None,
17775                presentation_state: None,
17776                channel_start_tick: None,
17777                channel_end_tick: None,
17778                harvest_drop_templates: vec![],
17779            }],
17780            buildings: vec![],
17781            doors: vec![],
17782            interior_map: None,
17783            npcs: vec![],
17784            inventory: vec![],
17785            blueprints: vec![],
17786            building_materials: vec![],
17787            world_clock: flatland_protocol::WorldClock::default(),
17788            ground_drops: vec![],
17789            placed_containers: vec![],
17790            combat: None,
17791            quest_log: vec![],
17792            hired_workers: Vec::new(),
17793            interactables: vec![],
17794            ledger: None,
17795            career: None,
17796            combat_fx: Vec::new(),
17797            ground_hazards: Vec::new(),
17798            property_plots: Vec::new(),
17799            terrain_overlays: Vec::new(),
17800        };
17801
17802        state.apply_tick_fields(&delta, 1);
17803
17804        assert!(matches!(
17805            state.resource_nodes[0].state,
17806            ResourceNodeState::Cooldown
17807        ));
17808    }
17809
17810    #[test]
17811    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17812        let mut state = GameState {
17813            session_id: 1,
17814            entity_id: 1,
17815            character_id: None,
17816            tick: 0,
17817            chunk_rev: 0,
17818            content_rev: 0,
17819            publish_rev: 0,
17820            entities: vec![EntityState {
17821                id: 1,
17822                label: "You".into(),
17823                transform: Transform {
17824                    position: WorldCoord::surface(4.5, 2.0),
17825                    yaw: 0.0,
17826                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17827                },
17828                vitals: None,
17829                attributes: None,
17830                skills: None,
17831                inside_building: Some("broker_hut".into()),
17832                tile_id: None,
17833                paperdoll_ref: None,
17834                draw_scale: 1.0,
17835                presentation_state: None,
17836                sprite_mode: None,
17837                progression_xp: None,
17838                combat_cues: vec![],
17839                statuses: vec![],
17840            }],
17841            player: None,
17842            resource_nodes: vec![],
17843            ground_drops: vec![],
17844            placed_containers: vec![],
17845            buildings: vec![BuildingView {
17846                id: "broker_hut".into(),
17847                label: "Broker".into(),
17848                x: 158.0,
17849                y: 124.0,
17850                width_m: 8.0,
17851                depth_m: 6.0,
17852                interior_blueprint: Some("broker_hut".into()),
17853                tags: vec![],
17854                market_boundary_zone_ids: vec![],
17855                market_max_volume: None,
17856                wall_set: None,
17857                roof_set: None,
17858            }],
17859            doors: vec![flatland_protocol::DoorView {
17860                id: "broker_hut_exit".into(),
17861                building_id: "broker_hut".into(),
17862                x: 4.3,
17863                y: 0.9,
17864                open: true,
17865                portal: Some("front".into()),
17866                locked: false,
17867                accessible: true,
17868                lock_id: None,
17869            }],
17870            interior_map: None,
17871            npcs: vec![flatland_protocol::NpcView {
17872                id: "ada_broker".into(),
17873                label: "Ada".into(),
17874                x: 4.5,
17875                y: 2.0,
17876                building_id: Some("broker_hut".into()),
17877                role: "broker".into(),
17878                entity_id: None,
17879                life_state: None,
17880                hp_pct: None,
17881                can_trade: true,
17882                buy_templates: vec!["lumber".into()],
17883                tile_id: None,
17884                behavior_state: None,
17885                presentation_state: None,
17886                sprite_mode: None,
17887                paperdoll_ref: None,
17888                draw_scale: 1.0,
17889                yaw: None,
17890                perception_fov_deg: None,
17891                perception_sight_m: None,
17892                perception_hear_m: None,
17893                quest_verbs: Vec::new(),
17894            }],
17895            blueprints: vec![],
17896            building_materials: vec![],
17897            world_x0: 0.0,
17898            world_y0: 0.0,
17899            world_width_m: 256.0,
17900            world_height_m: 256.0,
17901            terrain_zones: Vec::new(),
17902            z_platforms: Vec::new(),
17903            z_transitions: Vec::new(),
17904            z_bands_outdoor_backup: None,
17905            world_clock: flatland_protocol::WorldClock::default(),
17906            inventory: std::collections::HashMap::new(),
17907            inventory_hints: std::collections::HashMap::new(),
17908            item_catalog: std::collections::HashMap::new(),
17909            logs: VecDeque::new(),
17910            intents_sent: 0,
17911            ticks_received: 0,
17912            connected: true,
17913            disconnect_reason: None,
17914            show_stats: false,
17915            hud_log_hidden: false,
17916            show_equip_menu: false,
17917            equip_menu_index: 0,
17918            show_craft_menu: false,
17919            show_plot_build_menu: false,
17920            plot_build_focus_wall: true,
17921            plot_build_wall_index: 0,
17922            plot_build_roof_index: 0,
17923            craft_menu_index: 0,
17924            craft_batch_quantity: 1,
17925            craft_tab: CraftTab::Ready,
17926            craft_filter: String::new(),
17927            craft_filter_focused: false,
17928            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17929            show_shop_menu: false,
17930            shop_catalog: None,
17931            bank_panel: None,
17932            bank_menu_index: 0,
17933            bank_ui_mode: BankUiMode::Menu,
17934            storage_panel: None,
17935            market_panel: None,
17936            market_menu_index: 0,
17937            market_filter: String::new(),
17938            market_filter_focused: false,
17939            market_category_filter: None,
17940            market_buy_confirm: None,
17941            market_ui_mode: MarketUiMode::Browse,
17942            storage_menu_index: 0,
17943            storage_ui_mode: StorageUiMode::Menu,
17944            shop_tab: ShopTab::default(),
17945            shop_menu_index: 0,
17946            shop_quantity: 1,
17947            shop_trade_log: VecDeque::new(),
17948            show_npc_verb_menu: false,
17949            npc_verb_target: None,
17950            npc_verb_index: 0,
17951            npc_verb_notice: None,
17952            player_verbs: crate::social::PlayerVerbState::default(),
17953            social_chat: crate::social::SocialChatState::default(),
17954            trade_ui: crate::social::TradeUiState::default(),
17955            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17956            show_npc_chat: false,
17957            npc_chat: None,
17958            show_inventory_menu: false,
17959            inventory_menu_index: 0,
17960            inventory_tab: InventoryTab::OnPerson,
17961            inventory_filter: String::new(),
17962            inventory_filter_focused: false,
17963            show_move_picker: false,
17964            show_rename_prompt: false,
17965            rename_plot_id: None,
17966            highlighted_plot_id: None,
17967            show_worker_rename: false,
17968            rename_buffer: String::new(),
17969            move_picker_index: 0,
17970            move_picker: None,
17971            show_grant_picker: false,
17972            grant_picker_index: 0,
17973            grant_picker: None,
17974            show_destroy_picker: false,
17975            destroy_confirm_pending: false,
17976            destroy_picker: None,
17977            combat_target: None,
17978            combat_target_label: None,
17979            ground_target: None,
17980            combat_fx: Vec::new(),
17981            ground_hazards: Vec::new(),
17982            property_zones: Vec::new(),
17983            tax_zones: Vec::new(),
17984            growth_zones: Vec::new(),
17985            biome_zones: Vec::new(),
17986            terrain_kind_nav: Vec::new(),
17987            property_plots: Vec::new(),
17988            property_plot_settings: None,
17989            claim_mode: None,
17990            relocate_mode: None,
17991            sell_plot_confirm: None,
17992            sell_plot_armed_at: None,
17993            show_plant_menu: false,
17994            plant_menu_index: 0,
17995            show_farm_access: false,
17996            farm_access_name_draft: String::new(),
17997            farm_access_discount_bps: 0,
17998            farm_access_index: 0,
17999            plant_quantity: 1,
18000            in_combat: false,
18001            auto_attack: true,
18002            combat_has_los: false,
18003            attack_cd_ticks: 0,
18004            gcd_ticks: 0,
18005            weapon_ability_id: "unarmed".into(),
18006            mainhand_template_id: None,
18007            mainhand_label: None,
18008            mainhand_instance_id: None,
18009            offhand_template_id: None,
18010            offhand_label: None,
18011            offhand_instance_id: None,
18012            mainhand_hand_slots: 1,
18013            defense: None,
18014            worn: BTreeMap::new(),
18015            carry_mass: 0.0,
18016            carry_mass_max: 0.0,
18017            encumbrance: flatland_protocol::EncumbranceState::Light,
18018            move_speed_mps: 0.0,
18019            move_speed_mult: 0.0,
18020            inventory_stacks: Vec::new(),
18021            keychain_stacks: Vec::new(),
18022            whisper_pouch_stacks: Vec::new(),
18023            combat_target_detail: None,
18024            statuses: Vec::new(),
18025            cast_progress: None,
18026            timed_channel: None,
18027            plot_build_offer: None,
18028            ability_cooldowns: Vec::new(),
18029            blocking_active: false,
18030            max_target_slots: 1,
18031            combat_slots: Vec::new(),
18032            rotation_presets: Vec::new(),
18033            known_abilities: Vec::new(),
18034            ability_meta: std::collections::HashMap::new(),
18035            ability_mastery: std::collections::HashMap::new(),
18036            hotbar: vec![None; 9],
18037            max_abilities_per_rotation: 0,
18038            show_loadout_menu: false,
18039            show_keychain_menu: false,
18040            keychain_menu_index: 0,
18041            show_rotation_editor: false,
18042            loadout_menu_index: 0,
18043            loadout_hotbar_slot: 1,
18044            loadout_ability_index: 0,
18045            loadout_focus_presets: false,
18046            rotation_editor: RotationEditorState::default(),
18047            harvest_in_progress: false,
18048            harvest_started_at: None,
18049            pending_craft_ack: None,
18050            craft_channel_blueprint_id: None,
18051            pending_worker_job_ack: None,
18052            attending_worker_instance_id: None,
18053            quest_log: Vec::new(),
18054            interactables: Vec::new(),
18055            ledger: None,
18056            career: None,
18057            character_sheet_tab: CharacterSheetTab::Character,
18058            ledger_period: LedgerPeriod::Day,
18059            show_quest_offer: false,
18060            pending_quest_offers: Vec::new(),
18061                quest_offer_index: 0,
18062            show_quest_menu: false,
18063            quest_menu_index: 0,
18064            quest_withdraw_confirm: false,
18065            hired_workers: Vec::new(),
18066            show_workers_menu: false,
18067            workers_menu_index: 0,
18068            worker_dismiss_confirmation: None,
18069            workers_menu_compact: false,
18070            worker_step_display: BTreeMap::new(),
18071            worker_error_display: BTreeMap::new(),
18072            worker_health_ring_until: BTreeMap::new(),
18073            pending_worker_hire_since: None,
18074            show_worker_give_picker: false,
18075            worker_give_picker_index: 0,
18076            worker_give_picker: None,
18077            show_worker_give_target_picker: false,
18078            worker_give_target_picker_index: 0,
18079            worker_give_target_picker: None,
18080            show_worker_take_picker: false,
18081            worker_take_picker_index: 0,
18082            worker_take_picker: None,
18083            show_worker_teach_picker: false,
18084            worker_teach_picker_index: 0,
18085            worker_teach_picker: None,
18086            worker_route_editor: None,
18087            progression_curve: None,
18088        };
18089        state.player = state.entities.first().cloned();
18090        assert_eq!(
18091            state.nearest_interact_target().as_deref(),
18092            Some("ada_broker")
18093        );
18094    }
18095
18096    #[test]
18097    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18098        let mut state = sample_state();
18099        // Player is at (128, 128) per sample_state(). One chest just inside
18100        // CONTAINER_RANGE_M, one clearly beyond it.
18101        state.placed_containers = vec![
18102            flatland_protocol::PlacedContainerView {
18103                id: "near".into(),
18104                template_id: "wooden_chest_small".into(),
18105                display_name: "Wooden Chest".into(),
18106                x: 130.0,
18107                y: 128.0,
18108                z: 0.0,
18109                locked: true,
18110                accessible: true,
18111                owner_character_id: None,
18112                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18113                lock_id: None,
18114                capacity_volume: None,
18115                item_instance_id: Some(uuid::Uuid::from_u128(1)),
18116                tile_id: None,
18117                worker_lodging_capacity: None,
18118                blocking: false,
18119                blocking_radius_m: 0.0,
18120                building_id: None,
18121            },
18122            flatland_protocol::PlacedContainerView {
18123                id: "far".into(),
18124                template_id: "wooden_chest_small".into(),
18125                display_name: "Distant Chest".into(),
18126                x: 128.0 + CONTAINER_RANGE_M + 5.0,
18127                y: 128.0,
18128                z: 0.0,
18129                locked: false,
18130                accessible: true,
18131                owner_character_id: None,
18132                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18133                lock_id: None,
18134                capacity_volume: None,
18135                item_instance_id: Some(uuid::Uuid::from_u128(2)),
18136                tile_id: None,
18137                worker_lodging_capacity: None,
18138                blocking: false,
18139                blocking_radius_m: 0.0,
18140                building_id: None,
18141            },
18142        ];
18143
18144        let nearby = state.nearby_containers();
18145        assert_eq!(
18146            nearby.len(),
18147            1,
18148            "far chest must not appear once out of range"
18149        );
18150        assert_eq!(nearby[0].view.id, "near");
18151        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18152        assert!(nearby[0].rows[0].is_chest_shell);
18153
18154        // The same chest, but locked and inaccessible (no key held), must hide
18155        // contents but still show the selectable chest shell row.
18156        state.placed_containers[0].accessible = false;
18157        let nearby = state.nearby_containers();
18158        assert_eq!(nearby.len(), 1);
18159        assert_eq!(nearby[0].rows.len(), 1);
18160        assert!(nearby[0].rows[0].is_chest_shell);
18161    }
18162
18163    #[test]
18164    fn chest_pickup_destinations_offer_person_and_worn_bag() {
18165        let mut state = sample_state();
18166        let back_id = uuid::Uuid::from_u128(42);
18167        state.worn.insert(
18168            BodySlot::Back,
18169            flatland_protocol::ItemStack {
18170                template_id: "travel_backpack".into(),
18171                quantity: 1,
18172                item_instance_id: Some(back_id),
18173                props: Default::default(),
18174                status_bindings: Vec::new(),
18175                contents: Vec::new(),
18176                display_name: Some("Travel Backpack".into()),
18177                category: Some("container".into()),
18178                base_mass: Some(2.5),
18179                base_volume: Some(12.0),
18180                capacity_volume: Some(80.0),
18181                stackable: Some(false),
18182                world_placeable: Some(false),
18183                worker_lodging_capacity: None,
18184                equip_slot: None,
18185                armor_physical: None,
18186                resists: vec![],
18187                hand_slots: None,
18188                listable: None,
18189                ..Default::default()
18190            },
18191        );
18192        let opts = state.chest_pickup_destinations("chest-1");
18193        assert!(matches!(
18194            opts.first().map(|o| &o.kind),
18195            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18196        ));
18197        assert!(opts.iter().any(|o| matches!(
18198            &o.kind,
18199            MoveOptionKind::PickupPlaced {
18200                nest_parent_instance_id: None,
18201                ..
18202            }
18203        )));
18204        assert!(opts.iter().any(|o| matches!(
18205            &o.kind,
18206            MoveOptionKind::PickupPlaced {
18207                nest_parent_instance_id: Some(id),
18208                ..
18209            } if *id == back_id
18210        )));
18211        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18212    }
18213
18214    #[test]
18215    fn placed_container_public_label_hides_owner_custom_name() {
18216        let owner = uuid::Uuid::from_u128(99);
18217        let mut state = sample_state();
18218        state.character_id = Some(uuid::Uuid::from_u128(1));
18219        state.inventory_hints.insert(
18220            "wooden_chest_medium".into(),
18221            InventoryHint {
18222                display_name: "Medium Wooden Chest".into(),
18223                category: "container".into(),
18224                base_mass: None,
18225                base_volume: None,
18226                capacity_volume: None,
18227                stackable: false,
18228                listable: true,
18229                base_value_copper: None,
18230            },
18231        );
18232        let chest = flatland_protocol::PlacedContainerView {
18233            id: "c1".into(),
18234            template_id: "wooden_chest_medium".into(),
18235            display_name: "Barry's Loot #a3f2".into(),
18236            x: 128.0,
18237            y: 128.0,
18238            z: 0.0,
18239            locked: false,
18240            accessible: true,
18241            owner_character_id: Some(owner),
18242            contents: vec![],
18243            lock_id: None,
18244            capacity_volume: None,
18245            item_instance_id: None,
18246            tile_id: None,
18247            worker_lodging_capacity: None,
18248            blocking: false,
18249            blocking_radius_m: 0.0,
18250            building_id: None,
18251        };
18252        assert_eq!(
18253            state.placed_container_public_label(&chest),
18254            "Medium Wooden Chest"
18255        );
18256        state.character_id = Some(owner);
18257        assert_eq!(
18258            state.placed_container_public_label(&chest),
18259            "Barry's Loot #a3f2"
18260        );
18261    }
18262
18263    #[test]
18264    fn location_context_shows_crop_growth_percent_not_depleted() {
18265        let mut state = sample_state();
18266        state.player = state.entities.first().cloned();
18267        state.resource_nodes[0].label = "Carrot (growing)".into();
18268        state.resource_nodes[0].x = 128.2;
18269        state.resource_nodes[0].y = 128.0;
18270        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18271        state.resource_nodes[0].growth_progress = Some(0.47);
18272        let lines = state.location_context_lines();
18273        let line = lines
18274            .iter()
18275            .find(|l| l.text.contains("Carrot"))
18276            .map(|l| l.text.as_str())
18277            .unwrap_or("");
18278        assert!(
18279            line.contains("(growing, 47%)"),
18280            "expected growth percent, got: {line}"
18281        );
18282        assert!(
18283            !line.contains("depleted"),
18284            "growing crop should not show depleted: {line}"
18285        );
18286    }
18287
18288    #[test]
18289    fn resource_node_near_action_suffix_prefers_growth() {
18290        let node = ResourceNodeView {
18291            id: "crop".into(),
18292            label: "Wheat".into(),
18293            x: 0.0,
18294            y: 0.0,
18295            z: 0.0,
18296            item_template: "wheat".into(),
18297            state: ResourceNodeState::Cooldown,
18298            blocking: false,
18299            blocking_radius_m: 0.0,
18300            harvest_off: false,
18301            tile_id: None,
18302            yaw: 0.0,
18303            pitch: 0.0,
18304            roll: 0.0,
18305            draw_scale: 1.0,
18306            sprite_mode: None,
18307            growth_progress: Some(0.12),
18308            presentation_state: None,
18309            channel_start_tick: None,
18310            channel_end_tick: None,
18311            harvest_drop_templates: vec![],
18312        };
18313        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18314    }
18315
18316    #[test]
18317    fn location_context_lists_nearby_resource_node() {
18318        let mut state = sample_state();
18319        state.player = state.entities.first().cloned();
18320        state.resource_nodes[0].x = 128.2;
18321        state.resource_nodes[0].y = 128.0;
18322        let lines = state.location_context_lines();
18323        assert!(
18324            lines
18325                .iter()
18326                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18327            "expected resource node in context: {:?}",
18328            lines
18329        );
18330    }
18331
18332    #[test]
18333    fn quest_board_usable_within_board_radius() {
18334        let mut state = sample_state();
18335        state.player = state.entities.first().cloned();
18336        state.interactables = vec![flatland_protocol::InteractableView {
18337            id: "board-1".into(),
18338            kind: "quest_board".into(),
18339            label: "Town Quest Board".into(),
18340            x: 130.5,
18341            y: 128.0,
18342            z: 0.0,
18343            board_id: Some("starter_town_board".into()),
18344        }];
18345        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18346        assert_eq!(
18347            state.nearest_interact_target().as_deref(),
18348            Some("board-1"),
18349            "quest board should be selectable at ~2.5m"
18350        );
18351        let lines = state.location_context_lines();
18352        assert!(
18353            lines
18354                .iter()
18355                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18356            "HUD should advertise f when board is in range: {:?}",
18357            lines
18358        );
18359    }
18360
18361    #[test]
18362    fn quest_board_keeps_multiple_offers() {
18363        let mut state = sample_state();
18364        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18365            quest_id: id.into(),
18366            title: title.into(),
18367            description: format!("{title} desc"),
18368            step_count: 2,
18369        };
18370        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18371        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18372        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18373        assert_eq!(state.pending_quest_offers.len(), 2);
18374        assert_eq!(
18375            state.selected_quest_offer().unwrap().quest_id,
18376            "ada_goblin_hunt"
18377        );
18378        state.move_quest_offer_selection(1);
18379        assert_eq!(
18380            state.selected_quest_offer().unwrap().quest_id,
18381            "daily_20695_1"
18382        );
18383        state.remove_quest_offer("daily_20695_1");
18384        assert_eq!(state.pending_quest_offers.len(), 1);
18385        assert!(state.show_quest_offer);
18386        state.remove_quest_offer("ada_goblin_hunt");
18387        assert!(!state.show_quest_offer);
18388        assert!(state.pending_quest_offers.is_empty());
18389    }
18390
18391    #[test]
18392    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18393        let mut state = sample_state();
18394        state.worn.insert(
18395            BodySlot::Back,
18396            flatland_protocol::ItemStack {
18397                template_id: "travel_backpack".into(),
18398                quantity: 1,
18399                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18400                props: Default::default(),
18401                status_bindings: Vec::new(),
18402                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18403                display_name: None,
18404                category: None,
18405                base_mass: None,
18406                base_volume: None,
18407                capacity_volume: None,
18408                stackable: None,
18409                world_placeable: None,
18410                worker_lodging_capacity: None,
18411                equip_slot: None,
18412                armor_physical: None,
18413                resists: vec![],
18414                hand_slots: None,
18415                listable: None,
18416                ..Default::default()
18417            },
18418        );
18419        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18420        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18421            id: "chest-1".into(),
18422            template_id: "wooden_chest_small".into(),
18423            display_name: "Wooden Chest".into(),
18424            x: 129.0,
18425            y: 128.0,
18426            z: 0.0,
18427            locked: false,
18428            accessible: true,
18429            owner_character_id: None,
18430            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18431            lock_id: None,
18432            capacity_volume: None,
18433            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18434            tile_id: None,
18435            worker_lodging_capacity: None,
18436            blocking: false,
18437            blocking_radius_m: 0.0,
18438            building_id: None,
18439        }];
18440
18441        state.inventory_tab = InventoryTab::OnPerson;
18442        let rows = state.inventory_selectable_rows();
18443        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18444        assert_eq!(
18445            sections,
18446            vec![
18447                InventorySection::Person, // iron_ore carried in backpack
18448                InventorySection::Person, // lumber
18449            ]
18450        );
18451        assert_eq!(rows[0].stack.template_id, "iron_ore");
18452        assert_eq!(rows[0].depth, 0);
18453        assert!(!rows[0].is_equip_shell);
18454        assert_eq!(rows[1].stack.template_id, "lumber");
18455
18456        let lines = state.inventory_browser_lines();
18457        assert!(lines.iter().any(|l| matches!(
18458            l,
18459            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18460        )));
18461        assert!(lines.iter().any(|l| matches!(
18462            l,
18463            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18464        )));
18465        assert!(!lines.iter().any(|l| matches!(
18466            l,
18467            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18468        )));
18469        assert!(!lines.iter().any(|l| matches!(
18470            l,
18471            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18472        )));
18473
18474        state.inventory_tab = InventoryTab::Nearby;
18475        let nearby_rows = state.inventory_selectable_rows();
18476        assert_eq!(nearby_rows.len(), 2);
18477        assert!(nearby_rows[0].is_chest_shell);
18478        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18479        let nearby_lines = state.inventory_browser_lines();
18480        assert!(nearby_lines.iter().any(|l| matches!(
18481            l,
18482            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18483        )));
18484    }
18485
18486    #[test]
18487    fn give_worker_notice_does_not_put_item_back_in_bag() {
18488        let mut state = sample_state();
18489        let id = uuid::Uuid::from_u128(42);
18490        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18491        saw.item_instance_id = Some(id);
18492        saw.display_name = Some("Handsaw".into());
18493        state.sync_inventory_from_stacks(&[saw]);
18494        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18495
18496        state.remove_carried_instance(id, None);
18497        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18498        assert!(state.inventory_stacks.is_empty());
18499
18500        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18501            target_id: "worker-1".into(),
18502            message: "Gave 1x Handsaw to Laborer".into(),
18503            coins_delta: 0,
18504            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18505        });
18506        assert_eq!(
18507            state.inventory.get("handsaw").copied().unwrap_or(0),
18508            0,
18509            "Gave notice must not restore the handed stack"
18510        );
18511    }
18512
18513    #[test]
18514    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18515        let mut state = sample_state();
18516        let back_id = uuid::Uuid::from_u128(5);
18517        state.worn.insert(
18518            BodySlot::Back,
18519            flatland_protocol::ItemStack {
18520                template_id: "travel_backpack".into(),
18521                quantity: 1,
18522                item_instance_id: Some(back_id),
18523                props: Default::default(),
18524                status_bindings: Vec::new(),
18525                contents: Vec::new(),
18526                display_name: None,
18527                category: Some("container".into()),
18528                base_mass: None,
18529                base_volume: None,
18530                capacity_volume: Some(80.0),
18531                stackable: None,
18532                world_placeable: None,
18533                worker_lodging_capacity: None,
18534                equip_slot: None,
18535                armor_physical: None,
18536                resists: vec![],
18537                hand_slots: None,
18538                listable: None,
18539                ..Default::default()
18540            },
18541        );
18542        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18543            id: "chest-1".into(),
18544            template_id: "wooden_chest_small".into(),
18545            display_name: "Wooden Chest".into(),
18546            x: 129.0,
18547            y: 128.0,
18548            z: 0.0,
18549            locked: false,
18550            accessible: true,
18551            owner_character_id: None,
18552            contents: Vec::new(),
18553            lock_id: None,
18554            capacity_volume: None,
18555            item_instance_id: Some(uuid::Uuid::from_u128(6)),
18556            tile_id: None,
18557            worker_lodging_capacity: None,
18558            blocking: false,
18559            blocking_radius_m: 0.0,
18560            building_id: None,
18561        }];
18562
18563        // Item currently sitting loose on the person (Root): backpack + nearby
18564        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
18565        let opts = state.move_destinations_for(
18566            &flatland_protocol::InventoryLocation::Root,
18567            None,
18568            None,
18569            "lumber",
18570        );
18571        assert!(!opts.iter().any(|o| matches!(
18572            &o.kind,
18573            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18574        )));
18575        assert!(opts.iter().any(|o| matches!(
18576            &o.kind,
18577            MoveOptionKind::Move { location, parent_instance_id, .. }
18578                if *location == flatland_protocol::InventoryLocation::Worn {
18579                    slot: BodySlot::Back,
18580                } && *parent_instance_id == Some(back_id)
18581        )));
18582        assert!(opts.iter().any(|o| matches!(
18583            &o.kind,
18584            MoveOptionKind::Move { location, .. }
18585                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18586        )));
18587        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18588        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18589
18590        // Item currently inside the worn backpack: the backpack itself must be
18591        // excluded from its own destination list (can't move an item into the
18592        // container it's already in).
18593        let from_backpack = flatland_protocol::InventoryLocation::Worn {
18594            slot: BodySlot::Back,
18595        };
18596        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18597        assert!(!opts.iter().any(|o| matches!(
18598            &o.kind,
18599            MoveOptionKind::Move { location, parent_instance_id, .. }
18600                if *location == from_backpack && *parent_instance_id == Some(back_id)
18601        )));
18602        assert!(opts.iter().any(|o| matches!(
18603            &o.kind,
18604            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18605        )));
18606    }
18607
18608    #[test]
18609    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18610        let mut state = sample_state();
18611        // Insert out of display order — BTreeMap iteration must still yield the
18612        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
18613        state.worn.insert(
18614            BodySlot::Waist,
18615            flatland_protocol::ItemStack {
18616                template_id: "simple_belt".into(),
18617                quantity: 1,
18618                item_instance_id: Some(uuid::Uuid::from_u128(10)),
18619                props: Default::default(),
18620                status_bindings: Vec::new(),
18621                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18622                display_name: None,
18623                category: Some("container".into()),
18624                base_mass: None,
18625                base_volume: None,
18626                capacity_volume: None,
18627                stackable: None,
18628                world_placeable: None,
18629                worker_lodging_capacity: None,
18630                equip_slot: None,
18631                armor_physical: None,
18632                resists: vec![],
18633                hand_slots: None,
18634                listable: None,
18635                ..Default::default()
18636            },
18637        );
18638        state.worn.insert(
18639            BodySlot::Head,
18640            flatland_protocol::ItemStack {
18641                template_id: "cloth_cap".into(),
18642                quantity: 1,
18643                item_instance_id: Some(uuid::Uuid::from_u128(11)),
18644                props: Default::default(),
18645                status_bindings: Vec::new(),
18646                contents: Vec::new(),
18647                display_name: None,
18648                category: Some("armor".into()),
18649                base_mass: None,
18650                base_volume: None,
18651                capacity_volume: None,
18652                stackable: None,
18653                world_placeable: None,
18654                worker_lodging_capacity: None,
18655                equip_slot: None,
18656                armor_physical: None,
18657                resists: vec![],
18658                hand_slots: None,
18659                listable: None,
18660                ..Default::default()
18661            },
18662        );
18663        state.worn.insert(
18664            BodySlot::Back,
18665            flatland_protocol::ItemStack {
18666                template_id: "travel_backpack".into(),
18667                quantity: 1,
18668                item_instance_id: Some(uuid::Uuid::from_u128(12)),
18669                props: Default::default(),
18670                status_bindings: Vec::new(),
18671                contents: Vec::new(),
18672                display_name: None,
18673                category: Some("container".into()),
18674                base_mass: None,
18675                base_volume: None,
18676                capacity_volume: None,
18677                stackable: None,
18678                world_placeable: None,
18679                worker_lodging_capacity: None,
18680                equip_slot: None,
18681                armor_physical: None,
18682                resists: vec![],
18683                hand_slots: None,
18684                listable: None,
18685                ..Default::default()
18686            },
18687        );
18688
18689        let rows = state.worn_rows();
18690        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
18691        assert_eq!(rows.len(), 4);
18692        assert_eq!(rows[0].stack.template_id, "cloth_cap");
18693        assert!(rows[0].is_equip_shell);
18694        assert_eq!(rows[1].stack.template_id, "travel_backpack");
18695        assert!(rows[1].is_equip_shell);
18696        assert_eq!(rows[2].stack.template_id, "simple_belt");
18697        assert!(rows[2].is_equip_shell);
18698        assert_eq!(rows[3].stack.template_id, "leather_pouch");
18699        assert_eq!(rows[3].depth, 1);
18700        assert!(!rows[3].is_equip_shell);
18701    }
18702
18703    #[test]
18704    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18705        let mut state = sample_state();
18706        state.worn.insert(
18707            BodySlot::Waist,
18708            flatland_protocol::ItemStack {
18709                template_id: "simple_belt".into(),
18710                quantity: 1,
18711                item_instance_id: Some(uuid::Uuid::from_u128(20)),
18712                props: Default::default(),
18713                status_bindings: Vec::new(),
18714                contents: Vec::new(),
18715                display_name: Some("Simple Belt".into()),
18716                category: Some("container".into()),
18717                base_mass: None,
18718                base_volume: None,
18719                capacity_volume: None,
18720                stackable: None,
18721                world_placeable: None,
18722                worker_lodging_capacity: None,
18723                equip_slot: None,
18724                armor_physical: None,
18725                resists: vec![],
18726                hand_slots: None,
18727                listable: None,
18728                ..Default::default()
18729            },
18730        );
18731        state.worn.insert(
18732            BodySlot::Head,
18733            flatland_protocol::ItemStack {
18734                template_id: "cloth_cap".into(),
18735                quantity: 1,
18736                item_instance_id: Some(uuid::Uuid::from_u128(21)),
18737                props: Default::default(),
18738                status_bindings: Vec::new(),
18739                contents: Vec::new(),
18740                display_name: Some("Cloth Cap".into()),
18741                category: Some("armor".into()),
18742                base_mass: None,
18743                base_volume: None,
18744                capacity_volume: None,
18745                stackable: None,
18746                world_placeable: None,
18747                worker_lodging_capacity: None,
18748                equip_slot: None,
18749                armor_physical: None,
18750                resists: vec![],
18751                hand_slots: None,
18752                listable: None,
18753                ..Default::default()
18754            },
18755        );
18756
18757        let opts = state.move_destinations_for(
18758            &flatland_protocol::InventoryLocation::Root,
18759            None,
18760            None,
18761            "leather_pouch",
18762        );
18763        assert!(
18764            opts.iter().any(|o| matches!(
18765                &o.kind,
18766                MoveOptionKind::Move { location, .. }
18767                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18768            )),
18769            "belt loop must be offered when moving a pouch"
18770        );
18771        assert!(
18772            !opts.iter().any(|o| matches!(
18773                &o.kind,
18774                MoveOptionKind::Move { location, .. }
18775                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18776            )),
18777            "armor slots can't hold other items and must not appear as move destinations"
18778        );
18779        let belt_opt = opts
18780            .iter()
18781            .find(|o| matches!(
18782                &o.kind,
18783                MoveOptionKind::Move { location, .. }
18784                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18785            ))
18786            .unwrap();
18787        assert!(belt_opt.label.contains("belt loop"));
18788
18789        let opts = state.move_destinations_for(
18790            &flatland_protocol::InventoryLocation::Root,
18791            None,
18792            None,
18793            "lumber",
18794        );
18795        assert!(
18796            !opts.iter().any(|o| o.label.contains("belt loop")),
18797            "loose materials must not target the belt shell — only nested pouches"
18798        );
18799    }
18800
18801    #[test]
18802    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18803        let mut state = sample_state();
18804        let belt_id = uuid::Uuid::from_u128(30);
18805        let pouch_id = uuid::Uuid::from_u128(31);
18806        state.worn.insert(
18807            BodySlot::Waist,
18808            flatland_protocol::ItemStack {
18809                template_id: "simple_belt".into(),
18810                quantity: 1,
18811                item_instance_id: Some(belt_id),
18812                props: Default::default(),
18813                status_bindings: Vec::new(),
18814                world_placeable: None,
18815                worker_lodging_capacity: None,
18816                equip_slot: None,
18817                armor_physical: None,
18818                resists: vec![],
18819                hand_slots: None,
18820                contents: vec![flatland_protocol::ItemStack {
18821                    template_id: "dimensional_pouch".into(),
18822                    quantity: 1,
18823                    item_instance_id: Some(pouch_id),
18824                    props: Default::default(),
18825                    status_bindings: Vec::new(),
18826                    contents: Vec::new(),
18827                    display_name: Some("Dimensional Pouch".into()),
18828                    category: Some("container".into()),
18829                    base_mass: None,
18830                    base_volume: None,
18831                    capacity_volume: Some(200.0),
18832                    stackable: None,
18833                    world_placeable: None,
18834                    worker_lodging_capacity: None,
18835                    equip_slot: None,
18836                    armor_physical: None,
18837                    resists: vec![],
18838                    hand_slots: None,
18839                    listable: None,
18840                    ..Default::default()
18841                }],
18842                display_name: Some("Simple Belt".into()),
18843                category: Some("container".into()),
18844                base_mass: None,
18845                base_volume: None,
18846                capacity_volume: None,
18847                stackable: None,
18848                listable: None,
18849                ..Default::default()
18850            },
18851        );
18852
18853        let opts = state.move_destinations_for(
18854            &flatland_protocol::InventoryLocation::Root,
18855            None,
18856            None,
18857            "iron_ore",
18858        );
18859        assert!(
18860            opts.iter().any(|o| matches!(
18861                &o.kind,
18862                MoveOptionKind::Move {
18863                    location,
18864                    parent_instance_id,
18865                    ..
18866                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18867                    && *parent_instance_id == Some(pouch_id)
18868            )),
18869            "dimensional pouch clipped on belt must accept loose items"
18870        );
18871        assert!(
18872            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18873            "destination label should name the pouch"
18874        );
18875    }
18876
18877    #[test]
18878    fn container_volume_label_on_placed_chest_shell() {
18879        let mut state = sample_state();
18880        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18881            id: "chest-1".into(),
18882            template_id: "wooden_chest_small".into(),
18883            display_name: "Camp Chest".into(),
18884            x: 129.0,
18885            y: 128.0,
18886            z: 0.0,
18887            locked: false,
18888            accessible: true,
18889            owner_character_id: None,
18890            contents: vec![flatland_protocol::ItemStack {
18891                template_id: "iron_ore".into(),
18892                quantity: 2,
18893                item_instance_id: None,
18894                props: Default::default(),
18895                status_bindings: Vec::new(),
18896                contents: Vec::new(),
18897                display_name: None,
18898                category: None,
18899                base_mass: None,
18900                base_volume: Some(2.0),
18901                capacity_volume: None,
18902                stackable: None,
18903                world_placeable: None,
18904                worker_lodging_capacity: None,
18905                equip_slot: None,
18906                armor_physical: None,
18907                resists: vec![],
18908                hand_slots: None,
18909                listable: None,
18910                ..Default::default()
18911            }],
18912            lock_id: None,
18913            capacity_volume: Some(60.0),
18914            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18915            tile_id: None,
18916            worker_lodging_capacity: None,
18917            blocking: false,
18918            blocking_radius_m: 0.0,
18919            building_id: None,
18920        }];
18921        let nearby = state.nearby_containers();
18922        let label = state.container_volume_label(&nearby[0].rows[0]);
18923        assert!(
18924            label.contains("vol 4/60"),
18925            "expected used/cap in label, got {label}"
18926        );
18927        assert!(
18928            label.contains("56 free"),
18929            "expected free space, got {label}"
18930        );
18931    }
18932
18933    #[test]
18934    fn key_pair_chest_label_from_placed_lock_id() {
18935        let mut state = sample_state();
18936        let owner = uuid::Uuid::from_u128(77);
18937        state.character_id = Some(owner);
18938        let lock = uuid::Uuid::from_u128(99).to_string();
18939        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18940            id: "chest-1".into(),
18941            template_id: "wooden_chest_small".into(),
18942            display_name: "Barry's Loot #a3f2".into(),
18943            x: 129.0,
18944            y: 128.0,
18945            z: 0.0,
18946            locked: true,
18947            accessible: true,
18948            owner_character_id: Some(owner),
18949            contents: Vec::new(),
18950            lock_id: Some(lock.clone()),
18951            capacity_volume: None,
18952            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18953            tile_id: None,
18954            worker_lodging_capacity: None,
18955            blocking: false,
18956            blocking_radius_m: 0.0,
18957            building_id: None,
18958        }];
18959        let key_id = uuid::Uuid::from_u128(5);
18960        let key = flatland_protocol::ItemStack {
18961            template_id: KEY_TEMPLATE.into(),
18962            quantity: 1,
18963            item_instance_id: Some(key_id),
18964            props: BTreeMap::from([
18965                (PROP_OPENS_LOCK_ID.into(), lock),
18966                (
18967                    PROP_OPENS_CONTAINER_NAME.into(),
18968                    "Barry's Loot #a3f2".into(),
18969                ),
18970            ]),
18971            status_bindings: Vec::new(),
18972            contents: Vec::new(),
18973            display_name: Some("Container Key".into()),
18974            category: Some("key".into()),
18975            base_mass: None,
18976            base_volume: None,
18977            capacity_volume: None,
18978            stackable: None,
18979            world_placeable: None,
18980            worker_lodging_capacity: None,
18981            equip_slot: None,
18982            armor_physical: None,
18983            resists: vec![],
18984            hand_slots: None,
18985            listable: None,
18986            ..Default::default()
18987        };
18988        state.inventory_stacks = vec![key.clone()];
18989        assert_eq!(
18990            state.key_pair_chest_label(&key).as_deref(),
18991            Some("Barry's Loot #a3f2")
18992        );
18993        assert!(state.key_drop_blocked(&key));
18994    }
18995
18996    #[test]
18997    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18998        let mut state = sample_state();
18999        let lock = uuid::Uuid::from_u128(101).to_string();
19000        let key = flatland_protocol::ItemStack {
19001            template_id: KEY_TEMPLATE.into(),
19002            quantity: 1,
19003            item_instance_id: Some(uuid::Uuid::from_u128(7)),
19004            props: BTreeMap::from([
19005                (PROP_OPENS_LOCK_ID.into(), lock),
19006                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19007            ]),
19008            status_bindings: Vec::new(),
19009            contents: Vec::new(),
19010            display_name: None,
19011            category: Some("key".into()),
19012            base_mass: None,
19013            base_volume: None,
19014            capacity_volume: None,
19015            stackable: None,
19016            world_placeable: None,
19017            worker_lodging_capacity: None,
19018            equip_slot: None,
19019            armor_physical: None,
19020            resists: vec![],
19021            hand_slots: None,
19022            listable: None,
19023            ..Default::default()
19024        };
19025        state.placed_containers.clear();
19026        assert_eq!(
19027            state.key_pair_chest_label(&key).as_deref(),
19028            Some("Camp Stash")
19029        );
19030    }
19031
19032    #[test]
19033    fn key_drop_allowed_when_paired_chest_unlocked() {
19034        let mut state = sample_state();
19035        let lock = uuid::Uuid::from_u128(100).to_string();
19036        let key_id = uuid::Uuid::from_u128(6);
19037        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19038            id: "chest-1".into(),
19039            template_id: "wooden_chest_small".into(),
19040            display_name: "Camp Chest".into(),
19041            x: 129.0,
19042            y: 128.0,
19043            z: 0.0,
19044            locked: false,
19045            accessible: true,
19046            owner_character_id: None,
19047            contents: Vec::new(),
19048            lock_id: Some(lock.clone()),
19049            capacity_volume: None,
19050            item_instance_id: None,
19051            tile_id: None,
19052            worker_lodging_capacity: None,
19053            blocking: false,
19054            blocking_radius_m: 0.0,
19055            building_id: None,
19056        }];
19057        let key = flatland_protocol::ItemStack {
19058            template_id: KEY_TEMPLATE.into(),
19059            quantity: 1,
19060            item_instance_id: Some(key_id),
19061            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19062            status_bindings: Vec::new(),
19063            contents: Vec::new(),
19064            display_name: None,
19065            category: Some("key".into()),
19066            base_mass: None,
19067            base_volume: None,
19068            capacity_volume: None,
19069            stackable: None,
19070            world_placeable: None,
19071            worker_lodging_capacity: None,
19072            equip_slot: None,
19073            armor_physical: None,
19074            resists: vec![],
19075            hand_slots: None,
19076            listable: None,
19077            ..Default::default()
19078        };
19079        state.inventory_stacks = vec![key.clone()];
19080        assert!(!state.key_drop_blocked(&key));
19081        let opts = state.move_destinations_for(
19082            &flatland_protocol::InventoryLocation::Root,
19083            None,
19084            Some(key_id),
19085            KEY_TEMPLATE,
19086        );
19087        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19088    }
19089
19090    #[test]
19091    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19092        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19093
19094        let mut state = sample_state();
19095        let curve = ProgressionCurve::default();
19096        let bootstrap =
19097            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19098        let mut fresh = bootstrap.clone();
19099        fresh.strength += 0.08;
19100        if let Some(player) = state.player.as_mut() {
19101            player.progression_xp = Some(bootstrap);
19102        }
19103
19104        let combat = CombatHud {
19105            progression_xp: Some(fresh.clone()),
19106            progression_baseline: curve.baseline_display,
19107            progression_xp_base: curve.xp_base,
19108            progression_xp_growth: curve.xp_growth,
19109            attributes: state.player.as_ref().and_then(|p| p.attributes),
19110            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19111            ..CombatHud::default()
19112        };
19113        state.apply_combat_hud(&combat);
19114
19115        let xp = state
19116            .player
19117            .as_ref()
19118            .and_then(|p| p.progression_xp.as_ref())
19119            .expect("xp");
19120        assert!((xp.strength - fresh.strength).abs() < 0.001);
19121        assert!(state.progression_curve.is_some());
19122    }
19123
19124    #[test]
19125    fn combat_hud_syncs_known_abilities_and_hotbar() {
19126        use flatland_protocol::CombatHud;
19127
19128        let mut state = sample_state();
19129        let combat = CombatHud {
19130            known_abilities: vec!["unarmed".into(), "fireball".into()],
19131            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19132            max_abilities_per_rotation: 4,
19133            ability_id: "short_sword_slash".into(),
19134            ..CombatHud::default()
19135        };
19136        state.apply_combat_hud(&combat);
19137
19138        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19139        assert_eq!(state.hotbar_ability(1), Some("fireball"));
19140        assert_eq!(state.hotbar_ability(2), None);
19141        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19142        assert_eq!(state.max_abilities_per_rotation, 4);
19143        let choices = state.loadout_ability_choices();
19144        assert!(choices.iter().any(|a| a == "short_sword_slash"));
19145        assert!(choices.iter().any(|a| a == "fireball"));
19146    }
19147
19148    #[test]
19149    fn loadout_hotbar_choices_include_inventory_consumables() {
19150        let mut state = sample_state();
19151        state.known_abilities = vec!["unarmed".into()];
19152        state.weapon_ability_id = "unarmed".into();
19153        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19154            template_id: "empty_bottle".into(),
19155            quantity: 1,
19156            item_instance_id: Some(uuid::Uuid::from_u128(9)),
19157            display_name: Some("Glass Bottle of Water".into()),
19158            category: Some("container".into()),
19159            props: [
19160                ("serving".into(), "1".into()),
19161                ("liquid_vessel".into(), "1".into()),
19162                ("serving_holds".into(), "liquid".into()),
19163            ]
19164            .into_iter()
19165            .collect(),
19166            ..Default::default()
19167        }];
19168        state.inventory.insert("empty_bottle".into(), 1);
19169        state.inventory_hints.insert(
19170            "empty_bottle".into(),
19171            InventoryHint {
19172                display_name: "Glass Bottle".into(),
19173                category: "container".into(),
19174                ..Default::default()
19175            },
19176        );
19177
19178        let choices = state.loadout_hotbar_choices();
19179        assert!(choices.iter().any(|c| c.binding == "unarmed"));
19180        let water = choices
19181            .iter()
19182            .find(|c| c.binding == "item:empty_bottle")
19183            .expect("serving bottle binding");
19184        assert_eq!(water.meta.as_deref(), Some("use"));
19185        assert!(water.label.contains("Glass Bottle of Water"));
19186        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19187        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19188        assert_eq!(
19189            state.hotbar_slot_label(5).as_deref(),
19190            Some("Glass Bottle×1")
19191        );
19192    }
19193
19194    #[test]
19195    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19196        let mut state = sample_state();
19197        state.known_abilities = vec!["unarmed".into()];
19198        state.weapon_ability_id = "unarmed".into();
19199        state.inventory_stacks = vec![
19200            flatland_protocol::ItemStack {
19201                template_id: "carrot".into(),
19202                quantity: 2,
19203                display_name: Some("Wild Carrot".into()),
19204                category: Some("consumable".into()),
19205                ..Default::default()
19206            },
19207            flatland_protocol::ItemStack {
19208                template_id: "blueprint_dimensional_pouch".into(),
19209                quantity: 1,
19210                display_name: Some("Blueprint — Dimensional Pouch".into()),
19211                category: Some("consumable".into()),
19212                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19213                    .into_iter()
19214                    .collect(),
19215                ..Default::default()
19216            },
19217        ];
19218        state.inventory.insert("carrot".into(), 2);
19219        state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19220        state.inventory_hints.insert(
19221            "carrot".into(),
19222            InventoryHint {
19223                display_name: "Wild Carrot".into(),
19224                category: "consumable".into(),
19225                ..Default::default()
19226            },
19227        );
19228        state.inventory_hints.insert(
19229            "blueprint_dimensional_pouch".into(),
19230            InventoryHint {
19231                display_name: "Blueprint — Dimensional Pouch".into(),
19232                category: "consumable".into(),
19233                ..Default::default()
19234            },
19235        );
19236
19237        let choices = state.loadout_hotbar_choices();
19238        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19239        assert!(
19240            choices
19241                .iter()
19242                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19243            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19244        );
19245    }
19246
19247    #[test]
19248    fn storage_store_options_excludes_hand_equipped() {
19249        let mut state = sample_state();
19250        let sword_id = uuid::Uuid::from_u128(11);
19251        let ore_id = uuid::Uuid::from_u128(22);
19252        state.inventory_stacks = vec![
19253            flatland_protocol::ItemStack {
19254                template_id: "short_sword".into(),
19255                quantity: 1,
19256                item_instance_id: Some(sword_id),
19257                display_name: Some("Short Sword".into()),
19258                category: Some("weapon".into()),
19259                ..Default::default()
19260            },
19261            flatland_protocol::ItemStack {
19262                template_id: "iron_ore".into(),
19263                quantity: 5,
19264                item_instance_id: Some(ore_id),
19265                display_name: Some("Iron Ore".into()),
19266                category: Some("resource".into()),
19267                ..Default::default()
19268            },
19269        ];
19270        state.mainhand_template_id = Some("short_sword".into());
19271        state.mainhand_instance_id = Some(sword_id);
19272
19273        let opts = state.storage_store_options();
19274        assert_eq!(opts.len(), 1);
19275        assert_eq!(opts[0].item_instance_id, ore_id);
19276        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19277    }
19278
19279    #[test]
19280    fn loose_consumable_move_picker_offers_use_and_storage() {
19281        let mut state = sample_state();
19282        let inst = uuid::Uuid::from_u128(77);
19283        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19284            template_id: "carrot".into(),
19285            quantity: 2,
19286            item_instance_id: Some(inst),
19287            props: Default::default(),
19288            status_bindings: Vec::new(),
19289            contents: Vec::new(),
19290            display_name: Some("Wild Carrot".into()),
19291            category: Some("consumable".into()),
19292            base_mass: None,
19293            base_volume: None,
19294            capacity_volume: None,
19295            stackable: Some(true),
19296            world_placeable: None,
19297            worker_lodging_capacity: None,
19298            equip_slot: None,
19299            armor_physical: None,
19300            resists: vec![],
19301            hand_slots: None,
19302            listable: None,
19303            ..Default::default()
19304        }];
19305        state.inventory_hints.insert(
19306            "carrot".into(),
19307            InventoryHint {
19308                display_name: "Wild Carrot".into(),
19309                category: "consumable".into(),
19310                base_mass: Some(0.15),
19311                base_volume: Some(0.3),
19312                capacity_volume: None,
19313                stackable: true,
19314                listable: true,
19315                base_value_copper: None,
19316            },
19317        );
19318        state.show_inventory_menu = true;
19319        state.inventory_menu_index = 0;
19320
19321        let row = state.inventory_selected_row().expect("carrot row");
19322        let mut options = state.move_destinations_for(
19323            &row.from,
19324            row.from_parent_instance_id,
19325            row.stack.item_instance_id,
19326            &row.stack.template_id,
19327        );
19328        if row.from == flatland_protocol::InventoryLocation::Root
19329            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19330        {
19331            options.insert(
19332                0,
19333                MoveOption {
19334                    label: "Use (eat / drink)".into(),
19335                    kind: MoveOptionKind::Use,
19336                },
19337            );
19338        }
19339
19340        assert_eq!(
19341            options.first().map(|o| &o.label),
19342            Some(&"Use (eat / drink)".into())
19343        );
19344        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19345        assert!(options
19346            .iter()
19347            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19348    }
19349
19350    #[test]
19351    fn inventory_category_group_order_is_stable() {
19352        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19353        assert_eq!(inventory_category_group("armor").0, "Armor");
19354        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19355        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19356        assert_eq!(inventory_category_group("resource").0, "Resources");
19357        assert_eq!(inventory_category_group("container").0, "Containers");
19358        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19359        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19360    }
19361
19362    #[test]
19363    fn page_list_index_clamps_without_wrap() {
19364        assert_eq!(page_list_index(0, -1, 25), 0);
19365        assert_eq!(page_list_index(0, 1, 25), 10);
19366        assert_eq!(page_list_index(12, 1, 25), 22);
19367        assert_eq!(page_list_index(22, 1, 25), 24);
19368        assert_eq!(page_list_index(5, 1, 0), 0);
19369        assert_eq!(page_list_index(3, -1, 8), 0);
19370    }
19371
19372    #[test]
19373    fn inventory_filter_hides_non_matching_person_items() {
19374        let mut state = sample_state();
19375        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19376        sword.display_name = Some("Iron Sword".into());
19377        sword.category = Some("weapon".into());
19378        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19379        herb.display_name = Some("Wild Herb".into());
19380        herb.category = Some("consumable".into());
19381        state.inventory_stacks = vec![sword, herb];
19382        state.inventory_tab = InventoryTab::OnPerson;
19383        state.inventory_filter = "sword".into();
19384
19385        let rows = state.inventory_selectable_rows();
19386        assert_eq!(rows.len(), 1);
19387        assert_eq!(rows[0].stack.template_id, "iron_sword");
19388
19389        let lines = state.inventory_browser_lines();
19390        assert!(lines.iter().any(|l| matches!(
19391            l,
19392            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19393        )));
19394        assert!(!lines.iter().any(|l| matches!(
19395            l,
19396            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19397        )));
19398    }
19399
19400    #[test]
19401    fn list_filter_chars_reject_mac_arrow_glyphs() {
19402        assert!(is_list_filter_char('a'));
19403        assert!(is_list_filter_char(' '));
19404        assert!(is_list_filter_char('-'));
19405        assert!(!is_list_filter_char('\u{F700}'));
19406        assert!(!is_list_filter_char('\u{F701}'));
19407        assert!(!is_list_filter_char('\n'));
19408    }
19409
19410    #[test]
19411    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19412        let mut state = sample_state();
19413        state.craft_tab = CraftTab::Ready;
19414        state.blueprints = vec![BlueprintView {
19415            id: "plank".into(),
19416            label: "Plank".into(),
19417            craft_tier: 1,
19418            craft_ticks: 30,
19419            output: "wood_plank".into(),
19420            output_qty: 1,
19421            output_display_name: "Wood Plank".into(),
19422            station: None,
19423            category: None,
19424            inputs: vec![flatland_protocol::BlueprintIngredientView {
19425                template_id: "oak_log".into(),
19426                quantity: 1,
19427                consumed: true,
19428                display_name: "Oak Log".into(),
19429            }],
19430            required_tools: vec![],
19431            skill: None,
19432            failure_chance: 0.0,
19433            worker_train_copper: 0,
19434        }];
19435        // Materials already consumed at craft begin — would drop off Ready without the pin.
19436        state.inventory.clear();
19437        state.craft_channel_blueprint_id = Some("plank".into());
19438        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19439            label: "Crafting Plank".into(),
19440            channel: flatland_protocol::TimedChannelKind::Craft,
19441            ticks_remaining: 20,
19442            ticks_total: 30,
19443            ..Default::default()
19444        });
19445
19446        let idxs = state.craft_filtered_indices();
19447        assert_eq!(idxs, vec![0]);
19448        assert!(state.craft_blueprint_in_channel("plank"));
19449
19450        // Channel finished → clear pin → Ready empty.
19451        state.timed_channel = None;
19452        state.craft_channel_blueprint_id = None;
19453        assert!(state.craft_filtered_indices().is_empty());
19454    }
19455
19456    #[test]
19457    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19458        let mut state = sample_state();
19459        let id_a = uuid::Uuid::from_u128(0xa1);
19460        let id_b = uuid::Uuid::from_u128(0xb2);
19461        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19462        sword_a.display_name = Some("Iron Sword".into());
19463        sword_a.category = Some("weapon".into());
19464        sword_a.item_instance_id = Some(id_a);
19465        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19466        sword_b.display_name = Some("Iron Sword".into());
19467        sword_b.category = Some("weapon".into());
19468        sword_b.item_instance_id = Some(id_b);
19469        state.inventory_stacks = vec![sword_a, sword_b];
19470        state.inventory_tab = InventoryTab::OnPerson;
19471
19472        let lines = state.inventory_browser_lines();
19473        let items: Vec<_> = lines
19474            .iter()
19475            .filter_map(|l| match l {
19476                InventoryBrowserLine::Item {
19477                    title,
19478                    instance_tooltip,
19479                    ..
19480                } => Some((title.clone(), instance_tooltip.clone())),
19481                _ => None,
19482            })
19483            .collect();
19484        assert_eq!(items.len(), 2);
19485        for (title, tip) in &items {
19486            assert!(
19487                !title.contains('#'),
19488                "title should not show instance suffix: {title}"
19489            );
19490            assert!(
19491                tip.is_some(),
19492                "two identical rows should expose instance on hover"
19493            );
19494        }
19495
19496        state.inventory_stacks.pop();
19497        let lines = state.inventory_browser_lines();
19498        let one = lines.iter().find_map(|l| match l {
19499            InventoryBrowserLine::Item {
19500                title,
19501                instance_tooltip,
19502                ..
19503            } => Some((title.clone(), instance_tooltip.clone())),
19504            _ => None,
19505        });
19506        let (title, tip) = one.expect("one sword row");
19507        assert!(!title.contains('#'));
19508        assert!(tip.is_none(), "single row should not need instance tooltip");
19509    }
19510
19511    #[test]
19512    fn inventory_person_rows_group_by_category() {
19513        let mut state = sample_state();
19514        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19515        sword.category = Some("weapon".into());
19516        sword.display_name = Some("Iron Sword".into());
19517        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19518        ore.category = Some("resource".into());
19519        ore.display_name = Some("Iron Ore".into());
19520        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19521        potion.category = Some("consumable".into());
19522        potion.display_name = Some("Health Potion".into());
19523        state.inventory_stacks = vec![ore, potion, sword];
19524        state.inventory_tab = InventoryTab::OnPerson;
19525
19526        let lines = state.inventory_browser_lines();
19527        let labels: Vec<&str> = lines
19528            .iter()
19529            .filter_map(|l| match l {
19530                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19531                _ => None,
19532            })
19533            .collect();
19534        assert!(
19535            labels.iter().any(|s| s.contains("Weapons")),
19536            "expected Weapons group: {labels:?}"
19537        );
19538        assert!(labels.iter().any(|s| s.contains("Consumables")));
19539        assert!(labels.iter().any(|s| s.contains("Resources")));
19540
19541        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19542        let consumable_pos = labels
19543            .iter()
19544            .position(|s| s.contains("Consumables"))
19545            .unwrap();
19546        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19547        assert!(weapon_pos < consumable_pos);
19548        assert!(consumable_pos < resource_pos);
19549    }
19550
19551    #[test]
19552    fn inventory_tab_cycle_resets_selection() {
19553        let mut state = sample_state();
19554        state.inventory_tab = InventoryTab::OnPerson;
19555        state.inventory_menu_index = 3;
19556        state.inventory_tab = state.inventory_tab.cycle(true);
19557        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19558        // Client method resets index; enum cycle alone does not — verify cycle labels.
19559        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19560        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19561        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19562        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19563    }
19564
19565    #[test]
19566    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19567        assert_eq!(parse_bank_copper_amount(""), Some(0));
19568        assert_eq!(parse_bank_copper_amount("  "), Some(0));
19569        assert_eq!(parse_bank_copper_amount("0"), Some(0));
19570        assert_eq!(parse_bank_copper_amount("250"), Some(250));
19571        assert_eq!(parse_bank_copper_amount("nope"), None);
19572    }
19573
19574    #[test]
19575    fn parse_storage_quantity_blank_and_zero_mean_all() {
19576        assert_eq!(parse_storage_quantity(""), Some(None));
19577        assert_eq!(parse_storage_quantity("  "), Some(None));
19578        assert_eq!(parse_storage_quantity("0"), Some(None));
19579        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19580        assert_eq!(parse_storage_quantity("nope"), None);
19581    }
19582
19583    #[test]
19584    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19585        assert!(worker_error_is_hud_noise("path stuck — repathing"));
19586        assert!(worker_error_is_hud_noise(
19587            "path stuck — nudged clear, repathing"
19588        ));
19589        assert!(worker_error_is_hud_noise(
19590            "returned to lodging after path failures"
19591        ));
19592        // Real problem: player may need to place lodging / fix assignment.
19593        assert!(!worker_error_is_hud_noise(
19594            "path stuck — no lodging to reset to"
19595        ));
19596        assert!(!worker_error_is_hud_noise(
19597            "cannot reach Eli — idling"
19598        ));
19599        assert!(worker_error_is_transient(
19600            "storage full; continuing route"
19601        ));
19602        assert!(!worker_error_is_transient(
19603            "storage full (Food Bank) — free chest space or reassign deposit"
19604        ));
19605        assert!(!worker_error_is_hud_noise(
19606            "storage full (Food Bank) — free chest space or reassign deposit"
19607        ));
19608    }
19609
19610    #[test]
19611    fn leaving_building_restores_outdoor_z_bands() {
19612        use flatland_protocol::{InteriorMapView, ZPlatformView};
19613
19614        let mut state = sample_state();
19615        state.z_platforms.clear();
19616        state.z_transitions.clear();
19617        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19618        state.interior_map = Some(InteriorMapView {
19619            building_id: "broker_hut".into(),
19620            blueprint_id: "broker_hut".into(),
19621            background_color: "#000".into(),
19622            default_floor_color: None,
19623            floor_height_m: 3.0,
19624            z_platforms: vec![ZPlatformView {
19625                id: "floor_0".into(),
19626                z: 0.0,
19627                x0: 0.0,
19628                y0: 0.0,
19629                x1: 8.0,
19630                y1: 8.0,
19631            }],
19632            z_transitions: vec![],
19633            rooms: vec![],
19634            room_doors: vec![],
19635        });
19636        state.sync_interior_map_context();
19637        assert_eq!(
19638            state.z_platforms.len(),
19639            1,
19640            "indoors installs interior platforms"
19641        );
19642        assert!(state.z_bands_outdoor_backup.is_some());
19643
19644        state.player.as_mut().unwrap().inside_building = None;
19645        state.sync_interior_map_context();
19646        assert!(
19647            state.z_platforms.is_empty(),
19648            "leaving must restore outdoor bands (empty), not leave interior platforms"
19649        );
19650        assert!(state.z_bands_outdoor_backup.is_none());
19651        assert!(state.interior_map.is_none());
19652    }
19653
19654    #[test]
19655    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19656        let node = ResourceNodeView {
19657            id: "crop-carrot-1_copy10".into(),
19658            label: "crop-carrot-1_copy10".into(),
19659            x: 0.0,
19660            y: 0.0,
19661            z: 0.0,
19662            item_template: "carrot".into(),
19663            state: ResourceNodeState::Available,
19664            blocking: false,
19665            blocking_radius_m: 0.5,
19666            harvest_off: false,
19667            tile_id: None,
19668            yaw: 0.0,
19669            pitch: 0.0,
19670            roll: 0.0,
19671            draw_scale: 1.0,
19672            sprite_mode: None,
19673            growth_progress: None,
19674            presentation_state: None,
19675            channel_start_tick: None,
19676            channel_end_tick: None,
19677            harvest_drop_templates: vec![],
19678        };
19679        let label = super::resource_node_route_label(&node);
19680        assert!(label.starts_with("Carrot ("), "got {label}");
19681        assert!(label.ends_with(')'), "got {label}");
19682
19683        let mut named = node;
19684        named.label = "Sweet Pad".into();
19685        named.id = "crop-carrot-a3f2b1c0".into();
19686        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19687    }
19688
19689    #[test]
19690    fn plot_public_label_uses_owner_zone_and_label() {
19691        let plot = flatland_protocol::PropertyPlotView {
19692            plot_id: uuid::Uuid::nil(),
19693            property_zone_id: "zone_a".into(),
19694            zone_label: Some("Starter Town East 1".into()),
19695            deed_instance_id: uuid::Uuid::nil(),
19696            x0: 0.0,
19697            y0: 0.0,
19698            x1: 4.0,
19699            y1: 4.0,
19700            upkeep_copper_per_day: 1,
19701            arrears_days: 0,
19702            is_mine: true,
19703            may_farm: true,
19704            purchase_basis_copper: 0,
19705            farm_public: false,
19706            public_tax_discount_bps: 0,
19707            farm_allow: vec![],
19708            owner_character_id: None,
19709            owner_label: Some("Madsin".into()),
19710            building_id: None,
19711            plot_code: "xyz1234a".into(),
19712            label: "Food Pad".into(),
19713        };
19714        assert_eq!(
19715            super::plot_public_label(&plot),
19716            "Madsin — Starter Town East 1 — Food Pad"
19717        );
19718    }
19719
19720    #[test]
19721    fn plot_public_label_uses_size_when_label_and_code_blank() {
19722        let plot = flatland_protocol::PropertyPlotView {
19723            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19724            property_zone_id: String::new(),
19725            zone_label: None,
19726            deed_instance_id: uuid::Uuid::nil(),
19727            x0: 10.0,
19728            y0: 20.0,
19729            x1: 18.0,
19730            y1: 28.0,
19731            upkeep_copper_per_day: 1,
19732            arrears_days: 0,
19733            is_mine: true,
19734            may_farm: true,
19735            purchase_basis_copper: 0,
19736            farm_public: false,
19737            public_tax_discount_bps: 0,
19738            farm_allow: vec![],
19739            owner_character_id: None,
19740            owner_label: None,
19741            building_id: None,
19742            plot_code: String::new(),
19743            label: String::new(),
19744        };
19745        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19746        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19747    }
19748
19749    #[test]
19750    fn plot_stop_label_prefers_view_over_hex() {
19751        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19752        let plot = flatland_protocol::PropertyPlotView {
19753            plot_id,
19754            property_zone_id: "zone_a".into(),
19755            zone_label: Some("Starter Town East".into()),
19756            deed_instance_id: uuid::Uuid::nil(),
19757            x0: 0.0,
19758            y0: 0.0,
19759            x1: 4.0,
19760            y1: 4.0,
19761            upkeep_copper_per_day: 1,
19762            arrears_days: 0,
19763            is_mine: true,
19764            may_farm: true,
19765            purchase_basis_copper: 0,
19766            farm_public: false,
19767            public_tax_discount_bps: 0,
19768            farm_allow: vec![],
19769            owner_character_id: None,
19770            owner_label: Some("Madsin".into()),
19771            building_id: None,
19772            plot_code: "xyz1234a".into(),
19773            label: "Food Pad".into(),
19774        };
19775        assert_eq!(
19776            super::plot_stop_label(&[plot.clone()], plot_id),
19777            "Madsin — Starter Town East — Food Pad"
19778        );
19779        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19780        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19781    }
19782}